ec57eff59a
Wire up named dashboards end-to-end. The /d/:slug route (already
referenced by useDashboards-driven nav entries from slice 4) now
renders NamedDashboardPage instead of 404ing.
Backend: GET /api/dashboards/slug/:slug resolves a dashboard by slug
(404 when not found). The store method existed from slice 3; only the
router endpoint was missing.
NamedDashboardPage: renders a named dashboard's payload -- an ordered
list of inline items with a type discriminator. This slice ships
'link' items (PinnedServiceLink -> navigates to /services/:type/:id).
Full widget composition on named dashboards is a follow-up; the main
Dashboard keeps the rich WidgetConfigDialog.
PinnedServiceLink: card component (lucide icon + label) navigating to
a service page or specific tab.
Dashboard management UI on the Services page (DashboardManagementCard):
list existing dashboards with reorder/delete, create via dialog
(label -> auto-slug), add pinned service links per dashboard (label +
enabled-service dropdown). Placed on Services (the admin hub) rather
than Settings to avoid an extra nav trip.
Dashboard payload model: inline items ({ items: [{ type: 'link', label,
target }] }) -- self-contained, no separate widget-instance fetch
needed. The type discriminator allows future widget items without
breaking existing payloads.
Tests: NamedDashboardPage (renders links, not-found state),
PinnedServiceLink (renders + navigates). 112 frontend tests pass (+6);
271 backend tests pass (no regression); lint/build green both sides.
Refs openspec/changes/services-as-hub-ia/ (spec R5, tasks slice 10).
628 lines
17 KiB
TypeScript
628 lines
17 KiB
TypeScript
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,
|
|
} from "../hooks/useServices";
|
|
import { useServiceTypes } from "../hooks/useServices";
|
|
import {
|
|
useDashboards,
|
|
useDeleteDashboard,
|
|
useSaveDashboard,
|
|
} from "../hooks/useDashboards";
|
|
import type {
|
|
SecretFieldInfo,
|
|
ServiceInstance,
|
|
ServiceInstanceInput,
|
|
ServiceTypeInfo,
|
|
} from "../types";
|
|
import { SectionCard } from "../components/SectionCard";
|
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
|
import { DialogFooter } from "../components/DialogFooter";
|
|
import { getServiceBinding } from "../integrations/registry";
|
|
import { serviceLinkTarget } from "../components/PinnedServiceLink";
|
|
import type { NamedDashboardInput } from "../api/dashboards";
|
|
|
|
interface CreateDraft {
|
|
serviceType: string;
|
|
name: string;
|
|
config: Record<string, unknown>;
|
|
secrets: Record<string, string>;
|
|
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 (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor={htmlFor}>{label}</Label>
|
|
{children}
|
|
{helper ? (
|
|
<p className="text-xs text-muted-foreground">{helper}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServiceConfigFields({
|
|
type,
|
|
config,
|
|
onChange,
|
|
}: {
|
|
type: ServiceTypeInfo;
|
|
config: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
}) {
|
|
const properties =
|
|
(
|
|
type.config_schema as {
|
|
properties?: Record<string, { type?: string; description?: string }>;
|
|
}
|
|
).properties ?? {};
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{Object.entries(properties).map(([key, schema]) => {
|
|
const isNumber = schema.type === "integer" || schema.type === "number";
|
|
return (
|
|
<Field
|
|
key={key}
|
|
label={key}
|
|
htmlFor={`cfg-${key}`}
|
|
helper={schema.description}
|
|
>
|
|
<Input
|
|
id={`cfg-${key}`}
|
|
type={isNumber ? "number" : "text"}
|
|
value={String(config[key] ?? "")}
|
|
onChange={(e) =>
|
|
onChange({
|
|
...config,
|
|
[key]: isNumber
|
|
? e.target.value === ""
|
|
? undefined
|
|
: Number(e.target.value)
|
|
: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</Field>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServiceSecretFields({
|
|
fields,
|
|
secrets,
|
|
onChange,
|
|
}: {
|
|
fields: SecretFieldInfo[];
|
|
secrets: Record<string, string>;
|
|
onChange: (secrets: Record<string, string>) => void;
|
|
}) {
|
|
if (fields.length === 0) return null;
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{fields.map((field) => (
|
|
<Field
|
|
key={field.key}
|
|
label={field.label}
|
|
htmlFor={`secret-${field.key}`}
|
|
helper={field.helper ?? (field.required ? "Required" : undefined)}
|
|
>
|
|
<Input
|
|
id={`secret-${field.key}`}
|
|
type="password"
|
|
value={secrets[field.key] ?? ""}
|
|
onChange={(e) =>
|
|
onChange({ ...secrets, [field.key]: e.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CreateServiceDialog({
|
|
open,
|
|
onClose,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const { data: types = [] } = useServiceTypes();
|
|
const saveService = useSaveServiceInstance();
|
|
const [draft, setDraft] = useState<CreateDraft | null>(null);
|
|
|
|
function reset() {
|
|
setDraft(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,
|
|
};
|
|
await saveService.mutateAsync(input);
|
|
reset();
|
|
onClose();
|
|
}
|
|
|
|
const selectedType = types.find((t) => t.service_type === draft?.serviceType);
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
if (!next) {
|
|
reset();
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>New service</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-4">
|
|
{!draft ? (
|
|
<div className="flex flex-col gap-2">
|
|
{types.map((t) => (
|
|
<Button
|
|
key={t.service_type}
|
|
variant="outline"
|
|
onClick={() => setDraft(emptyDraft(t.service_type))}
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
{t.name}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">
|
|
{selectedType?.description}
|
|
</p>
|
|
<Field label="Name" htmlFor="service-name">
|
|
<Input
|
|
id="service-name"
|
|
value={draft.name}
|
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
|
/>
|
|
</Field>
|
|
{selectedType ? (
|
|
<ServiceConfigFields
|
|
type={selectedType}
|
|
config={draft.config}
|
|
onChange={(config) => setDraft({ ...draft, config })}
|
|
/>
|
|
) : null}
|
|
{selectedType ? (
|
|
<ServiceSecretFields
|
|
fields={selectedType.secret_fields}
|
|
secrets={draft.secrets}
|
|
onChange={(secrets) => setDraft({ ...draft, secrets })}
|
|
/>
|
|
) : null}
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="service-enabled"
|
|
checked={draft.enabled}
|
|
onCheckedChange={(checked) =>
|
|
setDraft({ ...draft, enabled: checked })
|
|
}
|
|
/>
|
|
<Label htmlFor="service-enabled">Enabled</Label>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{draft ? (
|
|
<DialogFooter
|
|
onCancel={reset}
|
|
onConfirm={save}
|
|
confirmLabel="Create service"
|
|
confirmDisabled={!draft.name.trim() || saveService.isPending}
|
|
/>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// --- 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<string | null>(null);
|
|
const [linkDashId, setLinkDashId] = useState<string | null>(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 (
|
|
<SectionCard
|
|
title="Dashboards"
|
|
description="Named dashboards appear in the top nav. Compose them from pinned service links."
|
|
action={
|
|
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
New dashboard
|
|
</Button>
|
|
}
|
|
>
|
|
{dashboards.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No named dashboards yet. Create one to add pinned service links.
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-3">
|
|
{[...dashboards]
|
|
.sort((a, b) => a.sort_order - b.sort_order)
|
|
.map((d, idx, arr) => (
|
|
<div key={d.id} className="rounded border p-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{d.label}</span>
|
|
<Badge variant="outline">/{d.slug}</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
disabled={idx === 0}
|
|
onClick={() => reorder(d.id, -1)}
|
|
>
|
|
<ChevronUp className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
disabled={idx === arr.length - 1}
|
|
onClick={() => reorder(d.id, 1)}
|
|
>
|
|
<ChevronDown className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive"
|
|
onClick={() => setDeleteId(d.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
{Array.isArray(d.payload.items) &&
|
|
(d.payload.items as unknown[]).length > 0 ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{(d.payload.items as unknown[]).length} pinned link(s)
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">
|
|
No links yet
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap items-end gap-2">
|
|
<Field label="Link label" htmlFor={`link-label-${d.id}`}>
|
|
<Input
|
|
id={`link-label-${d.id}`}
|
|
className="w-40"
|
|
placeholder="My Jellyfin"
|
|
value={linkDashId === d.id ? linkLabel : ""}
|
|
onChange={(e) => {
|
|
setLinkDashId(d.id);
|
|
setLinkLabel(e.target.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor={`link-target-${d.id}`}>Service</Label>
|
|
<Select
|
|
value={linkDashId === d.id ? linkTarget : ""}
|
|
onValueChange={(v) => {
|
|
setLinkDashId(d.id);
|
|
setLinkTarget(v);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
id={`link-target-${d.id}`}
|
|
className="w-56"
|
|
>
|
|
<SelectValue placeholder="Pick a service" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{enabledServices.map((s) => (
|
|
<SelectItem
|
|
key={s.id}
|
|
value={serviceLinkTarget(s.service_type, s.id)}
|
|
>
|
|
{s.name} ({s.service_type})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={
|
|
linkDashId !== d.id ||
|
|
!linkLabel.trim() ||
|
|
!linkTarget.trim()
|
|
}
|
|
onClick={addPinnedLink}
|
|
>
|
|
Add link
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>New dashboard</DialogTitle>
|
|
</DialogHeader>
|
|
<Field label="Label" htmlFor="dash-label">
|
|
<Input
|
|
id="dash-label"
|
|
placeholder="Storage overview"
|
|
value={newLabel}
|
|
onChange={(e) => setNewLabel(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") createDashboard();
|
|
}}
|
|
/>
|
|
</Field>
|
|
<DialogFooter
|
|
onCancel={() => setCreateOpen(false)}
|
|
onConfirm={createDashboard}
|
|
confirmLabel="Create"
|
|
confirmDisabled={!newLabel.trim() || saveDashboard.isPending}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(deleteId)}
|
|
title="Delete dashboard?"
|
|
message="This removes the named dashboard and its pinned links."
|
|
confirmLabel="Delete"
|
|
onCancel={() => setDeleteId(null)}
|
|
onConfirm={() => {
|
|
if (deleteId) deleteDashboard.mutate(deleteId);
|
|
setDeleteId(null);
|
|
}}
|
|
/>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
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<string | null>(null);
|
|
|
|
const grouped = useMemo(() => {
|
|
const map = new Map<string, ServiceInstance[]>();
|
|
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 (
|
|
<div className="flex flex-col gap-4">
|
|
<SectionCard
|
|
title="Services"
|
|
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
|
|
action={
|
|
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
Add service
|
|
</Button>
|
|
}
|
|
>
|
|
{services.length === 0 ? (
|
|
<Alert>
|
|
<AlertDescription>
|
|
No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud,
|
|
or SSH task runner.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
{grouped.map(([serviceType, instances]) => (
|
|
<div key={serviceType} className="flex flex-col gap-2">
|
|
<div className="text-sm font-medium">
|
|
{typeName(serviceType)}
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{instances.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
className="flex items-center gap-2 rounded border p-2"
|
|
>
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{s.name}</span>
|
|
<Badge variant="outline">{s.service_type}</Badge>
|
|
{!s.enabled ? (
|
|
<Badge variant="secondary">disabled</Badge>
|
|
) : null}
|
|
{Object.entries(s.secrets_set).some(([, v]) => v) ? (
|
|
<Badge variant="outline">secrets set</Badge>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
navigate(`/services/${s.service_type}/${s.id}`)
|
|
}
|
|
>
|
|
Open <ExternalLink className="ml-1 h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive"
|
|
onClick={() => setDeleteId(s.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SectionCard>
|
|
|
|
<DashboardManagementCard />
|
|
|
|
<CreateServiceDialog
|
|
open={createOpen}
|
|
onClose={() => setCreateOpen(false)}
|
|
/>
|
|
<ConfirmDialog
|
|
open={Boolean(deleteId)}
|
|
title="Delete service?"
|
|
message="This removes the service and any widgets that reference it. This cannot be undone."
|
|
confirmLabel="Delete"
|
|
onCancel={() => setDeleteId(null)}
|
|
onConfirm={() => {
|
|
if (deleteId) deleteService.mutate(deleteId);
|
|
setDeleteId(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|