diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa78a8f..5bbe1ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,7 +27,10 @@ import { usePersistentState } from "./hooks/usePersistentState"; import { useIsMobile } from "./hooks/useIsMobile"; import { useServiceInstances } from "./hooks/useServices"; import { useDashboards } from "./hooks/useDashboards"; -import { configuredNavEntries } from "./integrations/navEntries"; +import { + configuredNavEntries, + remoteMachineNavEntries, +} from "./integrations/navEntries"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -97,10 +100,13 @@ function useNavItems() { const configuredTypes = new Set( services.filter((s) => s.enabled).map((s) => s.service_type), ); - const serviceEntries = configuredNavEntries(configuredTypes).map((e) => ({ - path: e.path, - label: e.label, - icon: e.icon, + const serviceEntries = [ + ...configuredNavEntries(configuredTypes), + ...remoteMachineNavEntries(services), + ].map((entry) => ({ + path: entry.path, + label: entry.label, + icon: entry.icon, })); const dashboardEntries = dashboards.map((d) => ({ path: `/d/${d.slug}`, diff --git a/frontend/src/integrations/__tests__/navEntries.test.ts b/frontend/src/integrations/__tests__/navEntries.test.ts index a8f6c3f..b881d88 100644 --- a/frontend/src/integrations/__tests__/navEntries.test.ts +++ b/frontend/src/integrations/__tests__/navEntries.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries"; +import { + configuredNavEntries, + remoteMachineNavEntries, + SERVICE_TYPE_NAV_ENTRIES, +} from "../navEntries"; describe("navEntries", () => { it("returns no entries when no types are configured", () => { @@ -13,8 +17,33 @@ describe("navEntries", () => { expect(entries[0].path).toBe("/services/jellyfin"); }); - it("does not expose remote machines as a top-level entry", () => { - expect(configuredNavEntries(new Set(["remote_machine"]))).toEqual([]); + it("creates one top-level entry per enabled remote machine", () => { + const entries = remoteMachineNavEntries([ + { + id: "storage", + name: "Storage", + service_type: "remote_machine", + enabled: true, + }, + { + id: "worker", + name: "Worker", + service_type: "remote_machine", + enabled: true, + }, + { + id: "disabled", + name: "Disabled", + service_type: "remote_machine", + enabled: false, + }, + ]); + + expect(entries.map((entry) => entry.label)).toEqual(["Storage", "Worker"]); + expect(entries.map((entry) => entry.path)).toEqual([ + "/services/remote_machine/storage", + "/services/remote_machine/worker", + ]); }); it("returns all observability entries", () => { diff --git a/frontend/src/integrations/navEntries.ts b/frontend/src/integrations/navEntries.ts index 344b0a0..df77477 100644 --- a/frontend/src/integrations/navEntries.ts +++ b/frontend/src/integrations/navEntries.ts @@ -12,6 +12,7 @@ import { GanttChartSquare, Magnet, Monitor, + Server, Users, type LucideIcon, } from "lucide-react"; @@ -24,6 +25,13 @@ export interface NavEntry { path: string; } +export interface RemoteMachineNavSource { + id: string; + name: string; + service_type: string; + enabled: boolean; +} + /** * Static mapping from service type to its conditional nav entry. * Uses the service type's display name. One entry per type. @@ -77,3 +85,20 @@ export function configuredNavEntries(configuredTypes: Set): NavEntry[] { configuredTypes.has(e.serviceType), ); } + +/** One direct sidebar entry for each enabled Remote Machine service. */ +export function remoteMachineNavEntries( + services: RemoteMachineNavSource[], +): NavEntry[] { + return services + .filter( + (service) => service.enabled && service.service_type === "remote_machine", + ) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((service) => ({ + serviceType: service.service_type, + label: service.name, + icon: Server, + path: `/services/remote_machine/${encodeURIComponent(service.id)}`, + })); +}