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
@@ -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.
});
});