Dashboard: kind filters on Programs, tools off Services page, in-app confirm modals

- /services was returning every non-job deployment (tools, statics, remotes);
  filter it to kind==service so the Services page shows only services.
- Programs page gains kind-filter chips (All / Service / Job / Tool / Static /
  Reference) with per-kind counts, colored to match KindBadge.
- Replace window.confirm with a reusable ConfirmModal (styled, danger variant,
  Esc/Enter, preserves bulleted bodies); FormFooter's delete uses it, so program/
  service/job deletion now confirms in-app with the enumerated cascade list.

castle-api 58 passed; /services live drops from 37 to 15 (services only).
This commit is contained in:
2026-07-01 11:59:14 -07:00
parent 38074806c1
commit 876b424722
7 changed files with 200 additions and 22 deletions

View File

@@ -0,0 +1,82 @@
import { useEffect } from "react"
import { AlertTriangle } from "lucide-react"
import { cn } from "@/lib/utils"
interface ConfirmModalProps {
open: boolean
title: string
/** Optional body; multi-line strings render with their line breaks preserved. */
body?: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
onConfirm: () => void
onCancel: () => void
}
/** A small in-app confirmation dialog. Replaces window.confirm so destructive
* actions get a styled, readable prompt (bulleted bodies, a danger variant). */
export function ConfirmModal({
open,
title,
body,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
danger = false,
onConfirm,
onCancel,
}: ConfirmModalProps) {
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel()
if (e.key === "Enter") onConfirm()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, onCancel, onConfirm])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-md rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="flex items-start gap-3 px-5 py-4 border-b border-[var(--border)]">
{danger && <AlertTriangle size={18} className="text-red-400 mt-0.5 shrink-0" />}
<h3 className="font-semibold">{title}</h3>
</div>
{body && (
<div className="px-5 py-4 text-sm text-[var(--muted)] whitespace-pre-wrap">
{body}
</div>
)}
<div className="flex justify-end gap-2 px-5 py-3 border-t border-[var(--border)]">
<button
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
autoFocus
className={cn(
"px-3 py-1.5 text-sm rounded text-white transition-colors",
danger ? "bg-red-700 hover:bg-red-600" : "bg-blue-700 hover:bg-blue-600",
)}
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,34 +1,75 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import type { ProgramSummary } from "@/types" import type { ProgramSummary } from "@/types"
import { ProgramCard } from "./ProgramCard" import { ProgramCard } from "./ProgramCard"
import { kindLabel } from "@/lib/labels"
import { cn } from "@/lib/utils"
interface ProgramListProps { interface ProgramListProps {
programs: ProgramSummary[] programs: ProgramSummary[]
} }
const KIND_ORDER = ["service", "job", "tool", "static", "reference"]
// Active chip color per kind — mirrors KindBadge so the filter reads as the badge.
const KIND_ACTIVE: Record<string, string> = {
service: "bg-green-700 text-white border-green-600",
job: "bg-purple-700 text-white border-purple-600",
tool: "bg-blue-700 text-white border-blue-600",
static: "bg-cyan-700 text-white border-cyan-600",
reference: "bg-gray-600 text-gray-200 border-gray-500",
}
export function ProgramList({ programs }: ProgramListProps) { export function ProgramList({ programs }: ProgramListProps) {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [kind, setKind] = useState<string | null>(null)
const counts = useMemo(() => {
const c: Record<string, number> = {}
for (const p of programs) if (p.kind) c[p.kind] = (c[p.kind] ?? 0) + 1
return c
}, [programs])
const kindsPresent = KIND_ORDER.filter((k) => counts[k])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const base = [...programs].sort((a, b) => a.id.localeCompare(b.id)) let base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
if (!search) return base if (kind) base = base.filter((p) => p.kind === kind)
const q = search.toLowerCase() if (search) {
return base.filter( const q = search.toLowerCase()
(c) => base = base.filter(
c.id.toLowerCase().includes(q) || (c) =>
(c.description?.toLowerCase().includes(q) ?? false), c.id.toLowerCase().includes(q) ||
) (c.description?.toLowerCase().includes(q) ?? false),
}, [programs, search]) )
}
return base
}, [programs, search, kind])
return ( return (
<div> <div>
<div className="mb-4"> <div className="mb-4 flex flex-wrap items-center gap-2">
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Filter programs..." placeholder="Filter programs..."
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56" className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
/> />
<div className="flex flex-wrap gap-1.5">
<Chip
label={`All (${programs.length})`}
active={kind === null}
activeClass="bg-[var(--primary)] text-white border-[var(--primary)]"
onClick={() => setKind(null)}
/>
{kindsPresent.map((k) => (
<Chip
key={k}
label={`${kindLabel(k)} (${counts[k]})`}
active={kind === k}
activeClass={KIND_ACTIVE[k]}
onClick={() => setKind(kind === k ? null : k)}
/>
))}
</div>
</div> </div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
@@ -43,3 +84,29 @@ export function ProgramList({ programs }: ProgramListProps) {
</div> </div>
) )
} }
function Chip({
label,
active,
activeClass,
onClick,
}: {
label: string
active: boolean
activeClass: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"text-xs px-2.5 py-1 rounded-full border transition-colors",
active
? activeClass
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)]",
)}
>
{label}
</button>
)
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { Trash2 } from "lucide-react" import { Trash2 } from "lucide-react"
import { SecretsEditor } from "@/components/SecretsEditor" import { SecretsEditor } from "@/components/SecretsEditor"
import { ConfirmModal } from "@/components/ConfirmModal"
const INPUT = const INPUT =
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]" "bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
@@ -185,15 +186,14 @@ export function FormFooter({
/** When set, removal is disallowed and this reason is shown instead of the button. */ /** When set, removal is disallowed and this reason is shown instead of the button. */
deleteBlocked?: string deleteBlocked?: string
}) { }) {
const [confirmOpen, setConfirmOpen] = useState(false)
return ( return (
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]"> <div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
{deleteBlocked ? ( {deleteBlocked ? (
<span className="text-xs text-amber-400">{deleteBlocked}</span> <span className="text-xs text-amber-400">{deleteBlocked}</span>
) : onDelete ? ( ) : onDelete ? (
<button <button
onClick={() => { onClick={() => setConfirmOpen(true)}
if (window.confirm(confirmMessage ?? `${deleteLabel}?`)) onDelete()
}}
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300" className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300"
> >
<Trash2 size={12} /> {deleteLabel} <Trash2 size={12} /> {deleteLabel}
@@ -208,6 +208,20 @@ export function FormFooter({
> >
{saving ? "Saving…" : saved ? "Saved" : "Save"} {saving ? "Saving…" : saved ? "Saved" : "Save"}
</button> </button>
{onDelete && (
<ConfirmModal
open={confirmOpen}
title={deleteLabel}
body={confirmMessage}
confirmLabel={deleteLabel}
danger
onConfirm={() => {
setConfirmOpen(false)
onDelete()
}}
onCancel={() => setConfirmOpen(false)}
/>
)}
</div> </div>
) )
} }

View File

@@ -405,9 +405,9 @@ def list_services(include_remote: bool = False) -> list[ServiceSummary]:
summaries: list[ServiceSummary] = [] summaries: list[ServiceSummary] = []
seen: set[str] = set() seen: set[str] = set()
# Deployed services (non-scheduled) # Deployed services only — not jobs, tools (path), statics (caddy), or remotes.
for name, deployed in registry.deployed.items(): for name, deployed in registry.deployed.items():
if deployed.schedule: if deployed.kind != "service":
continue continue
s = _service_from_deployed(name, deployed) s = _service_from_deployed(name, deployed)
s.node = hostname s.node = hostname

View File

@@ -124,6 +124,13 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
subdomain="test-svc", subdomain="test-svc",
managed=True, managed=True,
), ),
# A deployed tool (path) — must NOT leak into the /services list.
"test-tool": Deployment(
manager="path",
run_cmd=[],
description="Test tool",
kind="tool",
),
}, },
) )
save_registry(registry, reg_path) save_registry(registry, reg_path)

