From 876b4247227ed88f0107481adabb770c7ec5d5b2 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 11:59:14 -0700 Subject: [PATCH] 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). --- app/src/components/ConfirmModal.tsx | 82 ++++++++++++++++++++++++++ app/src/components/ProgramList.tsx | 87 ++++++++++++++++++++++++---- app/src/components/detail/fields.tsx | 20 ++++++- castle-api/src/castle_api/routes.py | 4 +- castle-api/tests/conftest.py | 7 +++ castle-api/tests/test_health.py | 12 +++- castle-api/tests/test_nodes.py | 10 ++-- 7 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 app/src/components/ConfirmModal.tsx diff --git a/app/src/components/ConfirmModal.tsx b/app/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..6999b36 --- /dev/null +++ b/app/src/components/ConfirmModal.tsx @@ -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 ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + > +
+ {danger && } +

{title}

+
+ {body && ( +
+ {body} +
+ )} +
+ + +
+
+
+ ) +} diff --git a/app/src/components/ProgramList.tsx b/app/src/components/ProgramList.tsx index 81fbbdb..0ab2b59 100644 --- a/app/src/components/ProgramList.tsx +++ b/app/src/components/ProgramList.tsx @@ -1,34 +1,75 @@ import { useMemo, useState } from "react" import type { ProgramSummary } from "@/types" import { ProgramCard } from "./ProgramCard" +import { kindLabel } from "@/lib/labels" +import { cn } from "@/lib/utils" interface ProgramListProps { 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 = { + 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) { const [search, setSearch] = useState("") + const [kind, setKind] = useState(null) + + const counts = useMemo(() => { + const c: Record = {} + 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 base = [...programs].sort((a, b) => a.id.localeCompare(b.id)) - if (!search) return base - const q = search.toLowerCase() - return base.filter( - (c) => - c.id.toLowerCase().includes(q) || - (c.description?.toLowerCase().includes(q) ?? false), - ) - }, [programs, search]) + let base = [...programs].sort((a, b) => a.id.localeCompare(b.id)) + if (kind) base = base.filter((p) => p.kind === kind) + if (search) { + const q = search.toLowerCase() + base = base.filter( + (c) => + c.id.toLowerCase().includes(q) || + (c.description?.toLowerCase().includes(q) ?? false), + ) + } + return base + }, [programs, search, kind]) return (
-
+
setSearch(e.target.value)} 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" /> +
+ setKind(null)} + /> + {kindsPresent.map((k) => ( + setKind(kind === k ? null : k)} + /> + ))} +
{filtered.length === 0 ? ( @@ -43,3 +84,29 @@ export function ProgramList({ programs }: ProgramListProps) {
) } + +function Chip({ + label, + active, + activeClass, + onClick, +}: { + label: string + active: boolean + activeClass: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/app/src/components/detail/fields.tsx b/app/src/components/detail/fields.tsx index a534cbd..1382ab9 100644 --- a/app/src/components/detail/fields.tsx +++ b/app/src/components/detail/fields.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react" import { Trash2 } from "lucide-react" import { SecretsEditor } from "@/components/SecretsEditor" +import { ConfirmModal } from "@/components/ConfirmModal" const INPUT = "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. */ deleteBlocked?: string }) { + const [confirmOpen, setConfirmOpen] = useState(false) return (
{deleteBlocked ? ( {deleteBlocked} ) : onDelete ? ( + {onDelete && ( + { + setConfirmOpen(false) + onDelete() + }} + onCancel={() => setConfirmOpen(false)} + /> + )}
) } diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 4d6bb7b..fc9ad31 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -405,9 +405,9 @@ def list_services(include_remote: bool = False) -> list[ServiceSummary]: summaries: list[ServiceSummary] = [] 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(): - if deployed.schedule: + if deployed.kind != "service": continue s = _service_from_deployed(name, deployed) s.node = hostname diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 4c34a70..907ca96 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -124,6 +124,13 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No subdomain="test-svc", 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) diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 2b67ccc..230bb5f 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -113,6 +113,13 @@ class TestServicesList: names = [s["id"] for s in data] 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: """GET /services/{name} endpoint tests.""" @@ -246,8 +253,9 @@ class TestGateway: data = response.json() assert data["port"] == 9000 assert data["hostname"] == "test-node" - # Registry has 1 deployed component (test-svc) - assert data["deployment_count"] == 1 + # Registry has 2 deployed components (test-svc + test-tool); only the + # service is a managed systemd deployment. + assert data["deployment_count"] == 2 assert data["service_count"] == 1 assert data["managed_count"] == 1 diff --git a/castle-api/tests/test_nodes.py b/castle-api/tests/test_nodes.py index 24ad5e8..adb8f99 100644 --- a/castle-api/tests/test_nodes.py +++ b/castle-api/tests/test_nodes.py @@ -28,8 +28,8 @@ class TestNodesList: response = client.get("/nodes") data = response.json() local = data[0] - assert local["deployed_count"] == 1 # test-svc - assert local["service_count"] == 1 + assert local["deployed_count"] == 2 # test-svc + test-tool + assert local["service_count"] == 1 # only test-svc is a service def test_includes_remote_nodes(self, client: TestClient, registry_path: Path) -> None: """Remote nodes from mesh state are included.""" @@ -81,9 +81,9 @@ class TestNodeDetail: data = response.json() assert data["hostname"] == "test-node" assert data["is_local"] is True - assert len(data["deployed"]) == 1 - assert data["deployed"][0]["id"] == "test-svc" - assert data["deployed"][0]["node"] == "test-node" + assert len(data["deployed"]) == 2 # test-svc + test-tool + svc = next(d for d in data["deployed"] if d["id"] == "test-svc") + assert svc["node"] == "test-node" def test_unknown_node_returns_404(self, client: TestClient) -> None: """Returns 404 for unknown hostname."""