From 8d2e4c9bfde885d2e0a40e1515fec78968cdc67c Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 22:25:46 +0000 Subject: [PATCH] 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. --- .pi-tmp/refine-23-out.md | 81 +++++ .pi-tmp/refine-4-out.md | 138 ++++++++ .../routers/widgets.py | 12 +- .../services/settings_store.py | 30 +- backend/tests/test_widgets.py | 38 ++ frontend/src/api/widgets.ts | 10 +- frontend/src/hooks/useWidgets.ts | 9 +- .../integrations/__tests__/navEntries.test.ts | 23 +- frontend/src/integrations/navEntries.ts | 31 +- frontend/src/pages/Dashboard.tsx | 6 +- frontend/src/pages/ServicePage.tsx | 331 ++---------------- frontend/src/pages/Settings.tsx | 240 ++++++++++++- .../src/pages/__tests__/ServicePage.test.tsx | 88 +++-- .../src/pages/service-tabs/OverviewTab.tsx | 78 +++++ .../__tests__/OverviewTab.test.tsx | 88 +++++ frontend/src/pages/service-tabs/index.ts | 2 +- frontend/src/pages/service-tabs/stubs.tsx | 29 -- 17 files changed, 812 insertions(+), 422 deletions(-) create mode 100644 .pi-tmp/refine-23-out.md create mode 100644 .pi-tmp/refine-4-out.md create mode 100644 frontend/src/pages/service-tabs/OverviewTab.tsx create mode 100644 frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx delete mode 100644 frontend/src/pages/service-tabs/stubs.tsx diff --git a/.pi-tmp/refine-23-out.md b/.pi-tmp/refine-23-out.md new file mode 100644 index 0000000..bc6a0c8 --- /dev/null +++ b/.pi-tmp/refine-23-out.md @@ -0,0 +1,81 @@ +# Service IA Refinement — Instance Tabs + Config to Settings + +## Files changed (5 files, +310/-381) + +| File | Status | Lines | +|------|--------|-------| +| `frontend/src/integrations/navEntries.ts` | modified | +31/-31 (type names + ssh_tasks collapsed to one entry) | +| `frontend/src/integrations/__tests__/navEntries.test.ts` | modified | +23/-23 (updated labels) | +| `frontend/src/pages/ServicePage.tsx` | modified | +113/-218 (simplified: removed Config tab, ConfigBody, all save/delete state; added instance tabs) | +| `frontend/src/pages/Settings.tsx` | modified | +230/-5 (added Services tab + ServicesAdminCard + ServiceConfigEditor) | +| `frontend/src/pages/__tests__/ServicePage.test.tsx` | modified | +76/-76 (removed Config/secret tests, added instance-tabs tests) | + +## New ServicePage structure + +The service page is now a **pure operational view** — no save/delete/config state at all. + +**When >1 enabled sibling:** + +``` +[Main Jellyfin] [Backup Jellyfin] ← instance tabs (click to navigate) +[Overview] [Media] [Requests] [Widgets] ← content tabs + +``` + +**When 1 instance:** + +``` +[Overview] [Media] [Requests] [Widgets] ← content tabs only + +``` + +- No Config tab. No ` navigate(`/services/${serviceType}/${id}`)} - > - - - - - {siblings.map((s) => ( - - {s.name} - - ))} - - - ) : null} - {binding.name} - + {binding.name} - {/* Tab skeleton */} + {/* Instance tabs (only when >1 enabled sibling) */} + {showInstanceTabs ? ( + + + {siblings.map((sibling: ServiceInstance) => ( + + navigate(`/services/${serviceType}/${sibling.id}`) + } + > + {sibling.name} + + ))} + + + ) : null} + + {/* Content tabs */} Overview @@ -234,7 +121,6 @@ export function ServicePage() { ))} Widgets - Config {allTabs.map((tab) => { @@ -254,168 +140,7 @@ export function ServicePage() { {widgetsContent} - - {configBody} - - setDeleteOpen(false)} - onConfirm={() => { - deleteService.mutate(instance.id); - setDeleteOpen(false); - navigate("/services"); - }} - /> ); } - -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 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 ( - -
- - onNameChange(e.target.value)} - /> - -
- - -
- - {configEntries.length === 0 ? ( -

No connection config.

- ) : ( -
- {configEntries.map(([key, schema]) => { - const isNumber = - schema.type === "integer" || schema.type === "number"; - return ( - - - onConfigChange({ - ...draftConfig, - [key]: isNumber - ? e.target.value === "" - ? undefined - : Number(e.target.value) - : e.target.value, - }) - } - /> - - ); - })} -
- )} - - {Object.keys(instance.secrets_set).length === 0 ? null : ( -
- {Object.entries(instance.secrets_set).map(([key, isSet]) => ( -
- - - onSecretsChange({ - ...draftSecrets, - [key]: e.target.value, - }) - } - /> - - {isSet ? set : null} -
- ))} -
- )} - -
- - -
-
-
- ); -} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index e344e7c..efa5a27 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 `` shell. */ function FormField({ @@ -995,6 +1006,9 @@ export function Settings() { SSH Keys , + + Services + , Danger Zone , @@ -1178,6 +1192,7 @@ export function Settings() { onSelectKeyId={setSelectedSSHKeyId} /> )} + {tab === "services" && } {tab === "danger" && } {isMobile ? ( @@ -1311,3 +1326,226 @@ export function Settings() { ); } + +/** + * 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(); + 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 ( +
+ {grouped.length === 0 ? ( +

+ No service instances configured. Create one from the Services page. +

+ ) : ( + grouped.map(([serviceType, instances]) => { + const typeInfo = types.find((t) => t.service_type === serviceType); + return ( + +
+ {instances.map((svc) => ( + + ))} +
+
+ ); + }) + )} +
+ ); +} + +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>({ + ...instance.config, + }); + const [draftSecrets, setDraftSecrets] = useState>({}); + const [deleteOpen, setDeleteOpen] = useState(false); + + const properties = + ( + (typeInfo?.config_schema ?? {}) as { + properties?: Record; + } + ).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 ( + <> +
+
+ {instance.name} + + {instance.enabled ? "enabled" : "disabled"} + +
+
+ + setName(e.target.value)} + /> + +
+ + +
+ + {configEntries.map(([key, schema]) => { + const isNumber = + schema.type === "integer" || schema.type === "number"; + return ( + + + setDraftConfig({ + ...draftConfig, + [key]: isNumber + ? e.target.value === "" + ? undefined + : Number(e.target.value) + : e.target.value, + }) + } + /> + + ); + })} + + {Object.keys(instance.secrets_set).length === 0 + ? null + : Object.entries(instance.secrets_set).map(([key, isSet]) => ( + + + setDraftSecrets({ + ...draftSecrets, + [key]: e.target.value, + }) + } + /> + + ))} + +
+ + +
+
+
+ + setDeleteOpen(false)} + onConfirm={() => { + deleteService.mutate(instance.id); + setDeleteOpen(false); + }} + /> + + ); +} diff --git a/frontend/src/pages/__tests__/ServicePage.test.tsx b/frontend/src/pages/__tests__/ServicePage.test.tsx index 9840ec5..7480da3 100644 --- a/frontend/src/pages/__tests__/ServicePage.test.tsx +++ b/frontend/src/pages/__tests__/ServicePage.test.tsx @@ -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; - }; - 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. }); }); diff --git a/frontend/src/pages/service-tabs/OverviewTab.tsx b/frontend/src/pages/service-tabs/OverviewTab.tsx new file mode 100644 index 0000000..0c2e9e4 --- /dev/null +++ b/frontend/src/pages/service-tabs/OverviewTab.tsx @@ -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 ( +
+
+

+ {instance.name} overview +

+ +
+ + {visibleWidgets.length > 0 ? ( +
+ {visibleWidgets.map((widget) => ( + + ))} +
+ ) : ( + + + + No widgets on this overview yet. Add widgets to show key metrics + and information for {instance.name}. + + + + + )} + + setConfigOpen(false)} + /> +
+ ); +} diff --git a/frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx new file mode 100644 index 0000000..cb40eb8 --- /dev/null +++ b/frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx @@ -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 } }) => ( +
{widget.title}
+ ), +})); + +vi.mock("../../../components/WidgetConfigDialog", () => ({ + WidgetConfigDialog: ({ open }: { open: boolean }) => + open ?
: 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(); + 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(); + 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(); + expect(screen.queryByTestId("config-dialog")).not.toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: /Edit widgets/i }), + ); + expect(screen.getByTestId("config-dialog")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/service-tabs/index.ts b/frontend/src/pages/service-tabs/index.ts index d169055..db60310 100644 --- a/frontend/src/pages/service-tabs/index.ts +++ b/frontend/src/pages/service-tabs/index.ts @@ -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"; diff --git a/frontend/src/pages/service-tabs/stubs.tsx b/frontend/src/pages/service-tabs/stubs.tsx deleted file mode 100644 index 64de68d..0000000 --- a/frontend/src/pages/service-tabs/stubs.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 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 ; -}