Move workload infrastructure links into routing owner

This commit is contained in:
rcourtman
2026-03-21 22:30:41 +00:00
parent 4be8deb672
commit 7add75a558
11 changed files with 147 additions and 119 deletions
@@ -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
@@ -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`,
@@ -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');
@@ -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<typeof import('@/routing/resourceLinks')>(
'@/routing/resourceLinks',
);
return {
...actual,
buildInfrastructureHrefForWorkload: () => '/infrastructure/node1',
};
});
vi.mock('../workloadSelectors', () => ({
getWorkloadDockerHostId: (guest: WorkloadGuest) => guest.dockerHostId || '',
@@ -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>): 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');
});
});
@@ -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 | null>): 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();
};
@@ -6,8 +6,8 @@ import {
getDiscoveryResourceTypeForWorkload,
getWebInterfaceTargetLabelForWorkload,
} from '@/utils/workloads';
import { buildInfrastructureHrefForWorkload } from '@/routing/resourceLinks';
import { buildInfrastructureHrefForWorkload } from './infrastructureLink';
import {
getDiscoveryHostIdForWorkload,
getDiscoveryResourceIdForWorkload,
@@ -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,
@@ -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>): 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',
+41 -1
View File
@@ -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 | null>): 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 {
@@ -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');