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:
82
app/src/components/ConfirmModal.tsx
Normal file
82
app/src/components/ConfirmModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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<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) {
|
||||
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 base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
|
||||
if (!search) return base
|
||||
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()
|
||||
return base.filter(
|
||||
base = base.filter(
|
||||
(c) =>
|
||||
c.id.toLowerCase().includes(q) ||
|
||||
(c.description?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}, [programs, search])
|
||||
}
|
||||
return base
|
||||
}, [programs, search, kind])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
@@ -43,3 +84,29 @@ export function ProgramList({ programs }: ProgramListProps) {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
|
||||
{deleteBlocked ? (
|
||||
<span className="text-xs text-amber-400">{deleteBlocked}</span>
|
||||
) : onDelete ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(confirmMessage ?? `${deleteLabel}?`)) onDelete()
|
||||
}}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 size={12} /> {deleteLabel}
|
||||
@@ -208,6 +208,20 @@ export function FormFooter({
|
||||
>
|
||||
{saving ? "Saving…" : saved ? "Saved" : "Save"}
|
||||
</button>
|
||||
{onDelete && (
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
title={deleteLabel}
|
||||
body={confirmMessage}
|
||||
confirmLabel={deleteLabel}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
setConfirmOpen(false)
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user