Skip to content

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

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=impersonate by 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 groups claim, and the BFF reads only that claim, so sessions come back with an empty group list. Nothing depends on it: cluster RBAC binds on the email claim, and the UI decides what to show by asking Kubernetes (SelfSubjectAccessReview / SelfSubjectRulesReview) rather than by inspecting groups. The application does need idTokenUserinfoAssertion enabled so email and name are 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 namespaces RBAC 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 namespaces on its own identity. In the local Kind setup this comes from the admin kubeconfig that make ui-dev passes 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:

  1. PUT /api/auth/impersonate updates session.ImpersonatingAs (and clears identity-dependent caches like CanListAllNamespaces).
  2. The frontend calls queryClient.resetQueries() — all cached lists drop to a loading state instead of showing the previous identity's rows.
  3. The new requests go out with the BFF's K8s client wrapped in K8s Impersonate-User headers, so K8s evaluates RBAC as the previewed user.
  4. The SSE EventSource is closed and re-opened on the new identity (it's re-keyed by impersonatedUsername in useSSE).

"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.