Show Harbor Image Artifacts in Backstage as an Entity Tab (Harbor API v2 + Backstage Proxy)

This post shows how I added a Harbor tab to my Backstage catalog entities so I can browse Harbor image artifacts (digest, tags, size, labels, push/pull time) directly in Backstage.

The key idea is:

Backstage frontend → Backstage proxy (/api/proxy/harbor) → Harbor API v2

So the browser never talks to Harbor directly (no CORS mess), and the tab only appears when an entity has a Harbor repo annotation.


What you’ll build

  • A frontend plugin at plugins/harbor (generated by Backstage CLI, then customized)
  • A Harbor proxy entry in app-config.production.yaml
  • A Harbor entity tab wired into EntityPage.tsx
  • An annotation on entities: harbor.maksonlee.com/repository: <project>/<repository>

Assumptions

  • Harbor endpoint: https://harbor.maksonlee.com
  • Harbor API v2 base: /api/v2.0
  • Repository ref format: <project>/<repository> (example: backstage/homelab-backstage)
  • Harbor is publicly readable (no Authorization header required)

  1. Create the Harbor plugin (CLI scaffolding)

Create a new plugin with the Backstage CLI:

yarn backstage-cli new --select plugin
# name: harbor
# role: frontend-plugin

This generates the plugins/harbor/ folder and boilerplate files, including:

  • plugins/harbor/package.json
  • plugins/harbor/src/plugin.ts
  • plugins/harbor/src/index.ts
  • plugins/harbor/dev/index.tsx
  • test/lint scaffolding

In my repo, this flow also resulted in the app depending on the workspace package (as shown in the diff: packages/app/package.json + yarn.lock changed). So there’s no separate “manual add dependency” step in this post.


  1. Add Harbor to the Backstage proxy

Edit app-config.production.yaml and add a proxy target:

proxy:
  '/harbor':
    target: https://harbor.maksonlee.com
    changeOrigin: true
    secure: true
    headers:
      accept: application/json

What this enables:

  • Frontend calls /api/proxy/harbor/api/v2.0/...
  • Backstage forwards to https://harbor.maksonlee.com/api/v2.0/...

  1. Implement the Harbor artifacts table UI

Create:

plugins/harbor/src/components/EntityHarborArtifacts.tsx

This component:

  • Reads the entity annotation harbor.maksonlee.com/repository
  • Calls Harbor’s artifacts endpoint through the Backstage proxy
  • Displays a table with search + refresh
import { useEffect, useMemo, useState } from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Progress, WarningPanel } from '@backstage/core-components';
import { fetchApiRef, useApi } from '@backstage/core-plugin-api';
import {
  Box,
  Button,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  TextField,
} from '@material-ui/core';

type HarborArtifact = {
  digest: string;
  size?: number;
  push_time?: string;
  pull_time?: string;
  tags?: Array<{ name?: string }>;
  labels?: Array<{ name?: string }>;
};

function formatBytes(bytes?: number) {
  if (bytes === undefined || bytes === null) return '-';
  const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
  let v = bytes;
  let i = 0;
  while (v >= 1024 && i < units.length - 1) {
    v /= 1024;
    i += 1;
  }
  return `${v.toFixed(2)}${units[i]}`;
}

function shortDigest(digest: string) {
  const d = digest.startsWith('sha256:') ? digest.slice('sha256:'.length) : digest;
  return `sha256:${d.slice(0, 7)}`;
}

function toLocal(ts?: string) {
  if (!ts) return '-';
  const d = new Date(ts);
  if (Number.isNaN(d.getTime())) return ts;
  return d.toLocaleString();
}

async function readTextSafe(r: Response) {
  try {
    return await r.text();
  } catch {
    return `${r.status} ${r.statusText}`;
  }
}

