From bd0ef3985b790c98d621c11279900b5233f37790 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:44:21 +0100 Subject: [PATCH] Link alert cards to their platform from incident metadata The alert card chose its platform page by resource type, then by searching the message for "vmware". Provider incidents sit on canonical vm, storage, network and agent types, so that only worked while a summary happened to name the platform. #2559 added an incidentProvider check for vSphere alone; other platforms still fell through: - TrueNAS pool incidents linked to the Proxmox overview, and their Monitoring menu said "Proxmox remains the inventory owner". - Agent alerts linked to /machines, a route that does not exist, so the link opened "Page not found". - TrueNAS metric alerts (truenas-pool, truenas-dataset, ...) fell through to Proxmox the same way. The card now reads the platform from an explicit platformType, then the incident provider, then the resource's sources, through the shared resolvePlatformTypeFromSources. It maps that platform to its page via the navigation model's scope ids and the resourceLinks path builders, and passes the same canonical id to the Monitoring policy menu, so the link and the named owner agree. The resource-type and message fallbacks remain only for alerts whose metadata names no platform, now with truenas- types and with agent alerts sent to /standalone/machines. --- .../v6/internal/subsystems/alerts.md | 18 ++- .../subsystems/frontend-primitives.md | 8 ++ .../977a32825e65be8f.json | 42 +++++++ .../alerts/AlertOverviewAlertCard.tsx | 99 +++++++++++++--- ...ertOverviewAlertCard.system-scope.test.tsx | 106 +++++++++++++++++- 5 files changed, 250 insertions(+), 23 deletions(-) create mode 100644 frontend-modern/browser-verification/977a32825e65be8f.json diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 3f831e45c6..cb03c572e2 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -2432,11 +2432,21 @@ line; held delivery states that can surprise an operator use the attention tone, while cooldown, quiet-hours, monitor-only, and successful/pending states remain neutral. The card's resource link resolves the owning platform page from alert -metadata, never from message wording alone: a provider incident whose -`incidentProvider` is `vmware` links to the vSphere overview even though its -message is only the alarm vCenter raised, and VMware incidents land on +metadata, never from message wording alone. Provider incidents land on canonical `vm`, `storage`, `network`, and `agent` resource types rather than a -`vmware-` prefixed type. +platform-prefixed type, so the card reads the platform from an explicit +`platformType`, then the incident's `incidentProvider`, then the resource's +`resourceSources`, all through `resolvePlatformTypeFromSources` in +`frontend-modern/src/utils/sourcePlatforms.ts`: a vCenter alarm whose message +is only the alarm's name links to the vSphere overview, a TrueNAS pool +incident to TrueNAS, and a PBS incident that Pulse itself raised (provider +`pulse`) to Proxmox by its `pbs` source. The resolved platform maps to its +page through `PRIMARY_PLATFORM_NAV_SCOPE_IDS` and the `routing/resourceLinks.ts` +path builders, and the same canonical platform id goes to the Monitoring +policy menu, so the link and the named inventory owner cannot disagree. Only +an alert whose metadata names no platform falls back to its resource type +(`agent`, `docker-*`, `k8s-*`, `truenas-*`, `vmware-*`) and finally the message; +agent alerts open `/standalone/machines`, because no `/machines` route exists. The retired dashboard recent-alert panel must not be reintroduced as a parallel alert surface. Alert summary/tone copy belongs to the alert overview presentation owner, and any future compact alert surface must compose the diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 3425d3b61f..9c3ed2e922 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4334,6 +4334,14 @@ Storage / Recovery hrefs) were deleted from panel and Patrol findings panel that consumed them now keep investigation in-place through their existing handoff buttons and inline actions. Future cross-surface drilldown chips must not reanimate the legacy helpers. +An alert card's single resource link opens the owning platform's page. It +resolves the platform through the shared `utils/sourcePlatforms.ts` resolver, +maps it to a page through `PRIMARY_PLATFORM_NAV_SCOPE_IDS` in +`features/platformNavigation/platformNavigationModel.ts`, and builds the href +with the `routing/resourceLinks.ts` path builders. It must not freeze raw +route strings or keep its own platform alias table, because those drifted +before: agent alerts linked to a `/machines` route that never existed, and +TrueNAS incidents linked to Proxmox. Command palette and keyboard shortcuts moved to platform-first on 2026-05-16, and top-level aggregate workspace routes were retired on 2026-05-25 diff --git a/frontend-modern/browser-verification/977a32825e65be8f.json b/frontend-modern/browser-verification/977a32825e65be8f.json new file mode 100644 index 0000000000..cbf10a70c2 --- /dev/null +++ b/frontend-modern/browser-verification/977a32825e65be8f.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "base_sha": "af4e6a493cbca1e6c7d4397964efb146e950fca2", + "verified_at": "2026-10-06T10:36:32Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx" + ], + "content_sha256": { + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "73b1cd6cb17494a95c266697755f68537d3d71cac6bb6cb3f7ce9dda5b892f45" + }, + "routes": [ + "/alerts/overview", + "/vmware/overview", + "/truenas/overview", + "/standalone/machines" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Alerts > Overview on a private mock stack (backend :7711, sidecar Vite :5231) at main af4e6a493c with the change. A vCenter alarm on the real mock vSphere VM client-portal-01 (vm-aceb0f0c3469f43a; metadata resourceType vm, incidentProvider vmware, incidentCode vmware_alarm_state, resourceSources [vmware]; message 'Virtual machine memory usage', no 'VMware' in it) was appended to the live websocket activeAlerts snapshots, because the mock scenario clears VM alarms. An agent-host CPU alert on the real agent-only mock host Apollo-114 (metadata resourceType agent, no incident metadata) was appended the same way", + "The mock's own TrueNAS pool incident on 'archive' (zfs-pool-state, incidentProvider truenas, resourceSources [truenas]), not injected", + "With the change, at 1440 and 390: the vSphere VM alarm card links to /vmware/overview and its More > Monitoring menu reads 'VMware vSphere remains the inventory owner' and 'VMware vSphere keeps the inventory record'; the TrueNAS pool card links to /truenas/overview and the menu names TrueNAS; the agent-host card links to /standalone/machines and the menu names Pulse agent", + "Each link followed to a rendered platform page: the vSphere overview, the TrueNAS overview, and Machines listing Apollo-114; none showed Page not found", + "Same session with main's AlertOverviewAlertCard.tsx swapped back in under Vite: the vSphere VM alarm was already right on main (#2559 + #2560), the TrueNAS pool card linked to /proxmox/overview with 'Proxmox remains the inventory owner', and the agent-host card linked to /machines, which rendered 'Page not found: No route matched /machines', at 1440 and 390", + "Monitoring menu at 390 opens as a body portal within the viewport" + ], + "interactions": [ + "Proxied the real websocket with Playwright routeWebSocket and appended the two alerts to each activeAlerts array; also appended them to the /api/alerts/active REST response", + "Clicked More, then Monitoring, on each of the three cards and read the menu header and the Retire from monitoring description", + "Clicked each card's resource link, recorded the landed route, checked for the Page not found state, screenshotted the page, then went back", + "Swapped AlertOverviewAlertCard.tsx to main's version under the running Vite for a same-session before, then restored the change" + ] +} diff --git a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx index 5cb4c25676..2930ad9942 100644 --- a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx +++ b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx @@ -34,6 +34,29 @@ import { ResourceMonitoringPolicyAction } from './ResourceMonitoringPolicyAction import { AlertSnoozeAction } from './AlertSnoozeAction'; import { isAlertSnoozed } from './useAlertSnoozeState'; import { isPulseSystemAlert } from '@/utils/alertScope'; +import { + normalizeSourcePlatformQueryValue, + resolvePlatformTypeFromSources, +} from '@/utils/sourcePlatforms'; +import { + DOCKER_PATH, + KUBERNETES_PATH, + PROXMOX_PATH, + STANDALONE_PATH, + TRUENAS_PATH, + VMWARE_PATH, + buildDockerPath, + buildKubernetesPath, + buildProxmoxPath, + buildStandalonePath, + buildTrueNASPath, + buildVmwarePath, +} from '@/routing/resourceLinks'; +import { + PRIMARY_PLATFORM_NAV_IDS, + PRIMARY_PLATFORM_NAV_SCOPE_IDS, + type PrimaryPlatformNavId, +} from '@/features/platformNavigation/platformNavigationModel'; interface AlertOverviewAlertCardProps { alert: Alert; @@ -48,6 +71,25 @@ interface AlertOverviewAlertCardProps { // in one banner instead of on each card. const GLOBAL_DELIVERY_HOLD_REASONS = new Set(['notifications_inactive', 'notifications_disabled']); +// The page that lists each primary platform's resources, keyed by nav id so +// a new platform page cannot ship without an alert link. +const PLATFORM_PAGE_PATHS: Record = { + proxmox: buildProxmoxPath(), + docker: buildDockerPath(), + kubernetes: buildKubernetesPath(), + truenas: buildTrueNASPath(), + vmware: buildVmwarePath(), + standalone: buildStandalonePath(), +}; + +const platformPagePath = (platform: string | undefined): string | undefined => { + if (!platform) return undefined; + const navId = PRIMARY_PLATFORM_NAV_IDS.find((id) => + PRIMARY_PLATFORM_NAV_SCOPE_IDS[id].includes(platform), + ); + return navId ? PLATFORM_PAGE_PATHS[navId] : undefined; +}; + export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { const alertKey = () => getCanonicalAlertId(props.alert); const hasResource = () => @@ -80,40 +122,61 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { (isAlertSnoozed(props.alert) && Boolean(props.alert.operationalRecord?.suppression?.expiresAt)); const timelineOpen = () => props.timelineState.expandedIncidents().has(alertKey()); + // Incident alerts land on canonical agent/vm/storage/network resources, so + // the resource type alone cannot name the platform. Their metadata carries + // the incident's provider and the resource's sources, which do, whatever + // the message says. + const metadataPlatform = (): string | undefined => { + const metadata = props.alert.metadata; + const explicit = + typeof metadata?.platformType === 'string' + ? normalizeSourcePlatformQueryValue(metadata.platformType) + : ''; + if (explicit) return explicit; + const provider = + typeof metadata?.incidentProvider === 'string' + ? resolvePlatformTypeFromSources([metadata.incidentProvider]) + : undefined; + if (provider) return provider; + const sources = Array.isArray(metadata?.resourceSources) + ? metadata.resourceSources.filter((source): source is string => typeof source === 'string') + : []; + return resolvePlatformTypeFromSources(sources); + }; + const resourceLink = (): string => { + const platformPage = platformPagePath(metadataPlatform()); + if (platformPage) return platformPage; const rid = props.alert.resourceId ?? ''; const resourceType = typeof props.alert.metadata?.resourceType === 'string' ? (props.alert.metadata.resourceType as string) : ''; - // vCenter incidents land on canonical agent/vm/storage/network resources - // and say only what vCenter flagged, so the incident's provider names the - // page, ahead of the generic resource-type routes: an ESXi host is an - // `agent` resource that the Machines page does not list. - if (props.alert.metadata?.incidentProvider === 'vmware') return '/vmware/overview'; - if (rid.startsWith('agent:') || resourceType === 'agent') return '/machines'; + if (rid.startsWith('agent:') || resourceType === 'agent') return buildStandalonePath(); if ( rid.includes('docker') || resourceType === 'docker-container' || resourceType === 'docker-host' ) - return '/docker/overview'; + return buildDockerPath(); if (resourceType === 'kubernetes' || resourceType.startsWith('k8s-')) - return '/kubernetes/overview'; + return buildKubernetesPath(); + if (resourceType.startsWith('truenas-')) return buildTrueNASPath(); if (resourceType.startsWith('vmware-') || props.alert.message?.toLowerCase().includes('vmware')) - return '/vmware/overview'; - return '/proxmox/overview'; + return buildVmwarePath(); + return buildProxmoxPath(); }; const platformTypeForPolicy = (): string | undefined => { - const metadataPlatform = props.alert.metadata?.platformType; - if (typeof metadataPlatform === 'string' && metadataPlatform.trim()) return metadataPlatform; + const platform = metadataPlatform(); + if (platform) return platform; const link = resourceLink(); - if (link.startsWith('/proxmox')) return 'proxmox'; - if (link.startsWith('/docker')) return 'docker'; - if (link.startsWith('/kubernetes')) return 'kubernetes'; - if (link.startsWith('/vmware')) return 'vmware'; - if (link.startsWith('/machines')) return 'agent'; + if (link.startsWith(PROXMOX_PATH)) return 'proxmox'; + if (link.startsWith(DOCKER_PATH)) return 'docker'; + if (link.startsWith(KUBERNETES_PATH)) return 'kubernetes'; + if (link.startsWith(TRUENAS_PATH)) return 'truenas'; + if (link.startsWith(VMWARE_PATH)) return 'vmware'; + if (link.startsWith(STANDALONE_PATH)) return 'agent'; return undefined; }; @@ -124,7 +187,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { if (resourceId.startsWith('agent:')) return 'agent'; if (resourceId.includes('docker')) return 'app-container'; if (resourceId.includes('k8s') || resourceId.includes('kubernetes')) return 'pod'; - if (resourceLink() === '/proxmox/overview') return 'vm'; + if (resourceLink() === buildProxmoxPath()) return 'vm'; return undefined; }; diff --git a/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx b/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx index 954d607c65..44bd58031d 100644 --- a/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx @@ -15,7 +15,9 @@ vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({ InvestigateAlertButton: () => null, })); vi.mock('../ResourceMonitoringPolicyAction', () => ({ - ResourceMonitoringPolicyAction: () => , + ResourceMonitoringPolicyAction: (props: any) => ( + + ), })); vi.mock('../AlertSnoozeAction', () => ({ AlertSnoozeAction: () => null })); import { AlertOverviewAlertCard } from '../AlertOverviewAlertCard'; @@ -136,6 +138,108 @@ describe('Pulse system-alert overview scope', () => { ); }); + it.each([ + { + name: 'a vCenter alarm on a VM whose message does not say VMware', + resourceId: 'vm:vc-1:vm-2041', + resourceName: 'sql-prod-01', + message: 'Virtual machine memory usage on sql-prod-01 (red)', + metadata: { + resourceType: 'vm', + incidentProvider: 'vmware', + incidentCode: 'vmware_alarm_state', + resourceSources: ['vmware'], + }, + href: '/vmware/overview', + platformType: 'vmware-vsphere', + }, + { + name: 'a TrueNAS pool incident', + resourceId: 'storage:truenas-1:tank', + resourceName: 'tank', + message: 'Pool tank is degraded', + metadata: { + resourceType: 'storage', + incidentProvider: 'truenas', + incidentCode: 'pool_degraded', + resourceSources: ['truenas'], + }, + href: '/truenas/overview', + platformType: 'truenas', + }, + { + name: 'a PBS datastore incident whose provider is Pulse itself', + resourceId: 'storage:pbs-1:main', + resourceName: 'main', + message: 'Datastore main is nearly full', + metadata: { + resourceType: 'storage', + incidentProvider: 'pulse', + incidentCode: 'capacity_runway_low', + resourceSources: ['pbs'], + }, + href: '/proxmox/overview', + platformType: 'proxmox-pbs', + }, + ])( + 'links $name to its platform page and policy owner', + ({ resourceId, resourceName, message, metadata, href, platformType }) => { + const alert = makeSystemAlert('resource-incident', { + id: resourceId, + resourceId, + resourceName, + message, + metadata, + }); + render(() => ( + + )); + expect(screen.getByRole('link', { name: resourceName })).toHaveAttribute('href', href); + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(screen.getByText('Resource monitoring policy')).toHaveAttribute( + 'data-platform-type', + platformType, + ); + }, + ); + + it.each([ + { + name: 'an agent host metric alert to Machines', + resourceId: 'agent:host-12', + resourceType: 'agent', + href: '/standalone/machines', + platformType: 'agent', + }, + { + name: 'a TrueNAS pool metric alert to TrueNAS', + resourceId: 'truenas-1:pool:tank', + resourceType: 'truenas-pool', + href: '/truenas/overview', + platformType: 'truenas', + }, + ])('links $name when metadata names no platform', (example) => { + const alert = makeSystemAlert('usage', { + id: example.resourceId, + resourceId: example.resourceId, + resourceName: 'monitored-resource', + message: 'Usage at 92%', + metadata: { resourceType: example.resourceType }, + }); + render(() => ( + + )); + expect(screen.getByRole('link', { name: 'monitored-resource' })).toHaveAttribute( + 'href', + example.href, + ); + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(screen.getByText('Resource monitoring policy')).toHaveAttribute( + 'data-platform-type', + example.platformType, + ); + }); + it('closes the open timeline when the disclosure is collapsed', () => { const alert = makeSystemAlert('cpu', { id: 'open-timeline', resourceId: 'vm-pulse' }); const openTimeline = {