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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{label} for {instance.name} — coming soon.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Service overview" instance={instance} />;
|
||||
}
|
||||
Reference in New Issue
Block a user