PlatformEngr® Backstage plugins course

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 EntityCardBlueprint and EntityContentBlueprint attach to the catalog's entity page, how useEntity gives you the entity, and how an extension gets its own config schema.
  • You do: Write the card and the tab, give the card a passScore setting, set it in app-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
EntityCardBlueprint adds a card to the overview; EntityContentBlueprint adds 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
makeWithOverrides with a configSchema (zod) lets the app set values under app.extensions by id: entity-card:readiness/scorecard.

Files to know

Open them in your editor or on GitHub; the bullets say what to look at.

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

  1. 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
  2. How does the platform team set the pass score to 80?
    • By editing the default in plugin.tsx and releasing the plugin
    • Under app.extensions, config for entity-card:readiness/scorecard
    • With an environment variable read by the frontend at build time
  3. 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
  1. useEntity(), from the entity page it is rendered on The entity page provides the entity to everything inside it.
  2. Under app.extensions, config for entity-card:readiness/scorecard The card declared a config schema, so its id accepts config.
  3. 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.

Report a problem with this module