Frontend: data-driven nav + service-page tab skeleton + stubs (Slice 4)

The IA shell lands. The static navItems array is replaced by useNavItems(),
which combines useServiceInstances (enabled instances) + useDashboards to
build the nav in spec order: Main Dashboard, named dashboards, conditional
service-type entries (one per configured type; ssh_tasks contributes Files
+ Actions, nextcloud contributes none), Services, Settings.

Legacy top-level routes (/media, /files, /actions, /users, /observability,
/backups, /monitoring, /applications) are removed; a NotFoundPage catch-all
returns 404 (R4.7).

ServicePage is refactored to a tab skeleton: Overview | type-specific
content tabs | Widgets | Config. serviceContentTabs(type) returns the
per-type set (jellyfin=Media+Requests, ssh_tasks=Files+Actions, backups=Jobs,
authentik=Users+Messaging, alertmanager=Alerts, grafana=Links,
prometheus=Metrics, nextcloud=none). Content tabs are stubs ('coming soon');
real content migrates in slices 5-9. Widgets + Config tabs preserve the
existing widget-list and config/secrets editing verbatim.

ServiceTypePage resolves /services/:type (no id) by redirecting to the
first enabled instance; empty state when none.

Instance switcher (Select) appears when >1 ENABLED sibling of the same
type exists (R3.1).

Empty states: Dashboard shows an 'Add a service' CTA when no instances
exist; ServicesPage already had a strong empty state.

Fixes from Slice 4 review:
- B1 (blocker): secret editing regressed because buildInput() hardcoded
  secrets:{} after the ConfigBody lift orphaned draftSecrets. Lifted
  draftSecrets to the parent ServicePage; buildInput now sends only the
  non-blank typed drafts ('leave blank to keep' semantics restored).
- S1: switcher trigger keys off enabled siblings, not total.

New: navEntries.ts + test, dashboards api/hook, service-tabs/ stubs +
index, ServiceTypePage, ServicePage tab skeleton + ConfigBody lift,
Dashboard empty-state CTA, ServicePage tab/switcher/secret-save tests.

Note: this branch is based on main (mobile-responsive-parity is unmerged);
the mobile SheetForm on ServicePage will be re-added when content tabs
get real content (slices 5-9). 84 tests pass (+1 secret-save guard);
lint/build green.

