frontend(platforms): add Docker, Kubernetes, TrueNAS, vSphere platform pages

Introduce a shared platform-page primitive and four new top-level family
pages that mirror the v5-style Proxmox surface: chrome only, embedding
the canonical WorkloadsSurface, StorageSurface, RecoverySurface, and
UnifiedResourceTable in tableOnly/embedded mode with forced platform or
source filters. No dashboard cards, no fake data, no bespoke per-family
tables.

Pages added:
- /docker      Hosts / Containers / Swarm services
- /kubernetes  Clusters / Nodes / Pods / Deployments / Services
- /truenas     Hosts / Storage / Apps
- /vmware      Hosts / VMs / Storage (vSphere, first-lab-ready)

Top-level navigation entries are gated on platform presence in
state.resources, so empty platforms stay hidden by default; Proxmox
remains alwaysShow. Infrastructure, Workloads, Storage, and Recovery
remain available unchanged. Routing, navigation tab IDs, and the
document-title map are extended to match. Route preload and mobile-nav
priority changes are deliberately deferred to a follow-up commit to
avoid wider entanglement with parallel-agent edits to those shared
shell files.

Contracts extended for the new platform-page boundary:
- cloud-paid.md
- unified-resources.md
- storage-recovery.md
- ai-runtime.md
- frontend-primitives.md

Tests:
- dockerPageModel, kubernetesPageModel, truenasPageModel, vmwarePageModel
  vitest suites (12 new tests).
- resourceLinks.test.ts extended for the new platform path builders.
- App.architecture.test.ts extended for the new lazy imports and routes.
- 68-platform-pages-shell.spec.ts Playwright smoke covering all four
  pages and every sub-tab link against the live Pulse dev runtime.
- App.architecture, proxmox model, and Workloads suites continue to pass.

Skipped (canonical model not ready):
- Unraid as a top-level page: an agentHostProfile, no platform
  projections; already surfaces through the Pulse-managed Hosts / Storage
  views.
- Synology DSM, Microsoft Hyper-V, AWS, Azure, GCP:
  governanceState=presentation-only, no canonical projections.
