This document tracks the rollout of the openctl browser UI — the typed,
form-driven console that sits on top of the controller's gRPC API. Mark
deliverables as [x] as they land. Architectural decisions are pinned at
the top so future-self can trace any line of code back to the choice that
motivated it.
The UI is an independent track from the controller's architectural Phase 7+ work (verifying-trace cache, typed task IR, DAG scheduler). They can interleave; the UI consumes whatever the controller reports and benefits from the architectural work but doesn't block on it.
These were settled via design walkthrough with the user before any code went in. Any change requires re-opening the discussion.
- Controller SQLite is canonical.
applied_manifestsand the resource state are the truth. The UI is a typed editor over that truth, not a separate source. - Manifests on disk are a materialized output, not an input. The controller writes the desired manifest to a configured directory on every successful apply/delete. The UI and CLI both read this directory for "show me my config in git" purposes.
- Git is one-way for v1. controller → disk → git. Manual file edits don't trigger reapply. Two-way GitOps (file change → reconciler picks up) is a future goal.
- Two affordances over one manifest. Form view and CUE/YAML view. Toggle between them; the form view is disabled if hand-edits in the CUE pane can't be round-tripped through the form schema.
- Same destructive gates as CLI.
--allow-destructiveand--i-know-this-breaks-the-clustersurface as explicit checkboxes; Apply blocks until they're checked when the change requires them. - Composite resources: the parent is editable; children are read-only, rendered as a tree underneath. Per-child drift/health badges aggregate into the parent badge.
- Apply preview: before submit, the UI shows what's about to happen
(add VM-x, remove VM-y, leave others). Mirrors
terraform plan.
- Frontend: Vite + Svelte. Compact, reactive, manageable build.
- Transport: gRPC over the existing 9444 port for live streams
(Watch), plus a
grpc-gateway-generated REST/JSON gateway for request/response RPCs. Same protos, two surfaces. - Asset hosting: built UI is embedded as
embed.FSinopenctl-controllerand served from/ui/*. Single binary, single install command.make uiruns the Vite build into the embed dir. - Auth: bearer token exchanged for an
HttpOnly,Secure,SameSite=Strictsession cookie. Cookie has the same authority as the bearer token. Single-user for v1; multi-user (named sessions, per-user tokens, RBAC) is future work — but the cookie/session layer is designed up front to leave room.
- CUE AST → form schema bridge. A direct walk of the CUE AST produces the form schema. Faithful: disjunctions, constraints, defaults, and conditional fields all round-trip. Lossy intermediates (CUE → OpenAPI → form) are explicitly rejected.
- Server-side validation for v1. The UI calls
SchemaService.Validateon debounced edits and renders errors as Monaco markers. The validation path is the same code the controller uses at Apply time. - Client-side WASM validation is a future enhancement. Ships once edit latency becomes a real complaint; not before.
- Materialize on apply success. Dispatcher writes
<manifest-dir>/<apiVersion>/<kind>/<name>.yamlafterapplied_manifestsis updated. Removes the file on delete success. - Default location:
~/.openctl/manifests/. Configurable in~/.openctl/config.yaml. - Git is opt-in. When enabled, the controller initializes the dir as
a git repo on first start (or no-ops if already one), commits after
every materialize/delete with a message like
apply VirtualMachine/foo via UIor... via CLI. - Remote push is optional. Config carries an optional remote URL and
push cadence (e.g.
onCommit,every:5m,manual). Push failures are logged but never block the apply. - UI surface: a "Git status" indicator (clean/dirty/ahead-of-remote) and a manual "Push now" button.
- Two-way GitOps (file edits don't trigger reapply).
- Multi-user auth (OIDC, RBAC, named sessions).
- Provider credential editing via UI (read-only; operator edits file + restarts controller).
- Cancel of
runningops (onlypendingops cancelable in U7). - Client-side CUE WASM validation.
- UI for controller config (UI reads provider config from disk silently; surfaces deferred).
- Resource diff against arbitrary historical commits (only against currently-applied manifest).
Status: complete. (This foundation shipped first — the marker simply lagged; every deliverable is in the running controller, and Phases U2–U9 are built on top of it.)
Goal: make the existing gRPC API browser-reachable and add the
streaming/schema/asset surfaces the UI needs. Verifiable entirely via
curl/grpcurl — no frontend in this phase.
Deliverables:
-
Watch(stream WatchEvent)RPC onResourceService: streams add/modify/delete events plus drift updates. First-pass implementation polls Get/List internally; replace with notification hooks from the dispatcher later. (api.protorpc Watch;server/watch.go.) -
WatchOperations(stream OperationEvent)RPC: streams op state transitions and substep updates. Filter by operation id, resource ref, or status. (api.protorpc WatchOperations.) -
SchemaServiceproto withListSchemas,GetSchema(kind), andValidate(manifest)RPCs. Returns the embedded CUE schema text plus runs the same validation path the controller uses at Apply. (api.protoservice SchemaService.) -
grpc-gatewayannotations on existing protos + a REST/JSON gateway mounted alongside gRPC. (google.api.httpannotations throughoutapi.proto; generatedapi.pb.gw.go; served over HTTP/2+TLS.) -
SessionService:Login(bearer_token)→Set-Cookie: openctl_session=...; HttpOnly; Secure; SameSite=Strict.Logoutrevokes. Session storage in SQLite (sessionstable) so it survives restart. Sessions carry an internal user id even though v1 only has one user — leaves room for multi-user without a schema migration. (api.protorpc Login;server/session.go; role-carrying sessions extended later by the RBAC work.) -
embed.FSof UI assets incmd/openctl-controller, served from/ui/*. Returns a friendly "UI not built — runmake ui" page when the embedded dir is empty. (server/uiassets/;server/http.go.) - Tests: Watch streams emit when a sibling client applies; session
cookie round-trips; SchemaService returns embedded CUE; REST
gateway reaches all CRUD RPCs. (
server/watch_test.go,session_test.go,http_test.go.)
Verifiable: grpcurl -plaintext localhost:9444 openctl.v1.ResourceService/Watch
emits an event when another terminal does openctl ctl apply -f vm.yaml;
curl --cookie-jar c.txt --data '{"token":"..."}' http://localhost:9444/v1/session
returns a cookie that subsequent curl --cookie c.txt /v1/resources
requests use without re-auth.
Status: complete.
Goal: materialize the controller's desired state to disk so it's visible to users outside the UI, with optional git tracking.
Deliverables:
-
internal/controller/manifests/disk.go: wraps the existing manifests Store with a "write to disk on commit" side-effect. Files go to<manifest-dir>/<apiVersion>/<kind>/<name>.yaml(apiVersion's/becomes a nested directory; names path-scrubbed so hostile inputs can't escape the root). Atomic write via temp + rename. Hook-point lets the git layer plug in without disk.go knowing about git. - Dispatcher integration: on apply success, write the manifest;
on delete success, remove the file. On startup, reconcile disk
against
applied_manifests— missing-on-disk rows get re-materialized; orphan files (no applied_manifests row) are logged but never deleted (user may have committed them). - Config schema:
manifests: { dir, git: { enabled, branch, remote, pushMode, pushInterval } }in~/.openctl/config.yaml. - Git integration (
internal/controller/manifests/git.go):git init -b <branch>on first start when enabled (idempotent if already a repo),git add -A+git commit -m "..."after every materialize/delete with the structured messageapply <kind>/<name> via <source>. Source ("CLI"/"UI") comes from gRPC metadata: the HTTP gateway middleware injectsx-openctl-source: uion every browser-proxied request; direct gRPC calls default to "CLI". - Optional remote push:
manifests.git.remotepluspushMode(onCommitdefault when remote is set,periodic,manual). Periodic usespushIntervalparsed as atime.Duration. Push failures are logged but never bubble back into the dispatcher. -
RepoServiceproto:GetStatus(enabled, dir, branch, head_sha, clean, dirty_paths, ahead/behind, push_mode),Push,Pull(advisory — does NOT trigger reapply in v1). Wired into both gRPC and HTTP gateway; UI consumes in U3. - Op source persisted on the
operationsrow (migration0007_op_source.sql); dispatcher attaches it to context viamanifests.WithSource(ctx, op.Source)so the git hook can use it after Save/Delete. - Tests: round-trip materialize/delete, atomic overwrite (no leftover .tmp files), path-traversal scrubbing, startup reconciliation behavior, commit-message formatting, source propagation via metadata, nothing-to-commit swallowed cleanly, RepoService.Push/Pull preconditions, ahead/behind reporting.
Verifiable: apply a VM via CLI, see ~/.openctl/manifests/proxmox.openctl.io/v1/VirtualMachine/foo.yaml
appear with a matching git commit. Delete the VM, see the file gone and
a deletion commit. With remote configured, see the commit reach origin.
Status: complete.
Sub-phases:
- U3.1 — Vite + Svelte + TypeScript project under
ui/; Vite writes tointernal/controller/server/uiassets/dist/(embedded by//go:embed all:uiassets/dist);make uiorchestrates install + build +.gitkeeprestore; login screen exchanges the root bearer token for an HttpOnly session cookie viaPOST /v1/session/login;WhoAmIconfirms the session on first load and post-login; Logout button + 401 handling routes back to login. Hand-rolled REST client over the grpc-gateway surface (60 LoC, no protoc dep); generated client deferred until U3.4 (streaming Watch RPCs) when typed schemas pay for themselves. - U3.2 — Layout shell with header (logout) + left nav grouped
by provider + main pane; tiny hash-based router (
#/k/<apiVersion>/ <kind>[/<name>], no SPA framework); kind catalogue derived fromSchemaService.ListSchemaswith per-kind live resource counts (parallelListfan-out); per-kind resource list table with state/drift badges.stateadapter centralises the proxmox-vs-k3s status-key inconsistency (status.statevsstatus.phase). Bottom ops drawer deferred to U3.4 alongside Watch wiring; detail pane deferred to U3.3. - U3.3 — Resource detail pane: desired manifest (from
applied_manifests), observed state (provider Get), drift diff
table, last-applied timestamp. Proto extension: GetResponse now
carries
applied(Resource) +applied_at(RFC3339). Server loads from manifests.Store via newLoadWithTimehelper that preserves the existing Load signature. Owner-ref + composite children tree deferred — needs proto-level relationship surface (planned alongside arch Phase 8 K3sNode work). - U3.4 — Ops drawer + live Watch streams. Streaming bridge
(
lib/stream.ts) reads grpc-gateway's newline-delimited{"result": <event>}chunks overfetch+ReadableStream, cancellable via AbortSignal. Typed wrappers (watchResources,watchOperations) feed: ResourceList (initialListsnapshot + Watch deltas merged in by name), Detail (single-resource Watch triggers re-Get so applied/ drift refresh, not just observed), and a shell-wide ops store (lib/ops.ts) that drives a collapsible bottom drawer with the last 200 ops, in-flight count, and per-op status/error/resource links. Reconnect-with-backoff on transient errors; AbortError on route change is silenced. - U3.5 — Git status indicator in the chrome
(
components/GitStatus.svelte): pollsRepoService.GetStatusevery 10s and renders a colour-coded pill (clean/dirty/behind); Push-now button shows when a remote is configured and there's something to push (or push_mode=manual). Catalogue counts now live-update via one Watch stream per kind — ADDED grows the count, DELETED shrinks it, MODIFIED is a no-op. Vitest harness (happy-dom env) with unit tests for the streaming bridge (chunk reassembly, error envelope, auth), the router (apiVersion splitting, encoding round-trips), and the status-badge format adapter. Playwright headless-Chrome e2e is explicitly deferred — ~200MB of browsers + non-trivial CI is too much weight for a homelab project; revisit if/when collaboration warrants it.
Deliverables:
- Vite + Svelte project under
ui/with embed pipeline. TypeScript on. Hand-rolled REST client for now; generated client revisited in U3.4. - Login screen: bearer token in → session cookie out. Logout button. Token never persisted in JS (cookie is HttpOnly); "signed in as <userId>" with session-id-on-hover replaces the planned last-4-chars hint since the JS layer can't see the cookie value.
- Layout shell: left nav grouped by provider/kind with resource counts; main pane. Bottom drawer for op history deferred to U3.4 (lands with Watch streams).
- Resource list per kind: name, state badge, drift badge. Click → detail. Last-applied timestamp surfaces on the detail pane (not the list) via GetResponse.applied_at; adding it to List would require N joins on applied_manifests per row.
- Resource detail (read-only): desired (applied) manifest, observed state, drift diff. Owner-ref + children tree deferred — see U3.3 sub-phase note above.
- Operations drawer: collapsible bottom pane fed by WatchOperations. Each row links to its resource detail. Substep checklist comes with U7.
- Live updates throughout — ResourceList, Detail, ops drawer, AND catalogue counts all subscribe to Watch streams. The shell needs no refresh button.
- Git status indicator + manual Push-now button (U3.5).
- Tests: unit-level Vitest covers stream/router/format. Playwright headless-Chrome e2e deferred — see U3.5 sub-phase note for reasoning.
Verifiable: make ui && openctl-controller serve →
http://localhost:9444/ui/ shows the console. Apply a VM via CLI; see
it appear without refresh. Take it down in Proxmox; see drift surface
in <2s.
Status: complete.
Goal: "kubectl edit" in a browser, with live validation against the real schema.
Sub-phases:
- U4.1 —
ResourceService.DryRunApplyRPC server-side. Optionalproviders.DryRunnerinterface lets composite providers expose per-child verbs (create/destroy/respec/no-op) and required destructive gates (allow_destructive,i_know_this_breaks_the_cluster). Atomic providers don't implement it; the handler still returns the spec-level diff against the persisted applied manifest. Schema validation errors surface inline invalidation_errors(not as RPC errors) so the editor can mark them without a second roundtrip. k3sClusterprovider wired up — reusescomputeChangePlan+computeSpecRespecs+catastrophicReasonso the gates fire on the same conditions Apply would. - U4.4 — Monaco diff view. Tab toggle in the edit pane
("Editor" / "Diff vs applied"). Diff is read-only and uses
Monaco's
createDiffEditorwith the applied manifest on the left and the current edited text on the right. Tab is disabled when there are no unsaved changes and auto-snaps back to Editor view if the user reverts to baseline while on Diff. Shares the lazy Monaco bundle with U4.2 (no extra download cost). Closes UI Phase U4. - U4.3 — Apply panel inline in the edit pane. One debounce fires Validate + DryRunApply in parallel; the preview pane lists the spec diff (current → will become), per-child verbs (create/destroy/respec/no-op with colour-coded pills), and the provider's summary. Required gates render as labelled checkboxes in an amber-bordered "destructive change" card; Apply stays disabled until every required gate is checked and there's a real change to make. On Apply, the request includes the gate flags and returns an op_id; we tail the existing shell-wide ops store (no double WatchOperations) and surface a coloured op card — pending/running/succeeded/failed/interrupted. On succeeded we navigate back to detail after a 600 ms green flash; on failed the apply card surfaces the error.
- U4.2 — Monaco editor wired into a new
/edit/...route. Lazy-loaded — the editor + YAML language ship in their own chunks that only download on first /edit visit; list/detail/home stay light (~180 KB index). Pre-fills fromGetResponse.applied(falls back to a skeleton built from observed state when no manifest is on file). 350 ms debounce on edits → YAML parse → if shaped right,SchemaService.Validate→ errors surfaced as Monaco markers and listed in a diagnostics card below the editor. "Discard" reverts to the applied baseline; "Apply" is wired into the UI but disabled until U4.3 (with a tooltip pointing to it). Hash route gains#/k/.../<name>/edit. Detail pane gets an "Edit" button.
Deliverables:
- Monaco editor integration. YAML mode for the editor surface (CUE doesn't ship a Monaco grammar; if/when manifests graduate to raw CUE, add a custom grammar).
- Debounced server-side validation via
SchemaService.Validate. Errors render as Monaco markers with hover detail. - Side-by-side diff vs. currently-applied manifest (Monaco's diff editor).
- Apply panel: destructive/i-know checkboxes that surface
conditionally based on what the change requires (UI calls a new
ResourceService.DryRunApplyRPC to learn which gates apply); submit → live op progress inline. - Cancel/discard reverts the editor to the applied manifest.
- New
DryRunApplyRPC on the controller: runs the same change-plan logic as Apply (computeChangePlan,catastrophicReason) without actually applying. Returns the diff and the list of required gates. Used by both the editor and the form view. - Tests: validation roundtrip latency budget, gate surfacing for no-op/scale-down/catastrophic cases, op progress streams through to the apply panel.
Verifiable: edit a VM's memory in the UI → apply → controller
errors with the same drift-on-atomic message the CLI surfaces.
Delete + re-create via the editor works end-to-end. Editing a Cluster
to scale workers down surfaces the --allow-destructive checkbox.
Status: complete.
Goal: form-driven creation/editing for users who don't want to write CUE. AWS-console-shape.
Sub-phases:
- U5.4 — Form ↔ CUE round-trip detection.
extraKeys(schema, data)walks the form schema and parsed YAML to find dotted pathsdatacarries that the schema can't represent (unknown keys inside objects, unknown fields inside array items). When the list is non-empty the Form view tab is disabled with a tooltip listing up to 5 offending paths; if the user is already on Form and an external edit introduces an unknown key, the view auto-snaps to Editor so the form can't silently drop it. Map keys are intentionally NOT flagged —{[string]: T}accepts anything by design. Closes Phase U5. - U5.3 — Advanced field types. CUE walker detects:
string-literal disjunctions (
"a" | "b" | "c"→enumfield array; renderer shows a<select>), regex constraints (=~"..."→patternstring; renderer attaches the HTML pattern attr + a:invalidred border + atitlehover with the regex), and{[string]: T}maps → newFieldMaptype withvalueType; renderer surfaces a key/value add-row editor with collision-safe key edits and per-row remove. Stepped sections deferred (none of the shipped schemas demand them — single scrollable form is fine for the homelab use). Disjunctions over non-literal types (e.g.string | int) still emit{type:"unsupported"}— that's discriminated-union territory and lands later if/when a shipped schema needs it. - U5.2 — Svelte form renderer with live YAML preview. The
edit pane gets a three-way view toggle: Form / Editor / Diff.
Form view loads the U5.1 Field tree via
schemas.getForm(av, kind), seeds state from the currently-applied manifest viafromManifest, and renders a recursiveFormField.sveltethat dispatches onfield.type: text input (string), number input with min/max/step (int/number), checkbox (bool), recursive object/array, freeform textarea (any), greyed-out tile (unsupported). Array fields get add/remove row controls; const fields render read-only. Edits in the form drive the sametextstate the editor uses, so Validate + DryRunApply preview + Apply keep working unchanged. The right pane shows the live-generated YAML manifest.scrubEmptydrops empty optional fields from the generated manifest so the preview stays clean. - U5.1 — Form schema Go package +
SchemaService.GetFormSchemaRPC.internal/schema/formwalks a CUE value into a typedFieldtree (strings/ints/numbers/bools/objects/arrays/any/ unsupported) carrying optional+required, defaults, number bounds (>=, <=, >, <), const literals (pinned values likeapiVersion), and CUE doc comments. RPC returns the tree as JSON-in-a-string so the proto stays non-recursive; the browser parses and renders. Same SchemaSelector the validator uses → form + Validate always agree on which CUE def to consult. 10 unit tests cover each construct, including a round-trip against the real shipped k3sClusterschema. Unsupported constructs (free disjunctions, regex patterns, key-value maps) deferred to U5.3 — they emit a{type:"unsupported", reason:"..."}leaf the renderer can grey out.
Deliverables:
-
internal/schema/formpackage: walks the CUE AST and produces a typed form schema. Handles scalars, strings, numbers (with range constraints), bools, arrays (homogeneous), nested structs, optional fields, defaults, and const literals. Regex patterns + enums + free disjunctions deferred to U5.3 (emitunsupportedleaves for now). -
SchemaService.GetFormSchema(kind)RPC returns the form schema. - Form renderer in Svelte: recursive
FormField.sveltewith sensible defaults from CUE defaults. Stepped sections deferred to U5.3; current renderer is a single scrollable form. - Live preview pane: the manifest the form is currently generating, rendered as YAML, updates as you type.
- Toggle between form view and CUE editor view (same underlying manifest, three-way: Form / Editor / Diff). Switching Editor → Form re-seeds state from the parsed YAML. Non-roundtrippable hand-edits disable the Form tab with a tooltip listing the offending paths (U5.4).
- Review-before-apply screen showing the diff (via
DryRunApply) and the same destructive gates as U4. - Tests: form-schema bridge unit tests covering every CUE construct we use; round-trip tests (manifest → form state → manifest) for every shipped kind; e2e create-via-form for VirtualMachine and Cluster.
Verifiable: create a new VM entirely through the form, see it
apply, see it in the list. Edit a Cluster's worker count via the form,
hit apply with --allow-destructive checked, see workers drop.
Status: complete.
Goal: clusters feel like a first-class object, not "a parent and some opaque children."
Deliverables:
- Cluster detail page: parent manifest (form or CUE) + children
tree underneath, with per-child drift/health badges. Detail.svelte
fans out one
resources.getper child and renders the child's state pill + a drift-count pill alongside the kind/name link. - Children are read-only, clickable to drill into their own (read-only) detail page. Owner card on the child's detail page explains the relationship; Edit.svelte additionally blocks Apply on owned resources with a banner pointing to the owner ("Edit X instead →") so the read-only constraint is enforced, not just explained.
- Aggregated badges: parent header carries a "N children · M drifted · K unhealthy" pill whose tone reflects whether any child needs attention. Counts ticks up live as the per-child fanout completes.
- Apply preview shows per-child verbs (shipped in U4.3). U6
additionally links each existing child's name in the preview
to its detail page so the user can drill into the resource
that's about to be destroyed/respec'd.
createverbs intentionally don't link (target doesn't exist yet). - Tree → DAG view (later, gated on architectural Phase 7+): the
same tree, rendered as a graph with dep edges. Becomes useful
once
K3sNodeand per-step ops exist. Deferred. - Tests: parent edit doesn't accidentally write to children; aggregation logic for mixed drift states; preview matches the actual apply behavior. Deferred — the aggregation logic is small enough that unit tests would mostly re-test Svelte; manual smoke catches it.
Verifiable: edit a Cluster to scale workers 1→2 — preview shows
"add 1 worker VM"; apply; children tree updates live as the new VM
moves through pending → running.
Status: complete.
Goal: apply-progress UX that doesn't make you guess.
Deliverables:
- Per-substep progress for composite operations. ops.ts subscribes
with
includeChildren: true; expandable parent rows in the ops drawer render the child op list as a live status checklist, each row showing its own state pill + label + (when applicable) a link to the child's resource detail. -
CancelOperationRPC on the controller. NewStatusCancelledop status (distinct fromfailedso the UI can show intentional cancellation differently);Store.CancelPendingatomically flipspending → cancelled; running ops return FailedPrecondition with a "cooperative cancellation of running ops is not supported in v1" explanation. UI exposes a per-row Cancel button on pending ops in the drawer. - Retry / re-apply for
failed,interrupted, andcancelledops. GetOperation now returns the submittedmanifest_json; the ops drawer's Retry button uses asessionStoragehandoff to tell Edit.svelte which op id to re-load, and Edit.svelte fetches that op + seeds the editor with the exact failed YAML (with a "Pre-filled from operation ..." banner so the user knows the diff is against the failed payload, not the applied baseline). List and Watch keepmanifest_jsonempty to stay cheap. - Op history filtering.
ListOperationsRequestacceptssource(CLI / UI),since, anduntil(RFC3339). The ops drawer surfaces status + source + text-search controls that filter the in-memory stream client-side; the server-side filters are exposed for future "load older history" pagination. - Tests:
Store.CancelPending(pending succeeds, running refuses, missing → sql.ErrNoRows), gRPC handler (NotFound for missing, FailedPrecondition for running, success for pending); ListOperations source + time-range filters. Substep checklist rendering covered by manual smoke — the path through Svelte was not worth a happy-dom Vitest scaffold.
Verifiable: apply a 3-node cluster, see VM creates progress
one-by-one in the UI checklist. Kill the controller mid-apply, restart,
see op marked interrupted with a "retry" button. Cancel a pending op
before the dispatcher picks it up.
| Phase | Hard prereq | Strong nice-to-have |
|---|---|---|
| U1 | — | — |
| U2 | U1 | — |
| U3 | U1, U2 | — |
| U4 | U3 | — |
| U5 | U3 | 5.x (count-up + spec changes) |
| U6 | U5 | 5.x, 4.5 |
| U7 | U3 | 4.5 (parent-child ops) |
4.5 → 5.x → U1 → U2 → U3 → U4 → U5 → U6 → U7, interleaving
architectural Phase 7+ between U3 and U4 if you want the IR/DAG
groundwork before the form/composite work.
Tracked here so they don't get lost; not blocking the core UI rollout.
- Two-way GitOps: file edits trigger reconciler reapply. Requires conflict resolution (UI edit vs file edit), watch on the manifest dir, and a "GitOps mode" toggle.
- Multi-user auth: OIDC integration, named sessions in the UI,
RBAC on
ResourceService. Cookie/session layer already in place. - Provider credential editing: surface
~/.openctl/config.yamlproviders in the UI with secret-write affordances. - Cancel of running ops: cooperative cancellation hooks in proxmox and k3s providers.
- Client-side CUE WASM validation: faster editor diagnostics.
- Historical diff: diff a resource against arbitrary commits in the manifest repo.
- UI for controller config: tunable retention, dispatcher concurrency, etc.
- Mobile-friendly layout: not a v1 concern but worth flagging.