mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-21 18:46:08 +00:00
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:
parent
6aad1118cd
commit
cdaeb3b84d
29 changed files with 1306 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';");
|
||||
|
|
|
|||
109
frontend-modern/src/features/docker/DockerPageSurface.tsx
Normal file
109
frontend-modern/src/features/docker/DockerPageSurface.tsx
Normal 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;
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
56
frontend-modern/src/features/docker/dockerPageModel.ts
Normal file
56
frontend-modern/src/features/docker/dockerPageModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
110
frontend-modern/src/features/platformPage/sharedPlatformPage.tsx
Normal file
110
frontend-modern/src/features/platformPage/sharedPlatformPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
106
frontend-modern/src/features/truenas/TrueNASPageSurface.tsx
Normal file
106
frontend-modern/src/features/truenas/TrueNASPageSurface.tsx
Normal 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;
|
||||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
49
frontend-modern/src/features/truenas/truenasPageModel.ts
Normal file
49
frontend-modern/src/features/truenas/truenasPageModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
105
frontend-modern/src/features/vmware/VmwarePageSurface.tsx
Normal file
105
frontend-modern/src/features/vmware/VmwarePageSurface.tsx
Normal 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;
|
||||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
46
frontend-modern/src/features/vmware/vmwarePageModel.ts
Normal file
46
frontend-modern/src/features/vmware/vmwarePageModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
7
frontend-modern/src/pages/Docker.tsx
Normal file
7
frontend-modern/src/pages/Docker.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { DockerPageSurface } from '@/features/docker/DockerPageSurface';
|
||||
|
||||
export function Docker() {
|
||||
return <DockerPageSurface />;
|
||||
}
|
||||
|
||||
export default Docker;
|
||||
7
frontend-modern/src/pages/Kubernetes.tsx
Normal file
7
frontend-modern/src/pages/Kubernetes.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { KubernetesPageSurface } from '@/features/kubernetes/KubernetesPageSurface';
|
||||
|
||||
export function Kubernetes() {
|
||||
return <KubernetesPageSurface />;
|
||||
}
|
||||
|
||||
export default Kubernetes;
|
||||
7
frontend-modern/src/pages/TrueNAS.tsx
Normal file
7
frontend-modern/src/pages/TrueNAS.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { TrueNASPageSurface } from '@/features/truenas/TrueNASPageSurface';
|
||||
|
||||
export function TrueNAS() {
|
||||
return <TrueNASPageSurface />;
|
||||
}
|
||||
|
||||
export default TrueNAS;
|
||||
7
frontend-modern/src/pages/Vmware.tsx
Normal file
7
frontend-modern/src/pages/Vmware.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { VmwarePageSurface } from '@/features/vmware/VmwarePageSurface';
|
||||
|
||||
export function Vmware() {
|
||||
return <VmwarePageSurface />;
|
||||
}
|
||||
|
||||
export default Vmware;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
122
tests/integration/tests/68-platform-pages-shell.spec.ts
Normal file
122
tests/integration/tests/68-platform-pages-shell.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue