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 { 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user