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 269e8b4e05..ba079d4f1d 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 = {