feat(catalyst): sortable deployments list + two-mode delete (Fix #178) (#1382)

Adds operator-friendly admin controls to /sovereign/deployments:

* Sortable column headers — click any of FQDN / Status / Started /
  Finished / Region to sort the table; second click toggles ASC↔DESC.
  Default is Started DESC (newest first). Sort is client-side; the
  list is small enough that round-tripping via ?sort= would only add
  latency without operator benefit.

* Per-row Delete button → opens DeleteDeploymentModal with TWO modes
  via a radio group:
  1. "Delete record only (mother)" — DELETE /api/v1/deployments/{id}.
     Removes the catalyst-api row (in-memory map + on-disk store +
     kubeconfig file) but LEAVES THE HETZNER SOVEREIGN RUNNING.
  2. "Delete record AND wipe Sovereign (kill the kid)" — POSTs to
     the existing /wipe endpoint (tofu destroy + Hetzner orphan
     purge + PDM release + record cleanup in one pass).

  Both modes require typing the deployment FQDN to confirm (same
  safety pattern WipeDeploymentModal uses, per Fix #46 / #914).
  Deep-delete additionally requires the Hetzner token, which flows
  straight through to the wipe handler (S3 + Hetzner creds never
  logged, per principle #10).

Backend:
* New DeleteDeployment handler (record-only). Refuses adopted (422)
  + in-flight (409) + unknown (404, matching the issue #689
  anti-enumeration posture). Idempotent: a second DELETE on a
  vanished row returns 404 cleanly.
* Route wired in cmd/api/main.go alongside the existing /wipe and
  /release-subdomain endpoints, inside the session-required group.
* 5 unit tests covering happy path / adopted / in-flight / unknown /
  terminal-wiped paths.

Frontend:
* DeploymentsList now mounts the new modal and invalidates the
  React Query cache (`catalyst, deployments, list`) on success so
  the table refreshes without a hard reload.
* 8 unit tests covering default sort order, header-click sort
  switching, ASC↔DESC toggle, status sort, delete button rendering
  (enabled for terminal rows, disabled for in-flight), modal open
  with both radios, conditional Hetzner-token field per mode.

Files:
* products/catalyst/bootstrap/api/internal/handler/deployments_delete.go
* products/catalyst/bootstrap/api/internal/handler/deployments_delete_test.go
* products/catalyst/bootstrap/api/cmd/api/main.go (route)
* products/catalyst/bootstrap/ui/src/components/CrudModals/DeleteDeploymentModal.tsx
* products/catalyst/bootstrap/ui/src/components/CrudModals/index.ts (export)
* products/catalyst/bootstrap/ui/src/pages/sovereign/DeploymentsList.tsx
* products/catalyst/bootstrap/ui/src/pages/sovereign/DeploymentsList.test.tsx

Co-authored-by: e3mrah <1234567+e3mrah@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-11 12:33:52 +04:00 committed by GitHub
parent d134c538c9
commit 67eae51587
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1061 additions and 15 deletions

View File

@ -710,6 +710,13 @@ func main() {
// silently overridden when a session is present.
rg.Get("/api/v1/deployments", h.ListDeployments)
rg.Get("/api/v1/deployments/{id}", h.GetDeployment)
// Record-only delete (issue #178). Removes the deployment from
// the in-memory map + on-disk store + kubeconfig file. Does NOT
// touch Hetzner — for the "kill the kid" path the operator's UI
// POSTs /wipe instead (which already chains record-delete on
// success). Refuses adopted (422) + in-flight (409) deployments
// to keep the customer breadcrumb + Commit safety intact.
rg.Delete("/api/v1/deployments/{id}", h.DeleteDeployment)
rg.Get("/api/v1/deployments/{id}/logs", h.StreamLogs)
// Buffered event history endpoint (issue #180). Returns the full event
// slice + state JSON so the wizard's ProvisionPage can render history

View File

@ -0,0 +1,173 @@
// deployments_delete.go — record-only delete for the operator's
// deployments admin list (issue #178).
//
// Two delete modes are exposed to operators on the /sovereign/deployments
// page:
//
// - "Delete record only" — calls DELETE /api/v1/deployments/{id} (this
// file). Removes the deployment from the in-memory map + on-disk
// store + the kubeconfig file on the catalyst-api Pod. Does NOT
// touch Hetzner. The Sovereign that was provisioned by this
// deployment KEEPS RUNNING in Hetzner — the operator has chosen to
// orphan it. Useful when the customer Sovereign has been handed
// over but the breadcrumb row is no longer wanted in the admin UI,
// or when a stuck record persists after a manual cleanup.
//
// - "Delete record AND wipe Sovereign" — the UI calls POST
// /api/v1/deployments/{id}/wipe FIRST (existing wipe.go handler,
// which already destroys Hetzner + deletes the on-disk record on
// success). The "deep delete" toggle on the modal simply chooses
// which endpoint to POST against — both paths funnel back to
// /sovereign/deployments with a refreshed list afterwards.
//
// Refusal rules — identical posture to ReleaseSubdomain (wipe.go) so an
// operator can't surprise themselves into orphaning a still-converging
// Sovereign or stripping the breadcrumb of a customer-adopted one:
//
// 200/204 — record deleted (or already absent)
// 404 — unknown deployment id (also returned on ownership mismatch
// per the issue #689 anti-enumeration posture)
// 409 — deployment is still in-flight; refuse so the
// runProvisioning goroutine can't try to Commit a row that
// no longer exists
// 422 — deployment has been adopted by a customer; refuse so the
// handover breadcrumb stays intact
package handler
import (
"net/http"
"os"
"path/filepath"
"github.com/go-chi/chi/v5"
)
// deleteDeploymentResponse is the wire shape of DELETE
// /api/v1/deployments/{id}.
type deleteDeploymentResponse struct {
DeploymentID string `json:"deploymentId"`
SovereignFQDN string `json:"sovereignFQDN,omitempty"`
StoreDeleted bool `json:"storeDeleted"`
LocalCleaned bool `json:"localCleaned"`
Mode string `json:"mode"`
Note string `json:"note,omitempty"`
}
// DeleteDeployment handles DELETE /api/v1/deployments/{id}.
//
// This is the "record-only" delete: the deployment row is removed from
// catalyst-api but NO cloud resources are touched. For the destructive
// "kill the kid" path, the UI POSTs /wipe first; this handler is then
// not called (wipe.go already deletes the record on its way out).
func (h *Handler) DeleteDeployment(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
val, ok := h.deployments.Load(id)
if !ok {
// Per the issue #689 posture: don't differentiate "not found"
// from "exists but not yours". Returning 404 here also makes
// the endpoint idempotent — a second DELETE after a successful
// first one is a clean 404 (matches HTTP DELETE semantics).
http.Error(w, "deployment not found", http.StatusNotFound)
return
}
dep := val.(*Deployment)
// Ownership check FIRST so a hostile probe walking ids can't tell
// "exists but not yours" from "doesn't exist" via timing or body
// differences. checkOwnership writes the 404 response for us.
if !h.checkOwnership(w, r, dep) {
return
}
dep.mu.Lock()
status := dep.Status
fqdn := dep.Request.SovereignFQDN
adopted := dep.AdoptedAt != nil
dep.mu.Unlock()
// Adopted deployments are customer-owned Sovereigns. Don't allow
// the operator to strip the handover breadcrumb — they should use
// the customer Sovereign's own admin surface to remove the record
// on the other side of handover.
if adopted {
writeJSON(w, http.StatusUnprocessableEntity, deleteDeploymentResponse{
DeploymentID: id,
SovereignFQDN: fqdn,
Mode: "record-only",
Note: "deployment has been adopted by a customer; the breadcrumb record is protected and cannot be deleted here",
})
return
}
// Refuse to delete a deployment that is still in-flight — the
// runProvisioning goroutine may still call Commit on a row that
// would suddenly be missing from the in-memory map.
if isInFlightStatus(status) {
writeJSON(w, http.StatusConflict, deleteDeploymentResponse{
DeploymentID: id,
SovereignFQDN: fqdn,
Mode: "record-only",
Note: "deployment is still in-flight (status=" + status + "); wait for terminal state or use POST /wipe to destroy + delete in one shot",
})
return
}
out := deleteDeploymentResponse{
DeploymentID: id,
SovereignFQDN: fqdn,
Mode: "record-only",
}
// Step 1 — on-disk record delete. Best-effort; surface a "note" but
// don't fail the response if the underlying file is already missing.
if h.store != nil {
if err := h.store.Delete(id); err != nil {
// IsNotFound at the store layer is treated as success
// (idempotent). Other errors are surfaced as a note but
// the in-memory map delete still runs so the admin UI
// reflects the operator's intent immediately.
if !os.IsNotExist(err) {
h.log.Warn("delete-deployment: store delete failed",
"id", id, "err", err)
out.Note = "store delete: " + err.Error()
} else {
out.StoreDeleted = true
}
} else {
out.StoreDeleted = true
}
} else {
out.StoreDeleted = true
}
// Step 2 — kubeconfig file on disk. Same mode-0600 file the wipe
// path removes. Best-effort; absence is success.
if h.kubeconfigsDir != "" {
kcPath := filepath.Join(h.kubeconfigsDir, id+".yaml")
if err := os.Remove(kcPath); err != nil && !os.IsNotExist(err) {
h.log.Warn("delete-deployment: kubeconfig delete failed",
"id", id, "path", kcPath, "err", err)
} else {
out.LocalCleaned = true
}
} else {
out.LocalCleaned = true
}
// Step 3 — drop the in-memory row. The wizard's polling loop will
// then see the deployment disappear from GET /api/v1/deployments
// on its next tick (~30s).
h.deployments.Delete(id)
h.log.Info("delete-deployment: record-only delete complete",
"id", id,
"sovereignFQDN", fqdn,
"priorStatus", status,
"storeDeleted", out.StoreDeleted,
"localCleaned", out.LocalCleaned,
)
writeJSON(w, http.StatusOK, out)
}

View File

@ -0,0 +1,154 @@
// Tests for DELETE /api/v1/deployments/{id} — record-only delete
// (issue #178). The destructive "deep delete" path lives on /wipe and is
// tested in wipe_test.go; this file covers the record-only seam and the
// refusal policies (adopted → 422, in-flight → 409, unknown → 404).
package handler
import (
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/provisioner"
)
// newDepForDelete builds a Deployment with a chi-url-param-friendly id +
// the closed channels every "settled" deployment needs.
func newDepForDelete(id, status, owner string, adoptedAt *time.Time) *Deployment {
dep := &Deployment{
ID: id,
Status: status,
OwnerEmail: owner,
Request: provisioner.Request{
SovereignFQDN: id + ".example.com",
Region: "fsn1",
},
StartedAt: time.Now().Add(-1 * time.Hour),
eventsCh: make(chan provisioner.Event),
done: make(chan struct{}),
AdoptedAt: adoptedAt,
}
close(dep.eventsCh)
close(dep.done)
return dep
}
// routerForDelete wires the DeleteDeployment handler into a chi router so
// the {id} URL param is parsed the same way it is in production.
func routerForDelete(h *Handler) *chi.Mux {
r := chi.NewRouter()
r.Delete("/api/v1/deployments/{id}", h.DeleteDeployment)
return r
}
// TestDeleteDeployment_RecordOnly_HappyPath — terminal deployment is
// successfully removed from the in-memory map. The wipe path is NOT
// invoked.
func TestDeleteDeployment_RecordOnly_HappyPath(t *testing.T) {
h := &Handler{log: slog.Default()}
dep := newDepForDelete("dep-1", "failed", "", nil)
h.deployments.Store(dep.ID, dep)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/deployments/dep-1", nil)
rec := httptest.NewRecorder()
routerForDelete(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
var out deleteDeploymentResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode body: %v", err)
}
if out.DeploymentID != "dep-1" {
t.Errorf("DeploymentID=%q, want dep-1", out.DeploymentID)
}
if out.Mode != "record-only" {
t.Errorf("Mode=%q, want record-only", out.Mode)
}
if !out.LocalCleaned {
t.Errorf("LocalCleaned=false, want true")
}
if _, present := h.deployments.Load("dep-1"); present {
t.Errorf("deployments map still contains dep-1 after DELETE")
}
}
// TestDeleteDeployment_AdoptedReturns422 — handover breadcrumb stays
// intact so the post-handover Sovereign isn't orphaned in the operator's
// admin view.
func TestDeleteDeployment_AdoptedReturns422(t *testing.T) {
h := &Handler{log: slog.Default()}
adopted := time.Now().Add(-10 * time.Minute)
dep := newDepForDelete("dep-adopted", "ready", "", &adopted)
h.deployments.Store(dep.ID, dep)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/deployments/dep-adopted", nil)
rec := httptest.NewRecorder()
routerForDelete(h).ServeHTTP(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422, got %d: %s", rec.Code, rec.Body.String())
}
if _, present := h.deployments.Load("dep-adopted"); !present {
t.Errorf("adopted deployment was deleted from in-memory map; must stay")
}
}
// TestDeleteDeployment_InFlightReturns409 — runProvisioning may still
// commit, so the record can't disappear mid-flight.
func TestDeleteDeployment_InFlightReturns409(t *testing.T) {
h := &Handler{log: slog.Default()}
dep := newDepForDelete("dep-running", "phase1-watching", "", nil)
h.deployments.Store(dep.ID, dep)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/deployments/dep-running", nil)
rec := httptest.NewRecorder()
routerForDelete(h).ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
}
if _, present := h.deployments.Load("dep-running"); !present {
t.Errorf("in-flight deployment was deleted; must stay until terminal")
}
}
// TestDeleteDeployment_UnknownReturns404 — also covers idempotency: a
// second DELETE after a successful first one returns 404.
func TestDeleteDeployment_UnknownReturns404(t *testing.T) {
h := &Handler{log: slog.Default()}
req := httptest.NewRequest(http.MethodDelete, "/api/v1/deployments/never-existed", nil)
rec := httptest.NewRecorder()
routerForDelete(h).ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestDeleteDeployment_TerminalWipedAllowed — the "wiped" status is
// terminal, not in-flight; the operator IS allowed to drop the row
// after a wipe so the admin list isn't permanently cluttered with
// already-purged Sovereigns.
func TestDeleteDeployment_TerminalWipedAllowed(t *testing.T) {
h := &Handler{log: slog.Default()}
dep := newDepForDelete("dep-wiped", "wiped", "", nil)
h.deployments.Store(dep.ID, dep)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/deployments/dep-wiped", nil)
rec := httptest.NewRecorder()
routerForDelete(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
if _, present := h.deployments.Load("dep-wiped"); present {
t.Errorf("wiped deployment was not removed from map")
}
}

View File

@ -0,0 +1,332 @@
/**
* DeleteDeploymentModal operator-facing delete from the deployments
* admin list (issue #178). Reuses the same ModalShell scaffold +
* type-to-confirm pattern as WipeDeploymentModal, but offers TWO modes
* via a radio group:
*
* 1. record-only calls DELETE /api/v1/deployments/{id}. Removes the
* catalyst-api state (in-memory map + on-disk store + kubeconfig)
* but LEAVES THE HETZNER SOVEREIGN RUNNING. Useful when the
* Sovereign is fine but the breadcrumb row is no longer wanted.
*
* 2. deep calls POST /api/v1/deployments/{id}/wipe FIRST (which
* destroys every Hetzner resource tagged for this deployment AND
* deletes the on-disk record on success), then no second call is
* needed. The Hetzner token is collected up-front so the wipe
* handler can authenticate `tofu destroy` + the orphan purge.
*
* Both modes require typing the deployment FQDN to confirm the
* destructive deep-delete additionally requires the Hetzner token,
* mirroring WipeDeploymentModal's defensive posture (issue #166 + #914).
*
* The "deep" mode is the founder's "kill the kid delivered by the
* mother" semantic; record-only is "remove the records from the
* mother only".
*/
import { useState } from 'react'
import { ModalShell } from './_shared'
import { API_BASE } from '@/shared/config/urls'
import type { WipeReport } from './WipeDeploymentModal'
export type DeleteMode = 'record-only' | 'deep'
export interface DeleteDeploymentModalProps {
open: boolean
deploymentId: string
sovereignFQDN: string | null
onClose: () => void
/** Fired after a successful delete (either mode) so the caller can
* refresh the list. */
onDeleted: (mode: DeleteMode) => void
}
interface DeleteResponse {
deploymentId: string
sovereignFQDN?: string
mode: string
storeDeleted: boolean
localCleaned: boolean
note?: string
}
export function DeleteDeploymentModal({
open,
deploymentId,
sovereignFQDN,
onClose,
onDeleted,
}: DeleteDeploymentModalProps) {
const [mode, setMode] = useState<DeleteMode>('record-only')
const [confirmText, setConfirmText] = useState('')
const [hetznerToken, setHetznerToken] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [wipeReport, setWipeReport] = useState<WipeReport | null>(null)
const [recordReport, setRecordReport] = useState<DeleteResponse | null>(null)
const requiredText = sovereignFQDN ?? deploymentId
// record-only mode: only the FQDN gate. deep mode: FQDN gate AND a
// Hetzner token (>20 chars — same heuristic as WipeDeploymentModal).
const ready =
!busy &&
confirmText.trim() === requiredText &&
(mode === 'record-only' || hetznerToken.trim().length > 20)
async function performDelete() {
setBusy(true)
setError(null)
try {
if (mode === 'deep') {
// Deep delete: POST /wipe — that handler runs tofu destroy +
// Hetzner orphan purge + PDM release + record delete in one
// pass. No separate record-only DELETE call afterwards.
const res = await fetch(
`${API_BASE}/v1/deployments/${encodeURIComponent(deploymentId)}/wipe`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ hetznerToken: hetznerToken.trim() }),
},
)
const text = await res.text()
if (!res.ok) {
setError(`HTTP ${res.status}: ${text.slice(0, 400)}`)
setBusy(false)
return
}
const parsed = JSON.parse(text) as WipeReport
setWipeReport(parsed)
} else {
// record-only delete: DELETE /api/v1/deployments/{id}.
const res = await fetch(
`${API_BASE}/v1/deployments/${encodeURIComponent(deploymentId)}`,
{
method: 'DELETE',
headers: { Accept: 'application/json' },
credentials: 'include',
},
)
const text = await res.text()
if (!res.ok) {
setError(`HTTP ${res.status}: ${text.slice(0, 400)}`)
setBusy(false)
return
}
const parsed = JSON.parse(text) as DeleteResponse
setRecordReport(parsed)
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
if (!open) return null
// Success view — render after either mode succeeds.
if (wipeReport || recordReport) {
const isDeep = wipeReport != null
return (
<ModalShell
id="delete-deployment"
open={open}
title={isDeep ? 'Deployment wiped' : 'Record deleted'}
subtitle={requiredText}
onClose={() => onDeleted(isDeep ? 'deep' : 'record-only')}
primary={{
label: 'Done',
onClick: () => onDeleted(isDeep ? 'deep' : 'record-only'),
}}
>
{isDeep && wipeReport ? (
<>
<p style={{ marginTop: 0, fontSize: 12, color: 'var(--color-text-dim)' }}>
Hetzner resources removed:{' '}
{(wipeReport.hetznerPurge.servers?.length ?? 0)} servers,{' '}
{(wipeReport.hetznerPurge.load_balancers?.length ?? 0)} load balancers,{' '}
{(wipeReport.hetznerPurge.networks?.length ?? 0)} networks,{' '}
{(wipeReport.hetznerPurge.firewalls?.length ?? 0)} firewalls,{' '}
{(wipeReport.hetznerPurge.ssh_keys?.length ?? 0)} ssh-keys,{' '}
{(wipeReport.hetznerPurge.s3_buckets?.length ?? 0)} S3 buckets.
</p>
<p style={{ marginTop: 4, fontSize: 12, color: 'var(--color-text-dim)' }}>
Tofu destroy: {wipeReport.tofuDestroyed ? '✓' : '✗'} · PDM released:{' '}
{wipeReport.pdmReleased ? '✓' : 'n/a'} · Local cleaned:{' '}
{wipeReport.localCleaned ? '✓' : '✗'}
</p>
{wipeReport.errors && wipeReport.errors.length > 0 ? (
<pre
data-testid="delete-deployment-report-errors"
style={{
marginTop: 8, padding: 8, fontSize: 11,
background: 'var(--color-bg-2)', color: 'var(--color-warn)',
borderRadius: 4, overflowX: 'auto',
}}
>
{wipeReport.errors.join('\n')}
</pre>
) : null}
</>
) : (
<p style={{ marginTop: 0, fontSize: 12, color: 'var(--color-text-dim)' }}>
Deployment record removed from catalyst-api. The Sovereign
cluster at <code>{requiredText}</code> is still running in
Hetzner destroy it from the Hetzner Console if you want
to release the cloud resources too.
</p>
)}
</ModalShell>
)
}
// Pre-delete confirmation view.
return (
<ModalShell
id="delete-deployment"
open={open}
title="Delete deployment"
subtitle={requiredText}
onClose={() => { if (!busy) onClose() }}
primary={{
label: busy ? 'Working…' : (mode === 'deep' ? 'Wipe Sovereign and delete record' : 'Delete record only'),
onClick: performDelete,
disabled: !ready,
loading: busy,
danger: mode === 'deep',
}}
secondary={{ label: 'Cancel', onClick: onClose }}
>
<fieldset
data-testid="delete-deployment-mode"
style={{ border: 'none', padding: 0, margin: 0, marginBottom: 12 }}
>
<legend style={{ fontSize: 12, color: 'var(--color-text-dim)', marginBottom: 6 }}>
What should I delete?
</legend>
<label
style={{
display: 'flex', alignItems: 'flex-start', gap: 8, padding: 8,
border: '1px solid var(--color-border)', borderRadius: 4,
marginBottom: 6, cursor: 'pointer',
background: mode === 'record-only' ? 'var(--color-bg-2)' : 'transparent',
}}
>
<input
type="radio"
name="delete-mode"
value="record-only"
checked={mode === 'record-only'}
onChange={() => setMode('record-only')}
disabled={busy}
data-testid="delete-deployment-mode-record-only"
style={{ marginTop: 3 }}
/>
<span>
<strong style={{ display: 'block', fontSize: 13 }}>
Delete record only (mother)
</strong>
<span style={{ fontSize: 11, color: 'var(--color-text-dim)' }}>
Removes the deployment from catalyst-api (in-memory + on-disk
store + kubeconfig). The Sovereign cluster KEEPS RUNNING in
Hetzner you'll need to destroy it from the Hetzner Console
separately if you want to release the cloud resources.
</span>
</span>
</label>
<label
style={{
display: 'flex', alignItems: 'flex-start', gap: 8, padding: 8,
border: '1px solid var(--color-border)', borderRadius: 4,
cursor: 'pointer',
background: mode === 'deep' ? 'var(--color-bg-2)' : 'transparent',
}}
>
<input
type="radio"
name="delete-mode"
value="deep"
checked={mode === 'deep'}
onChange={() => setMode('deep')}
disabled={busy}
data-testid="delete-deployment-mode-deep"
style={{ marginTop: 3 }}
/>
<span>
<strong style={{ display: 'block', fontSize: 13, color: 'var(--color-danger)' }}>
Delete record AND wipe Sovereign (kill the kid)
</strong>
<span style={{ fontSize: 11, color: 'var(--color-text-dim)' }}>
Runs tofu destroy + Hetzner orphan purge + PDM release +
record cleanup. Every Hetzner resource tagged{' '}
<code style={{ background: 'var(--color-bg-2)', padding: '0 4px', borderRadius: 3 }}>
catalyst-deployment-id={deploymentId.slice(0, 12)}
</code>{' '}
is destroyed. THIS CANNOT BE UNDONE.
</span>
</span>
</label>
</fieldset>
<label style={{ display: 'block', marginTop: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-dim)' }}>
Type{' '}
<code style={{ background: 'var(--color-bg-2)', padding: '0 4px', borderRadius: 3 }}>
{requiredText}
</code>{' '}
to confirm:
</span>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
disabled={busy}
data-testid="delete-deployment-confirm-text"
style={{
marginTop: 4, width: '100%', padding: '4px 8px',
fontFamily: 'monospace', fontSize: 13,
background: 'var(--color-bg-2)', color: 'var(--color-text)',
border: '1px solid var(--color-border)', borderRadius: 4,
}}
/>
</label>
{mode === 'deep' ? (
<label style={{ display: 'block', marginTop: 12 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-dim)' }}>
Hetzner Cloud API token (required for wipe never logged):
</span>
<input
type="password"
value={hetznerToken}
onChange={(e) => setHetznerToken(e.target.value)}
disabled={busy}
placeholder="Paste your Hetzner Cloud API token"
data-testid="delete-deployment-hetzner-token"
style={{
marginTop: 4, width: '100%', padding: '4px 8px',
fontFamily: 'monospace', fontSize: 13,
background: 'var(--color-bg-2)', color: 'var(--color-text)',
border: '1px solid var(--color-border)', borderRadius: 4,
}}
/>
</label>
) : null}
{error ? (
<pre
data-testid="delete-deployment-error"
style={{
marginTop: 8, padding: 8, fontSize: 11,
background: 'var(--color-bg-2)', color: 'var(--color-danger)',
borderRadius: 4, overflowX: 'auto',
}}
>
{error}
</pre>
) : null}
</ModalShell>
)
}

View File

@ -37,6 +37,9 @@ export type { DeleteCascadeConfirmProps } from './DeleteCascadeConfirm'
export { WipeDeploymentModal } from './WipeDeploymentModal'
export type { WipeDeploymentModalProps, WipeReport } from './WipeDeploymentModal'
export { DeleteDeploymentModal } from './DeleteDeploymentModal'
export type { DeleteDeploymentModalProps, DeleteMode } from './DeleteDeploymentModal'
/* ── #349 — Update / Delete on every resource type ───────────────── */
export { EditRegionModal } from './EditRegionModal'

View File

@ -0,0 +1,223 @@
/**
* DeploymentsList.test.tsx coverage for the deployments admin list
* (issue #178): sortable columns + per-row delete-with-two-modes modal.
*
* Contract under test:
* - Initial sort is startedAt DESC (newest first).
* - Clicking a column header changes the sort key.
* - Clicking the active header toggles asc/desc.
* - Each non-in-flight row exposes a Delete button.
* - In-flight rows render Delete but it's disabled.
* - Clicking Delete opens the DeleteDeploymentModal with the matching
* deployment id wired through.
* - Modal exposes record-only + deep radio options.
*
* The hook + session mocks are vi.mock-ed at the module level so this
* test focuses on table behaviour without a real React Query cache.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import type { ReactNode } from 'react'
import { DeploymentsList } from './DeploymentsList'
import type { DeploymentListEntry } from '@/shared/lib/useInflightDeployment'
// Mock the hooks so the table renders with a deterministic dataset
// without any real fetch / cookie wiring.
const mockSession = vi.fn()
vi.mock('@/shared/lib/useSession', () => ({
useSession: () => mockSession(),
}))
const mockUseInflight = vi.fn()
vi.mock('@/shared/lib/useInflightDeployment', async () => {
const actual = await vi.importActual<typeof import('@/shared/lib/useInflightDeployment')>(
'@/shared/lib/useInflightDeployment',
)
return {
...actual,
useInflightDeployment: () => mockUseInflight(),
}
})
function renderList(): void {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sovereign/deployments',
component: DeploymentsList,
})
// Stub the deep-link targets so <Link to=...> resolves without warnings.
const provDashRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/dashboard',
component: () => null,
})
const wizardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/wizard',
component: () => null,
})
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: () => null,
})
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: () => null,
})
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, provDashRoute, wizardRoute, loginRoute, dashboardRoute]),
history: createMemoryHistory({ initialEntries: ['/sovereign/deployments'] }),
})
render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
const ROWS: DeploymentListEntry[] = [
{
id: 'dep-a',
status: 'failed',
sovereignFQDN: 'alpha.example.com',
region: 'fsn1',
startedAt: '2026-05-01T08:00:00Z',
finishedAt: '2026-05-01T09:00:00Z',
},
{
id: 'dep-b',
status: 'phase1-watching',
sovereignFQDN: 'bravo.example.com',
region: 'nbg1',
startedAt: '2026-05-05T12:00:00Z',
},
{
id: 'dep-c',
status: 'wiped',
sovereignFQDN: 'charlie.example.com',
region: 'hel1',
startedAt: '2026-05-03T15:00:00Z',
finishedAt: '2026-05-03T17:00:00Z',
},
]
beforeEach(() => {
mockSession.mockReturnValue({
signedIn: true,
email: 'alice@example.com',
sub: 'sub-1',
loading: false,
refetch: () => undefined,
signOut: async () => undefined,
})
mockUseInflight.mockReturnValue({
inflight: null,
completed: ROWS,
all: ROWS,
loading: false,
isError: false,
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('DeploymentsList — sortable columns', () => {
it('renders rows sorted by startedAt DESC by default (bravo / charlie / alpha)', async () => {
renderList()
const table = await screen.findByTestId('deployments-list-table')
expect(table.getAttribute('data-sort-key')).toBe('startedAt')
expect(table.getAttribute('data-sort-order')).toBe('desc')
const rows = within(table).getAllByRole('row').slice(1) // skip header
const ids = rows.map((r) => r.getAttribute('data-testid'))
expect(ids).toEqual([
'deployments-list-row-dep-b',
'deployments-list-row-dep-c',
'deployments-list-row-dep-a',
])
})
it('clicking the FQDN header sorts by sovereignFQDN ASC (alpha / bravo / charlie)', async () => {
renderList()
fireEvent.click(await screen.findByTestId('deployments-list-sort-sovereignFQDN'))
const table = screen.getByTestId('deployments-list-table')
expect(table.getAttribute('data-sort-key')).toBe('sovereignFQDN')
expect(table.getAttribute('data-sort-order')).toBe('asc')
const ids = within(table).getAllByRole('row').slice(1).map((r) => r.getAttribute('data-testid'))
expect(ids).toEqual([
'deployments-list-row-dep-a',
'deployments-list-row-dep-b',
'deployments-list-row-dep-c',
])
})
it('clicking the active header toggles ASC ↔ DESC', async () => {
renderList()
const hdr = await screen.findByTestId('deployments-list-sort-sovereignFQDN')
fireEvent.click(hdr)
fireEvent.click(hdr)
const table = screen.getByTestId('deployments-list-table')
expect(table.getAttribute('data-sort-order')).toBe('desc')
})
it('sorts by status ASC alphabetically', async () => {
renderList()
fireEvent.click(await screen.findByTestId('deployments-list-sort-status'))
const table = screen.getByTestId('deployments-list-table')
const ids = within(table).getAllByRole('row').slice(1).map((r) => r.getAttribute('data-testid'))
// statuses sorted asc: failed, phase1-watching, wiped
expect(ids).toEqual([
'deployments-list-row-dep-a',
'deployments-list-row-dep-b',
'deployments-list-row-dep-c',
])
})
})
describe('DeploymentsList — delete button per row', () => {
it('renders a Delete button on every non-in-flight row', async () => {
renderList()
expect(await screen.findByTestId('deployments-list-delete-dep-a')).toBeDefined()
expect(screen.getByTestId('deployments-list-delete-dep-c')).toBeDefined()
})
it('renders the Delete button as DISABLED on in-flight rows', async () => {
renderList()
const btn = (await screen.findByTestId('deployments-list-delete-dep-b')) as HTMLButtonElement
expect(btn.disabled).toBe(true)
})
it('opens the DeleteDeploymentModal with both record-only and deep mode radios', async () => {
renderList()
fireEvent.click(await screen.findByTestId('deployments-list-delete-dep-a'))
expect(screen.getByTestId('delete-deployment-mode')).toBeDefined()
expect(screen.getByTestId('delete-deployment-mode-record-only')).toBeDefined()
expect(screen.getByTestId('delete-deployment-mode-deep')).toBeDefined()
})
it('record-only mode does NOT show the Hetzner token field; deep mode does', async () => {
renderList()
fireEvent.click(await screen.findByTestId('deployments-list-delete-dep-a'))
// Default mode is record-only — no Hetzner token field.
expect(screen.queryByTestId('delete-deployment-hetzner-token')).toBeNull()
fireEvent.click(screen.getByTestId('delete-deployment-mode-deep'))
expect(screen.getByTestId('delete-deployment-hetzner-token')).toBeDefined()
})
})

View File

@ -8,21 +8,37 @@
* one historical row).
* - /sovereign/deployments (this route).
*
* The page is intentionally minimal a single table with the columns
* the operator needs to reopen a previous run (id, FQDN, status, dates,
* error preview). Each row links to /sovereign/provision/<id> which
* already renders the canonical Sovereign-Admin shell for both live
* and terminated deployments (issue #689 reuses the same surface).
* Issue #178 adds two operator features:
*
* No filters, no search, no per-row actions: anything beyond
* "go look at this deployment" belongs on the deployment page itself.
* - Sortable columns: click any column header to sort the table by
* that field. Second click reverses the direction. The default
* sort is "Started DESC" so the newest deployment appears at the
* top. Sort is purely client-side (the list is small enough that
* adding ?sort=&order= query params would just add round-trip cost
* without operator benefit).
*
* - Per-row Delete with a two-mode confirmation modal:
* 1. Delete record only drops the catalyst-api row but leaves
* the Hetzner Sovereign running.
* 2. Delete record AND wipe Sovereign chains POST /wipe (the
* existing destructive purge) followed by an implicit record
* delete on the wipe handler's way out.
* Destructive mode requires typing the FQDN to confirm (same
* safety pattern WipeDeploymentModal uses).
*
* Each row's FQDN cell still links to /sovereign/provision/<id> which
* renders the canonical Sovereign-Admin shell for both live and
* terminated deployments (issue #689 reuses the same surface).
*/
import { useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useSession } from '@/shared/lib/useSession'
import { useInflightDeployment, INFLIGHT_STATUSES } from '@/shared/lib/useInflightDeployment'
import type { DeploymentListEntry } from '@/shared/lib/useInflightDeployment'
import { DETECTED_MODE } from '@/shared/lib/detectMode'
import { DeleteDeploymentModal } from '@/components/CrudModals'
function formatDate(iso?: string): string {
if (!iso) return '—'
@ -37,13 +53,106 @@ function statusVariant(status: string): 'live' | 'done' | 'failed' {
return 'done'
}
type SortKey =
| 'sovereignFQDN'
| 'status'
| 'startedAt'
| 'finishedAt'
| 'region'
type SortOrder = 'asc' | 'desc'
const SORT_LABELS: Record<SortKey, string> = {
sovereignFQDN: 'FQDN',
status: 'Status',
startedAt: 'Started',
finishedAt: 'Finished',
region: 'Region',
}
/**
* Comparator factory empty / undefined sort lowest in ASC.
* Dates are parsed once per key, strings compared case-insensitively.
*/
function makeComparator(key: SortKey, order: SortOrder) {
const dir = order === 'asc' ? 1 : -1
return (a: DeploymentListEntry, b: DeploymentListEntry): number => {
const av = a[key]
const bv = b[key]
// Treat undefined / empty as "lowest" — they pile up at the bottom
// in ASC and the top in DESC, which matches operator intuition
// ("show me rows with real values first").
if (!av && !bv) return 0
if (!av) return 1
if (!bv) return -1
if (key === 'startedAt' || key === 'finishedAt') {
const ta = Date.parse(av)
const tb = Date.parse(bv)
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0
if (Number.isNaN(ta)) return 1
if (Number.isNaN(tb)) return -1
return (ta - tb) * dir
}
return av.toString().toLowerCase().localeCompare(bv.toString().toLowerCase()) * dir
}
}
interface SortHeaderProps {
label: string
sortKey: SortKey
active: SortKey
order: SortOrder
onSort: (k: SortKey) => void
}
function SortHeader({ label, sortKey, active, order, onSort }: SortHeaderProps) {
const isActive = active === sortKey
const arrow = isActive ? (order === 'asc' ? ' ▲' : ' ▼') : ''
return (
<th
data-testid={`deployments-list-sort-${sortKey}`}
data-sort-active={isActive ? 'true' : 'false'}
data-sort-order={isActive ? order : ''}
onClick={() => onSort(sortKey)}
style={{
padding: '8px 12px',
borderBottom: '1px solid var(--wiz-border)',
cursor: 'pointer',
userSelect: 'none',
}}
>
{label}
<span aria-hidden style={{ opacity: isActive ? 1 : 0.3 }}>{arrow || ' ↕'}</span>
</th>
)
}
export function DeploymentsList() {
const session = useSession()
const queryClient = useQueryClient()
const { all, loading, isError } = useInflightDeployment({
ownerEmail: session.email,
enabled: !session.loading,
})
const [sortKey, setSortKey] = useState<SortKey>('startedAt')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const [deleteTarget, setDeleteTarget] = useState<DeploymentListEntry | null>(null)
const sorted = useMemo(() => {
const copy = [...all]
copy.sort(makeComparator(sortKey, sortOrder))
return copy
}, [all, sortKey, sortOrder])
function onSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
// Default: dates DESC (newest-first), strings ASC.
setSortOrder(key === 'startedAt' || key === 'finishedAt' ? 'desc' : 'asc')
}
}
if (session.loading || loading) {
return (
<div data-testid="deployments-list-loading" style={{ padding: 24 }}>
@ -95,7 +204,7 @@ export function DeploymentsList() {
</Link>
</header>
{all.length === 0 ? (
{sorted.length === 0 ? (
<p data-testid="deployments-list-empty">
You don&rsquo;t have any deployments yet.{' '}
<Link to="/wizard">Start the wizard</Link> to provision your first
@ -104,6 +213,8 @@ export function DeploymentsList() {
) : (
<table
data-testid="deployments-list-table"
data-sort-key={sortKey}
data-sort-order={sortOrder}
style={{
width: '100%',
borderCollapse: 'collapse',
@ -112,15 +223,16 @@ export function DeploymentsList() {
>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--wiz-text-sub)' }}>
<th style={{ padding: '8px 12px', borderBottom: '1px solid var(--wiz-border)' }}>FQDN</th>
<th style={{ padding: '8px 12px', borderBottom: '1px solid var(--wiz-border)' }}>Status</th>
<th style={{ padding: '8px 12px', borderBottom: '1px solid var(--wiz-border)' }}>Started</th>
<th style={{ padding: '8px 12px', borderBottom: '1px solid var(--wiz-border)' }}>Finished</th>
<th style={{ padding: '8px 12px', borderBottom: '1px solid var(--wiz-border)' }}>Region</th>
<SortHeader label={SORT_LABELS.sovereignFQDN} sortKey="sovereignFQDN" active={sortKey} order={sortOrder} onSort={onSort} />
<SortHeader label={SORT_LABELS.status} sortKey="status" active={sortKey} order={sortOrder} onSort={onSort} />
<SortHeader label={SORT_LABELS.startedAt} sortKey="startedAt" active={sortKey} order={sortOrder} onSort={onSort} />
<SortHeader label={SORT_LABELS.finishedAt} sortKey="finishedAt" active={sortKey} order={sortOrder} onSort={onSort} />
<SortHeader label={SORT_LABELS.region} sortKey="region" active={sortKey} order={sortOrder} onSort={onSort} />
<th style={{ padding: '8px 12px', borderBottom: '1px solid var(--wiz-border)', width: 90 }}>Actions</th>
</tr>
</thead>
<tbody>
{all.map((d: DeploymentListEntry) => {
{sorted.map((d: DeploymentListEntry) => {
const variant = statusVariant(d.status)
const colour =
variant === 'live' ? '#38bdf8' : variant === 'failed' ? '#f87171' : '#10b981'
@ -128,7 +240,6 @@ export function DeploymentsList() {
<tr
key={d.id}
data-testid={`deployments-list-row-${d.id}`}
style={{ cursor: 'pointer' }}
>
<td style={{ padding: '10px 12px', borderBottom: '1px solid var(--wiz-border)' }}>
<Link
@ -168,12 +279,55 @@ export function DeploymentsList() {
<td style={{ padding: '10px 12px', borderBottom: '1px solid var(--wiz-border)' }}>
{d.region || '—'}
</td>
<td style={{ padding: '10px 12px', borderBottom: '1px solid var(--wiz-border)' }}>
<button
type="button"
data-testid={`deployments-list-delete-${d.id}`}
onClick={() => setDeleteTarget(d)}
// In-flight deployments can't be deleted (the API
// refuses 409); reflect that in the button.
disabled={INFLIGHT_STATUSES.has(d.status)}
title={
INFLIGHT_STATUSES.has(d.status)
? 'Wait for terminal state before deleting'
: 'Delete deployment'
}
style={{
padding: '4px 10px',
fontSize: 11,
fontWeight: 600,
borderRadius: 5,
border: '1px solid rgba(248,113,113,0.4)',
background: 'rgba(248,113,113,0.1)',
color: '#f87171',
cursor: INFLIGHT_STATUSES.has(d.status) ? 'not-allowed' : 'pointer',
opacity: INFLIGHT_STATUSES.has(d.status) ? 0.5 : 1,
}}
>
Delete
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
{deleteTarget ? (
<DeleteDeploymentModal
open
deploymentId={deleteTarget.id}
sovereignFQDN={deleteTarget.sovereignFQDN ?? null}
onClose={() => setDeleteTarget(null)}
onDeleted={() => {
setDeleteTarget(null)
// Invalidate every cached "deployments list" query so the
// table re-fetches without the just-deleted row.
queryClient.invalidateQueries({ queryKey: ['catalyst', 'deployments', 'list'] })
}}
/>
) : null}
</div>
)
}