From caf6c226ff382603d2c11f7bd065779d5b679479 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 19:03:18 +0000 Subject: [PATCH] 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). --- frontend/src/App.tsx | 126 ++++---- frontend/src/api/dashboards.ts | 42 +++ frontend/src/hooks/useDashboards.ts | 37 +++ .../integrations/__tests__/navEntries.test.ts | 55 ++++ frontend/src/integrations/navEntries.ts | 91 ++++++ frontend/src/pages/Dashboard.tsx | 23 ++ frontend/src/pages/ServicePage.tsx | 285 ++++++++++++------ frontend/src/pages/ServiceTypePage.tsx | 46 +++ .../src/pages/__tests__/Dashboard.test.tsx | 3 + .../src/pages/__tests__/ServicePage.test.tsx | 135 +++++++++ frontend/src/pages/service-tabs/index.ts | 68 +++++ frontend/src/pages/service-tabs/stubs.tsx | 69 +++++ 12 files changed, 830 insertions(+), 150 deletions(-) create mode 100644 frontend/src/api/dashboards.ts create mode 100644 frontend/src/hooks/useDashboards.ts create mode 100644 frontend/src/integrations/__tests__/navEntries.test.ts create mode 100644 frontend/src/integrations/navEntries.ts create mode 100644 frontend/src/pages/ServiceTypePage.tsx create mode 100644 frontend/src/pages/__tests__/ServicePage.test.tsx create mode 100644 frontend/src/pages/service-tabs/index.ts create mode 100644 frontend/src/pages/service-tabs/stubs.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0547fb7..36b4379 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(() => { + 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 ( @@ -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 ( +
+

Not found

+

This page doesn't exist.

+ +
+ ); +} + function AppInner() { const [darkMode, toggleDarkMode] = useDarkMode(); @@ -438,26 +474,17 @@ function AppInner() { }> } /> - } - /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> } /> } /> + } + /> } /> + } /> @@ -474,26 +501,17 @@ function AppInner() { } > } /> - } - /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> } /> } /> + } + /> } /> + } /> diff --git a/frontend/src/api/dashboards.ts b/frontend/src/api/dashboards.ts new file mode 100644 index 0000000..f33dc16 --- /dev/null +++ b/frontend/src/api/dashboards.ts @@ -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; + created_at: number; + updated_at: number; +} + +export interface NamedDashboardInput { + id?: string | null; + label: string; + slug?: string; + sort_order: number; + payload: Record; +} + +export async function fetchDashboards(): Promise { + return get("/api/dashboards"); +} + +export async function createDashboard( + input: NamedDashboardInput, +): Promise { + return post("/api/dashboards", input); +} + +export async function updateDashboard( + input: NamedDashboardInput, +): Promise { + return put(`/api/dashboards`, input); +} + +export async function deleteDashboard(id: string): Promise<{ status: string }> { + return del<{ status: string }>(`/api/dashboards/${id}`); +} diff --git a/frontend/src/hooks/useDashboards.ts b/frontend/src/hooks/useDashboards.ts new file mode 100644 index 0000000..bfa8481 --- /dev/null +++ b/frontend/src/hooks/useDashboards.ts @@ -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"] }); + }, + }); +} diff --git a/frontend/src/integrations/__tests__/navEntries.test.ts b/frontend/src/integrations/__tests__/navEntries.test.ts new file mode 100644 index 0000000..1991958 --- /dev/null +++ b/frontend/src/integrations/__tests__/navEntries.test.ts @@ -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", + ]); + }); +}); diff --git a/frontend/src/integrations/navEntries.ts b/frontend/src/integrations/navEntries.ts new file mode 100644 index 0000000..b914c3c --- /dev/null +++ b/frontend/src/integrations/navEntries.ts @@ -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): NavEntry[] { + return SERVICE_TYPE_NAV_ENTRIES.filter((e) => + configuredTypes.has(e.serviceType), + ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0545080..6402718 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(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 (
+ {services.length === 0 ? ( + +
+

+ No services configured yet. Add a Jellyfin, SSH target, Authentik, + or observability service to populate the navigation and + dashboards. +

+ +
+
+ ) : null} (); 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>({}); + const [draftSecrets, setDraftSecrets] = useState>({}); 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 ? ( +
+ {binding.widgets.map((w) => ( +
+
+
{w.name}
+
+ {w.description} +
+
+ {w.kind} +
+ ))} +

+ Add these to the dashboard from the dashboard's edit dialog. +

+
+ ) : ( +

+ No widget kinds for this service type. +

+ ); + + const configBody = ( + setDeleteOpen(true)} + /> + ); + return (
-
-
+ {/* Header + instance switcher */} +
+

{instance.name}

{binding.description}

- {binding.name} +
+ {showSwitcher ? ( + + ) : null} + {binding.name} +
- -
- - setName(e.target.value)} - /> - -
- - -
-
- - -
-
-
+ {/* Tab skeleton */} + + + Overview + {contentTabs.map((tab) => ( + + {tab.label} + + ))} + Widgets + Config + - + {allTabs.map((tab) => { + const TabComponent = tab.Component; + return ( + + + + ); + })} - {binding.widgets.length > 0 ? ( - -
- {binding.widgets.map((w) => ( -
-
-
{w.name}
-
- {w.description} -
-
- {w.kind} -
- ))} -

- Add these to the dashboard from the dashboard's edit dialog. -

-
-
- ) : null} + + + {widgetsContent} + + + + {configBody} +
{ deleteService.mutate(instance.id); setDeleteOpen(false); + navigate("/services"); }} />
); } -function ServiceConnectionCard({ +function ConfigBody({ instance, typeInfo, draftConfig, onConfigChange, + draftSecrets, + onSecretsChange, + name, + enabled, + onNameChange, + onEnabledChange, + onSave, + savePending, + onDelete, }: { instance: ServiceInstance; typeInfo: ServiceTypeInfo | undefined; draftConfig: Record; onConfigChange: (config: Record) => void; + draftSecrets: Record; + onSecretsChange: (secrets: Record) => 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>({}); - const properties = ( (typeInfo?.config_schema ?? {}) as { @@ -233,11 +325,24 @@ function ServiceConnectionCard({ ]); return ( - +
+ + onNameChange(e.target.value)} + /> + +
+ + +
+ {configEntries.length === 0 ? (

No connection config.

) : ( @@ -273,9 +378,7 @@ function ServiceConnectionCard({
)} - {Object.keys(instance.secrets_set).length === 0 ? ( -

No secret fields.

- ) : ( + {Object.keys(instance.secrets_set).length === 0 ? null : (
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
@@ -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({
)} - +
+ + +
); diff --git a/frontend/src/pages/ServiceTypePage.tsx b/frontend/src/pages/ServiceTypePage.tsx new file mode 100644 index 0000000..269f7f8 --- /dev/null +++ b/frontend/src/pages/ServiceTypePage.tsx @@ -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 ( +
+
+
+ ); + } + + if (firstEnabled) { + return ( + + ); + } + + return ( + + + No {serviceType} service configured. + + + + ); +} diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx index 6c33411..3e4d99b 100644 --- a/frontend/src/pages/__tests__/Dashboard.test.tsx +++ b/frontend/src/pages/__tests__/Dashboard.test.tsx @@ -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(); diff --git a/frontend/src/pages/__tests__/ServicePage.test.tsx b/frontend/src/pages/__tests__/ServicePage.test.tsx new file mode 100644 index 0000000..9840ec5 --- /dev/null +++ b/frontend/src/pages/__tests__/ServicePage.test.tsx @@ -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( + + + } + /> + + , + ); +} + +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; + }; + expect(input.secrets).toEqual({ api_key: "new-secret-value" }); + }); +}); diff --git a/frontend/src/pages/service-tabs/index.ts b/frontend/src/pages/service-tabs/index.ts new file mode 100644 index 0000000..4f16998 --- /dev/null +++ b/frontend/src/pages/service-tabs/index.ts @@ -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 []; + } +} diff --git a/frontend/src/pages/service-tabs/stubs.tsx b/frontend/src/pages/service-tabs/stubs.tsx new file mode 100644 index 0000000..c8b070e --- /dev/null +++ b/frontend/src/pages/service-tabs/stubs.tsx @@ -0,0 +1,69 @@ +/** + * Service-page content tab stubs. + * + * Each stub renders a "coming soon" placeholder. Slices 5–9 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 ( + + + {label} for {instance.name} — coming soon. + + + ); +} + +export function OverviewTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function MediaTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function RequestsTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function FilesTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function ActionsTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function JobsTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function UsersTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function MessagingTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function AlertsTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function LinksTab({ instance }: { instance: ServiceInstance }) { + return ; +} + +export function MetricsTab({ instance }: { instance: ServiceInstance }) { + return ; +}