Module 07 · The frontend plugin · 45 min · You are: Platform and product
On the entity page: a card and a tab
In this module
- You learn: How
EntityCardBlueprintandEntityContentBlueprintattach to the catalog's entity page, howuseEntitygives you the entity, and how an extension gets its own config schema. - You do: Write the card and the tab, give the card a
passScoresetting, set it inapp-config.yaml, and see team-checkout's score. - You end with: Component pages show a Production readiness card and a Readiness tab with Evaluate now. The pass score is 80, from config. Merged.
A page nobody visits changes nothing. Teams look at their own component page every day, so the score belongs there. The catalog plugin offers places for cards and tabs; your plugin only fills them.
Open this module in the interactive course to track progress.
Concepts
- Entity cards and content
EntityCardBlueprintadds a card to the overview;EntityContentBlueprintadds a tab. Both come from@backstage/plugin-catalog-react/alpha. Catalog integration.- filter
filter: { kind: 'component' }shows the card only where it makes sense.- useEntity
- Inside the entity page,
useEntity()returns the entity being shown. - Extension config
makeWithOverrideswith aconfigSchema(zod) lets the app set values underapp.extensionsby id:entity-card:readiness/scorecard.
Files to know
Open them in your editor or on GitHub; the bullets say what to look at.
portal/plugins/readiness/src/plugin.tsx(reference, your repo): The card and the tab.portal/plugins/readiness/src/components/ScorecardCard.tsx(reference, your repo): The card.- The entity, then its scorecard from the API L15–21
portal/app-config.yaml(reference, your repo): The pass score, set by the platform team.- Config for one extension, by id L9–12
Steps
1. Write the card and the tab
Both show the same scorecard view. The tab adds a button that evaluates now.
cd ~/work/portal && yarn --cwd plugins/readiness add @backstage/plugin-catalog-react@^3.3.0 zod@^4.6.7
cp ~/work/backstage-workshop/portal/plugins/readiness/src/components/ScorecardView.tsx ~/work/portal/plugins/readiness/src/components/ScorecardView.tsx
Create ~/work/portal/plugins/readiness/src/components/ScorecardCard.tsx
import {
InfoCard,
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
import { useApi } from '@backstage/frontend-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { stringifyEntityRef } from '@backstage/catalog-model';
import useAsync from 'react-use/esm/useAsync';
import { readinessApiRef } from '../api';
import { ScorecardView } from './ScorecardView';
/** The card on the entity overview. */
export const ScorecardCard = (props: { passScore: number }) => {
const { entity } = useEntity();
const readinessApi = useApi(readinessApiRef);
const entityRef = stringifyEntityRef(entity);
const { value, loading, error } = useAsync(
() => readinessApi.getScorecard(entityRef),
[readinessApi, entityRef],
);
let body;
if (loading) body = <Progress />;
else if (error) body = <ResponseErrorPanel error={error} />;
else if (!value) body = 'Not evaluated yet.';
else body = <ScorecardView card={value} passScore={props.passScore} />;
return <InfoCard title="Production readiness">{body}</InfoCard>;
};
Create ~/work/portal/plugins/readiness/src/components/ReadinessContent.tsx
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
import { useApi } from '@backstage/frontend-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { Button, Container, Flex } from '@backstage/ui';
import useAsyncFn from 'react-use/esm/useAsyncFn';
import { useEffect } from 'react';
import { readinessApiRef } from '../api';
import { ScorecardView } from './ScorecardView';
/** The Readiness tab: the scorecard, and a button to evaluate now. */
export const ReadinessContent = () => {
const { entity } = useEntity();
const readinessApi = useApi(readinessApiRef);
const entityRef = stringifyEntityRef(entity);
const [load, fetchCard] = useAsyncFn(
() => readinessApi.getScorecard(entityRef),
[readinessApi, entityRef],
);
const [run, evaluate] = useAsyncFn(async () => {
await readinessApi.evaluate(entityRef);
await fetchCard();
}, [readinessApi, entityRef, fetchCard]);
useEffect(() => {
fetchCard();
}, [fetchCard]);
const error = load.error ?? run.error;
return (
<Container>
<Flex direction="column" gap="4">
{error && <ResponseErrorPanel error={error} />}
{load.loading && <Progress />}
{load.value && <ScorecardView card={load.value} passScore={75} />}
<Button isDisabled={run.loading} onPress={() => evaluate()}>
Evaluate now
</Button>
</Flex>
</Container>
);
};
2. Add them to the plugin
The card has a config schema with a default of 75.
Replace the whole file ~/work/portal/plugins/readiness/src/plugin.tsx
import {
ApiBlueprint,
createFrontendPlugin,
discoveryApiRef,
fetchApiRef,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import {
EntityCardBlueprint,
EntityContentBlueprint,
} from '@backstage/plugin-catalog-react/alpha';
import { z } from 'zod/v4';
import { readinessApiRef, ReadinessClient } from './api';
import { rootRouteRef } from './routes';
/** How the app gets a ReadinessApi: one client, built from two core APIs. */
export const readinessApi = ApiBlueprint.make({
params: defineParams =>
defineParams({
api: readinessApiRef,
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
factory: ({ discoveryApi, fetchApi }) =>
new ReadinessClient(discoveryApi, fetchApi),
}),
});
/** /readiness: every scorecard. The title puts it in the sidebar. */
export const readinessPage = PageBlueprint.make({
params: {
path: '/readiness',
routeRef: rootRouteRef,
title: 'Readiness',
loader: () =>
import('./components/ReadinessPage').then(m => <m.ReadinessPage />),
},
});
/** The card on a component's overview. passScore comes from app-config. */
export const readinessCard = EntityCardBlueprint.makeWithOverrides({
name: 'scorecard',
configSchema: {
passScore: z.number().default(75),
},
factory(originalFactory, { config }) {
return originalFactory({
filter: { kind: 'component' },
loader: () =>
import('./components/ScorecardCard').then(m => (
<m.ScorecardCard passScore={config.passScore} />
)),
});
},
});
/** The Readiness tab on a component's page. */
export const readinessContent = EntityContentBlueprint.make({
params: {
path: 'readiness',
title: 'Readiness',
filter: { kind: 'component' },
loader: () =>
import('./components/ReadinessContent').then(m => <m.ReadinessContent />),
},
});
export const readinessPlugin = createFrontendPlugin({
pluginId: 'readiness',
extensions: [readinessApi, readinessPage, readinessCard, readinessContent],
routes: {
root: rootRouteRef,
},
});
Add to ~/work/portal/app-config.yaml under app: → extensions:, as the first item
# The readiness card on component pages (plugin course, module 07).
- entity-card:readiness/scorecard:
config:
passScore: 80
cd ~/work/portal && yarn tsc
3. Test the card
The test renders the card inside an entity with a fake API: no backend needed.
cp ~/work/backstage-workshop/portal/plugins/readiness/src/components/ScorecardCard.test.tsx ~/work/portal/plugins/readiness/src/components/ScorecardCard.test.tsx
yarn --cwd plugins/readiness test --watchAll=false
4. team-checkout: see your score
Open the catalog and click checkout.
Overview has a Production readiness card: 83% Ready (pass: 80%), with a ❌ on Links a runbook.
The Readiness tab shows the same, and Evaluate now scores it again. Open a Group page: no card, because of the filter.
5. Merge it
cd ~/work/portal
Propose it as a pull request. Put it on its own branch and push it:
git switch -c readiness-entity-page && git add -A && git commit -qm "Readiness: entity card and tab"
git push -qu origin HEAD
Open the pull request. It prints a link:
gh pr create --fill
Open that link. Files changed shows exactly your diff.
Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:
gh pr merge --squash --delete-branch
Bring your copy up to date:
git switch -q main && git pull -q
Simplified for the course
The tab uses the default pass score of 75. A real plugin would share the setting between the card and the tab.
Break it on purpose: A card everywhere
Someone wants the card on systems too and deletes the filter instead of widening it.
You practise: why every entity card needs a filter.
1. Break it
Change in ~/work/portal/plugins/readiness/src/plugin.tsx
No filter.
- filter: { kind: 'component' },
- loader: () =>
- import('./components/ScorecardCard')
+ loader: () =>
+ import('./components/ScorecardCard')
Open the team-checkout Group page and the shop System page.
2. What you see
Expected: no readiness card on groups and systems. You get: a Production readiness card on both, saying Not evaluated yet. forever.
3. Fix it yourself
Show the card on components only again.
Hint 1
The schedule evaluates components only. What decides which entity pages get the card?
The fix
Change in ~/work/portal/plugins/readiness/src/plugin.tsx
- loader: () =>
- import('./components/ScorecardCard')
+ filter: { kind: 'component' },
+ loader: () =>
+ import('./components/ScorecardCard')
After you fixed it: the five questions
- What failed?
- Without a filter the card attached to every entity page, and only components ever get a scorecard.
- How was it detected?
- A card that never fills, on pages where it means nothing.
- Who responds?
- The platform team.
- How is it corrected?
- Put the filter back.
- How is it prevented?
- Review every entity card and tab for a filter that matches what the backend evaluates.
Quiz
- Where does the card get the entity it shows?
useEntity(), from the entity page it is rendered on- From the URL, which the card parses on every render
- From a prop the catalog passes to every card by name
- How does the platform team set the pass score to 80?
- By editing the default in
plugin.tsxand releasing the plugin - Under
app.extensions, config forentity-card:readiness/scorecard - With an environment variable read by the frontend at build time
- By editing the default in
- The card should show on APIs too. What do you change?
- Remove the filter so the card shows on every kind
- Add a second card extension that copies the first one
- Widen the filter, and make the backend evaluate APIs as well
Answers
useEntity(), from the entity page it is rendered on The entity page provides the entity to everything inside it.- Under
app.extensions, config forentity-card:readiness/scorecardThe card declared a config schema, so its id accepts config. - Widen the filter, and make the backend evaluate APIs as well The filter and the schedule must agree on which kinds have scorecards.
Field note
Entity pages fill up fast. I give every card a filter and a reason, and I ask teams which three cards they actually read. The rest go to a tab.