Refs openspec/changes/services-as-hub-ia/ (spec R1-R4/R9, tasks slice 4).
This commit is contained in:
Developer
2026-06-26 19:03:18 +00:00
parent a43d6a6206
commit caf6c226ff
12 changed files with 830 additions and 150 deletions
+72 -54
View File
@@ -5,7 +5,6 @@ import {
NavLink,
useLocation,
Outlet,
Navigate,
} from "react-router-dom";
import {
QueryClient,
@@ -13,21 +12,20 @@ import {
useQuery,
} from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Applications } from "./pages/Applications";
import { Settings } from "./pages/Settings";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
import { ServicePage } from "./pages/ServicePage";
import { ServiceTypePage } from "./pages/ServiceTypePage";
import { ServicesPage } from "./pages/ServicesPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
import { usePersistentState } from "./hooks/usePersistentState";
import { useServiceInstances } from "./hooks/useServices";
import { useDashboards } from "./hooks/useDashboards";
import { configuredNavEntries } from "./integrations/navEntries";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@@ -44,12 +42,6 @@ import {
} from "@/components/ui/sheet";
import {
LayoutDashboard,
Activity,
DatabaseBackup,
Monitor,
Users,
Zap,
FolderOpen,
Settings as SettingsIcon,
Menu,
Sun,
@@ -58,10 +50,17 @@ import {
ChevronLeft,
ChevronRight,
Boxes,
LayoutTemplate,
} from "lucide-react";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
refetchIntervalInBackground: false,
},
},
});
function useDarkMode() {
@@ -82,18 +81,40 @@ function useDarkMode() {
return [darkMode, () => setDarkMode((prev) => !prev)] as const;
}
// Navigation items for sidebar
const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/observability", label: "Observability", icon: Activity },
{ path: "/media", label: "Media", icon: Monitor },
{ path: "/files", label: "Files", icon: FolderOpen },
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
// Navigation items are data-driven (spec R1). Built from configured services + dashboards.
interface NavItem {
path: string;
label: string;
icon: LucideIcon;
}
function useNavItems() {
const { data: services = [] } = useServiceInstances();
const { data: dashboards = [] } = useDashboards();
return useMemo<NavItem[]>(() => {
const configuredTypes = new Set(
services.filter((s) => s.enabled).map((s) => s.service_type),
);
const serviceEntries = configuredNavEntries(configuredTypes).map((e) => ({
path: e.path,
label: e.label,
icon: e.icon,
}));
const dashboardEntries = dashboards.map((d) => ({
path: `/d/${d.slug}`,
label: d.label,
icon: LayoutTemplate,
}));
return [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
...dashboardEntries,
...serviceEntries,
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
}, [services, dashboards]);
}
function Sidebar({
collapsed,
@@ -105,6 +126,7 @@ function Sidebar({
isMobile: boolean;
}) {
const location = useLocation();
const navItems = useNavItems();
if (isMobile) return null;
@@ -189,6 +211,7 @@ function Sidebar({
function MobileDrawer() {
const [open, setOpen] = useState(false);
const location = useLocation();
const navItems = useNavItems();
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -253,6 +276,7 @@ function TopBar({
});
const backendLabel = appVersion?.backend_label || "…";
const navItems = useNavItems();
const pageTitle =
navItems.find((item) => item.path === location.pathname)?.label ||
"Dashboard";
@@ -427,6 +451,18 @@ function AuthenticatedApp() {
);
}
function NotFoundPage() {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<h2 className="text-xl font-semibold">Not found</h2>
<p className="text-sm text-muted-foreground">This page doesn't exist.</p>
<Button asChild>
<NavLink to="/">Back to dashboard</NavLink>
</Button>
</div>
);
}
function AppInner() {
const [darkMode, toggleDarkMode] = useDarkMode();
@@ -438,26 +474,17 @@ function AppInner() {
<Routes>
<Route element={<AuthenticatedApp />}>
<Route path="/" element={<Dashboard />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
/>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
@@ -474,26 +501,17 @@ function AppInner() {
}
>
<Route path="/" element={<Dashboard />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
/>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
+42
View File
@@ -0,0 +1,42 @@
/**
* API client for the named-dashboards backend (Slice 3).
*/
import { del, get, post, put } from "./shared";
export interface NamedDashboard {
id: string;
label: string;
slug: string;
sort_order: number;
payload: Record<string, unknown>;
created_at: number;
updated_at: number;
}
export interface NamedDashboardInput {
id?: string | null;
label: string;
slug?: string;
sort_order: number;
payload: Record<string, unknown>;
}
export async function fetchDashboards(): Promise<NamedDashboard[]> {
return get<NamedDashboard[]>("/api/dashboards");
}
export async function createDashboard(
input: NamedDashboardInput,
): Promise<NamedDashboard> {
return post<NamedDashboard>("/api/dashboards", input);
}
export async function updateDashboard(
input: NamedDashboardInput,
): Promise<NamedDashboard> {
return put<NamedDashboard>(`/api/dashboards`, input);
}
export async function deleteDashboard(id: string): Promise<{ status: string }> {
return del<{ status: string }>(`/api/dashboards/${id}`);
}
+37
View File
@@ -0,0 +1,37 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createDashboard,
deleteDashboard,
fetchDashboards,
updateDashboard,
type NamedDashboardInput,
} from "../api/dashboards";
export function useDashboards() {
return useQuery({
queryKey: ["dashboards"],
queryFn: fetchDashboards,
staleTime: 30 * 1000,
});
}
export function useSaveDashboard() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: NamedDashboardInput) =>
input.id ? updateDashboard(input) : createDashboard(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
},
});
}
export function useDeleteDashboard() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteDashboard(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
},
});
}
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries";
describe("navEntries", () => {
it("returns no entries when no types are configured", () => {
expect(configuredNavEntries(new Set())).toEqual([]);
});
it("returns Media when jellyfin is configured", () => {
const entries = configuredNavEntries(new Set(["jellyfin"]));
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe("Media");
expect(entries[0].path).toBe("/services/jellyfin");
});
it("returns Files + Actions 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"]);
});
it("returns all observability entries", () => {
const entries = configuredNavEntries(
new Set(["alertmanager", "grafana", "prometheus"]),
);
expect(entries.map((e) => e.label)).toEqual([
"Alerts",
"Grafana",
"Prometheus",
]);
});
it("returns Backups + Users when configured", () => {
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
expect(entries.map((e) => e.label)).toEqual(["Backups", "Users"]);
});
it("nextcloud has no nav entries in the static map", () => {
expect(
SERVICE_TYPE_NAV_ENTRIES.filter((e) => e.serviceType === "nextcloud"),
).toEqual([]);
});
it("preserves declaration order across mixed types", () => {
const entries = configuredNavEntries(
new Set(["authentik", "ssh_tasks", "jellyfin"]),
);
expect(entries.map((e) => e.label)).toEqual([
"Media",
"Files",
"Actions",
"Users",
]);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* 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.
*/
import {
Activity,
DatabaseBackup,
FolderOpen,
GanttChartSquare,
Link2,
Monitor,
Users,
Zap,
type LucideIcon,
} from "lucide-react";
export interface NavEntry {
serviceType: string;
label: string;
icon: LucideIcon;
/** Route path for this entry. */
path: string;
}
/**
* Static mapping from service type to its conditional nav entries.
* `nextcloud` has no entries (no operational content).
*/
export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
{
serviceType: "jellyfin",
label: "Media",
icon: Monitor,
path: "/services/jellyfin",
},
{
serviceType: "ssh_tasks",
label: "Files",
icon: FolderOpen,
path: "/services/ssh_tasks",
},
{
serviceType: "ssh_tasks",
label: "Actions",
icon: Zap,
path: "/services/ssh_tasks",
},
{
serviceType: "alertmanager",
label: "Alerts",
icon: Activity,
path: "/services/alertmanager",
},
{
serviceType: "grafana",
label: "Grafana",
icon: Link2,
path: "/services/grafana",
},
{
serviceType: "prometheus",
label: "Prometheus",
icon: GanttChartSquare,
path: "/services/prometheus",
},
{
serviceType: "backups",
label: "Backups",
icon: DatabaseBackup,
path: "/services/backups",
},
{
serviceType: "authentik",
label: "Users",
icon: Users,
path: "/services/authentik",
},
];
/**
* Filter the static entries to those whose service type is configured (present
* in the `configuredTypes` set). Returns a flat list in declaration order.
*/
export function configuredNavEntries(configuredTypes: Set<string>): NavEntry[] {
return SERVICE_TYPE_NAV_ENTRIES.filter((e) =>
configuredTypes.has(e.serviceType),
);
}
+23
View File
@@ -26,6 +26,7 @@ import {
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import { useWidgetInstances } from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
@@ -336,6 +337,7 @@ export function Dashboard() {
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
const { data: widgetInstances = [] } = useWidgetInstances();
const { data: services = [] } = useServiceInstances();
const visibleWidgets = useMemo(
() =>
@@ -374,6 +376,27 @@ export function Dashboard() {
return (
<div className="flex flex-col gap-4">
{services.length === 0 ? (
<SectionCard
title="Welcome to Manage"
description="Add a service to get started."
>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
No services configured yet. Add a Jellyfin, SSH target, Authentik,
or observability service to populate the navigation and
dashboards.
</p>
<Button
variant="outline"
onClick={() => navigate("/services")}
className="w-fit"
>
Add a service
</Button>
</div>
</SectionCard>
) : null}
<SectionCard
title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later."
+189 -96
View File
@@ -1,11 +1,19 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
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,
@@ -20,6 +28,11 @@ import type {
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { getServiceBinding } from "../integrations/registry";
import {
OVERVIEW_TAB,
serviceContentTabs,
type ContentTab,
} from "./service-tabs";
function Field({
label,
@@ -50,6 +63,7 @@ export function ServicePage() {
}>();
const { data: services = [] } = useServiceInstances(serviceType || undefined);
const { data: types = [] } = useServiceTypes();
const navigate = useNavigate();
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
@@ -62,18 +76,33 @@ export function ServicePage() {
() => 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, 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 [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);
// Hydrate local form state once the instance loads.
if (instance && !hydrated) {
setName(instance.name);
setEnabled(instance.enabled);
setDraftConfig({ ...instance.config });
setDraftSecrets({});
setHydrated(true);
}
@@ -94,91 +123,139 @@ 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: {}, // secrets are managed via the dedicated inputs below
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, shared between desktop tabs and mobile SheetForm.
const widgetsContent =
binding.widgets.length > 0 ? (
<div className="flex flex-col gap-2">
{binding.widgets.map((w) => (
<div
key={w.kind}
className="flex items-center justify-between rounded border p-2"
>
<div>
<div className="font-medium">{w.name}</div>
<div className="text-xs text-muted-foreground">
{w.description}
</div>
</div>
<Badge variant="outline">{w.kind}</Badge>
</div>
))}
<p className="text-xs text-muted-foreground">
Add these to the dashboard from the dashboard's edit dialog.
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">
No widget kinds for this service type.
</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">
<div className="flex items-center justify-between">
<div>
{/* Header + instance switcher */}
<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>
<Badge variant="outline">{binding.name}</Badge>
<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>
</div>
<SectionCard title="General">
<div className="flex flex-col gap-3">
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
<div className="flex justify-between">
<Button onClick={save} disabled={saveService.isPending}>
Save
</Button>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
</div>
</div>
</SectionCard>
{/* Tab skeleton */}
<Tabs defaultValue="Overview">
<TabsList>
<TabsTrigger value="Overview">Overview</TabsTrigger>
{contentTabs.map((tab) => (
<TabsTrigger key={tab.label} value={tab.label}>
{tab.label}
</TabsTrigger>
))}
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
<TabsTrigger value="Config">Config</TabsTrigger>
</TabsList>
<ServiceConnectionCard
instance={instance}
typeInfo={typeInfo}
draftConfig={draftConfig}
onConfigChange={setDraftConfig}
/>
{allTabs.map((tab) => {
const TabComponent = tab.Component;
return (
<TabsContent key={tab.label} value={tab.label}>
<TabComponent instance={instance} />
</TabsContent>
);
})}
{binding.widgets.length > 0 ? (
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
<div className="flex flex-col gap-2">
{binding.widgets.map((w) => (
<div
key={w.kind}
className="flex items-center justify-between rounded border p-2"
>
<div>
<div className="font-medium">{w.name}</div>
<div className="text-xs text-muted-foreground">
{w.description}
</div>
</div>
<Badge variant="outline">{w.kind}</Badge>
</div>
))}
<p className="text-xs text-muted-foreground">
Add these to the dashboard from the dashboard's edit dialog.
</p>
</div>
</SectionCard>
) : null}
<TabsContent value="Widgets">
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
{widgetsContent}
</SectionCard>
</TabsContent>
<TabsContent value="Config">{configBody}</TabsContent>
</Tabs>
<ConfirmDialog
open={deleteOpen}
@@ -189,27 +266,42 @@ export function ServicePage() {
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
navigate("/services");
}}
/>
</div>
);
}
function ServiceConnectionCard({
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 saveService = useSaveServiceInstance();
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const properties =
(
(typeInfo?.config_schema ?? {}) as {
@@ -233,11 +325,24 @@ function ServiceConnectionCard({
]);
return (
<SectionCard
title="Connection"
description="Edit non-secret connection config and secret values."
>
<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>
) : (
@@ -273,9 +378,7 @@ function ServiceConnectionCard({
</div>
)}
{Object.keys(instance.secrets_set).length === 0 ? (
<p className="text-sm text-muted-foreground">No secret fields.</p>
) : (
{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">
@@ -290,7 +393,7 @@ function ServiceConnectionCard({
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
setDraftSecrets({
onSecretsChange({
...draftSecrets,
[key]: e.target.value,
})
@@ -303,24 +406,14 @@ function ServiceConnectionCard({
</div>
)}
<Button
onClick={() => {
const onlyChanged = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
saveService.mutate({
id: instance.id,
service_type: instance.service_type,
name: instance.name,
config: draftConfig,
secrets: onlyChanged,
enabled: instance.enabled,
});
setDraftSecrets({});
}}
>
Update connection
</Button>
<div className="flex justify-between">
<Button onClick={onSave} disabled={savePending}>
Save
</Button>
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
</div>
</div>
</SectionCard>
);
+46
View File
@@ -0,0 +1,46 @@
/**
* Handles `/services/:type` (no instance id). Resolves the first enabled
* instance and redirects. Shows an empty state if none are configured.
*/
import { useMemo } from "react";
import { Link, useParams, Navigate } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useServiceInstances } from "../hooks/useServices";
export function ServiceTypePage() {
const { serviceType = "" } = useParams<{ serviceType: string }>();
const { data: instances = [], isLoading } = useServiceInstances(
serviceType || undefined,
);
const firstEnabled = useMemo(
() => instances.find((s) => s.enabled) ?? instances[0],
[instances],
);
if (isLoading) {
return (
<div className="flex h-32 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (firstEnabled) {
return (
<Navigate to={`/services/${serviceType}/${firstEnabled.id}`} replace />
);
}
return (
<Alert>
<AlertDescription className="flex flex-col gap-3">
<span>No {serviceType} service configured.</span>
<Button asChild className="w-fit">
<Link to="/services">Add a service</Link>
</Button>
</AlertDescription>
</Alert>
);
}
@@ -24,6 +24,9 @@ vi.mock("../../hooks/useSettings", () => ({
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
const deleteShortcutMutate = vi.fn();
@@ -0,0 +1,135 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServicePage } from "../ServicePage";
import type { ServiceInstance, ServiceTypeInfo } from "../../types";
const instance: ServiceInstance = {
id: "svc-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config: { base_url: "https://jf.example.com", user_id: "u1" },
secrets_set: { api_key: true },
enabled: true,
created_at: 1_700_000_000,
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: [],
};
const secondInstance: ServiceInstance = {
...instance,
id: "svc-2",
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("../../integrations/registry", () => ({
getServiceBinding: () => ({
name: "Jellyfin",
description: "Media server",
widgets: [],
}),
}));
function renderServicePage(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Routes>
</MemoryRouter>,
);
}
describe("ServicePage tab skeleton", () => {
it("renders Overview + Media + Requests + Widgets + Config 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 Media/Requests for non-jellyfin types", () => {
const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [sshInstance];
renderServicePage("/services/ssh_tasks/ssh-1");
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: "Media" }),
).not.toBeInTheDocument();
});
it("shows instance switcher when >1 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();
});
it("hides instance switcher 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).
expect(
container.querySelector("[role='combobox']"),
).not.toBeInTheDocument();
});
it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
const { userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
saveMutateAsync.mockReset();
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" });
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Per-type content-tab descriptors for the service page skeleton.
*
* Each entry names a tab and its component. The service page renders
* `[Overview, ...contentTabs(type), Widgets, Config]`.
*/
import type { ComponentType } from "react";
import type { ServiceInstance } from "../../types";
import {
ActionsTab,
AlertsTab,
FilesTab,
JobsTab,
LinksTab,
MediaTab,
MessagingTab,
MetricsTab,
OverviewTab,
RequestsTab,
UsersTab,
} from "./stubs";
export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
export interface ContentTab {
label: string;
Component: ServiceTabComponent;
}
/** Overview tab (shared across all service types). */
export const OVERVIEW_TAB: ContentTab = {
label: "Overview",
Component: OverviewTab,
};
/**
* Returns the type-specific content tabs for a service type.
* Types with no operational content return `[]` (only Overview + Widgets + Config).
*/
export function serviceContentTabs(serviceType: string): ContentTab[] {
switch (serviceType) {
case "jellyfin":
return [
{ label: "Media", Component: MediaTab },
{ label: "Requests", Component: RequestsTab },
];
case "ssh_tasks":
return [
{ label: "Files", Component: FilesTab },
{ label: "Actions", Component: ActionsTab },
];
case "backups":
return [{ label: "Jobs", Component: JobsTab }];
case "authentik":
return [
{ label: "Users", Component: UsersTab },
{ label: "Messaging", Component: MessagingTab },
];
case "alertmanager":
return [{ label: "Alerts", Component: AlertsTab }];
case "grafana":
return [{ label: "Links", Component: LinksTab }];
case "prometheus":
return [{ label: "Metrics", Component: MetricsTab }];
default:
return [];
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 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} />;
}
export function MediaTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Media" instance={instance} />;
}
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Requests" instance={instance} />;
}
export function FilesTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Files" instance={instance} />;
}
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Actions" instance={instance} />;
}
export function JobsTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Backup jobs" instance={instance} />;
}
export function UsersTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Users" instance={instance} />;
}
export function MessagingTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Messaging" instance={instance} />;
}
export function AlertsTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Alerts" instance={instance} />;
}
export function LinksTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Links" instance={instance} />;
}
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Metrics" instance={instance} />;
}