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)
- 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.jsonplugins/harbor/src/plugin.tsplugins/harbor/src/index.tsplugins/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.
- 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/...
- 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>
);
}
- 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';
- 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>
- 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
- 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