Frontend Development¶
This guide describes how to develop the SolAr frontend (the web/ package) against a local Kind cluster.
Warning
This setup is intended for local development only. Do not use it in production.
Prerequisites¶
- The general dev-cluster prerequisites from Development Cluster with Kind (Docker, Kind, kubectl, Helm, yq)
- Node.js 22 with Corepack enabled (
corepack enable) — pnpm is pinned via Corepack
If you use the provided Nix flake (nix develop or direnv), all of these are already on PATH.
Architecture in one picture¶
make ui-dev runs three processes concurrently:
┌────────────────────────────────────────────────────────────────────┐
│ Browser │
│ http://localhost:8090 ◄── you open this URL │
└──────────────────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ solar-ui (Go BFF) :8090 │
│ • handles /api/* → Kubernetes API (kind cluster) │
│ • proxies everything else → Vite dev server (HMR + assets) │
└─────────────┬──────────────────────────────────┬───────────────────┘
│ /api/* │ /, /assets/*, etc.
▼ ▼
┌──────────────────────────────┐ ┌───────────────────────────────┐
│ kind cluster `solar-ui-dev` │ │ Vite dev server :5173 │
│ • SolAr controllers/CRDs │ │ • React + HMR │
│ • Dex (OIDC) on :5556 │ │ • Tailwind, TanStack Router │
└──────────────────────────────┘ └───────────────────────────────┘
Always open http://localhost:8090 — not :5173. Hitting the Vite port directly will 404 every API call because Vite knows nothing about /api/*; the routing is done by the Go BFF via the --dev-vite-url flag (see pkg/ui/server.go).
The three commands¶
| Command | When to run | What it does |
|---|---|---|
make ui-dev-cluster |
Once, or after make ui-cleanup-dev-cluster |
Creates the solar-ui-dev Kind cluster, builds and loads dev images, installs SolAr, sets up Dex for OIDC. |
make ui-seed-data |
Once after creating the cluster | Seeds demo Target, Release, Component, etc. resources so the UI has something to render. |
make ui-dev |
Every dev session | Starts Dex port-forward + Vite dev server (:5173) + solar-ui BFF (:8090), wired together. |
Typical first-time flow¶
make ui-dev-cluster # ~5 min — builds images, sets up cluster, configures Dex
make ui-seed-data # ~10 s — creates demo resources
make ui-dev # open http://localhost:8090
Subsequent sessions¶
make ui-dev # auto-creates the cluster if missing, otherwise just starts
make ui-dev checks for the solar-ui-dev Kind cluster and re-runs ui-dev-cluster if it's gone. It also requires the Dex CA cert at test/fixtures/dex-ca.crt (generated by ui-dev-cluster).
Logging in¶
The UI uses OIDC against the in-cluster Dex. After opening http://localhost:8090, click through the Dex login. Static demo users are configured in test/fixtures/e2e/dex/dex-config.yaml and mirror the personas in Roles:
| Dex login | OIDC email (K8s identity) | Persona | Sees |
|---|---|---|---|
admin |
admin@solar.local |
Solar Operator (admin) | everything, plus can use "Preview as" |
acm |
app-catalog-maintainer@solar.local |
App Catalog Maintainer | Components, ComponentVersions in app-catalog-maintainer |
kcp |
k8s-cluster-provider@solar.local |
K8s Cluster Provider | Releases, Profiles, Registries, ReleaseBindings in k8s-cluster-provider; Targets in k8s-cluster-user; read-only catalog |
kcu |
k8s-cluster-user@solar.local |
K8s Cluster User | Releases, Profiles, Registries, ReleaseBindings in k8s-cluster-user; read/update Targets; read-only catalog |
All passwords are the literal string password. Cluster RBAC bindings live in test/fixtures/e2e/dex/dex-rbac.yaml (inlined from docs/developer-guide/manifests/).
Testing against the remote Zitadel¶
Production authenticates against Zitadel, not Dex. To point the dev UI at it:
make ui-dev-zitadel ZITADEL_USER=you@example.com
ZITADEL_USER is your Zitadel email. It becomes your Kubernetes username, and the target grants it cluster-admin in the dev cluster. Issuer and client ID default to the real ones (see Makefile); override ZITADEL_ISSUER, ZITADEL_CLIENT_ID or ZITADEL_REDIRECT_URL to point elsewhere.
Three things differ from the Dex flow:
- Public client with PKCE. The BFF holds no client secret; it authenticates the code exchange with an S256 challenge. Zitadel registers us as a native app, which is what permits the loopback redirect URI. PKCE is sent on every login regardless of IdP, so there is nothing to switch.
--auth-mode=impersonateby default. The BFF authenticates with the admin kubeconfig and impersonates you, so the cluster needs no OIDC configuration at all. Production uses token mode; to run the dev cluster the same way:
make ui-dev-zitadel ZITADEL_USER=you@example.com ZITADEL_AUTH_MODE=token
That runs hack/trust-zitadel-issuer.sh, which registers the issuer in the API server's authentication config (audience = client ID, email claim as the username) and waits for the hot reload. The Kind node needs egress and DNS to fetch the issuer's JWKS. The Dex issuer stays registered alongside it, so make ui-dev keeps working.
- No groups. Zitadel emits no
groupsclaim, and the BFF reads only that claim, so sessions come back with an empty group list. Nothing depends on it: cluster RBAC binds on theemailclaim, and the UI decides what to show by asking Kubernetes (SelfSubjectAccessReview/SelfSubjectRulesReview) rather than by inspecting groups. The application does needidTokenUserinfoAssertionenabled soemailandnameare in the ID token at all — the BFF never calls the userinfo endpoint.
Zitadel ignores the port of a loopback redirect URI, so if :8090 is busy you can run on another port as long as ZITADEL_REDIRECT_URL and the browser agree.
Namespace selector¶
The sidebar's namespace dropdown is the global scope for every list page (Targets, Releases, Components, Profiles, …). It has two modes:
- A specific namespace. Pages call
GET /api/namespaces/{ns}/{resource}; the BFF forwards the user's identity to the K8s API, RBAC decides what's visible. Live updates come over/api/namespaces/{ns}/events(SSE). - All namespaces. Pages call
GET /api/{resource}(no namespace path segment); the BFF forwards the user's identity to K8s' cluster-scope list. Watch events come over/api/events. This option is only shown to users who can list cluster-wide — see "Why 'All namespaces' may be hidden" below.
How the dropdown is populated¶
The user's token is not used to enumerate cluster namespaces — most personas don't have that permission. Instead the BFF runs a discovery proxy + per-user filter:
GET /api/namespaces
│
▼
┌────────────────────────────────────────────────────┐
│ solar-ui (BFF) │
│ │
│ 1. List all cluster namespaces │
│ ──► uses BFF's own kubeconfig / SA creds │
│ (privileged: needs cluster-scope │
│ 'list namespaces' on the BFF identity) │
│ │
│ 2. For each candidate, run │
│ SelfSubjectRulesReview(namespace=…) │
│ ──► uses the *user's* identity │
│ (every authenticated user may run this on │
│ themselves; no RBAC needed) │
│ │
│ 3. Keep namespaces where at least one rule covers │
│ `solar.opendefense.cloud` API group. │
│ │
│ Returns the filtered list. │
└────────────────────────────────────────────────────┘
This split has two consequences worth knowing:
- The user does not need
list namespacesRBAC to populate the dropdown. They only ever see namespaces where they have actual SolAr resource access;kube-system,default, etc. are filtered out. - The BFF process does need cluster-scope
list namespaceson its own identity. In the local Kind setup this comes from the admin kubeconfig thatmake ui-devpasses via--kubeconfig. In production, the BFF's ServiceAccount needs a matching ClusterRoleBinding.
Source: pkg/ui/api/handler.go::HandleListNamespaces.
Why "All namespaces" may be hidden¶
The selector hides the "All namespaces" option when the current identity can't satisfy a cluster-scope list namespaces SelfSubjectAccessReview. The check is cached on the session and invalidated whenever impersonation changes, so previewing as a persona correctly removes the option until the admin restores their real identity.
| Identity | Can pick "All"? | Why |
|---|---|---|
admin@solar.local |
yes | bound to cluster-admin |
| Any persona | no | persona RoleBindings are namespace-scoped |
| Admin previewing as persona | no | impersonated identity has no cluster-scope perm |
If the persisted "All" choice becomes invalid (impersonation switch, RBAC change), the selector falls back to the first namespace the user can still see.
Testing impersonation¶
Log in as admin@solar.local. The sidebar shows a "Preview as" form (only for admins — gated by a cluster-scope impersonate users check). Type one of the persona emails:
| Preview as | Expected views |
|---|---|
app-catalog-maintainer@solar.local |
Components/ComponentVersions visible only in the app-catalog-maintainer namespace. Targets/Releases/Profiles all 403 → the page shows the lock-screen explainer. Selector hides "All namespaces". |
k8s-cluster-provider@solar.local |
Releases, Profiles, Registries, ReleaseBindings in k8s-cluster-provider; Targets in k8s-cluster-user; catalog read-only. |
k8s-cluster-user@solar.local |
Targets read/update in k8s-cluster-user; Releases / Profiles / Registries in k8s-cluster-user; catalog read-only. |
| Any string K8s doesn't recognise | Form succeeds (BFF accepts whatever you type), but every subsequent list 403s — that's RBAC working as designed. |
What happens under the hood on each switch:
PUT /api/auth/impersonateupdatessession.ImpersonatingAs(and clears identity-dependent caches likeCanListAllNamespaces).- The frontend calls
queryClient.resetQueries()— all cached lists drop to a loading state instead of showing the previous identity's rows. - The new requests go out with the BFF's K8s client wrapped in K8s
Impersonate-Userheaders, so K8s evaluates RBAC as the previewed user. - The SSE EventSource is closed and re-opened on the new identity (it's re-keyed by
impersonatedUsernameinuseSSE).
"Stop previewing" in the sidebar calls DELETE /api/auth/impersonate and reverses all of the above.
Tearing down¶
make ui-cleanup-dev-cluster
This deletes the solar-ui-dev Kind cluster. The next make ui-dev-cluster rebuilds it from scratch.
Working on frontend code only¶
You don't need to rebuild images for frontend changes — Vite serves them with HMR. Edit any file under web/src/, save, and the browser updates. Restart make ui-dev only if you change Vite config, Tailwind config, or web/package.json.
Working on the BFF (Go) code¶
make ui-dev runs go run ./cmd/solar-ui, so each restart rebuilds the BFF. Stop with Ctrl-C and re-run make ui-dev. For API handler changes in pkg/ui/, this is the path.
Working on controllers / CRDs¶
If you change anything outside web/ and pkg/ui/ that ships in a container image (controllers, apiserver, renderer, discovery), the running cluster has stale images. Rebuild and reload:
make docker-build-local-images TAG=dev
make kind-load-local-images TAG=dev KIND_CLUSTER=solar-ui-dev
kubectl --context kind-solar-ui-dev rollout restart deployment -n solar-system
Common issues¶
404 on every /api/* request. You opened http://localhost:5173 instead of :8090. Use :8090.
Dex CA cert not found. test/fixtures/dex-ca.crt is missing. Run make ui-dev-cluster once to regenerate it.
UI dev cluster not found. Expected on a fresh checkout — make ui-dev will create it for you, or run make ui-dev-cluster explicitly.
Empty lists in the UI. You skipped make ui-seed-data, or you deleted the demo namespace. Re-seed.
OIDC redirect loop. Dex port-forward died. Check the dex pane in the concurrently output and restart make ui-dev.
"Failed to list namespaces" in the selector. Usually means you're hitting an old BFF binary that doesn't have /api/namespaces yet. The BFF is started once by make ui-dev and doesn't hot-reload Go code — restart with Ctrl-C and make ui-dev. If it persists, the BFF's identity is missing cluster-scope list namespaces (check the network response for a 403).
Selector is empty even though you're logged in. No namespace in the cluster has a RoleBinding granting the SolAr API group to your identity. Either log in as a persona with bindings, or kubectl apply -f test/fixtures/e2e/dex/dex-rbac.yaml to re-apply the demo RBAC. Personas only see namespaces where the SSRR returns at least one solar.opendefense.cloud rule.
"All namespaces" option is missing. Your identity doesn't have cluster-scope list namespaces — see the table in Why "All namespaces" may be hidden. For personas this is expected; pick a specific namespace.
Page shows "No cluster-wide access to X". You're in "All namespaces" mode but K8s rejected the cluster-wide list. Most often this is mid-impersonation: the previewed identity can't do a cluster-scope list. Switch to a specific namespace from the sidebar.
Running tests¶
make ui-lint # ESLint
cd web && pnpm test # Vitest unit tests
make ui-test-e2e # Playwright e2e (uses a separate `solar-test-e2e-ui` cluster)
In CI the Playwright suite runs as the test-ui-e2e job in .github/workflows/test-e2e.yaml, in parallel with the Go e2e job and gated by the same trigger (push to main, a release, or a PR labelled ok-to-e2e / ok-to-image). It reuses the images docker.yaml pushed to GHCR instead of building them, failures are annotated in the PR diff by Playwright's github reporter, and the HTML report is attached to the run as the playwright-report artifact.
The two e2e suites keep separate Kind clusters on purpose: solar-test-e2e-ui is created with an OIDC-enabled apiserver plus Dex and needs one stable SolAr install for the whole run, while the Go suite installs and uninstalls SolAr repeatedly. Sharing a cluster would serialise both suites and couple their failures.
See also: UI Architecture ADR.