View File

@@ -113,6 +113,13 @@ class TestServicesList:
names = [s["id"] for s in data] names = [s["id"] for s in data]
assert "test-job" not in names assert "test-job" not in names
def test_excludes_tools(self, client: TestClient) -> None:
"""Tools (path deployments) are not in the services list."""
response = client.get("/services")
data = response.json()
names = [s["id"] for s in data]
assert "test-tool" not in names
class TestServiceDetail: class TestServiceDetail:
"""GET /services/{name} endpoint tests.""" """GET /services/{name} endpoint tests."""
@@ -246,8 +253,9 @@ class TestGateway:
data = response.json() data = response.json()
assert data["port"] == 9000 assert data["port"] == 9000
assert data["hostname"] == "test-node" assert data["hostname"] == "test-node"
# Registry has 1 deployed component (test-svc) # Registry has 2 deployed components (test-svc + test-tool); only the
assert data["deployment_count"] == 1 # service is a managed systemd deployment.
assert data["deployment_count"] == 2
assert data["service_count"] == 1 assert data["service_count"] == 1
assert data["managed_count"] == 1 assert data["managed_count"] == 1

View File

@@ -28,8 +28,8 @@ class TestNodesList:
response = client.get("/nodes") response = client.get("/nodes")
data = response.json() data = response.json()
local = data[0] local = data[0]
assert local["deployed_count"] == 1 # test-svc assert local["deployed_count"] == 2 # test-svc + test-tool
assert local["service_count"] == 1 assert local["service_count"] == 1 # only test-svc is a service
def test_includes_remote_nodes(self, client: TestClient, registry_path: Path) -> None: def test_includes_remote_nodes(self, client: TestClient, registry_path: Path) -> None:
"""Remote nodes from mesh state are included.""" """Remote nodes from mesh state are included."""
@@ -81,9 +81,9 @@ class TestNodeDetail:
data = response.json() data = response.json()
assert data["hostname"] == "test-node" assert data["hostname"] == "test-node"
assert data["is_local"] is True assert data["is_local"] is True
assert len(data["deployed"]) == 1 assert len(data["deployed"]) == 2 # test-svc + test-tool
assert data["deployed"][0]["id"] == "test-svc" svc = next(d for d in data["deployed"] if d["id"] == "test-svc")
assert data["deployed"][0]["node"] == "test-node" assert svc["node"] == "test-node"
def test_unknown_node_returns_404(self, client: TestClient) -> None: def test_unknown_node_returns_404(self, client: TestClient) -> None:
"""Returns 404 for unknown hostname.""" """Returns 404 for unknown hostname."""