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
@@ -0,0 +1,78 @@
/**
* Configurable per-service Overview tab.
*
* Each service instance manages its own set of widgets on this tab. The
* widget system is reused from the main Dashboard: widget instances with
* a `service_id` matching this instance are fetched and rendered in a
* responsive grid. An edit button opens the WidgetConfigDialog (same one
* the Dashboard uses) for add/remove/reorder/enable/disable.
*/
import { useMemo, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Settings2 } from "lucide-react";
import { useWidgetInstances } from "../../hooks/useWidgets";
import { WidgetInstanceCard } from "../../components/WidgetInstance";
import { WidgetConfigDialog } from "../../components/WidgetConfigDialog";
import type { ServiceInstance } from "../../types";
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
const { data: widgets = [] } = useWidgetInstances(instance.id);
const [configOpen, setConfigOpen] = useState(false);
const visibleWidgets = useMemo(
() =>
widgets
.filter((w) => w.enabled)
.sort((a, b) => a.sort_order - b.sort_order),
[widgets],
);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{instance.name} overview
</h3>
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => setConfigOpen(true)}
>
<Settings2 className="size-4" />
Edit widgets
</Button>
</div>
{visibleWidgets.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{visibleWidgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))}
</div>
) : (
<Alert>
<AlertDescription className="flex flex-col gap-3">
<span>
No widgets on this overview yet. Add widgets to show key metrics
and information for {instance.name}.
</span>
<Button
size="sm"
className="w-fit mobile-touch-target"
onClick={() => setConfigOpen(true)}
>
Add widgets
</Button>
</AlertDescription>
</Alert>
)}
<WidgetConfigDialog
open={configOpen}
onClose={() => setConfigOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OverviewTab } from "../OverviewTab";
import type { ServiceInstance } from "../../../types";
// Default mock returns an empty list; individual tests override via
// `vi.mocked()` to return widget data.
vi.mock("../../../hooks/useWidgets", () => ({
useWidgetInstances: vi.fn(() => ({ data: [] })),
}));
vi.mock("../../../components/WidgetInstance", () => ({
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
<div data-testid="widget-card">{widget.title}</div>
),
}));
vi.mock("../../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: ({ open }: { open: boolean }) =>
open ? <div data-testid="config-dialog" /> : null,
}));
const { useWidgetInstances } = await import("../../../hooks/useWidgets");
const instance: ServiceInstance = {
id: "svc-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config: {},
secrets_set: {},
enabled: true,
created_at: 0,
updated_at: 0,
};
function mockWidgets(
widgets: { id: string; title: string; enabled: boolean }[],
) {
vi.mocked(useWidgetInstances).mockReturnValue({
data: widgets.map((w, i) => ({
id: w.id,
service_id: "svc-1",
widget_kind: "activity",
title: w.title,
config: {},
enabled: w.enabled,
sort_order: i,
created_at: 0,
updated_at: 0,
})),
} as never);
}
describe("OverviewTab", () => {
it("renders enabled widgets in a grid and hides disabled ones", () => {
mockWidgets([
{ id: "w1", title: "Live Sessions", enabled: true },
{ id: "w2", title: "Disabled Widget", enabled: false },
]);
render(<OverviewTab instance={instance} />);
const cards = screen.getAllByTestId("widget-card");
expect(cards).toHaveLength(1);
expect(screen.getByText("Live Sessions")).toBeInTheDocument();
expect(screen.queryByText("Disabled Widget")).not.toBeInTheDocument();
});
it("shows an empty state with an add button when no widgets exist", () => {
mockWidgets([]);
render(<OverviewTab instance={instance} />);
expect(
screen.getByText(/No widgets on this overview/i),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Add widgets/i }),
).toBeInTheDocument();
});
it("opens the config dialog when Edit widgets is clicked", async () => {
mockWidgets([{ id: "w1", title: "Live", enabled: true }]);
render(<OverviewTab instance={instance} />);
expect(screen.queryByTestId("config-dialog")).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: /Edit widgets/i }),
);
expect(screen.getByTestId("config-dialog")).toBeInTheDocument();
});
});
+1 -1
View File
@@ -6,7 +6,7 @@
*/
import type { ComponentType } from "react";
import type { ServiceInstance } from "../../types";
import { OverviewTab } from "./stubs";
import { OverviewTab } from "./OverviewTab";
import { AlertsTab } from "./AlertsTab";
import { LinksTab } from "./LinksTab";
import { MetricsTab } from "./MetricsTab";
-29
View File
@@ -1,29 +0,0 @@
/**
* Service-page content tab stubs.
*
* Each stub renders a "coming soon" placeholder. Slices 59 replace these with
* real operational content lifted from the old top-level pages. All stubs accept
* an `instance` prop so the real implementations can scope queries by instance.
*/
import type { ServiceInstance } from "../../types";
import { Alert, AlertDescription } from "@/components/ui/alert";
function Stub({
label,
instance,
}: {
label: string;
instance: ServiceInstance;
}) {
return (
<Alert>
<AlertDescription>
{label} for {instance.name} coming soon.
</AlertDescription>
</Alert>
);
}
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Service overview" instance={instance} />;
}