Antrea UI supports loading frontend plugins at runtime, without rebuilding
the antrea-ui-frontend image. A plugin adds its own page and sidebar entry
to the UI. The mechanism is modeled on
Headlamp's plugin system:
a plugin is a self-contained JS bundle plus a manifest, discovered and
loaded by the host app at runtime instead of being compiled in. See
plugins/examples/pod-counter for a
complete, minimal example.
-
A plugin is delivered as a Kubernetes
ConfigMap, in the namespace the backend watches for plugins (plugins.namespace, default: antrea-ui's own release namespace), labeled to matchplugins.labelSelector(default:ui.antrea.io/plugin=true). Its data holdsmanifest.jsonplus the plugin's JS entry file.The RBAC this requires (
get/list/watchon ConfigMaps) is granted on every ConfigMap in that namespace, not just labeled plugin ones — the label selector is applied by the backend's watch, not by RBAC. Since antrea-ui is commonly installed intokube-system, which can host other, unrelated, more sensitive ConfigMaps, setplugins.namespaceto a dedicated namespace if you want plugin ConfigMaps isolated from it. If that namespace differs from the release namespace, whoever runshelm install/upgradeneeds permission to create aRole/RoleBindingthere too — the chart can't grant permissions outside its own release namespace. -
The Go backend watches ConfigMaps matching that label (see
pkg/pluginsandpkg/server/api/plugins.go) and serves them, unauthenticated, atGET /api/v1/plugins/index.json(the merged list of manifests) andGET /api/v1/plugins/<name>/<file>(any file from that plugin's ConfigMap). A ConfigMap can be created, updated, or deleted at any time — the backend picks up the change immediately, with no antrea-ui restart. Both routes are served withCache-Control: no-storefor exactly that reason. If a plugin's entry file is stored as gzip-compressedbinaryData(worth doing once bundled with dependencies — detected by its magic number, no manifest changes needed), the backend passes it through as-is withContent-Encoding: gziprather than decompressing and re-serving it. -
On load, Antrea UI fetches
/api/v1/plugins/index.jsonandimport()s each plugin's JS module at runtime — the code doesn't need to exist when the frontend is built. Seeplugins.ts. -
Each module registers a Lit custom element via
customElements.define(...), same as@antrea/ui-componentsdoes for Antrea UI's own pages, and tells the host about it by calling one of@antrea/ui-plugin-sdk'sregisterX()functions —registerRouteandregisterSidebarEntryfor a whole new page, or one of the "Extending an existing page" functions below to extend one Antrea UI already ships. This is modeled on Headlamp's plugin registry.
{
"name": "pod-counter",
"version": "0.1.0",
"entry": "index.js"
}| Field | Required | Description |
|---|---|---|
name |
yes | Unique name; also the path segment used to serve the plugin, e.g. /api/v1/plugins/<name>/. |
version |
yes | Informational only. |
entry |
yes | Plugin's JS module filename; must be a key in the same ConfigMap's data. |
That's the whole schema — the manifest only carries enough for the host to
fetch and import() the right file. Everything that affects the UI (routes,
sidebar entries, page extensions) is registered in code, via
@antrea/ui-plugin-sdk, so a plugin's actual shape is never split between
JSON and JS.
A plugin is a standalone package — not part of the client/web Yarn
workspace, and it doesn't depend on @antrea/ui-components internals. It
relies on @antrea/ui-plugin-sdk to register itself with the host, and on
Antrea UI's REST API. Its own vite.config.ts must bundle dependencies like
lit in, rather than externalizing them (unlike @antrea/ui-components) —
there's no host-provided import map for a runtime import().
The host passes your element no credential, and there is none to ask for:
requests to the Antrea UI backend authenticate with the antrea-ui-session
cookie the browser already holds, so credentials: 'include' is the whole of
it. (apiFetch/apiFetchJSON from @antrea/ui-components do this for you,
along with turning a non-2xx response into an APIError, if you would rather
not hand-roll it.) Never send an Authorization header of your own.
plugins/examples/pod-counter/src/index.ts:
import { LitElement, html } from 'lit';
import { state } from 'lit/decorators.js';
import { registerRoute, registerSidebarEntry } from '@antrea/ui-plugin-sdk';
class AntreaPluginPodCounter extends LitElement {
@state() private _count: number | null = null;
@state() private _error: string | null = null;
connectedCallback() {
super.connectedCallback();
fetch('/api/v1/k8s/api/v1/pods', { credentials: 'include' })
.then((res) => {
if (res.status === 403) throw new Error('you do not have permission to list Pods');
if (!res.ok) throw new Error(`request failed: ${res.status}`);
return res.json();
})
.then((data) => { this._count = data.items?.length ?? 0; })
.catch((e) => { this._error = e instanceof Error ? e.message : String(e); });
}
render() {
if (this._error) return html`<p>Failed to load pod count: ${this._error}</p>`;
return html`<h1>Pods in cluster: ${this._count ?? '...'}</h1>`;
}
}
customElements.define('antrea-plugin-pod-counter', AntreaPluginPodCounter);
registerRoute({ path: '/plugin/pod-counter', tag: 'antrea-plugin-pod-counter' });
registerSidebarEntry({ label: 'Pod Counter', path: '/plugin/pod-counter' });registerRoute's path must not collide with a built-in route or another
plugin's; the host drops (and logs) whichever registration loses the race. registerSidebarEntry is independent of
registerRoute, so a plugin can add a sidebar entry without a route (e.g.
an external link) or vice versa; pass the same path to both to link them,
as above. registerSidebarEntry's optional icon is SVG path d data,
16x16 (viewBox="0 0 16 16"), matching the built-in nav icons' style.
@antrea/ui-plugin-sdk is a devDependency resolved from this repo's
workspace (file:../../../client/web/antrea-ui-plugin-sdk in
package.json) — build it once before building any example plugin:
cd client/web/antrea-ui-plugin-sdk && yarn build
cd ../../../plugins/examples/pod-counter
npm install && npm run build # vite build, then copies manifest.json into dist/Your plugin can proxy any K8s API path: there is no allowlist in front of the
proxy, RBAC is the only guard (see
pkg/server/api/k8s.go). What decides whether the
call succeeds is the RBAC of whoever is logged in — which is why the example
handles a 403 rather than assuming the call goes through. Before the backend
called Kubernetes as the end user, every UI user had the same access and that
branch was dead code; now it is the ordinary outcome for a user whose role does
not cover the path your plugin reads.
RBAC for that path is not added to Antrea UI's own clusterroles.yaml.
Instead, ship your own ClusterRole labeled
rbac.ui.antrea.io/aggregate-to-antrea-ui-admin: "true", which
aggregates
into the antrea-ui-admin ClusterRole automatically — see
plugins/examples/pod-counter/clusterrole.yaml.
Creating (and RBAC-scoping) that ClusterRole is the responsibility of
whoever deploys the plugin, not Antrea UI itself.
That aggregation only covers the admin-password mode, which impersonates the
antrea-ui-admin ServiceAccount. Users who log in with their own Kubernetes
identity (OIDC, kubeconfig, token) are bound to a role the cluster admin wrote,
which your ClusterRole does not aggregate into — so your plugin's pages will
403 for them until that admin grants the same permissions deliberately. Say so
in your plugin's install instructions. See
authentication.md.
Everything above adds a whole new page. A plugin can instead extend a page
Antrea UI already ships — e.g. render extra content in the service map's
edge details card, or add a column to the flow list table — via
@antrea/ui-plugin-sdk, modeled on
Headlamp's plugin registry
(registerDetailsViewSection, registerResourceTableColumnsProcessor).
Call one of the SDK's registerX() functions as an import side effect in
your plugin's entry module — the same place a whole-page plugin calls
customElements.define(...). A plugin can do both in the same module.
import { registerEdgeExtraRenderer, registerFlowTableColumnsProcessor } from '@antrea/ui-plugin-sdk';
// Renders into the service map's edge details card for the currently
// selected edge. Return null to render nothing for a given selection.
registerEdgeExtraRenderer((selection) => {
const el = document.createElement('a');
el.href = `/plugin/my-plugin?source=${selection.source}&target=${selection.target}`;
el.textContent = 'Open in My Plugin';
return el;
});
// Inserts, removes, updates, or reorders flow list table columns. Receives
// the current list — built-ins plus any earlier plugin's additions — and
// returns the new list.
registerFlowTableColumnsProcessor((columns) => [
...columns,
{ key: 'my-column', label: 'My Column', render: (entry) => entry.flow.k8s.flowType },
]);All registration functions, including the whole-new-page ones from "Writing a plugin" above:
| Function | Extends | Notes |
|---|---|---|
registerRoute |
Router | Adds a whole new page at route.path, rendering route.tag's custom element. |
registerSidebarEntry |
Sidebar | Adds a nav entry linking to entry.path. |
registerEdgeExtraRenderer |
Service map edge details card | Called with an EdgeSelection on each selection change; return null to render nothing. |
registerFlowTableColumnsProcessor |
Flow list table | Plugin-added columns aren't sortable — only built-in columns carry the sort key. |
These functions call into a small registry the host sets up on window
before loading any plugin — see
plugins.ts for the host side and
antrea-flow-visibility-page.ts
for how the registered functions are consumed. @antrea/ui-plugin-sdk only
re-exports types from @antrea/ui-components (a peer dependency,
types-only — nothing from it ends up in your plugin's bundle) so the shapes
stay in sync with what the host actually expects.
Antrea UI only makes sense running against a real cluster, so test against
one directly rather than a standalone dev server. The commands below assume
@antrea/ui-plugin-sdk is already built (see "Writing a plugin" above), and
that <namespace> is wherever the backend watches for plugins —
plugins.namespace if set, otherwise Antrea UI's own release namespace (see
"How it works" above).
cd plugins/examples/pod-counter
npm install && npm run build
kubectl create configmap pod-counter-plugin -n <namespace> \
--from-file=dist/index.js --from-file=dist/manifest.json
kubectl label configmap pod-counter-plugin -n <namespace> \
ui.antrea.io/plugin=true
kubectl apply -f clusterrole.yamlThe last command grants the plugin's own RBAC (see above); without it, the plugin's page loads but its K8s API call gets a 403. No Helm upgrade, no pod restart otherwise: the backend's ConfigMap watch picks this up immediately (note ConfigMaps are capped at 1MiB total, so this only works for reasonably small plugins). Refresh the browser and the plugin's page shows up. To iterate on the plugin's code:
npm run build
kubectl delete configmap pod-counter-plugin -n <namespace>
kubectl create configmap pod-counter-plugin -n <namespace> \
--from-file=dist/index.js --from-file=dist/manifest.json
kubectl label configmap pod-counter-plugin -n <namespace> \
ui.antrea.io/plugin=trueTo remove the plugin: kubectl delete configmap pod-counter-plugin -n <namespace> — it disappears from /api/v1/plugins/index.json immediately.
This same flow — build the plugin, create the labeled ConfigMap, install
Antrea UI — is what
test/e2e/plugin_test.go automates as a real
e2e test in kind_e2e.yml, against an
actual Kind cluster rather than a standalone container.
- More extension points.
@antrea/ui-plugin-sdk(see "Extending an existing page" above) currently only covers the service map's edge details card and the flow list table. Other built-in pages (Summary, Traceflow) don't expose any extension points yet — adding one means the same shape: a new registration function in the SDK, a registry entry inplugins.ts, and the target Lit component consuming the registered functions when it renders.