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).
This commit is contained in:
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string>): NavEntry[] {
|
||||
return SERVICE_TYPE_NAV_ENTRIES.filter((e) =>
|
||||
configuredTypes.has(e.serviceType),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user