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:
parent
d134c538c9
commit
67eae51587
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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’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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user