export function EntityHarborArtifacts() {
  const { entity } = useEntity();
  const repoRef = entity.metadata.annotations?.['harbor.maksonlee.com/repository'];
  const fetchApi = useApi(fetchApiRef);

  const [selected, setSelected] = useState<Record<string, boolean>>({});
  const [query, setQuery] = useState('');

  const [{ loading, error, value }, run] = useAsyncFn(async () => {
    if (!repoRef) return [] as HarborArtifact[];

    const [project, repository] = repoRef.split('/');
    if (!project || !repository) {
      throw new Error(
        'Invalid harbor.maksonlee.com/repository (expected: <project>/<repository>)',
      );
    }

    const url =
      `/api/proxy/harbor/api/v2.0/projects/${encodeURIComponent(project)}` +
      `/repositories/${encodeURIComponent(repository)}` +
      `/artifacts?with_tag=true&with_label=true&page=1&page_size=100`;

    const r = await fetchApi.fetch(url);
    if (!r.ok) {
      const body = await readTextSafe(r);
      throw new Error(`Harbor API failed: ${r.status} ${r.statusText} — ${body}`);
    }

    return (await r.json()) as HarborArtifact[];
  }, [repoRef, fetchApi]);

  useEffect(() => {
    run();
  }, [run]);

  const rows = useMemo(() => {
    return (value ?? []).map(a => {
      const tags = (a.tags ?? []).map(t => t?.name).filter(Boolean) as string[];
      const labels = (a.labels ?? []).map(l => l?.name).filter(Boolean) as string[];

      return {
        digest: a.digest,
        artifact: shortDigest(a.digest),
        tags: tags.length ? tags.join(', ') : '-',
        size: formatBytes(a.size),
        labels: labels.length ? labels.join(', ') : '-',
        pushTime: toLocal(a.push_time),
        pullTime: toLocal(a.pull_time),
      };
    });
  }, [value]);

  const filtered = useMemo(() => {
    if (!query) return rows;
    const q = query.toLowerCase();
    return rows.filter(r => {
      return (
        r.artifact.toLowerCase().includes(q) ||
        r.digest.toLowerCase().includes(q) ||
        r.tags.toLowerCase().includes(q) ||
        r.labels.toLowerCase().includes(q)
      );
    });
  }, [rows, query]);

  const allSelected =
    filtered.length > 0 && filtered.every(r => selected[r.digest] === true);

  const toggleAll = () => {
    if (allSelected) {
      const next = { ...selected };
      for (const r of filtered) delete next[r.digest];
      setSelected(next);
      return;
    }
    const next = { ...selected };
    for (const r of filtered) next[r.digest] = true;
    setSelected(next);
  };

  const toggleOne = (digest: string) => {
    setSelected(s => ({ ...s, [digest]: !s[digest] }));
  };

  if (!repoRef) {
    return (
      <WarningPanel title="Harbor repository not configured">
        Add annotation <code>harbor.maksonlee.com/repository</code> to this entity.
      </WarningPanel>
    );
  }

  if (loading) return <Progress />;
  if (error) {
    return (
      <WarningPanel title="Failed to load Harbor artifacts">
        {String(error)}
      </WarningPanel>
    );
  }

  return (
    <Box>
      <Box display="flex" alignItems="center" justifyContent="space-between" mb={2} gridGap={16}>
        <Button variant="outlined" onClick={() => run()}>
          Refresh
        </Button>

        <TextField
          variant="outlined"
          size="small"
          placeholder="Search"
          value={query}
          onChange={e => setQuery(e.target.value)}
        />
      </Box>

      <Table size="small">
        <TableHead>
          <TableRow>
            <TableCell padding="checkbox">
              <input type="checkbox" checked={allSelected} onChange={toggleAll} />
            </TableCell>
            <TableCell>Artifacts</TableCell>
            <TableCell>Tags</TableCell>
            <TableCell>Size</TableCell>
            <TableCell>Labels</TableCell>
            <TableCell>Push Time</TableCell>
            <TableCell>Pull Time</TableCell>
          </TableRow>
        </TableHead>

        <TableBody>
          {filtered.map(r => (
            <TableRow key={r.digest} hover>
              <TableCell padding="checkbox">
                <input
                  type="checkbox"
                  checked={selected[r.digest] === true}
                  onChange={() => toggleOne(r.digest)}
                />
              </TableCell>
              <TableCell>{r.artifact}</TableCell>
              <TableCell>{r.tags}</TableCell>
              <TableCell>{r.size}</TableCell>
              <TableCell>{r.labels}</TableCell>
              <TableCell>{r.pushTime}</TableCell>
              <TableCell>{r.pullTime}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </Box>
  );
}

  1. Export an Entity tab from the plugin

Edit plugins/harbor/src/plugin.ts so the plugin provides an entity tab extension:

import { createPlugin, createComponentExtension } from '@backstage/core-plugin-api';

export const harborPlugin = createPlugin({
  id: 'harbor',
});

export const EntityHarborArtifactsTab = harborPlugin.provide(
  createComponentExtension({
    name: 'EntityHarborArtifactsTab',
    component: {
      lazy: () =>
        import('./components/EntityHarborArtifacts').then(m => m.EntityHarborArtifacts),
    },
  }),
);

Export it from plugins/harbor/src/index.ts:

export { harborPlugin, EntityHarborArtifactsTab } from './plugin';

  1. Add the Harbor tab into the entity page

Edit:

packages/app/src/components/catalog/EntityPage.tsx

Import the plugin tab:

import { EntityHarborArtifactsTab } from '@internal/backstage-plugin-harbor';

Show the tab only when the annotation exists:

const isHarborAvailable = (entity: any) =>
  Boolean(entity?.metadata?.annotations?.['harbor.maksonlee.com/repository']);

Add the route (I added it for service, website, and default):

<EntityLayout.Route path="/harbor" title="Harbor" if={isHarborAvailable}>
  <EntityHarborArtifactsTab />
</EntityLayout.Route>

  1. Add the annotation to enable the tab

Any entity with this annotation will get the Harbor tab:

metadata:
  annotations:
    harbor.maksonlee.com/repository: backstage/homelab-backstage

  1. Run and verify

Restart your backend (example):

yarn tsc
yarn build:backend
yarn --cwd packages/backend start \
  --config ../../app-config.yaml \
  --config ../../app-config.production.yaml

Open your entity page. If the annotation exists, you should see Harbor tab and a table populated from:

Did this guide save you time?

Support this site

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top