import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ChevronDown, ChevronUp, ExternalLink, Plus, Trash2, } from "lucide-react"; import { useDeleteServiceInstance, useSaveServiceInstance, useServiceInstances, useTestServiceInstance, } from "../hooks/useServices"; import { useServiceTypes } from "../hooks/useServices"; import { useDashboards, useDeleteDashboard, useSaveDashboard, } from "../hooks/useDashboards"; import type { SecretFieldInfo, ServiceInstance, ServiceInstanceInput, ServiceTestResult, ServiceTypeInfo, } from "../types"; import { SectionCard } from "../components/SectionCard"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { DialogFooter } from "../components/DialogFooter"; import { getServiceBinding } from "../integrations/registry"; import { ServiceTestPanel } from "../components/ServiceTestPanel"; import { serviceLinkTarget } from "../components/PinnedServiceLink"; import type { NamedDashboardInput } from "../api/dashboards"; interface CreateDraft { serviceType: string; name: string; config: Record; secrets: Record; enabled: boolean; } function emptyDraft(serviceType: string): CreateDraft { return { serviceType, name: "", config: {}, secrets: {}, enabled: true }; } function Field({ label, htmlFor, helper, children, }: { label: string; htmlFor: string; helper?: string; children: React.ReactNode; }) { return (
{children} {helper ? (

{helper}

) : null}
); } function ServiceConfigFields({ type, config, onChange, }: { type: ServiceTypeInfo; config: Record; onChange: (config: Record) => void; }) { const properties = ( type.config_schema as { properties?: Record; } ).properties ?? {}; return (
{Object.entries(properties).map(([key, schema]) => { const isNumber = schema.type === "integer" || schema.type === "number"; return ( onChange({ ...config, [key]: isNumber ? e.target.value === "" ? undefined : Number(e.target.value) : e.target.value, }) } /> ); })}
); } function ServiceSecretFields({ fields, secrets, onChange, }: { fields: SecretFieldInfo[]; secrets: Record; onChange: (secrets: Record) => void; }) { if (fields.length === 0) return null; return (
{fields.map((field) => ( onChange({ ...secrets, [field.key]: e.target.value }) } /> ))}
); } function CreateServiceDialog({ open, onClose, }: { open: boolean; onClose: () => void; }) { const { data: types = [] } = useServiceTypes(); const saveService = useSaveServiceInstance(); const testService = useTestServiceInstance(); const [draft, setDraft] = useState(null); const [submitError, setSubmitError] = useState(null); const [testResult, setTestResult] = useState(null); const [saveAnyway, setSaveAnyway] = useState(false); // Reset test state when draft changes (React "store previous" pattern — // avoids setState-in-effect). Any field edit creates a new draft object. const [prevDraft, setPrevDraft] = useState(draft); if (draft !== prevDraft) { setPrevDraft(draft); setTestResult(null); setSaveAnyway(false); } const testPassed = (testResult?.ok ?? false) || saveAnyway; function reset() { setDraft(null); setSubmitError(null); setTestResult(null); setSaveAnyway(false); } async function handleTest() { if (!draft) return; try { const res = await testService.mutateAsync({ service_type: draft.serviceType, name: draft.name.trim(), config: draft.config, secrets: draft.secrets, enabled: draft.enabled, }); setTestResult(res); } catch (err) { setTestResult({ ok: false, detail: err instanceof Error ? err.message : String(err), evidence: null, }); } } async function save() { if (!draft) return; if (!draft.name.trim()) return; const input: ServiceInstanceInput = { service_type: draft.serviceType, name: draft.name.trim(), config: draft.config, secrets: draft.secrets, enabled: draft.enabled, }; setSubmitError(null); try { await saveService.mutateAsync(input); reset(); onClose(); } catch (err) { setSubmitError(err instanceof Error ? err.message : String(err)); } } const selectedType = types.find((t) => t.service_type === draft?.serviceType); return ( { if (!next) { reset(); onClose(); } }} > New service
{!draft ? (
{types.map((t) => ( ))}
) : ( <>

{selectedType?.description}

setDraft({ ...draft, name: e.target.value })} /> {selectedType ? ( setDraft({ ...draft, config })} /> ) : null} {selectedType ? ( setDraft({ ...draft, secrets })} /> ) : null}
setDraft({ ...draft, enabled: checked }) } />
)}
{submitError ? ( {submitError} ) : null} {draft ? ( ) : null}
); } // --- Named dashboards management (Slice 10.3) --- function DashboardManagementCard() { const { data: dashboards = [] } = useDashboards(); const saveDashboard = useSaveDashboard(); const deleteDashboard = useDeleteDashboard(); const { data: services = [] } = useServiceInstances(); const [createOpen, setCreateOpen] = useState(false); const [newLabel, setNewLabel] = useState(""); const [deleteId, setDeleteId] = useState(null); const [linkDashId, setLinkDashId] = useState(null); const [linkLabel, setLinkLabel] = useState(""); const [linkTarget, setLinkTarget] = useState(""); const enabledServices = useMemo( () => services.filter((s) => s.enabled), [services], ); function createDashboard() { if (!newLabel.trim()) return; const input: NamedDashboardInput = { label: newLabel.trim(), sort_order: dashboards.length, payload: { items: [] }, }; saveDashboard.mutate(input); setNewLabel(""); setCreateOpen(false); } function reorder(dashId: string, direction: -1 | 1) { const sorted = [...dashboards].sort((a, b) => a.sort_order - b.sort_order); const idx = sorted.findIndex((d) => d.id === dashId); const swapIdx = idx + direction; if (swapIdx < 0 || swapIdx >= sorted.length) return; const a = sorted[idx]; const b = sorted[swapIdx]; saveDashboard.mutate({ ...a, sort_order: b.sort_order, payload: a.payload, }); saveDashboard.mutate({ ...b, sort_order: a.sort_order, payload: b.payload, }); } function addPinnedLink() { if (!linkDashId || !linkLabel.trim() || !linkTarget.trim()) return; const dash = dashboards.find((d) => d.id === linkDashId); if (!dash) return; const items = Array.isArray(dash.payload.items) ? (dash.payload.items as unknown[]) : []; items.push({ type: "link", label: linkLabel.trim(), target: linkTarget }); saveDashboard.mutate({ id: dash.id, label: dash.label, sort_order: dash.sort_order, payload: { items }, }); setLinkLabel(""); setLinkTarget(""); } return ( setCreateOpen(true)}> New dashboard } > {dashboards.length === 0 ? (

