diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c18248ef8..04cb82daf 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -116,7 +116,10 @@ runtime cost control, and shared AI transport surfaces. intact: they extend `aiChatStore`-aware route shells rather than mounting bespoke layouts that suppress the Assistant launcher, Patrol entry, or the AI keyboard-shortcut handlers. New per-platform sub-routes inherit - the shared AI-aware chrome by virtue of routing through `AppLayout`. + the shared AI-aware chrome by virtue of routing through `AppLayout`, and + must not introduce a parallel chat surface, launcher button, or model + picker on the platform page itself; cross-platform AI guidance stays + routed through Assistant and Patrol. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index a651afabc..a8730317c 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -485,6 +485,19 @@ or other self-hosted uncapped continuity plans. `PULSE_EMAIL_REPLY_TO`, defaulting to `support@pulserelay.pro`, so magic links, checkout handoffs, and other hosted-account messages remain directly replyable by customers. + `App.tsx` and `AppLayout.tsx` may extend the platform-first top-level + surface set by registering new family-scoped routes (for example + `DOCKER_PATH`, `KUBERNETES_PATH`, `TRUENAS_PATH`, `VMWARE_PATH`) and the + matching `PlatformTab` entries, but those entries must be gated on + canonical resource presence in `state.resources` so unconnected platforms + stay hidden and do not displace the always-shown Infrastructure, + Workloads, Storage, and Recovery tabs. Each new platform page must remain + chrome-only: routing plus sub-tab navigation that embeds the canonical + `WorkloadsSurface`, `StorageSurface`, `RecoverySurface`, or + `UnifiedResourceTable` in `embedded tableOnly` mode with a forced + platform/source filter. The shell must not introduce dashboard cards, + bespoke per-family tables, or synthetic placeholder data for that + expansion path. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index dfdd3b9bf..00f9c6f88 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -924,6 +924,12 @@ AI runtime. shared canonical surface already exists; new shared platform-page primitives live under `frontend-modern/src/features/platformPage/` so the chrome stays reusable across families. + `frontend-modern/src/AppLayout.tsx` may extend the `PlatformTab` list with + new family entries; those entries must declare `alwaysShow` and `enabled` + derived from the family's canonical resource presence in + `state.resources` rather than hard-coded `true`, so unconnected platforms + stay hidden by default and do not displace the always-shown + Infrastructure, Workloads, Storage, and Recovery tabs. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index f992714ee..fca13fdf8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -828,6 +828,16 @@ bypass the API fail-closed execution gate. Storage and recovery UI must keep sourcing those signals from their existing canonical page models instead of polling the connections ledger for per-datastore or per-backup truth. + Platform-first top-level pages may embed `StorageSurface` and + `RecoverySurface` with `embedded tableOnly` and forced source or + platform filters (e.g. `forcedSourceFilter`, `forcedPlatformFilter`) + so platform-scoped storage and recovery rows render through the same + canonical surfaces rather than a forked per-platform table. + `frontend-modern/src/App.tsx` may carry the platform-page route + registrations that mount those embedded canonical surfaces, but the + routes themselves must derive their paths from the canonical builders + in `frontend-modern/src/routing/resourceLinks.ts`; ad hoc storage or + recovery route strings inside per-platform features are not permitted. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 04d90a615..ef023fe9f 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -393,6 +393,14 @@ AI-only summary payloads, or page-local heuristics. another shared surface needs to explain how the same governed policy counts should be read, but those framing lines must extend the shared card API rather than spawning page-local policy summary shells. +16. Keep platform-first top-level route paths on the canonical resource-link + helper. `frontend-modern/src/routing/resourceLinks.ts` owns the + `DOCKER_PATH`, `KUBERNETES_PATH`, `TRUENAS_PATH`, `VMWARE_PATH` constants + and the `buildDockerPath`, `buildKubernetesPath`, `buildTrueNASPath`, + `buildVmwarePath` builders. Per-platform surfaces and tab specs must + derive every internal link from those builders so the canonical resource + URL vocabulary stays single-sourced; ad hoc string concatenation of + platform routes inside feature directories is not permitted. ## Forbidden Paths diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 49d292042..32463594d 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -27,8 +27,12 @@ import { aiChatStore } from './stores/aiChat'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useKioskMode } from '@/hooks/useKioskMode'; import { + DOCKER_PATH, + KUBERNETES_PATH, PATROL_PATH, PROXMOX_PATH, + TRUENAS_PATH, + VMWARE_PATH, buildRecoveryPath, buildInfrastructurePath, buildStoragePath, @@ -59,6 +63,10 @@ const AlertsPage = lazy(() => const SettingsPage = lazy(() => import('./components/Settings/Settings')); const InfrastructurePage = lazy(() => import('./pages/Infrastructure')); const ProxmoxPage = lazy(() => import('./pages/Proxmox')); +const DockerPage = lazy(() => import('./pages/Docker')); +const KubernetesPage = lazy(() => import('./pages/Kubernetes')); +const TrueNASPage = lazy(() => import('./pages/TrueNAS')); +const VmwarePage = lazy(() => import('./pages/Vmware')); const WorkloadsPage = lazy(() => import('./pages/Workloads')); const AIIntelligencePage = lazy(() => import('./pages/AIIntelligence').then((module) => ({ default: module.AIIntelligence })), @@ -465,6 +473,14 @@ function App() { + + + + + + + + diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 7f8aae98e..892d6133e 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -10,7 +10,12 @@ import SettingsIcon from 'lucide-solid/icons/settings'; import Maximize2Icon from 'lucide-solid/icons/maximize-2'; import Minimize2Icon from 'lucide-solid/icons/minimize-2'; import SparklesIcon from 'lucide-solid/icons/sparkles'; +import ContainerIcon from 'lucide-solid/icons/container'; +import ShipWheelIcon from 'lucide-solid/icons/ship-wheel'; +import DatabaseIcon from 'lucide-solid/icons/database'; +import CpuIcon from 'lucide-solid/icons/cpu'; import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; +import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; import { MobileNavBar, type MobileNavBarPlatformTab as PlatformTab, @@ -29,8 +34,12 @@ import { logger } from '@/utils/logger'; import { getActiveTabForPath } from '@/routing/navigation'; import { preloadRouteModule } from '@/routing/routePreload'; import { + buildDockerPath, buildInfrastructurePath, + buildKubernetesPath, buildProxmoxPath, + buildTrueNASPath, + buildVmwarePath, buildWorkloadsPath, } from '@/routing/resourceLinks'; import { buildStorageRecoveryTabSpecs } from '@/routing/platformTabs'; @@ -44,6 +53,10 @@ import type { AppConnectionStatus } from '@/useAppRuntimeState'; const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath(); const ROOT_PROXMOX_PATH = buildProxmoxPath(); +const ROOT_DOCKER_PATH = buildDockerPath(); +const ROOT_KUBERNETES_PATH = buildKubernetesPath(); +const ROOT_TRUENAS_PATH = buildTrueNASPath(); +const ROOT_VMWARE_PATH = buildVmwarePath(); const ROOT_WORKLOADS_PATH = buildWorkloadsPath(); const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0'; const AI_CHAT_LAUNCHER_BUTTON_CLASS = @@ -194,6 +207,10 @@ export function AppLayout(props: AppLayoutProps) { // as the bare app name. const tabTitleByActive: Record>, string> = { proxmox: 'Proxmox', + docker: 'Docker', + kubernetes: 'Kubernetes', + truenas: 'TrueNAS', + vmware: 'vSphere', infrastructure: 'Infrastructure', workloads: 'Workloads', storage: 'Storage', @@ -287,7 +304,17 @@ export function AppLayout(props: AppLayoutProps) { const getActiveTabDesktop = () => getActiveTabForPath(location.pathname); const getActiveTabMobile = () => getActiveTabForPath(location.pathname); + const platformPresence = createMemo(() => { + const presence = new Set(); + for (const resource of props.state().resources || []) { + const key = normalizeSourcePlatformQueryValue(resource.platformType || ''); + if (key) presence.add(key); + } + return presence; + }); + const platformTabs = createMemo(() => { + const presence = platformPresence(); const allPlatforms: PlatformTab[] = [ { id: 'proxmox', @@ -300,6 +327,50 @@ export function AppLayout(props: AppLayoutProps) { icon: ProxmoxIcon, alwaysShow: true, }, + { + id: 'docker', + label: 'Docker', + route: ROOT_DOCKER_PATH, + settingsRoute: '/settings/workloads/docker', + tooltip: 'Docker and Podman hosts, containers, and Swarm services', + enabled: presence.has('docker'), + live: presence.has('docker'), + icon: ContainerIcon, + alwaysShow: presence.has('docker'), + }, + { + id: 'kubernetes', + label: 'Kubernetes', + route: ROOT_KUBERNETES_PATH, + settingsRoute: '/settings', + tooltip: 'Kubernetes clusters, nodes, pods, deployments, and services', + enabled: presence.has('kubernetes'), + live: presence.has('kubernetes'), + icon: ShipWheelIcon, + alwaysShow: presence.has('kubernetes'), + }, + { + id: 'truenas', + label: 'TrueNAS', + route: ROOT_TRUENAS_PATH, + settingsRoute: '/settings/infrastructure', + tooltip: 'TrueNAS hosts, storage, and apps', + enabled: presence.has('truenas'), + live: presence.has('truenas'), + icon: DatabaseIcon, + alwaysShow: presence.has('truenas'), + }, + { + id: 'vmware', + label: 'vSphere', + route: ROOT_VMWARE_PATH, + settingsRoute: '/settings/infrastructure', + tooltip: 'VMware vSphere hosts, virtual machines, and datastores', + enabled: presence.has('vmware-vsphere'), + live: presence.has('vmware-vsphere'), + icon: CpuIcon, + alwaysShow: presence.has('vmware-vsphere'), + }, { id: 'infrastructure', label: 'Infrastructure', diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index e2ca5e0b5..720eb9e84 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -50,6 +50,14 @@ describe('App architecture', () => { ); expect(appSource).toContain(''); expect(appSource).toContain(''); + expect(appSource).toContain("const DockerPage = lazy(() => import('./pages/Docker'));"); + expect(appSource).toContain("const KubernetesPage = lazy(() => import('./pages/Kubernetes'));"); + expect(appSource).toContain("const TrueNASPage = lazy(() => import('./pages/TrueNAS'));"); + expect(appSource).toContain("const VmwarePage = lazy(() => import('./pages/Vmware'));"); + expect(appSource).toContain(''); + expect(appSource).toContain(''); + expect(appSource).toContain(''); + expect(appSource).toContain(''); expect(appSource).not.toContain('DashboardPage'); expect(headerAuditSource).not.toContain("['src/pages/Dashboard.tsx', 'PageHeader']"); expect(appSource).toContain("import RuntimeHomePage from '@/pages/RuntimeHome';"); diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx new file mode 100644 index 000000000..b09c34174 --- /dev/null +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -0,0 +1,109 @@ +import { useLocation } from '@solidjs/router'; +import ContainerIcon from 'lucide-solid/icons/container'; +import { Show, createMemo } from 'solid-js'; +import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; +import { useUnifiedResources } from '@/hooks/useUnifiedResources'; +import { + PlatformErrorState, + PlatformResourceTable, + PlatformSectionTabs, + PlatformTableEmptyState, +} from '@/features/platformPage/sharedPlatformPage'; +import { + DOCKER_TAB_SPECS, + buildDockerPageModel, + type DockerPageTabId, +} from './dockerPageModel'; + +const DOCKER_RESOURCE_QUERY = 'type=agent,docker-host,app-container,docker-service'; +const DOCKER_PLATFORM_FILTER = 'docker'; +const VALID_TABS = new Set(DOCKER_TAB_SPECS.map((tab) => tab.id)); + +const dockerIcon = () => ; + +export function DockerPageSurface() { + const location = useLocation(); + const { resources, loading, error, refetch } = useUnifiedResources({ + query: DOCKER_RESOURCE_QUERY, + cacheKey: 'docker-workspace', + initialHydration: 'prefer-ws-then-rest', + }); + const activeTab = createMemo(() => { + const segment = location.pathname.split('/').filter(Boolean)[1] as DockerPageTabId | undefined; + return segment && VALID_TABS.has(segment) ? segment : 'overview'; + }); + const model = createMemo(() => buildDockerPageModel(resources())); + + return ( +
+ + + 0} + fallback={ + + } + > + void refetch()} + /> + } + > + 0} + fallback={ + + } + > + + + + + + + + + + + + +
+ ); +} + +export default DockerPageSurface; diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts new file mode 100644 index 000000000..e4e797bbc --- /dev/null +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { DOCKER_TAB_SPECS, buildDockerPageModel } from '../dockerPageModel'; + +const makeResource = (resource: Partial & Pick): Resource => ({ + name: resource.id, + displayName: resource.id, + platformId: 'lab', + platformType: 'docker', + sourceType: 'agent', + status: 'online', + lastSeen: 1_700_000_000_000, + ...resource, +}); + +describe('dockerPageModel', () => { + it('declares the Docker section set in the v5-style order', () => { + expect(DOCKER_TAB_SPECS.map((tab) => tab.id)).toEqual([ + 'overview', + 'containers', + 'services', + ]); + }); + + it('buckets Docker hosts, containers, and swarm services from canonical resources', () => { + const model = buildDockerPageModel([ + makeResource({ id: 'docker-host-1', type: 'agent' }), + makeResource({ id: 'svc-1', type: 'docker-service' }), + makeResource({ + id: 'ctr-1', + type: 'app-container', + platformType: 'docker', + }), + makeResource({ + id: 'pve-node-1', + type: 'agent', + platformType: 'proxmox-pve', + }), + ]); + + expect(model.hosts.map((r) => r.id)).toEqual(['docker-host-1']); + expect(model.containers.map((r) => r.id)).toEqual(['ctr-1']); + expect(model.services.map((r) => r.id)).toEqual(['svc-1']); + expect(model.resources.map((r) => r.id).sort()).toEqual( + ['ctr-1', 'docker-host-1', 'svc-1'].sort(), + ); + }); + + it('excludes non-Docker hosts that share the agent type', () => { + const model = buildDockerPageModel([ + makeResource({ + id: 'truenas-host', + type: 'agent', + platformType: 'truenas', + }), + ]); + expect(model.hosts).toEqual([]); + expect(model.resources).toEqual([]); + }); +}); diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts new file mode 100644 index 000000000..6a5d6a545 --- /dev/null +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -0,0 +1,56 @@ +import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; +import type { Resource, ResourceType } from '@/types/resource'; + +export type DockerPageTabId = 'overview' | 'containers' | 'services'; + +export type DockerTabSpec = { + id: DockerPageTabId; + label: string; + path: string; +}; + +export const DOCKER_TAB_SPECS: readonly DockerTabSpec[] = [ + { id: 'overview', label: 'Hosts', path: '/docker/overview' }, + { id: 'containers', label: 'Containers', path: '/docker/containers' }, + { id: 'services', label: 'Swarm services', path: '/docker/services' }, +] as const; + +const DOCKER_HOST_TYPES = new Set(['agent', 'docker-host']); +const DOCKER_CONTAINER_TYPES = new Set(['app-container']); +const DOCKER_SERVICE_TYPES = new Set(['docker-service']); + +const isDockerPlatform = (resource: Resource): boolean => + normalizeSourcePlatformQueryValue(resource.platformType || '') === 'docker'; + +export type DockerPageModel = { + resources: Resource[]; + hosts: Resource[]; + containers: Resource[]; + services: Resource[]; +}; + +export function buildDockerPageModel(resources: Resource[]): DockerPageModel { + const dockerResources = resources.filter( + (resource) => + isDockerPlatform(resource) || + DOCKER_CONTAINER_TYPES.has(resource.type) || + DOCKER_SERVICE_TYPES.has(resource.type), + ); + + const hosts = dockerResources.filter( + (resource) => DOCKER_HOST_TYPES.has(resource.type) && isDockerPlatform(resource), + ); + const containers = dockerResources.filter( + (resource) => DOCKER_CONTAINER_TYPES.has(resource.type) && isDockerPlatform(resource), + ); + const services = dockerResources.filter( + (resource) => DOCKER_SERVICE_TYPES.has(resource.type) && isDockerPlatform(resource), + ); + + return { + resources: dockerResources, + hosts, + containers, + services, + }; +} diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx new file mode 100644 index 000000000..1d56d497d --- /dev/null +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -0,0 +1,128 @@ +import { useLocation } from '@solidjs/router'; +import ShipWheelIcon from 'lucide-solid/icons/ship-wheel'; +import { Show, createMemo } from 'solid-js'; +import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; +import { useUnifiedResources } from '@/hooks/useUnifiedResources'; +import { + PlatformErrorState, + PlatformResourceTable, + PlatformSectionTabs, + PlatformTableEmptyState, +} from '@/features/platformPage/sharedPlatformPage'; +import { + KUBERNETES_TAB_SPECS, + buildKubernetesPageModel, + type KubernetesPageTabId, +} from './kubernetesPageModel'; + +const KUBERNETES_RESOURCE_QUERY = + 'type=k8s-cluster,k8s-node,pod,k8s-deployment,k8s-service'; +const KUBERNETES_PLATFORM_FILTER = 'kubernetes'; +const VALID_TABS = new Set(KUBERNETES_TAB_SPECS.map((tab) => tab.id)); + +const k8sIcon = () => ; + +export function KubernetesPageSurface() { + const location = useLocation(); + const { resources, loading, error, refetch } = useUnifiedResources({ + query: KUBERNETES_RESOURCE_QUERY, + cacheKey: 'kubernetes-workspace', + initialHydration: 'prefer-ws-then-rest', + }); + const activeTab = createMemo(() => { + const segment = location.pathname.split('/').filter(Boolean)[1] as + | KubernetesPageTabId + | undefined; + return segment && VALID_TABS.has(segment) ? segment : 'overview'; + }); + const model = createMemo(() => buildKubernetesPageModel(resources())); + + return ( +
+ + + 0} + fallback={ + + } + > + void refetch()} + /> + } + > + 0} + fallback={ + + } + > + + + + + + + + + + + + + + + + + + +
+ ); +} + +export default KubernetesPageSurface; diff --git a/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts new file mode 100644 index 000000000..56b5b1101 --- /dev/null +++ b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { KUBERNETES_TAB_SPECS, buildKubernetesPageModel } from '../kubernetesPageModel'; + +const makeResource = (resource: Partial & Pick): Resource => ({ + name: resource.id, + displayName: resource.id, + platformId: 'lab', + platformType: 'kubernetes', + sourceType: 'agent', + status: 'online', + lastSeen: 1_700_000_000_000, + ...resource, +}); + +describe('kubernetesPageModel', () => { + it('declares the Kubernetes section set', () => { + expect(KUBERNETES_TAB_SPECS.map((tab) => tab.id)).toEqual([ + 'overview', + 'nodes', + 'pods', + 'deployments', + 'services', + ]); + }); + + it('buckets clusters, nodes, pods, deployments, and services', () => { + const model = buildKubernetesPageModel([ + makeResource({ id: 'cluster-1', type: 'k8s-cluster' }), + makeResource({ id: 'node-1', type: 'k8s-node' }), + makeResource({ id: 'pod-1', type: 'pod' }), + makeResource({ id: 'dep-1', type: 'k8s-deployment' }), + makeResource({ id: 'svc-1', type: 'k8s-service' }), + makeResource({ id: 'proxmox-vm', type: 'vm', platformType: 'proxmox-pve' }), + ]); + + expect(model.clusters.map((r) => r.id)).toEqual(['cluster-1']); + expect(model.nodes.map((r) => r.id)).toEqual(['node-1']); + expect(model.pods.map((r) => r.id)).toEqual(['pod-1']); + expect(model.deployments.map((r) => r.id)).toEqual(['dep-1']); + expect(model.services.map((r) => r.id)).toEqual(['svc-1']); + expect(model.resources).toHaveLength(5); + }); +}); diff --git a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts new file mode 100644 index 000000000..a92287ac4 --- /dev/null +++ b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts @@ -0,0 +1,63 @@ +import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; +import type { Resource, ResourceType } from '@/types/resource'; + +export type KubernetesPageTabId = + | 'overview' + | 'nodes' + | 'pods' + | 'deployments' + | 'services'; + +export type KubernetesTabSpec = { + id: KubernetesPageTabId; + label: string; + path: string; +}; + +export const KUBERNETES_TAB_SPECS: readonly KubernetesTabSpec[] = [ + { id: 'overview', label: 'Clusters', path: '/kubernetes/overview' }, + { id: 'nodes', label: 'Nodes', path: '/kubernetes/nodes' }, + { id: 'pods', label: 'Pods', path: '/kubernetes/pods' }, + { id: 'deployments', label: 'Deployments', path: '/kubernetes/deployments' }, + { id: 'services', label: 'Services', path: '/kubernetes/services' }, +] as const; + +const KUBERNETES_RESOURCE_TYPES = new Set([ + 'k8s-cluster', + 'k8s-node', + 'pod', + 'k8s-deployment', + 'k8s-service', +]); + +const isKubernetesPlatform = (resource: Resource): boolean => { + if (normalizeSourcePlatformQueryValue(resource.platformType || '') === 'kubernetes') return true; + return KUBERNETES_RESOURCE_TYPES.has(resource.type); +}; + +export type KubernetesPageModel = { + resources: Resource[]; + clusters: Resource[]; + nodes: Resource[]; + pods: Resource[]; + deployments: Resource[]; + services: Resource[]; +}; + +export function buildKubernetesPageModel(resources: Resource[]): KubernetesPageModel { + const k8sResources = resources.filter(isKubernetesPlatform); + const clusters = k8sResources.filter((resource) => resource.type === 'k8s-cluster'); + const nodes = k8sResources.filter((resource) => resource.type === 'k8s-node'); + const pods = k8sResources.filter((resource) => resource.type === 'pod'); + const deployments = k8sResources.filter((resource) => resource.type === 'k8s-deployment'); + const services = k8sResources.filter((resource) => resource.type === 'k8s-service'); + + return { + resources: k8sResources, + clusters, + nodes, + pods, + deployments, + services, + }; +} diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx new file mode 100644 index 000000000..e59e0e1f0 --- /dev/null +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -0,0 +1,110 @@ +import { A } from '@solidjs/router'; +import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert'; +import { For, Show, createSignal, type Component, type JSX } from 'solid-js'; +import { EmptyState } from '@/components/shared/EmptyState'; +import { TableCard } from '@/components/shared/TableCard'; +import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable'; +import type { Resource } from '@/types/resource'; + +export type PlatformTabSpec = { + id: TabId; + label: string; + path: string; +}; + +export function PlatformSectionTabs(props: { + tabs: readonly PlatformTabSpec[]; + active: TabId; + ariaLabel: string; +}) { + return ( + + ); +} + +export function PlatformTableEmptyState(props: { + icon: JSX.Element; + title: string; + description: string; +}) { + return ( + +
+ +
+
+ ); +} + +export function PlatformErrorState(props: { + title: string; + description: string; + onRefresh: () => void; +}) { + return ( + +
+ } + title={props.title} + description={props.description} + actions={ + + } + /> +
+
+ ); +} + +export const PlatformResourceTable: Component<{ + resources: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; + groupingMode?: 'grouped' | 'flat'; +}> = (props) => { + const [expandedResourceId, setExpandedResourceId] = createSignal(null); + + return ( + 0} + fallback={ + + } + > + + + ); +}; diff --git a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx new file mode 100644 index 000000000..9e9d31a62 --- /dev/null +++ b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx @@ -0,0 +1,106 @@ +import { useLocation } from '@solidjs/router'; +import DatabaseIcon from 'lucide-solid/icons/database'; +import { Show, createMemo } from 'solid-js'; +import StorageSurface from '@/components/Storage/Storage'; +import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; +import { useUnifiedResources } from '@/hooks/useUnifiedResources'; +import { + PlatformErrorState, + PlatformResourceTable, + PlatformSectionTabs, + PlatformTableEmptyState, +} from '@/features/platformPage/sharedPlatformPage'; +import { + TRUENAS_TAB_SPECS, + buildTrueNASPageModel, + type TrueNASPageTabId, +} from './truenasPageModel'; + +const TRUENAS_RESOURCE_QUERY = + 'type=agent,app-container,storage,pool,dataset,physical_disk'; +const TRUENAS_PLATFORM_FILTER = 'truenas'; +const VALID_TABS = new Set(TRUENAS_TAB_SPECS.map((tab) => tab.id)); + +const truenasIcon = () => ; + +export function TrueNASPageSurface() { + const location = useLocation(); + const { resources, loading, error, refetch } = useUnifiedResources({ + query: TRUENAS_RESOURCE_QUERY, + cacheKey: 'truenas-workspace', + initialHydration: 'prefer-ws-then-rest', + }); + const activeTab = createMemo(() => { + const segment = location.pathname.split('/').filter(Boolean)[1] as TrueNASPageTabId | undefined; + return segment && VALID_TABS.has(segment) ? segment : 'overview'; + }); + const model = createMemo(() => buildTrueNASPageModel(resources())); + + return ( +
+ + + 0} + fallback={ + + } + > + void refetch()} + /> + } + > + 0} + fallback={ + + } + > + + + + + + + + + + + + +
+ ); +} + +export default TrueNASPageSurface; diff --git a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts new file mode 100644 index 000000000..aa8b94b2b --- /dev/null +++ b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { TRUENAS_TAB_SPECS, buildTrueNASPageModel } from '../truenasPageModel'; + +const makeResource = (resource: Partial & Pick): Resource => ({ + name: resource.id, + displayName: resource.id, + platformId: 'lab', + platformType: 'truenas', + sourceType: 'api', + status: 'online', + lastSeen: 1_700_000_000_000, + ...resource, +}); + +describe('truenasPageModel', () => { + it('declares the TrueNAS section set', () => { + expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual(['overview', 'storage', 'apps']); + }); + + it('buckets hosts and apps and ignores non-TrueNAS resources', () => { + const model = buildTrueNASPageModel([ + makeResource({ id: 'truenas-host', type: 'agent' }), + makeResource({ id: 'truenas-app', type: 'app-container' }), + makeResource({ id: 'truenas-pool', type: 'pool' }), + makeResource({ id: 'docker-host', type: 'agent', platformType: 'docker' }), + makeResource({ id: 'pve-node', type: 'agent', platformType: 'proxmox-pve' }), + ]); + + expect(model.hosts.map((r) => r.id)).toEqual(['truenas-host']); + expect(model.apps.map((r) => r.id)).toEqual(['truenas-app']); + expect(model.resources.map((r) => r.id).sort()).toEqual( + ['truenas-app', 'truenas-host', 'truenas-pool'].sort(), + ); + }); +}); diff --git a/frontend-modern/src/features/truenas/truenasPageModel.ts b/frontend-modern/src/features/truenas/truenasPageModel.ts new file mode 100644 index 000000000..e865c5720 --- /dev/null +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -0,0 +1,49 @@ +import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; +import type { Resource, ResourceType } from '@/types/resource'; + +export type TrueNASPageTabId = 'overview' | 'storage' | 'apps'; + +export type TrueNASTabSpec = { + id: TrueNASPageTabId; + label: string; + path: string; +}; + +export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [ + { id: 'overview', label: 'Hosts', path: '/truenas/overview' }, + { id: 'storage', label: 'Storage', path: '/truenas/storage' }, + { id: 'apps', label: 'Apps', path: '/truenas/apps' }, +] as const; + +const TRUENAS_RESOURCE_TYPES = new Set([ + 'agent', + 'app-container', + 'storage', + 'pool', + 'dataset', + 'physical_disk', +]); + +const isTrueNASPlatform = (resource: Resource): boolean => + normalizeSourcePlatformQueryValue(resource.platformType || '') === 'truenas'; + +export type TrueNASPageModel = { + resources: Resource[]; + hosts: Resource[]; + apps: Resource[]; +}; + +export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel { + const trueNasResources = resources.filter( + (resource) => isTrueNASPlatform(resource) && TRUENAS_RESOURCE_TYPES.has(resource.type), + ); + + const hosts = trueNasResources.filter((resource) => resource.type === 'agent'); + const apps = trueNasResources.filter((resource) => resource.type === 'app-container'); + + return { + resources: trueNasResources, + hosts, + apps, + }; +} diff --git a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx new file mode 100644 index 000000000..38934f644 --- /dev/null +++ b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx @@ -0,0 +1,105 @@ +import { useLocation } from '@solidjs/router'; +import CpuIcon from 'lucide-solid/icons/cpu'; +import { Show, createMemo } from 'solid-js'; +import StorageSurface from '@/components/Storage/Storage'; +import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; +import { useUnifiedResources } from '@/hooks/useUnifiedResources'; +import { + PlatformErrorState, + PlatformResourceTable, + PlatformSectionTabs, + PlatformTableEmptyState, +} from '@/features/platformPage/sharedPlatformPage'; +import { + VMWARE_TAB_SPECS, + buildVmwarePageModel, + type VmwarePageTabId, +} from './vmwarePageModel'; + +const VMWARE_RESOURCE_QUERY = 'type=agent,vm,storage,datastore'; +const VMWARE_PLATFORM_FILTER = 'vmware-vsphere'; +const VALID_TABS = new Set(VMWARE_TAB_SPECS.map((tab) => tab.id)); + +const vmwareIcon = () => ; + +export function VmwarePageSurface() { + const location = useLocation(); + const { resources, loading, error, refetch } = useUnifiedResources({ + query: VMWARE_RESOURCE_QUERY, + cacheKey: 'vmware-workspace', + initialHydration: 'prefer-ws-then-rest', + }); + const activeTab = createMemo(() => { + const segment = location.pathname.split('/').filter(Boolean)[1] as VmwarePageTabId | undefined; + return segment && VALID_TABS.has(segment) ? segment : 'overview'; + }); + const model = createMemo(() => buildVmwarePageModel(resources())); + + return ( +
+ + + 0} + fallback={ + + } + > + void refetch()} + /> + } + > + 0} + fallback={ + + } + > + + + + + + + + + + + + +
+ ); +} + +export default VmwarePageSurface; diff --git a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts new file mode 100644 index 000000000..bc627d9a1 --- /dev/null +++ b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { VMWARE_TAB_SPECS, buildVmwarePageModel } from '../vmwarePageModel'; + +const makeResource = (resource: Partial & Pick): Resource => ({ + name: resource.id, + displayName: resource.id, + platformId: 'lab', + platformType: 'vmware-vsphere', + sourceType: 'api', + status: 'online', + lastSeen: 1_700_000_000_000, + ...resource, +}); + +describe('vmwarePageModel', () => { + it('declares the vSphere section set', () => { + expect(VMWARE_TAB_SPECS.map((tab) => tab.id)).toEqual(['overview', 'vms', 'storage']); + }); + + it('buckets vSphere hosts and VMs and ignores non-vSphere resources', () => { + const model = buildVmwarePageModel([ + makeResource({ id: 'esxi-host-1', type: 'agent' }), + makeResource({ id: 'vsphere-vm-1', type: 'vm' }), + makeResource({ id: 'datastore-1', type: 'datastore' }), + makeResource({ id: 'pve-vm', type: 'vm', platformType: 'proxmox-pve' }), + ]); + + expect(model.hosts.map((r) => r.id)).toEqual(['esxi-host-1']); + expect(model.vms.map((r) => r.id)).toEqual(['vsphere-vm-1']); + expect(model.resources.map((r) => r.id).sort()).toEqual( + ['datastore-1', 'esxi-host-1', 'vsphere-vm-1'].sort(), + ); + }); +}); diff --git a/frontend-modern/src/features/vmware/vmwarePageModel.ts b/frontend-modern/src/features/vmware/vmwarePageModel.ts new file mode 100644 index 000000000..ce5b5f3a7 --- /dev/null +++ b/frontend-modern/src/features/vmware/vmwarePageModel.ts @@ -0,0 +1,46 @@ +import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; +import type { Resource, ResourceType } from '@/types/resource'; + +export type VmwarePageTabId = 'overview' | 'vms' | 'storage'; + +export type VmwareTabSpec = { + id: VmwarePageTabId; + label: string; + path: string; +}; + +export const VMWARE_TAB_SPECS: readonly VmwareTabSpec[] = [ + { id: 'overview', label: 'Hosts', path: '/vmware/overview' }, + { id: 'vms', label: 'Virtual machines', path: '/vmware/vms' }, + { id: 'storage', label: 'Storage', path: '/vmware/storage' }, +] as const; + +const VMWARE_RESOURCE_TYPES = new Set([ + 'agent', + 'vm', + 'storage', + 'datastore', +]); + +const isVmwarePlatform = (resource: Resource): boolean => + normalizeSourcePlatformQueryValue(resource.platformType || '') === 'vmware-vsphere'; + +export type VmwarePageModel = { + resources: Resource[]; + hosts: Resource[]; + vms: Resource[]; +}; + +export function buildVmwarePageModel(resources: Resource[]): VmwarePageModel { + const vmwareResources = resources.filter( + (resource) => isVmwarePlatform(resource) && VMWARE_RESOURCE_TYPES.has(resource.type), + ); + const hosts = vmwareResources.filter((resource) => resource.type === 'agent'); + const vms = vmwareResources.filter((resource) => resource.type === 'vm'); + + return { + resources: vmwareResources, + hosts, + vms, + }; +} diff --git a/frontend-modern/src/pages/Docker.tsx b/frontend-modern/src/pages/Docker.tsx new file mode 100644 index 000000000..a256f475e --- /dev/null +++ b/frontend-modern/src/pages/Docker.tsx @@ -0,0 +1,7 @@ +import { DockerPageSurface } from '@/features/docker/DockerPageSurface'; + +export function Docker() { + return ; +} + +export default Docker; diff --git a/frontend-modern/src/pages/Kubernetes.tsx b/frontend-modern/src/pages/Kubernetes.tsx new file mode 100644 index 000000000..bde49eeec --- /dev/null +++ b/frontend-modern/src/pages/Kubernetes.tsx @@ -0,0 +1,7 @@ +import { KubernetesPageSurface } from '@/features/kubernetes/KubernetesPageSurface'; + +export function Kubernetes() { + return ; +} + +export default Kubernetes; diff --git a/frontend-modern/src/pages/TrueNAS.tsx b/frontend-modern/src/pages/TrueNAS.tsx new file mode 100644 index 000000000..6bef78bea --- /dev/null +++ b/frontend-modern/src/pages/TrueNAS.tsx @@ -0,0 +1,7 @@ +import { TrueNASPageSurface } from '@/features/truenas/TrueNASPageSurface'; + +export function TrueNAS() { + return ; +} + +export default TrueNAS; diff --git a/frontend-modern/src/pages/Vmware.tsx b/frontend-modern/src/pages/Vmware.tsx new file mode 100644 index 000000000..e55b62d03 --- /dev/null +++ b/frontend-modern/src/pages/Vmware.tsx @@ -0,0 +1,7 @@ +import { VmwarePageSurface } from '@/features/vmware/VmwarePageSurface'; + +export function Vmware() { + return ; +} + +export default Vmware; diff --git a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts index 846ea8515..36d4edb6c 100644 --- a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts +++ b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts @@ -2,13 +2,19 @@ import { describe, expect, it } from 'vitest'; import type { WorkloadGuest } from '@/types/workloads'; import { AI_PATROL_PATH, + DOCKER_PATH, + KUBERNETES_PATH, PMG_THRESHOLDS_PATH, PATROL_PATH, PROXMOX_DEFAULT_TAB, PROXMOX_PATH, RECOVERY_QUERY_PARAMS, + TRUENAS_PATH, + VMWARE_PATH, + buildDockerPath, buildInfrastructureResourceLink, buildInfrastructureHrefForWorkload, + buildKubernetesPath, buildRecoveryPath, buildRecoveryHrefForResource, buildInfrastructurePath, @@ -18,6 +24,8 @@ import { buildResourceSurfaceLinksForResource, buildStorageHrefForResource, buildStoragePath, + buildTrueNASPath, + buildVmwarePath, buildWorkloadsHrefForResource, buildWorkloadsPath, parseRecoveryLinkSearch, @@ -68,6 +76,25 @@ describe('resource link routing contract', () => { expect(buildProxmoxPath('')).toBe('/proxmox'); }); + it('builds canonical Docker, Kubernetes, TrueNAS, and vSphere platform tab paths', () => { + expect(DOCKER_PATH).toBe('/docker'); + expect(buildDockerPath()).toBe('/docker/overview'); + expect(buildDockerPath('containers')).toBe('/docker/containers'); + expect(buildDockerPath('')).toBe('/docker'); + + expect(KUBERNETES_PATH).toBe('/kubernetes'); + expect(buildKubernetesPath()).toBe('/kubernetes/overview'); + expect(buildKubernetesPath('pods')).toBe('/kubernetes/pods'); + + expect(TRUENAS_PATH).toBe('/truenas'); + expect(buildTrueNASPath()).toBe('/truenas/overview'); + expect(buildTrueNASPath('storage')).toBe('/truenas/storage'); + + expect(VMWARE_PATH).toBe('/vmware'); + expect(buildVmwarePath()).toBe('/vmware/overview'); + expect(buildVmwarePath('vms')).toBe('/vmware/vms'); + }); + it('builds and parses workloads query params', () => { const href = buildWorkloadsPath({ type: 'k8s', diff --git a/frontend-modern/src/routing/navigation.ts b/frontend-modern/src/routing/navigation.ts index 041604a02..7fb7ab2d0 100644 --- a/frontend-modern/src/routing/navigation.ts +++ b/frontend-modern/src/routing/navigation.ts @@ -1,7 +1,20 @@ -import { INFRASTRUCTURE_PATH, PATROL_PATH, PROXMOX_PATH, WORKLOADS_PATH } from './resourceLinks'; +import { + DOCKER_PATH, + INFRASTRUCTURE_PATH, + KUBERNETES_PATH, + PATROL_PATH, + PROXMOX_PATH, + TRUENAS_PATH, + VMWARE_PATH, + WORKLOADS_PATH, +} from './resourceLinks'; export type AppTabId = | 'proxmox' + | 'docker' + | 'kubernetes' + | 'truenas' + | 'vmware' | 'infrastructure' | 'workloads' | 'storage' @@ -14,6 +27,10 @@ export type ActiveAppTabId = AppTabId | null; export function getActiveTabForPath(path: string): ActiveAppTabId { if (path.startsWith(PROXMOX_PATH)) return 'proxmox'; + if (path.startsWith(DOCKER_PATH)) return 'docker'; + if (path.startsWith(KUBERNETES_PATH)) return 'kubernetes'; + if (path.startsWith(TRUENAS_PATH)) return 'truenas'; + if (path.startsWith(VMWARE_PATH)) return 'vmware'; if (path.startsWith(INFRASTRUCTURE_PATH)) return 'infrastructure'; if (path.startsWith(WORKLOADS_PATH)) return 'workloads'; if (path.startsWith('/storage')) return 'storage'; diff --git a/frontend-modern/src/routing/resourceLinks.ts b/frontend-modern/src/routing/resourceLinks.ts index ba3ae5d8c..8e03c1b93 100644 --- a/frontend-modern/src/routing/resourceLinks.ts +++ b/frontend-modern/src/routing/resourceLinks.ts @@ -40,6 +40,14 @@ export const WORKLOADS_QUERY_PARAMS = { export const WORKLOADS_PATH = '/workloads'; export const PROXMOX_PATH = '/proxmox'; export const PROXMOX_DEFAULT_TAB = 'overview'; +export const DOCKER_PATH = '/docker'; +export const DOCKER_DEFAULT_TAB = 'overview'; +export const KUBERNETES_PATH = '/kubernetes'; +export const KUBERNETES_DEFAULT_TAB = 'overview'; +export const TRUENAS_PATH = '/truenas'; +export const TRUENAS_DEFAULT_TAB = 'overview'; +export const VMWARE_PATH = '/vmware'; +export const VMWARE_DEFAULT_TAB = 'overview'; export const PMG_THRESHOLDS_PATH = '/alerts/thresholds/mail-gateway'; export const ALERTS_OVERVIEW_PATH = '/alerts/overview'; export const PATROL_PATH = '/patrol'; @@ -229,6 +237,26 @@ export const buildProxmoxPath = (tab: string = PROXMOX_DEFAULT_TAB): string => { return normalized ? `${PROXMOX_PATH}/${normalized}` : PROXMOX_PATH; }; +export const buildDockerPath = (tab: string = DOCKER_DEFAULT_TAB): string => { + const normalized = tab.trim().replace(/^\/+|\/+$/g, ''); + return normalized ? `${DOCKER_PATH}/${normalized}` : DOCKER_PATH; +}; + +export const buildKubernetesPath = (tab: string = KUBERNETES_DEFAULT_TAB): string => { + const normalized = tab.trim().replace(/^\/+|\/+$/g, ''); + return normalized ? `${KUBERNETES_PATH}/${normalized}` : KUBERNETES_PATH; +}; + +export const buildTrueNASPath = (tab: string = TRUENAS_DEFAULT_TAB): string => { + const normalized = tab.trim().replace(/^\/+|\/+$/g, ''); + return normalized ? `${TRUENAS_PATH}/${normalized}` : TRUENAS_PATH; +}; + +export const buildVmwarePath = (tab: string = VMWARE_DEFAULT_TAB): string => { + const normalized = tab.trim().replace(/^\/+|\/+$/g, ''); + return normalized ? `${VMWARE_PATH}/${normalized}` : VMWARE_PATH; +}; + export const parseInfrastructureLinkSearch = (search: string) => { const params = new URLSearchParams(search); return { diff --git a/tests/integration/tests/68-platform-pages-shell.spec.ts b/tests/integration/tests/68-platform-pages-shell.spec.ts new file mode 100644 index 000000000..95622383f --- /dev/null +++ b/tests/integration/tests/68-platform-pages-shell.spec.ts @@ -0,0 +1,122 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test as base, expect, type Page } from '@playwright/test'; +import { createAuthenticatedStorageState } from './helpers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type WorkerFixtures = { + authStorageStatePath: string; +}; + +const test = base.extend<{}, WorkerFixtures>({ + storageState: async ({ authStorageStatePath }, use) => { + await use(authStorageStatePath); + }, + authStorageStatePath: [ + async ({ browser }, use, workerInfo) => { + const storageStatePath = path.resolve( + __dirname, + '..', + '..', + 'tmp', + 'playwright-auth', + `platform-pages-shell-${workerInfo.project.name}.json`, + ); + fs.mkdirSync(path.dirname(storageStatePath), { recursive: true }); + await createAuthenticatedStorageState(browser, storageStatePath); + try { + await use(storageStatePath); + } finally { + fs.rmSync(storageStatePath, { force: true }); + } + }, + { scope: 'worker' }, + ], +}); + +type PlatformPageCase = { + id: string; + rootPath: string; + testId: string; + ariaLabel: string; + tabPaths: readonly string[]; +}; + +const PLATFORM_PAGES: readonly PlatformPageCase[] = [ + { + id: 'docker', + rootPath: '/docker', + testId: 'docker-page', + ariaLabel: 'Docker sections', + tabPaths: ['/docker/overview', '/docker/containers', '/docker/services'], + }, + { + id: 'kubernetes', + rootPath: '/kubernetes', + testId: 'kubernetes-page', + ariaLabel: 'Kubernetes sections', + tabPaths: [ + '/kubernetes/overview', + '/kubernetes/nodes', + '/kubernetes/pods', + '/kubernetes/deployments', + '/kubernetes/services', + ], + }, + { + id: 'truenas', + rootPath: '/truenas', + testId: 'truenas-page', + ariaLabel: 'TrueNAS sections', + tabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/apps'], + }, + { + id: 'vmware', + rootPath: '/vmware', + testId: 'vmware-page', + ariaLabel: 'VMware sections', + tabPaths: ['/vmware/overview', '/vmware/vms', '/vmware/storage'], + }, +]; + +const stubEmptyResources = async (page: Page) => { + await page.route('**/api/resources**', async (route) => { + const requestUrl = new URL(route.request().url()); + if (requestUrl.pathname === '/api/resources') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], links: { next: null } }), + }); + return; + } + await route.continue(); + }); +}; + +test.describe('Platform pages shell', () => { + test.setTimeout(180_000); + + for (const platform of PLATFORM_PAGES) { + test(`${platform.id} platform page renders with table-first sub-tab chrome`, async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop shell smoke'); + + await stubEmptyResources(page); + await page.goto(platform.rootPath, { waitUntil: 'domcontentloaded' }); + + const pageRoot = page.getByTestId(platform.testId); + await expect(pageRoot).toBeVisible({ timeout: 30_000 }); + + const sectionNav = page.getByRole('navigation', { name: platform.ariaLabel }); + await expect(sectionNav).toBeVisible(); + + for (const tabPath of platform.tabPaths) { + await expect(sectionNav.locator(`a[href="${tabPath}"]`)).toBeVisible(); + } + }); + } +});