Service IA refinement: nav naming, instance tabs, config to Settings, configurable Overview

Four coupled changes to the services-as-hub IA:

1. Nav entries use service TYPE names (Jellyfin, SSH Tasks, Alertmanager,
   Grafana, Prometheus, Backups, Authentik) instead of conceptual names
   (Media, Files, Actions, Alerts, Users). ssh_tasks collapses to one
   entry ('SSH Tasks') instead of two. The content tabs inside each
   service page surface the concepts (Files, Actions).

2. Service page gains a two-level tab structure when multiple enabled
   instances of the same type exist: instance tabs on top ([Main Jellyfin]
   [Backup Jellyfin]), content tabs below ([Overview] [Media] [Requests]
   [Widgets]). Clicking an instance tab navigates to the sibling's route.
   Single instance: no instance tabs. Replaces the dropdown switcher.

3. Config tab (connection fields, secrets, enable/disable, delete) moves
   from the service page to Settings > Services tab. The service page
   becomes a PURE operational view (Overview + content tabs + Widgets) --
   no save/delete/config state. Settings gains a 4th tab 'Services' with
   ServiceConfigEditor per instance (schema-driven config fields, secrets
   with leave-blank-to-keep semantics, ConfirmDialog on delete).

4. Overview tab is now a configurable widget grid per service instance.
   Each instance manages its own set of widgets on its Overview. Backend
   widget list endpoints gain ?service_id= and ?scope= (dashboard|service)
   filter params; the main Dashboard uses scope=dashboard to exclude
   service-scoped widgets. The OverviewTab reuses WidgetInstanceCard +
   WidgetConfigDialog. Empty state CTA for instances with no widgets.

All service-tab stubs are replaced; stubs.tsx deleted.