This commit is contained in:
rcourtman 2026-05-15 23:09:17 +01:00
parent 6aad1118cd
commit cdaeb3b84d
29 changed files with 1306 additions and 2 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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() {
<Route path="/" component={RuntimeHomePage} />
<Route path={PROXMOX_PATH} component={ProxmoxPage} />
<Route path={`${PROXMOX_PATH}/*`} component={ProxmoxPage} />
<Route path={DOCKER_PATH} component={DockerPage} />
<Route path={`${DOCKER_PATH}/*`} component={DockerPage} />
<Route path={KUBERNETES_PATH} component={KubernetesPage} />
<Route path={`${KUBERNETES_PATH}/*`} component={KubernetesPage} />
<Route path={TRUENAS_PATH} component={TrueNASPage} />
<Route path={`${TRUENAS_PATH}/*`} component={TrueNASPage} />
<Route path={VMWARE_PATH} component={VmwarePage} />
<Route path={`${VMWARE_PATH}/*`} component={VmwarePage} />
<Route path={ROOT_WORKLOADS_PATH} component={WorkloadsPage} />
<Route path={STORAGE_PATH} component={StoragePage} />
<Route path={RECOVERY_ROUTE_PATH} component={RecoveryPage} />

View file

@ -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<NonNullable<ReturnType<typeof getActiveTabForPath>>, 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<string>();
for (const resource of props.state().resources || []) {
const key = normalizeSourcePlatformQueryValue(resource.platformType || '');
if (key) presence.add(key);
}
return presence;
});
const platformTabs = createMemo<PlatformTab[]>(() => {
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',

View file

@ -50,6 +50,14 @@ describe('App architecture', () => {
);
expect(appSource).toContain('<Route path={PROXMOX_PATH} component={ProxmoxPage} />');
expect(appSource).toContain('<Route path={`${PROXMOX_PATH}/*`} component={ProxmoxPage} />');
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('<Route path={DOCKER_PATH} component={DockerPage} />');
expect(appSource).toContain('<Route path={KUBERNETES_PATH} component={KubernetesPage} />');
expect(appSource).toContain('<Route path={TRUENAS_PATH} component={TrueNASPage} />');
expect(appSource).toContain('<Route path={VMWARE_PATH} component={VmwarePage} />');
expect(appSource).not.toContain('DashboardPage');
expect(headerAuditSource).not.toContain("['src/pages/Dashboard.tsx', 'PageHeader']");
expect(appSource).toContain("import RuntimeHomePage from '@/pages/RuntimeHome';");

View file

@ -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<DockerPageTabId>(DOCKER_TAB_SPECS.map((tab) => tab.id));
const dockerIcon = () => <ContainerIcon class="h-6 w-6 text-slate-400" />;
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<DockerPageTabId>(() => {
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 (
<div data-testid="docker-page" class="space-y-3">
<PlatformSectionTabs
tabs={DOCKER_TAB_SPECS}
active={activeTab()}
ariaLabel="Docker sections"
/>
<Show
when={!loading() || model().resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={dockerIcon()}
title="Loading Docker resources"
description="Pulse is loading the Docker / Podman resource snapshot."
/>
}
>
<Show
when={!error()}
fallback={
<PlatformErrorState
title="Could not load Docker resources"
description="Refresh the resource snapshot or check the API connection state."
onRefresh={() => void refetch()}
/>
}
>
<Show
when={model().resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={dockerIcon()}
title="No Docker or Podman hosts"
description="Install the Pulse agent on a Docker or Podman host to populate this platform page."
/>
}
>
<Show when={activeTab() === 'overview'}>
<PlatformResourceTable
resources={model().hosts}
emptyIcon={dockerIcon()}
emptyTitle="No Docker hosts"
emptyDescription="Container hosts appear here once a Pulse agent registers them."
/>
</Show>
<Show when={activeTab() === 'containers'}>
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
forcedPlatform={DOCKER_PLATFORM_FILTER}
/>
</Show>
<Show when={activeTab() === 'services'}>
<PlatformResourceTable
resources={model().services}
emptyIcon={dockerIcon()}
emptyTitle="No Swarm services"
emptyDescription="Docker Swarm services appear here when an agent reports them."
/>
</Show>
</Show>
</Show>
</Show>
</div>
);
}
export default DockerPageSurface;

View file

@ -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<Resource> & Pick<Resource, 'id' | 'type'>): 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([]);
});
});

View file

@ -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<ResourceType>(['agent', 'docker-host']);
const DOCKER_CONTAINER_TYPES = new Set<ResourceType>(['app-container']);
const DOCKER_SERVICE_TYPES = new Set<ResourceType>(['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,
};
}

View file

@ -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<KubernetesPageTabId>(KUBERNETES_TAB_SPECS.map((tab) => tab.id));
const k8sIcon = () => <ShipWheelIcon class="h-6 w-6 text-slate-400" />;
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<KubernetesPageTabId>(() => {
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 (
<div data-testid="kubernetes-page" class="space-y-3">
<PlatformSectionTabs
tabs={KUBERNETES_TAB_SPECS}
active={activeTab()}
ariaLabel="Kubernetes sections"
/>
<Show
when={!loading() || model().resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={k8sIcon()}
title="Loading Kubernetes resources"
description="Pulse is loading the Kubernetes resource snapshot."
/>
}
>
<Show
when={!error()}
fallback={
<PlatformErrorState
title="Could not load Kubernetes resources"
description="Refresh the resource snapshot or check the API connection state."
onRefresh={() => void refetch()}
/>
}
>
<Show
when={model().resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={k8sIcon()}
title="No Kubernetes clusters"
description="Install the Pulse agent on a Kubernetes node to populate this platform page."
/>
}
>
<Show when={activeTab() === 'overview'}>
<PlatformResourceTable
resources={model().clusters}
emptyIcon={k8sIcon()}
emptyTitle="No clusters reported"
emptyDescription="Kubernetes clusters appear here once at least one agent reports cluster context."
/>
</Show>
<Show when={activeTab() === 'nodes'}>
<PlatformResourceTable
resources={model().nodes}
emptyIcon={k8sIcon()}
emptyTitle="No nodes reported"
emptyDescription="Kubernetes nodes appear here as soon as the agent enumerates them."
/>
</Show>
<Show when={activeTab() === 'pods'}>
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
forcedPlatform={KUBERNETES_PLATFORM_FILTER}
/>
</Show>
<Show when={activeTab() === 'deployments'}>
<PlatformResourceTable
resources={model().deployments}
emptyIcon={k8sIcon()}
emptyTitle="No deployments reported"
emptyDescription="Deployments appear here once the cluster reports them."
/>
</Show>
<Show when={activeTab() === 'services'}>
<PlatformResourceTable
resources={model().services}
emptyIcon={k8sIcon()}
emptyTitle="No services reported"
emptyDescription="Kubernetes services appear here once the cluster reports them."
/>
</Show>
</Show>
</Show>
</Show>
</div>
);
}
export default KubernetesPageSurface;

View file

@ -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<Resource> & Pick<Resource, 'id' | 'type'>): 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);
});
});

