Minikube cluster registration — fix command-before-Save UX

Goal & scope

The "Add Cluster" flow for the minikube self-service cluster type (docs/plans/done/remote-cluster-provisioning-and-registry.md) currently shows unhelpful "click Save to get a command" text, then only reveals the copy-paste command after Save has already created a pending Cluster row server-side. That's backwards from both the plan's own stated intent ("on selecting this type, triggers pending-registration creation, then shows the command") and from what's actually useful: the admin wants the command as soon as they've decided this is a minikube cluster, wants to see when it's landed, and wants Save to be the last step, not the first.

Fix the ordering: select type → get command immediately → run it → see confirmation it landed → optionally test connection → Save.

Getting this right also means changing where the Cluster row gets created. Today admin_create_cluster creates the row (and issues a registration token) as part of the admin's Save click — i.e. before the command has even been shown. This plan moves both token generation and row creation out of that path entirely:

This is a deliberate simplification over the original plan's "backend pre-creates a pending row + short-lived token at Save time" design. A row that's created but never claimed is cheap and inert — it does nothing, dispatches nothing, and doesn't need an expiry to bound its lifetime. So token expiry (registration_token_expires_at) is dropped entirely; validity relies purely on the token's randomness (client-generated, sufficient entropy), same as any bearer credential.

Out of scope: everything else in the original plan (registry addressing, the setup script itself, Kueue/RBAC provisioning, same_as_backend/kubeconfig cluster types) is unchanged. This is purely a reshuffling of when the Cluster row and its token come into existence, and the corresponding frontend flow.

Background — current state

(See docs/plans/done/remote-cluster-provisioning-and-registry.md for full prior design/rationale; summarizing only what this plan changes.)

Design decisions (settled in discussion)

  1. Token generated client-side, no backend call to show the command. crypto.randomUUID() (or equivalent CSPRNG source) generated in MinikubeClusterForm the moment "Minikube" is selected. The command is built entirely in the browser: `${ABSOLUTE_API}/static/assets/setup-minikube-remote.sh` piped through REGISTER_TOKEN=<token> bash, identical shape to today's server-built string. No round trip, no latency, no dependency on Name/Namespace being filled in yet.
  2. No pre-created row. The callback endpoint creates the Cluster row lazily, the first time it sees a token hash it doesn't recognize (registration_token_hash no longer set by admin_create_cluster for minikube — that branch goes away). On an unrecognized token: create Cluster(cluster_type="minikube", name=<placeholder>, namespace=<default>, active=False, provider_config=<posted payload>), run test_connection same as today, set provisioning_status to "pending" (success — config landed, awaiting admin activation) or "failed" (test failed), and keep registration_token_hash set (do not clear it) so the frontend can still find this row by polling and so a re-paste of the same command is a recognized, idempotent update rather than a second row.
  3. No token expiry. Drop registration_token_expires_at (column removed via migration — see Phase 4) and the REGISTRATION_TOKEN_TTL_MINUTES expiry check in the callback. An unclaimed pending row from an abandoned or never-run command just sits there, inactive, forever — it's already an accepted gap that stale rows are harmless (no delete-cluster endpoint exists either); this removes the need to reason about a time bound on top of that.
  4. New polling endpoint: GET /admin/clusters/by-registration-token?token=<raw> (admin-session-authenticated, same require_admin as the rest of backend/routers/admin.py). Hashes the raw token server-side (same SHA-256 helper as the callback) and looks up a Cluster row with a matching registration_token_hash, returning its admin dict (id, name, provisioning_status, namespace, ...) or 404 if no such row exists yet. The MinikubeClusterForm / ClusterFormModal poll this on an interval (e.g. every 3s) once a token has been generated and no match has been found yet, and stop polling once found.
  5. "Config exists" indicator. Once the poll finds a match, show a success line/icon under the command (e.g. "✓ Configuration received") and reveal the (previously hidden, for minikube) "Test Connection" button, now targeting the discovered cluster_id.
  6. Save = claim + normal field update. Once a row has been discovered via polling, clicking Save no longer calls createMutation — it calls the existing updateMutation (PATCH /admin/clusters/{id}) against the discovered cluster's id, sending whatever Name / Namespace / Sort Order / Max Runtime the admin has filled into the rest of the (still fully editable) form, plus active: true. This is the point the row actually goes live/dispatchable, and where its placeholder name gets replaced with whatever the admin typed. admin_update_cluster should also clear registration_token_hash on this transition (no longer needed once claimed).
  7. Cluster Type moves above Name/Namespace/etc. in the modal. Since choosing "Minikube" is now the trigger for showing the command — no other field needs to be filled first — the natural reading order is type first, then the rest. (Flag for final review when the plan is read back — this is a layout call, easy to reverse if it reads worse in practice.)
  8. Placeholder name for a callback-created row: something like f"minikube-{token[:8]}" — distinguishable in the admin cluster list, harmless if never claimed, always overwritten by Save's PATCH once claimed.

Phase 1 — Backend: lazy row creation in the callback

Phase 2 — Backend: polling endpoint + claim-on-update

Phase 3 — Frontend: generate token, build command, poll, claim

Phase 4 — Migration: drop unused token-expiry column

Manual verification

Open questions / follow-ups