272 backend tests pass (+1 widget filter); 121 frontend tests pass (+3
instance-tabs + OverviewTab); lint/build green both sides.
This commit is contained in:
Developer
2026-06-26 22:25:46 +00:00
parent fef0ded76f
commit 8d2e4c9bfd
17 changed files with 812 additions and 422 deletions
+8 -2
View File
@@ -12,8 +12,14 @@ export async function fetchBuiltinWidgetKinds(): Promise<
return get<BuiltinWidgetKindInfo[]>("/api/widgets/builtin");
}
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
return get<WidgetInstance[]>("/api/widgets/instances");
export async function fetchWidgetInstances(
serviceId?: string,
scope?: "dashboard" | "service",
): Promise<WidgetInstance[]> {
const params: Record<string, string> = {};
if (serviceId) params.service_id = serviceId;
if (scope) params.scope = scope;
return get<WidgetInstance[]>("/api/widgets/instances", params);
}
export async function createWidgetInstance(
+6 -3
View File
@@ -9,10 +9,13 @@ import {
} from "../api/widgets";
import type { WidgetInstanceInput } from "../types";
export function useWidgetInstances() {
export function useWidgetInstances(
serviceId?: string,
scope?: "dashboard" | "service",
) {
return useQuery({
queryKey: ["widgets", "instances"],
queryFn: fetchWidgetInstances,
queryKey: ["widgets", "instances", serviceId ?? null, scope ?? null],
queryFn: () => fetchWidgetInstances(serviceId, scope),
refetchInterval: 60_000,
});
}
@@ -6,17 +6,17 @@ describe("navEntries", () => {
expect(configuredNavEntries(new Set())).toEqual([]);
});
it("returns Media when jellyfin is configured", () => {
it("returns Jellyfin when jellyfin is configured", () => {
const entries = configuredNavEntries(new Set(["jellyfin"]));
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe("Media");
expect(entries[0].label).toBe("Jellyfin");
expect(entries[0].path).toBe("/services/jellyfin");
});
it("returns Files + Actions when ssh_tasks is configured", () => {
it("returns one SSH Tasks entry when ssh_tasks is configured", () => {
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.label)).toEqual(["Files", "Actions"]);
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe("SSH Tasks");
});
it("returns all observability entries", () => {
@@ -24,15 +24,15 @@ describe("navEntries", () => {
new Set(["alertmanager", "grafana", "prometheus"]),
);
expect(entries.map((e) => e.label)).toEqual([
"Alerts",
"Alertmanager",
"Grafana",
"Prometheus",
]);
});
it("returns Backups + Users when configured", () => {
it("returns Backups + Authentik when configured", () => {
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
expect(entries.map((e) => e.label)).toEqual(["Backups", "Users"]);
expect(entries.map((e) => e.label)).toEqual(["Backups", "Authentik"]);
});
it("nextcloud has no nav entries in the static map", () => {
@@ -46,10 +46,9 @@ describe("navEntries", () => {
new Set(["authentik", "ssh_tasks", "jellyfin"]),
);
expect(entries.map((e) => e.label)).toEqual([
"Media",
"Files",
"Actions",
"Users",
"Jellyfin",
"SSH Tasks",
"Authentik",
]);
});
});
+13 -18
View File
@@ -1,19 +1,19 @@
/**
* Service-type → conditional nav-entry map.
*
* Each configured service type contributes one or more top-level nav entries
* that appear only when at least one enabled instance of that type exists.
* See OpenSpec change `services-as-hub-ia`, spec R1.2.
* Each configured service type contributes ONE top-level nav entry that
* appears only when at least one enabled instance of that type exists. The
* label is the service TYPE name (Jellyfin, SSH Tasks), not a conceptual
* name (Media, Files) — the service page's content tabs surface the concepts.
*/
import {
Activity,
DatabaseBackup,
FolderOpen,
GanttChartSquare,
Link2,
Monitor,
Server,
Users,
Zap,
type LucideIcon,
} from "lucide-react";
@@ -26,31 +26,26 @@ export interface NavEntry {
}
/**
* Static mapping from service type to its conditional nav entries.
* `nextcloud` has no entries (no operational content).
* Static mapping from service type to its conditional nav entry.
* Uses the service type's display name. One entry per type.
* `nextcloud` has no entry (no operational content tabs).
*/
export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
{
serviceType: "jellyfin",
label: "Media",
label: "Jellyfin",
icon: Monitor,
path: "/services/jellyfin",
},
{
serviceType: "ssh_tasks",
label: "Files",
icon: FolderOpen,
path: "/services/ssh_tasks",
},
{
serviceType: "ssh_tasks",
label: "Actions",
icon: Zap,
label: "SSH Tasks",
icon: Server,
path: "/services/ssh_tasks",
},
{
serviceType: "alertmanager",
label: "Alerts",
label: "Alertmanager",
icon: Activity,
path: "/services/alertmanager",
},
@@ -74,7 +69,7 @@ export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
},
{
serviceType: "authentik",
label: "Users",
label: "Authentik",
icon: Users,
path: "/services/authentik",
},
+4 -2
View File
@@ -146,7 +146,6 @@ function MobileWidgetSections({
);
}
function emptyShortcut(): DashboardShortcutInput {
return {
id: null,
@@ -449,7 +448,10 @@ export function Dashboard() {
);
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
const { data: widgetInstances = [] } = useWidgetInstances();
const { data: widgetInstances = [] } = useWidgetInstances(
undefined,
"dashboard",
);
const { data: services = [] } = useServiceInstances();
const isMobile = useIsMobile();
+28 -303
View File
@@ -1,32 +1,11 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useParams, 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
useServiceTypes,
} from "../hooks/useServices";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../types";
import { useServiceInstances } from "../hooks/useServices";
import type { ServiceInstance } from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { getServiceBinding } from "../integrations/registry";
import {
OVERVIEW_TAB,
@@ -34,77 +13,30 @@ import {
type ContentTab,
} from "./service-tabs";
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>
);
}
export function ServicePage() {
const { serviceType = "", serviceId = "" } = useParams<{
serviceType: string;
serviceId: string;
}>();
const { data: services = [] } = useServiceInstances(serviceType || undefined);
const { data: types = [] } = useServiceTypes();
const navigate = useNavigate();
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
const instance = useMemo(
() => services.find((s) => s.id === serviceId),
[services, serviceId],
);
const binding = getServiceBinding(serviceType);
const typeInfo = useMemo(
() => types.find((t) => t.service_type === serviceType),
[types, serviceType],
);
const contentTabs = useMemo(
() => serviceContentTabs(serviceType),
[serviceType],
);
const siblings = useMemo(
() => services.filter((s) => s.service_type === serviceType),
() => services.filter((s) => s.service_type === serviceType && s.enabled),
[services, serviceType],
);
// R3.1: switcher trigger keys off ENABLED siblings (not total).
const enabledSiblings = useMemo(
() => siblings.filter((s) => s.enabled),
[siblings],
);
const showSwitcher = enabledSiblings.length > 1;
const showInstanceTabs = siblings.length > 1;
const [name, setName] = useState("");
const [enabled, setEnabled] = useState(true);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const [deleteOpen, setDeleteOpen] = useState(false);
const [hydrated, setHydrated] = useState(false);
if (instance && !hydrated) {
setName(instance.name);
setEnabled(instance.enabled);
setDraftConfig({ ...instance.config });
setDraftSecrets({});
setHydrated(true);
}
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
if (!binding) {
return (
@@ -122,33 +54,6 @@ export function ServicePage() {
);
}
function buildInput(): ServiceInstanceInput {
// R2.3/R10.1: collect typed secret drafts. Empty values mean "keep the
// existing value" so they are filtered out before sending.
const onlyChangedSecrets = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
return {
id: instance!.id,
service_type: instance!.service_type,
name,
config: draftConfig,
secrets: onlyChangedSecrets,
enabled,
};
}
async function save() {
await saveService.mutateAsync(buildInput());
// Clear secret drafts after a successful save so the inputs reset to
// "leave blank to keep" state.
setDraftSecrets({});
}
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
// The config + widgets body, rendered inside the Config and Widgets tabs.
const widgetsContent =
binding.widgets.length > 0 ? (
<div className="flex flex-col gap-2">
@@ -176,55 +81,37 @@ export function ServicePage() {
</p>
);
const configBody = (
<ConfigBody
instance={instance}
typeInfo={typeInfo}
draftConfig={draftConfig}
onConfigChange={setDraftConfig}
draftSecrets={draftSecrets}
onSecretsChange={setDraftSecrets}
name={name}
enabled={enabled}
onNameChange={setName}
onEnabledChange={setEnabled}
onSave={save}
savePending={saveService.isPending}
onDelete={() => setDeleteOpen(true)}
/>
);
return (
<div className="flex flex-col gap-4">
{/* Header + instance switcher */}
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="text-xl font-semibold">{instance.name}</h2>
<p className="text-sm text-muted-foreground">{binding.description}</p>
</div>
<div className="flex items-center gap-2">
{showSwitcher ? (
<Select
value={instance.id}
onValueChange={(id) => navigate(`/services/${serviceType}/${id}`)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{siblings.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Badge variant="outline">{binding.name}</Badge>
</div>
<Badge variant="outline">{binding.name}</Badge>
</div>
{/* Tab skeleton */}
{/* Instance tabs (only when >1 enabled sibling) */}
{showInstanceTabs ? (
<Tabs value={instance.id}>
<TabsList>
{siblings.map((sibling: ServiceInstance) => (
<TabsTrigger
key={sibling.id}
value={sibling.id}
onClick={() =>
navigate(`/services/${serviceType}/${sibling.id}`)
}
>
{sibling.name}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{/* Content tabs */}
<Tabs defaultValue="Overview">
<TabsList>
<TabsTrigger value="Overview">Overview</TabsTrigger>
@@ -234,7 +121,6 @@ export function ServicePage() {
</TabsTrigger>
))}
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
<TabsTrigger value="Config">Config</TabsTrigger>
</TabsList>
{allTabs.map((tab) => {
@@ -254,168 +140,7 @@ export function ServicePage() {
{widgetsContent}
</SectionCard>
</TabsContent>
<TabsContent value="Config">{configBody}</TabsContent>
</Tabs>
<ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
navigate("/services");
}}
/>
</div>
);
}
function ConfigBody({
instance,
typeInfo,
draftConfig,
onConfigChange,
draftSecrets,
onSecretsChange,
name,
enabled,
onNameChange,
onEnabledChange,
onSave,
savePending,
onDelete,
}: {
instance: ServiceInstance;
typeInfo: ServiceTypeInfo | undefined;
draftConfig: Record<string, unknown>;
onConfigChange: (config: Record<string, unknown>) => void;
draftSecrets: Record<string, string>;
onSecretsChange: (secrets: Record<string, string>) => void;
name: string;
enabled: boolean;
onNameChange: (name: string) => void;
onEnabledChange: (enabled: boolean) => void;
onSave: () => void;
savePending: boolean;
onDelete: () => void;
}) {
const properties =
(
(typeInfo?.config_schema ?? {}) as {
properties?: Record<
string,
{ type?: string; description?: string; default?: unknown }
>;
}
).properties ?? {};
const configEntries: Array<
[string, { type?: string; description?: string }]
> =
Object.keys(properties).length > 0
? Object.entries(properties).map(([key, schema]) => [
key,
{ type: schema?.type, description: schema?.description },
])
: Object.entries(instance.config).map(([key, value]) => [
key,
{ type: typeof value === "number" ? "integer" : "string" },
]);
return (
<SectionCard title="Config">
<div className="flex flex-col gap-3">
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={enabled}
onCheckedChange={onEnabledChange}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
{configEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No connection config.</p>
) : (
<div className="flex flex-col gap-3">
{configEntries.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(draftConfig[key] ?? "")}
onChange={(e) =>
onConfigChange({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</Field>
);
})}
</div>
)}
{Object.keys(instance.secrets_set).length === 0 ? null : (
<div className="flex flex-col gap-3">
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
<div key={key} className="flex flex-col gap-1.5">
<Field
label={key}
htmlFor={`secret-${key}`}
helper="Leave blank to keep the current value."
>
<Input
id={`secret-${key}`}
type="password"
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
onSecretsChange({
...draftSecrets,
[key]: e.target.value,
})
}
/>
</Field>
{isSet ? <Badge variant="secondary">set</Badge> : null}
</div>
))}
</div>
)}
<div className="flex justify-between">
<Button onClick={onSave} disabled={savePending}>
Save
</Button>
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
</div>
</div>
</SectionCard>
);
}
+239 -1
View File
@@ -50,6 +50,17 @@ import {
import { Switch } from "@/components/ui/switch";
import { TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
useServiceTypes,
} from "../hooks/useServices";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../types";
const SERVICE_OPTIONS = [
{ value: "monitoring", label: "Monitoring" },
@@ -61,7 +72,7 @@ const SERVICE_OPTIONS = [
// maps to this sentinel and converts back to "" at the draft boundary.
const NONE = "__none__";
type SettingsTab = "machines" | "ssh-keys" | "danger";
type SettingsTab = "machines" | "ssh-keys" | "services" | "danger";
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
function FormField({
@@ -995,6 +1006,9 @@ export function Settings() {
<TabsTrigger key="ssh-keys" value="ssh-keys">
SSH Keys
</TabsTrigger>,
<TabsTrigger key="services" value="services">
Services
</TabsTrigger>,
<TabsTrigger key="danger" value="danger">
Danger Zone
</TabsTrigger>,
@@ -1178,6 +1192,7 @@ export function Settings() {
onSelectKeyId={setSelectedSSHKeyId}
/>
)}
{tab === "services" && <ServicesAdminCard />}
{tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard>
{isMobile ? (
@@ -1311,3 +1326,226 @@ export function Settings() {
</div>
);
}
/**
* Services admin card for the Settings > Services tab.
*
* Lists all service instances grouped by type with inline config editing
* (enable/disable, config fields, secrets, save, delete). Lifted from the
* old ServicePage ConfigBody — the service page is now a pure operational
* view; all administration lives here.
*/
function ServicesAdminCard() {
const { data: services = [] } = useServiceInstances();
const { data: types = [] } = useServiceTypes();
// Group by service_type, alphabetical.
const grouped = useMemo(() => {
const map = new Map<string, ServiceInstance[]>();
for (const svc of services) {
const list = map.get(svc.service_type) ?? [];
list.push(svc);
map.set(svc.service_type, list);
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [services]);
return (
<div className="flex flex-col gap-4">
{grouped.length === 0 ? (
<p className="text-sm text-muted-foreground">
No service instances configured. Create one from the Services page.
</p>
) : (
grouped.map(([serviceType, instances]) => {
const typeInfo = types.find((t) => t.service_type === serviceType);
return (
<SectionCard
key={serviceType}
title={typeInfo?.name ?? serviceType}
description={typeInfo?.description ?? ""}
>
<div className="flex flex-col gap-4">
{instances.map((svc) => (
<ServiceConfigEditor
key={svc.id}
instance={svc}
typeInfo={typeInfo}
/>
))}
</div>
</SectionCard>
);
})
)}
</div>
);
}
function ServiceConfigEditor({
instance,
typeInfo,
}: {
instance: ServiceInstance;
typeInfo: ServiceTypeInfo | undefined;
}) {
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
const [name, setName] = useState(instance.name);
const [enabled, setEnabled] = useState(instance.enabled);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
...instance.config,
});
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const [deleteOpen, setDeleteOpen] = useState(false);
const properties =
(
(typeInfo?.config_schema ?? {}) as {
properties?: Record<string, { type?: string; description?: string }>;
}
).properties ?? {};
const configEntries: Array<
[string, { type?: string; description?: string }]
> =
Object.keys(properties).length > 0
? Object.entries(properties).map(([key, schema]) => [
key,
{ type: schema?.type, description: schema?.description },
])
: Object.entries(instance.config).map(([key, value]) => [
key,
{ type: typeof value === "number" ? "integer" : "string" },
]);
function buildInput(): ServiceInstanceInput {
const onlyChangedSecrets = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
return {
id: instance.id,
service_type: instance.service_type,
name,
config: draftConfig,
secrets: onlyChangedSecrets,
enabled,
};
}
async function handleSave() {
await saveService.mutateAsync(buildInput());
setDraftSecrets({});
}
return (
<>
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium">{instance.name}</span>
<Badge variant={instance.enabled ? "default" : "secondary"}>
{instance.enabled ? "enabled" : "disabled"}
</Badge>
</div>
<div className="flex flex-col gap-3">
<FormField label="Name" htmlFor={`svc-name-${instance.id}`}>
<Input
id={`svc-name-${instance.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
<div className="flex items-center gap-2">
<Switch
id={`svc-enabled-${instance.id}`}
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor={`svc-enabled-${instance.id}`}>Enabled</Label>
</div>
{configEntries.map(([key, schema]) => {
const isNumber =
schema.type === "integer" || schema.type === "number";
return (
<FormField
key={key}
label={key}
htmlFor={`svc-cfg-${instance.id}-${key}`}
helperText={schema.description}
>
<Input
id={`svc-cfg-${instance.id}-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
setDraftConfig({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</FormField>
);
})}
{Object.keys(instance.secrets_set).length === 0
? null
: Object.entries(instance.secrets_set).map(([key, isSet]) => (
<FormField
key={key}
label={key}
htmlFor={`svc-secret-${instance.id}-${key}`}
helperText="Leave blank to keep the current value."
>
<Input
id={`svc-secret-${instance.id}-${key}`}
type="password"
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
setDraftSecrets({
...draftSecrets,
[key]: e.target.value,
})
}
/>
</FormField>
))}
<div className="flex justify-between">
<Button
onClick={handleSave}
disabled={saveService.isPending}
className="mobile-touch-target"
>
Save
</Button>
<Button
variant="destructive"
onClick={() => setDeleteOpen(true)}
className="mobile-touch-target"
>
Delete
</Button>
</div>
</div>
</div>
<ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
}}
/>
</>
);
}
@@ -1,8 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServicePage } from "../ServicePage";
import type { ServiceInstance, ServiceTypeInfo } from "../../types";
import type { ServiceInstance } from "../../types";
const instance: ServiceInstance = {
id: "svc-1",
@@ -15,17 +16,7 @@ const instance: ServiceInstance = {
updated_at: 1_700_000_000,
};
const typeInfo: ServiceTypeInfo = {
service_type: "jellyfin",
name: "Jellyfin",
description: "Media server",
config_schema: {
type: "object",
properties: { base_url: { type: "string" } },
},
secret_fields: [{ key: "api_key", label: "API key", required: false }],
widget_kinds: [],
};
// typeInfo no longer needed on ServicePage (config moved to Settings).
const secondInstance: ServiceInstance = {
...instance,
@@ -33,20 +24,23 @@ const secondInstance: ServiceInstance = {
name: "Backup Jellyfin",
};
const saveMutateAsync = vi.fn();
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
?.__svcInstances ?? [instance],
}),
useServiceTypes: () => ({ data: [typeInfo] }),
useSaveServiceInstance: () => ({
mutateAsync: saveMutateAsync,
mutate: vi.fn(),
isPending: false,
}),
useDeleteServiceInstance: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }),
}));
vi.mock("../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: () => null,
}));
vi.mock("../../components/WidgetInstance", () => ({
WidgetInstanceCard: () => null,
}));
vi.mock("../../integrations/registry", () => ({
@@ -71,13 +65,19 @@ function renderServicePage(path: string) {
}
describe("ServicePage tab skeleton", () => {
it("renders Overview + Media + Requests + Widgets + Config for jellyfin", () => {
it("renders Overview + Media + Requests + Widgets for jellyfin", () => {
renderServicePage("/services/jellyfin/svc-1");
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Widgets" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Config" })).toBeInTheDocument();
});
it("does NOT render Config tab (moved to Settings)", () => {
renderServicePage("/services/jellyfin/svc-1");
expect(
screen.queryByRole("tab", { name: "Config" }),
).not.toBeInTheDocument();
});
it("does NOT render Media/Requests for non-jellyfin types", () => {
@@ -93,43 +93,37 @@ describe("ServicePage tab skeleton", () => {
).not.toBeInTheDocument();
});
it("shows instance switcher when >1 sibling of same type", () => {
it("shows instance tabs when >1 enabled sibling of same type", () => {
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [instance, secondInstance];
const { container } = renderServicePage("/services/jellyfin/svc-1");
// The switcher renders as a Select trigger (combobox).
expect(container.querySelector("[role='combobox']")).toBeInTheDocument();
renderServicePage("/services/jellyfin/svc-1");
expect(
screen.getByRole("tab", { name: "Main Jellyfin" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "Backup Jellyfin" }),
).toBeInTheDocument();
});
it("hides instance switcher when only one instance", () => {
it("hides instance tabs when only one instance", () => {
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [instance];
const { container } = renderServicePage("/services/jellyfin/svc-1");
// No select trigger rendered (only one instance).
renderServicePage("/services/jellyfin/svc-1");
expect(
container.querySelector("[role='combobox']"),
screen.queryByRole("tab", { name: "Main Jellyfin" }),
).not.toBeInTheDocument();
});
it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
const { userEvent } = await import("@testing-library/user-event");
it("clicking an instance tab navigates to that instance", async () => {
const user = userEvent.setup();
saveMutateAsync.mockReset();
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [instance, secondInstance];
renderServicePage("/services/jellyfin/svc-1");
// Open the Config tab and type a new api_key.
await user.click(screen.getByRole("tab", { name: "Config" }));
const secretInput = screen.getByLabelText("api_key");
await user.type(secretInput, "new-secret-value");
// Save and assert the typed secret is in the payload (not secrets: {}).
await user.click(screen.getByRole("button", { name: "Save" }));
expect(saveMutateAsync).toHaveBeenCalledTimes(1);
const input = saveMutateAsync.mock.calls[0][0] as {
secrets: Record<string, string>;
};
expect(input.secrets).toEqual({ api_key: "new-secret-value" });
await user.click(screen.getByRole("tab", { name: "Backup Jellyfin" }));
// The test router would navigate; we can't assert URL directly without
// a useNavigate mock, but the click should not throw.
});
});
@@ -0,0 +1,78 @@
/**
* Configurable per-service Overview tab.
*
* Each service instance manages its own set of widgets on this tab. The
* widget system is reused from the main Dashboard: widget instances with
* a `service_id` matching this instance are fetched and rendered in a
* responsive grid. An edit button opens the WidgetConfigDialog (same one
* the Dashboard uses) for add/remove/reorder/enable/disable.
*/
import { useMemo, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Settings2 } from "lucide-react";
import { useWidgetInstances } from "../../hooks/useWidgets";
import { WidgetInstanceCard } from "../../components/WidgetInstance";
import { WidgetConfigDialog } from "../../components/WidgetConfigDialog";
import type { ServiceInstance } from "../../types";
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
const { data: widgets = [] } = useWidgetInstances(instance.id);
const [configOpen, setConfigOpen] = useState(false);
const visibleWidgets = useMemo(
() =>
widgets
.filter((w) => w.enabled)
.sort((a, b) => a.sort_order - b.sort_order),
[widgets],
);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{instance.name} overview
</h3>
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => setConfigOpen(true)}
>
<Settings2 className="size-4" />
Edit widgets
</Button>
</div>
{visibleWidgets.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{visibleWidgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))}
</div>
) : (
<Alert>
<AlertDescription className="flex flex-col gap-3">
<span>
No widgets on this overview yet. Add widgets to show key metrics
and information for {instance.name}.
</span>
<Button
size="sm"
className="w-fit mobile-touch-target"
onClick={() => setConfigOpen(true)}
>
Add widgets
</Button>
</AlertDescription>
</Alert>
)}
<WidgetConfigDialog
open={configOpen}
onClose={() => setConfigOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OverviewTab } from "../OverviewTab";
import type { ServiceInstance } from "../../../types";
// Default mock returns an empty list; individual tests override via
// `vi.mocked()` to return widget data.
vi.mock("../../../hooks/useWidgets", () => ({
useWidgetInstances: vi.fn(() => ({ data: [] })),
}));
vi.mock("../../../components/WidgetInstance", () => ({
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
<div data-testid="widget-card">{widget.title}</div>
),
}));
vi.mock("../../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: ({ open }: { open: boolean }) =>
open ? <div data-testid="config-dialog" /> : null,
}));
const { useWidgetInstances } = await import("../../../hooks/useWidgets");
const instance: ServiceInstance = {
id: "svc-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config: {},
secrets_set: {},
enabled: true,
created_at: 0,
updated_at: 0,
};
function mockWidgets(
widgets: { id: string; title: string; enabled: boolean }[],
) {
vi.mocked(useWidgetInstances).mockReturnValue({
data: widgets.map((w, i) => ({
id: w.id,
service_id: "svc-1",
widget_kind: "activity",
title: w.title,
config: {},
enabled: w.enabled,
sort_order: i,
created_at: 0,
updated_at: 0,
})),
} as never);
}
describe("OverviewTab", () => {
it("renders enabled widgets in a grid and hides disabled ones", () => {
mockWidgets([
{ id: "w1", title: "Live Sessions", enabled: true },
{ id: "w2", title: "Disabled Widget", enabled: false },
]);
render(<OverviewTab instance={instance} />);
const cards = screen.getAllByTestId("widget-card");
expect(cards).toHaveLength(1);
expect(screen.getByText("Live Sessions")).toBeInTheDocument();
expect(screen.queryByText("Disabled Widget")).not.toBeInTheDocument();
});
it("shows an empty state with an add button when no widgets exist", () => {
mockWidgets([]);
render(<OverviewTab instance={instance} />);
expect(
screen.getByText(/No widgets on this overview/i),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Add widgets/i }),
).toBeInTheDocument();
});
it("opens the config dialog when Edit widgets is clicked", async () => {
mockWidgets([{ id: "w1", title: "Live", enabled: true }]);
render(<OverviewTab instance={instance} />);
expect(screen.queryByTestId("config-dialog")).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: /Edit widgets/i }),
);
expect(screen.getByTestId("config-dialog")).toBeInTheDocument();
});
});
+1 -1
View File
@@ -6,7 +6,7 @@
*/
import type { ComponentType } from "react";
import type { ServiceInstance } from "../../types";
import { OverviewTab } from "./stubs";
import { OverviewTab } from "./OverviewTab";
import { AlertsTab } from "./AlertsTab";
import { LinksTab } from "./LinksTab";
import { MetricsTab } from "./MetricsTab";
-29
View File
@@ -1,29 +0,0 @@
/**
* Service-page content tab stubs.
*
* Each stub renders a "coming soon" placeholder. Slices 59 replace these with
* real operational content lifted from the old top-level pages. All stubs accept
* an `instance` prop so the real implementations can scope queries by instance.
*/
import type { ServiceInstance } from "../../types";
import { Alert, AlertDescription } from "@/components/ui/alert";
function Stub({
label,
instance,
}: {
label: string;
instance: ServiceInstance;
}) {
return (
<Alert>
<AlertDescription>
{label} for {instance.name} coming soon.
</AlertDescription>
</Alert>
);
}
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Service overview" instance={instance} />;
}