From 7add75a5583a3c49a132d90b26d5a13d652f0c3a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 21 Mar 2026 22:30:41 +0000 Subject: [PATCH] Move workload infrastructure links into routing owner --- .../subsystems/performance-and-scalability.md | 9 +++ .../internal/subsystems/unified-resources.md | 6 ++ .../Dashboard.performance.contract.test.tsx | 4 + .../Dashboard/__tests__/GuestRow.test.tsx | 12 ++- .../__tests__/infrastructureLink.test.ts | 74 ------------------- .../Dashboard/infrastructureLink.ts | 39 ---------- .../Dashboard/useGuestDrawerState.ts | 2 +- .../components/Dashboard/useGuestRowState.ts | 2 +- .../routing/__tests__/resourceLinks.test.ts | 71 ++++++++++++++++++ frontend-modern/src/routing/resourceLinks.ts | 42 ++++++++++- .../frontendResourceTypeBoundaries.test.ts | 5 ++ 11 files changed, 147 insertions(+), 119 deletions(-) delete mode 100644 frontend-modern/src/components/Dashboard/__tests__/infrastructureLink.test.ts delete mode 100644 frontend-modern/src/components/Dashboard/infrastructureLink.ts diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 00717a9827..fd149a174d 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -156,6 +156,11 @@ and the canonical row contract and per-row hot-path derivations live in identity, column, cell-tooltip, anomaly-correlation, and link-state changes must extend through those owners instead of rebuilding row-local state inside the shell. +That per-row link state now also consumes the shared +`frontend-modern/src/routing/resourceLinks.ts` workload-to-infrastructure +helper instead of a dashboard-local routing shim. Future infrastructure-link +changes for dashboard rows must extend through the shared routing owner rather +than recreating feature-local path builders. That shell now also routes tag-dot rendering through the shared `frontend-modern/src/components/shared/TagBadges.tsx` primitive instead of keeping a dashboard-local badge helper. Future guest-row tag presentation @@ -171,6 +176,10 @@ and workload-derived navigation state live in `frontend-modern/src/components/Dashboard/useGuestDrawerState.ts`. Future drawer runtime and overview-surface changes must extend through those owners instead of adding more mixed state and helper drift back into the shell. +That drawer state now also consumes the same shared +`frontend-modern/src/routing/resourceLinks.ts` workload-to-infrastructure +helper, so row and drawer navigation stay aligned without a second +dashboard-local link-mapping file. The dashboard disk list now follows the same pattern: the shell stays in `frontend-modern/src/components/Dashboard/DiskList.tsx`, while disk-row presentation derivations and fallback tooltip/runtime wiring live in diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 9a3f25841e..484e4a3b74 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -260,6 +260,12 @@ The frontend now also consumes those facet reads through `frontend-modern/src/api/resources.ts` and the dedicated resource detail drawer, which keeps the presentation surface aligned with the governed API contract instead of rebuilding the relationship and timeline inline. +The canonical routing owner now also lives in +`frontend-modern/src/routing/resourceLinks.ts`, including the +workload-to-infrastructure href builder used by dashboard row and drawer +consumers. Future workload-to-resource navigation changes must extend through +that shared routing contract instead of reintroducing dashboard-local path +builders. That drawer shell now routes its canonical timeline filter, facet-bundle, and resource-intelligence state through `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`, diff --git a/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx b/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx index b189c32023..94a4a992e5 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx +++ b/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx @@ -600,6 +600,8 @@ describe('Dashboard performance contract', () => { expect(guestRowModelSource).toContain('export const VIEW_MODE_COLUMNS'); expect(guestRowStateSource).toContain('getCanonicalWorkloadId'); expect(guestRowStateSource).toContain('buildMetricKey'); + expect(guestRowStateSource).toContain("from '@/routing/resourceLinks'"); + expect(guestRowStateSource).not.toContain("./infrastructureLink"); expect(guestRowStateSource).toContain('getWorkloadTypeBadge'); expect(guestRowCellsSource).toContain('export { BackupIndicator'); expect(guestRowCellsSource).toContain('function NetworkInfoCell('); @@ -616,6 +618,8 @@ describe('Dashboard performance contract', () => { expect(guestDrawerSource).not.toContain('WebInterfaceUrlField'); expect(guestDrawerStateSource).toContain('getCanonicalWorkloadId'); expect(guestDrawerStateSource).toContain('buildInfrastructureHrefForWorkload'); + expect(guestDrawerStateSource).toContain("from '@/routing/resourceLinks'"); + expect(guestDrawerStateSource).not.toContain("./infrastructureLink"); expect(guestDrawerStateSource).toContain('getDiscoveryResourceTypeForWorkload'); expect(guestDrawerStateSource).toContain('getWebInterfaceTargetLabelForWorkload'); expect(guestDrawerStateSource).toContain('guestOsSummary'); diff --git a/frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx index 8bde526387..5546dc7cf7 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx @@ -99,9 +99,15 @@ vi.mock('@/components/shared/workloadTypeBadges', () => ({ }), })); -vi.mock('../infrastructureLink', () => ({ - buildInfrastructureHrefForWorkload: () => '/infrastructure/node1', -})); +vi.mock('@/routing/resourceLinks', async () => { + const actual = await vi.importActual( + '@/routing/resourceLinks', + ); + return { + ...actual, + buildInfrastructureHrefForWorkload: () => '/infrastructure/node1', + }; +}); vi.mock('../workloadSelectors', () => ({ getWorkloadDockerHostId: (guest: WorkloadGuest) => guest.dockerHostId || '', diff --git a/frontend-modern/src/components/Dashboard/__tests__/infrastructureLink.test.ts b/frontend-modern/src/components/Dashboard/__tests__/infrastructureLink.test.ts deleted file mode 100644 index 41902a6c46..0000000000 --- a/frontend-modern/src/components/Dashboard/__tests__/infrastructureLink.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { WorkloadGuest } from '@/types/workloads'; -import { buildInfrastructureHrefForWorkload } from '@/components/Dashboard/infrastructureLink'; - -const baseGuest = (overrides: Partial): WorkloadGuest => ({ - id: 'guest-1', - vmid: 101, - name: 'guest-1', - node: 'node-1', - instance: 'cluster-a', - status: 'running', - type: 'vm', - cpu: 0, - cpus: 2, - memory: { total: 0, used: 0, free: 0, usage: 0 }, - disk: { total: 0, used: 0, free: 0, usage: 0 }, - networkIn: 0, - networkOut: 0, - diskRead: 0, - diskWrite: 0, - uptime: 0, - template: false, - lastBackup: 0, - tags: [], - lock: '', - lastSeen: new Date().toISOString(), - ...overrides, -}); - -describe('buildInfrastructureHrefForWorkload', () => { - it('maps vm workloads to proxmox infrastructure source with node query', () => { - const href = buildInfrastructureHrefForWorkload( - baseGuest({ - type: 'vm', - workloadType: 'vm', - node: 'pve1', - instance: 'cluster-main', - }), - ); - expect(href).toBe('/infrastructure?source=proxmox-pve&q=pve1'); - }); - - it('maps app-container workloads to docker infrastructure source with context query', () => { - const href = buildInfrastructureHrefForWorkload( - baseGuest({ - type: 'app-container', - workloadType: 'app-container', - contextLabel: 'docker-host-1', - }), - ); - expect(href).toBe('/infrastructure?source=docker&q=docker-host-1'); - }); - - it('maps pod workloads to kubernetes infrastructure source with cluster query', () => { - const href = buildInfrastructureHrefForWorkload( - baseGuest({ - type: 'pod', - workloadType: 'pod', - contextLabel: 'cluster-a', - }), - ); - expect(href).toBe('/infrastructure?source=kubernetes&q=cluster-a'); - }); - - it('defaults unknown workload types to proxmox mapping (legacy compatibility)', () => { - const href = buildInfrastructureHrefForWorkload( - baseGuest({ - type: 'unknown', - workloadType: undefined, - }), - ); - expect(href).toBe('/infrastructure?source=proxmox-pve&q=node-1'); - }); -}); diff --git a/frontend-modern/src/components/Dashboard/infrastructureLink.ts b/frontend-modern/src/components/Dashboard/infrastructureLink.ts deleted file mode 100644 index 76333d10fa..0000000000 --- a/frontend-modern/src/components/Dashboard/infrastructureLink.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { WorkloadGuest } from '@/types/workloads'; -import { buildInfrastructurePath } from '@/routing/resourceLinks'; -import { resolveWorkloadType } from '@/utils/workloads'; - -const firstNonEmpty = (values: Array): string | undefined => { - for (const value of values) { - if (typeof value !== 'string') continue; - const trimmed = value.trim(); - if (trimmed.length > 0) return trimmed; - } - return undefined; -}; - -export const buildInfrastructureHrefForWorkload = (guest: WorkloadGuest): string => { - const type = resolveWorkloadType(guest); - - if (type === 'vm' || type === 'system-container') { - const query = firstNonEmpty([guest.node, guest.instance, guest.name]); - return buildInfrastructurePath({ source: 'proxmox-pve', query }); - } - - if (type === 'app-container') { - const query = firstNonEmpty([guest.contextLabel, guest.node, guest.instance, guest.name]); - return buildInfrastructurePath({ source: 'docker', query }); - } - - if (type === 'pod') { - const query = firstNonEmpty([ - guest.contextLabel, - guest.instance, - guest.namespace, - guest.node, - guest.name, - ]); - return buildInfrastructurePath({ source: 'kubernetes', query }); - } - - return buildInfrastructurePath(); -}; diff --git a/frontend-modern/src/components/Dashboard/useGuestDrawerState.ts b/frontend-modern/src/components/Dashboard/useGuestDrawerState.ts index ac71ec48ab..21b9558b6f 100644 --- a/frontend-modern/src/components/Dashboard/useGuestDrawerState.ts +++ b/frontend-modern/src/components/Dashboard/useGuestDrawerState.ts @@ -6,8 +6,8 @@ import { getDiscoveryResourceTypeForWorkload, getWebInterfaceTargetLabelForWorkload, } from '@/utils/workloads'; +import { buildInfrastructureHrefForWorkload } from '@/routing/resourceLinks'; -import { buildInfrastructureHrefForWorkload } from './infrastructureLink'; import { getDiscoveryHostIdForWorkload, getDiscoveryResourceIdForWorkload, diff --git a/frontend-modern/src/components/Dashboard/useGuestRowState.ts b/frontend-modern/src/components/Dashboard/useGuestRowState.ts index 13b5076355..869c117164 100644 --- a/frontend-modern/src/components/Dashboard/useGuestRowState.ts +++ b/frontend-modern/src/components/Dashboard/useGuestRowState.ts @@ -10,9 +10,9 @@ import { getWorkloadMetricsKind, resolveWorkloadType, } from '@/utils/workloads'; +import { buildInfrastructureHrefForWorkload } from '@/routing/resourceLinks'; import { getWorkloadTypeBadge } from '@/components/shared/workloadTypeBadges'; -import { buildInfrastructureHrefForWorkload } from './infrastructureLink'; import { getWorkloadDockerHostId } from './workloadSelectors'; import { DEFAULT_FIRST_CELL_INDENT, diff --git a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts index e245c5e69a..4483f89e6f 100644 --- a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts +++ b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; +import type { WorkloadGuest } from '@/types/workloads'; import { PMG_THRESHOLDS_PATH, RECOVERY_QUERY_PARAMS, + buildInfrastructureHrefForWorkload, buildRecoveryPath, buildInfrastructurePath, buildInfrastructureResourceHref, @@ -16,6 +18,31 @@ import { WORKLOADS_QUERY_PARAMS, } from '@/routing/resourceLinks'; +const baseGuest = (overrides: Partial): WorkloadGuest => ({ + id: 'guest-1', + vmid: 101, + name: 'guest-1', + node: 'node-1', + instance: 'cluster-a', + status: 'running', + type: 'vm', + cpu: 0, + cpus: 2, + memory: { total: 0, used: 0, free: 0, usage: 0 }, + disk: { total: 0, used: 0, free: 0, usage: 0 }, + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 0, + template: false, + lastBackup: 0, + tags: [], + lock: '', + lastSeen: new Date().toISOString(), + ...overrides, +}); + describe('resource link routing contract', () => { it('builds and parses workloads query params', () => { const href = buildWorkloadsPath({ @@ -93,6 +120,50 @@ describe('resource link routing contract', () => { expect(buildInfrastructureResourceHref('')).toBeNull(); }); + it('maps vm workloads to proxmox infrastructure source with node query', () => { + const href = buildInfrastructureHrefForWorkload( + baseGuest({ + type: 'vm', + workloadType: 'vm', + node: 'pve1', + instance: 'cluster-main', + }), + ); + expect(href).toBe('/infrastructure?source=proxmox-pve&q=pve1'); + }); + + it('maps app-container workloads to docker infrastructure source with context query', () => { + const href = buildInfrastructureHrefForWorkload( + baseGuest({ + type: 'app-container', + workloadType: 'app-container', + contextLabel: 'docker-host-1', + }), + ); + expect(href).toBe('/infrastructure?source=docker&q=docker-host-1'); + }); + + it('maps pod workloads to kubernetes infrastructure source with cluster query', () => { + const href = buildInfrastructureHrefForWorkload( + baseGuest({ + type: 'pod', + workloadType: 'pod', + contextLabel: 'cluster-a', + }), + ); + expect(href).toBe('/infrastructure?source=kubernetes&q=cluster-a'); + }); + + it('defaults unknown workload types to proxmox infrastructure compatibility mapping', () => { + const href = buildInfrastructureHrefForWorkload( + baseGuest({ + type: 'unknown', + workloadType: undefined, + }), + ); + expect(href).toBe('/infrastructure?source=proxmox-pve&q=node-1'); + }); + it('builds and parses storage query params', () => { const href = buildStoragePath({ tab: 'disks', diff --git a/frontend-modern/src/routing/resourceLinks.ts b/frontend-modern/src/routing/resourceLinks.ts index 8ca116e93c..a96754b3e9 100644 --- a/frontend-modern/src/routing/resourceLinks.ts +++ b/frontend-modern/src/routing/resourceLinks.ts @@ -1,6 +1,10 @@ import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; import { normalizeStorageSourceKey } from '@/utils/storageSources'; -import { canonicalizeWorkloadFilterType } from '@/utils/workloads'; +import { + canonicalizeWorkloadFilterType, + resolveWorkloadType, +} from '@/utils/workloads'; +import type { WorkloadGuest } from '@/types/workloads'; export const WORKLOADS_QUERY_PARAMS = { type: 'type', @@ -67,6 +71,15 @@ const normalizeQueryBooleanFlag = (value: string | null | undefined): string => const normalizeWorkloadsType = (value: string | null | undefined): string => canonicalizeWorkloadFilterType(normalizeQueryValue(value)); +const firstNonEmpty = (values: Array): string | undefined => { + for (const value of values) { + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (trimmed.length > 0) return trimmed; + } + return undefined; +}; + type WorkloadsLinkOptions = { type?: string | null; runtime?: string | null; @@ -166,6 +179,33 @@ export const buildInfrastructureResourceHref = (resourceId: string): string | nu return trimmed ? buildInfrastructurePath({ resource: trimmed }) : null; }; +export const buildInfrastructureHrefForWorkload = (guest: WorkloadGuest): string => { + const type = resolveWorkloadType(guest); + + if (type === 'vm' || type === 'system-container') { + const query = firstNonEmpty([guest.node, guest.instance, guest.name]); + return buildInfrastructurePath({ source: 'proxmox-pve', query }); + } + + if (type === 'app-container') { + const query = firstNonEmpty([guest.contextLabel, guest.node, guest.instance, guest.name]); + return buildInfrastructurePath({ source: 'docker', query }); + } + + if (type === 'pod') { + const query = firstNonEmpty([ + guest.contextLabel, + guest.instance, + guest.namespace, + guest.node, + guest.name, + ]); + return buildInfrastructurePath({ source: 'kubernetes', query }); + } + + return buildInfrastructurePath(); +}; + export const parseStorageLinkSearch = (search: string) => { const params = new URLSearchParams(search); return { diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 3e5f6d44e5..db4336e160 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -470,6 +470,7 @@ describe('frontend resource type boundaries', () => { it('keeps compatibility handling centralized in shared adapters and edge translators', () => { expect(resourceLinksSource).toContain('canonicalizeWorkloadFilterType'); expect(resourceLinksSource).toContain('normalizeSourcePlatformQueryValue'); + expect(resourceLinksSource).toContain('export const buildInfrastructureHrefForWorkload'); expect(resourceLinksSource).not.toContain("normalized === 'docker'"); expect(resourceLinksSource).not.toContain("normalized === 'k8s'"); expect(sourcePlatformsSource).toContain('export const normalizeSourcePlatformQueryValue'); @@ -604,6 +605,8 @@ describe('frontend resource type boundaries', () => { expect(guestRowSource).not.toContain('function NetworkInfoCell('); expect(guestRowSource).not.toContain('function OSInfoCell('); expect(guestRowStateSource).toContain('getCanonicalWorkloadId'); + expect(guestRowStateSource).toContain("from '@/routing/resourceLinks'"); + expect(guestRowStateSource).not.toContain("./infrastructureLink"); expect(guestRowModelSource).toContain('export const GUEST_COLUMNS'); expect(guestRowCellsSource).toContain('function NetworkInfoCell('); expect(guestRowCellsSource).toContain('function OSInfoCell('); @@ -617,6 +620,8 @@ describe('frontend resource type boundaries', () => { expect(guestDrawerSource).toContain('useGuestDrawerState'); expect(guestDrawerSource).toContain('GuestDrawerOverview'); expect(guestDrawerStateSource).toContain('getCanonicalWorkloadId'); + expect(guestDrawerStateSource).toContain("from '@/routing/resourceLinks'"); + expect(guestDrawerStateSource).not.toContain("./infrastructureLink"); expect(guestDrawerStateSource).toContain('guestOsSummary'); expect(guestDrawerSource).not.toContain('const guestId = () => {'); expect(guestDrawerSource).not.toContain('WebInterfaceUrlField');