View file

@ -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<ResourceType>([
'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,
};
}

View file

@ -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<TabId extends string> = {
id: TabId;
label: string;
path: string;
};
export function PlatformSectionTabs<TabId extends string>(props: {
tabs: readonly PlatformTabSpec<TabId>[];
active: TabId;
ariaLabel: string;
}) {
return (
<nav class="flex flex-wrap items-center gap-1 border-b border-border" aria-label={props.ariaLabel}>
<For each={props.tabs}>
{(tab) => (
<A
href={tab.path}
class={`inline-flex min-h-10 items-center border-b-2 px-3 text-sm font-medium transition-colors ${
props.active === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-300'
: 'border-transparent text-muted hover:border-border hover:text-base-content'
}`}
aria-current={props.active === tab.id ? 'page' : undefined}
>
{tab.label}
</A>
)}
</For>
</nav>
);
}
export function PlatformTableEmptyState(props: {
icon: JSX.Element;
title: string;
description: string;
}) {
return (
<TableCard>
<div class="p-6">
<EmptyState icon={props.icon} title={props.title} description={props.description} />
</div>
</TableCard>
);
}
export function PlatformErrorState(props: {
title: string;
description: string;
onRefresh: () => void;
}) {
return (
<TableCard>
<div class="p-6">
<EmptyState
icon={<TriangleAlertIcon class="h-6 w-6 text-slate-400" />}
title={props.title}
description={props.description}
actions={
<button
type="button"
onClick={props.onRefresh}
class="inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover"
>
Refresh
</button>
}
/>
</div>
</TableCard>
);
}
export const PlatformResourceTable: Component<{
resources: Resource[];
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
groupingMode?: 'grouped' | 'flat';
}> = (props) => {
const [expandedResourceId, setExpandedResourceId] = createSignal<string | null>(null);
return (
<Show
when={props.resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<UnifiedResourceTable
resources={props.resources}
expandedResourceId={expandedResourceId()}
onExpandedResourceChange={setExpandedResourceId}
groupingMode={props.groupingMode ?? 'grouped'}
/>
</Show>
);
};

View file

@ -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<TrueNASPageTabId>(TRUENAS_TAB_SPECS.map((tab) => tab.id));
const truenasIcon = () => <DatabaseIcon class="h-6 w-6 text-slate-400" />;
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<TrueNASPageTabId>(() => {
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 (
<div data-testid="truenas-page" class="space-y-3">
<PlatformSectionTabs
tabs={TRUENAS_TAB_SPECS}
active={activeTab()}
ariaLabel="TrueNAS sections"
/>
<Show
when={!loading() || model().resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={truenasIcon()}
title="Loading TrueNAS resources"
description="Pulse is loading the TrueNAS resource snapshot."
/>
}
>
<Show
when={!error()}
fallback={
<PlatformErrorState
title="Could not load TrueNAS resources"
description="Refresh the resource snapshot or check the API connection state."
onRefresh={() => void refetch()}
/>
}
>
<Show
when={model().resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={truenasIcon()}
title="No TrueNAS systems"
description="Add a TrueNAS connection in Settings or install the Pulse agent on a TrueNAS host."
/>
}
>
<Show when={activeTab() === 'overview'}>
<PlatformResourceTable
resources={model().hosts}
emptyIcon={truenasIcon()}
emptyTitle="No TrueNAS hosts"
emptyDescription="TrueNAS hosts appear here once they report via the agent or the API connection."
/>
</Show>
<Show when={activeTab() === 'storage'}>
<StorageSurface embedded tableOnly forcedSourceFilter={TRUENAS_PLATFORM_FILTER} />
</Show>
<Show when={activeTab() === 'apps'}>
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
forcedPlatform={TRUENAS_PLATFORM_FILTER}
/>
</Show>
</Show>
</Show>
</Show>
</div>
);
}
export default TrueNASPageSurface;

View file

@ -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<Resource> & Pick<Resource, 'id' | 'type'>): 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(),
);
});
});

View file

@ -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<ResourceType>([
'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,
};
}

View file

@ -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<VmwarePageTabId>(VMWARE_TAB_SPECS.map((tab) => tab.id));
const vmwareIcon = () => <CpuIcon class="h-6 w-6 text-slate-400" />;
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<VmwarePageTabId>(() => {
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 (
<div data-testid="vmware-page" class="space-y-3">
<PlatformSectionTabs
tabs={VMWARE_TAB_SPECS}
active={activeTab()}
ariaLabel="VMware sections"
/>
<Show
when={!loading() || model().resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={vmwareIcon()}
title="Loading VMware resources"
description="Pulse is loading the VMware vSphere resource snapshot."
/>
}
>
<Show
when={!error()}
fallback={
<PlatformErrorState
title="Could not load VMware resources"
description="Refresh the resource snapshot or check the API connection state."
onRefresh={() => void refetch()}
/>
}
>
<Show
when={model().resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={vmwareIcon()}
title="No vSphere hosts"
description="VMware vSphere is in first-lab-ready readiness. Add a vCenter connection in Settings to populate this page."
/>
}
>
<Show when={activeTab() === 'overview'}>
<PlatformResourceTable
resources={model().hosts}
emptyIcon={vmwareIcon()}
emptyTitle="No vSphere hosts"
emptyDescription="Hosts appear here once the vCenter connection enumerates them."
/>
</Show>
<Show when={activeTab() === 'vms'}>
<WorkloadsSurface
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
forcedPlatform={VMWARE_PLATFORM_FILTER}
/>
</Show>
<Show when={activeTab() === 'storage'}>
<StorageSurface embedded tableOnly forcedSourceFilter={VMWARE_PLATFORM_FILTER} />
</Show>
</Show>
</Show>
</Show>
</div>
);
}
export default VmwarePageSurface;

View file

@ -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<Resource> & Pick<Resource, 'id' | 'type'>): 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(),
);
});
});

View file

@ -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<ResourceType>([
'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,
};
}

View file

@ -0,0 +1,7 @@
import { DockerPageSurface } from '@/features/docker/DockerPageSurface';
export function Docker() {
return <DockerPageSurface />;
}
export default Docker;

View file

@ -0,0 +1,7 @@
import { KubernetesPageSurface } from '@/features/kubernetes/KubernetesPageSurface';
export function Kubernetes() {
return <KubernetesPageSurface />;
}
export default Kubernetes;

View file

@ -0,0 +1,7 @@
import { TrueNASPageSurface } from '@/features/truenas/TrueNASPageSurface';
export function TrueNAS() {
return <TrueNASPageSurface />;
}
export default TrueNAS;

View file

@ -0,0 +1,7 @@
import { VmwarePageSurface } from '@/features/vmware/VmwarePageSurface';
export function Vmware() {
return <VmwarePageSurface />;
}
export default Vmware;

View file

@ -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',

View file

@ -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';

View file

@ -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 {

View file

@ -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();
}
});
}
});