No named dashboards yet. Create one to add pinned service links.

) : (
{[...dashboards] .sort((a, b) => a.sort_order - b.sort_order) .map((d, idx, arr) => (
{d.label} /{d.slug}
{Array.isArray(d.payload.items) && (d.payload.items as unknown[]).length > 0 ? ( {(d.payload.items as unknown[]).length} pinned link(s) ) : ( No links yet )}
{ setLinkDashId(d.id); setLinkLabel(e.target.value); }} />
))}
)} New dashboard setNewLabel(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") createDashboard(); }} /> setCreateOpen(false)} onConfirm={createDashboard} confirmLabel="Create" confirmDisabled={!newLabel.trim() || saveDashboard.isPending} /> setDeleteId(null)} onConfirm={() => { if (deleteId) deleteDashboard.mutate(deleteId); setDeleteId(null); }} />
); } export function ServicesPage() { const navigate = useNavigate(); const { data: services = [] } = useServiceInstances(); const { data: types = [] } = useServiceTypes(); const deleteService = useDeleteServiceInstance(); const [createOpen, setCreateOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); const grouped = useMemo(() => { const map = new Map(); for (const s of services) { const list = map.get(s.service_type) ?? []; list.push(s); map.set(s.service_type, list); } return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])); }, [services]); const typeName = (t: string) => types.find((x) => x.service_type === t)?.name ?? getServiceBinding(t)?.name ?? t; return (
setCreateOpen(true)}> Add service } > {services.length === 0 ? ( No services yet. Add a Prometheus, Jellyfin, Nextcloud, or SSH task runner. ) : (
{grouped.map(([serviceType, instances]) => (
{typeName(serviceType)}
{instances.map((s) => (
{s.name} {s.service_type} {!s.enabled ? ( disabled ) : null} {Object.entries(s.secrets_set).some(([, v]) => v) ? ( secrets set ) : null}
))}
))}
)}
setCreateOpen(false)} /> setDeleteId(null)} onConfirm={() => { if (deleteId) deleteService.mutate(deleteId); setDeleteId(null); }} />
); }