From da3fcf69f760c760a8722c93d3a05abd73108340 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:14:42 +0100 Subject: [PATCH] Show open alerts in the Machines and Docker host drawers The Machines/Infrastructure drawer and the Docker host drawer built their "Needs attention" list from the alerts embedded in each resource. No backend code ever filled that list, so every resource carried `alerts: []` and both drawers stayed silent while an alert was open: a mock stack had 0 of 142 resources with embedded alerts while 14 alerts were active, and a real dev Pulse showed `alerts: []` on a host whose temperature alert was open. Both drawers now read the websocket activeAlerts map, the source the Proxmox node, guest and Mail Gateway drawers already use, through one matcher, getAlertsForUnifiedResource. It keys a resource on the ids the backend writes for it (unified id, canonical primary and superseded ids, Proxmox source id, metrics target) and, for agent and Docker hosts, agent: and docker: plus the disk, RAID, container and service alerts nested under them. Rows show the affected resource and metric like the other drawers, most severe first. The dead embedded path is gone: the frontend ResourceAlert type and its unused hasAlerts filter, the Proxmox summary alertCount nobody rendered, and ResourceFrontend.alerts / ResourceAlertInput on the backend, which also drops "alerts":[] from every resource in each broadcast. Now that incident alerts reach the infrastructure drawer, a health reason counts as stated when it is a whole sentence of an alert's text, so a storage incident alert that joins its summary and impact does not repeat beside either reason. --- .../v6/internal/subsystems/alerts.md | 12 ++ .../subsystems/frontend-primitives.md | 7 +- .../v6/internal/subsystems/monitoring.md | 4 + .../v6/internal/subsystems/registry.json | 2 + .../internal/subsystems/unified-resources.md | 15 ++ .../89dcce7f1458e2ba.json | 56 +++++++ .../ResourceDetailDrawerOverviewTab.tsx | 34 +++- ...urceDetailDrawer.docker-container.test.tsx | 5 + ...ResourceDetailDrawer.guest-safety.test.tsx | 5 + .../ResourceDetailDrawer.history.test.tsx | 1 + ...urceDetailDrawer.identity-runtime.test.tsx | 1 + ...urceDetailDrawer.k8s-capabilities.test.tsx | 5 + ...ourceDetailDrawer.machine-history.test.tsx | 5 + ...esourceDetailDrawer.service-cards.test.tsx | 130 +++++++++++++-- .../ResourceDetailDrawer.snapshot.test.tsx | 2 +- ...ourceDetailDrawer.truenas-details.test.tsx | 1 + .../features/docker/DockerHostDrawer.test.tsx | 54 ++++++ .../docker/DockerHostDrawerOverview.tsx | 13 +- ...rContainersTable.cpu-presentation.test.tsx | 5 + .../__tests__/DockerNativeTables.test.tsx | 5 + .../KubernetesControllersTable.test.tsx | 5 + .../proxmoxPageModel.branchcov2.test.ts | 27 +-- .../src/features/proxmox/proxmoxPageModel.ts | 7 - .../AvailabilityChecksTable.test.tsx | 5 + .../__tests__/VsphereDatastoresTable.test.tsx | 5 + .../__tests__/VsphereNetworksTable.test.tsx | 7 +- frontend-modern/src/hooks/useResources.ts | 5 - .../src/types/__tests__/resource.test.ts | 19 --- frontend-modern/src/types/resource.ts | 13 -- .../src/utils/__tests__/alerts.test.ts | 155 +++++++++++++++++- frontend-modern/src/utils/alerts.ts | 73 +++++++++ internal/models/converters.go | 27 --- internal/models/converters_additional_test.go | 15 +- internal/models/models_frontend.go | 25 +-- .../models_frontend_branchcov0718_test.go | 10 +- .../monitoring/monitor_extra_coverage_test.go | 3 - 36 files changed, 610 insertions(+), 153 deletions(-) create mode 100644 frontend-modern/browser-verification/89dcce7f1458e2ba.json diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 61495979e6..c270dc70d3 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -3452,3 +3452,15 @@ initial loading and current-request ownership; the production Notifications tab browser fixture verifies the connected read/retry states at desktop and phone widths. These are synthetic frontend controls, not recipient delivery or containing-release acceptance. + +### Resource drawers read open alerts from the active-alert map + +`getAlertsForUnifiedResource` in `frontend-modern/src/utils/alerts.ts` is the +canonical lookup from one unified resource to its open alerts in the websocket +`activeAlerts` map. It matches the alert keys the backend writes for that +resource (unified id, canonical primary and superseded ids, Proxmox source id, +metrics target, and for agent and Docker hosts `agent:` and +`docker:` plus the component alerts nested under them) and +orders them most severe first. The Machines/Infrastructure and Docker host +drawers build their "Needs attention" rows from it; resources no longer embed +an alert list, so no drawer can silently read an always-empty copy. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 308d1470e2..68d465c82a 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4985,7 +4985,12 @@ across the gateway's identity aliases, and its header status follows them, so it never shows "Healthy" beside an active warning; its inline detail row sets `whitespace-normal` because platform table cells do not wrap. The row itself marks the metric an alert is about instead of stacking a reason under the -name, so it keeps the shared single-line rhythm. The remaining Overview rows are additive +name, so it keeps the shared single-line rhythm. Drawers for unified resources +(Machines/Infrastructure, Docker hosts) read their open alerts from the +websocket `activeAlerts` map through `getAlertsForUnifiedResource` +(`frontend-modern/src/utils/alerts.ts`), most severe first, never from a +resource-embedded list, and show each alert's resource name and metric beside +its text like the other drawers. The remaining Overview rows are additive operator context that the parent table row cannot carry, such as OS/runtime, Pulse observation or action coverage, primary reachability, protection gaps, pending updates, or an identified service. Routine health, placement, and diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index f02a9c2224..9ae9a5d83a 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -2925,6 +2925,10 @@ canonical unified-resource metrics include `diskRead` or `diskWrite`, `ResourceFrontend.diskIO` through the shared resource converter, so `/api/state` and websocket consumers read disk throughput from the same freshness-gated resource metrics contract as CPU, memory, disk, and network. +That broadcast projection carries no per-resource alert copy: +`ResourceFrontend` has no `alerts` field and `ResourceConvertInput` no alert +input, because open alerts reach clients once, through the state-level +`activeAlerts` map, and nothing ever populated the embedded list. That same broadcast projection owns client-payload static-metadata slimming (governed gap `resource-payload-static-metadata`): identical resource capability blobs are deduped into the state-level `capabilityCatalog` under diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index de73a0f7ab..c822bee895 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2511,6 +2511,7 @@ "exact_files": [ "frontend-modern/src/stores/__tests__/websocket-resilience.test.ts", "frontend-modern/src/stores/__tests__/websocket-unified.test.ts", + "frontend-modern/src/utils/__tests__/alerts.test.ts", "frontend-modern/src/utils/__tests__/alertsActivation.branchcov0712c.test.ts", "frontend-modern/src/utils/__tests__/alertsActivation.test.ts" ] @@ -6360,6 +6361,7 @@ "test_prefixes": [], "exact_files": [ "internal/dockeragent/swarm_coverage_test.go", + "internal/models/converters_additional_test.go", "internal/models/deepcopy_test.go", "internal/models/host_transient_expiry_test.go", "internal/models/issue1639_pbs_collision_test.go", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index a22df85cf1..b1503b5430 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2211,6 +2211,21 @@ TrueNAS dataset's state (`state:readonly`, `state:locked`, `state:unmounted`) because a read-only dataset raises no incident, and uses the rollup's category label (Resource Health Issue, Capacity Pressure) only when no reason is more specific. +Those open alerts come from the websocket `activeAlerts` map through +`getAlertsForUnifiedResource` in `frontend-modern/src/utils/alerts.ts`, which +the Docker host drawer (`DockerHostDrawerOverview.tsx`) uses the same way. +Unified resources carry no embedded alert list: the frontend `ResourceAlert` +type and the backend `ResourceFrontend.alerts` / `ResourceAlertInput` path were +retired because no producer ever filled them, which left both drawers without +their open alerts. The matcher keys a resource on its unified id, canonical +primary and superseded ids, Proxmox source id and metrics target (PBS, PMG, +storage); agent and Docker hosts also answer to `agent:` and +`docker:` and to the component alerts nested under those keys +(disks, RAID arrays, sensors, containers, services). A health reason counts as +stated when it is a whole sentence of an open alert's text, because a unified +incident alert on storage with consumers joins the incident summary and its +impact ("... above threshold. Affects 2 dependent resources: ...") and neither +may repeat beside it. Machine and host overview cards that render compact system, hardware, disk, and temperature facts must also compose the frontend-primitives `InfoCardKeyValueRow`. Mobile rows retain their condensed endpoint layout; diff --git a/frontend-modern/browser-verification/89dcce7f1458e2ba.json b/frontend-modern/browser-verification/89dcce7f1458e2ba.json new file mode 100644 index 0000000000..b22fbfd0fe --- /dev/null +++ b/frontend-modern/browser-verification/89dcce7f1458e2ba.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "base_sha": "942f6b257af1058727da2b6e2e8007c8202af677", + "verified_at": "2026-10-06T13:13:48Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx", + "frontend-modern/src/features/proxmox/proxmoxPageModel.ts", + "frontend-modern/src/hooks/useResources.ts", + "frontend-modern/src/types/resource.ts", + "frontend-modern/src/utils/alerts.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "6e024f572aa0c914e241c3050339cb1000c4caedc7910b405e62f00351bee733", + "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "d59779e1651c973e9ce076ccea5cdb049abf48c30ad1f151c7ac229bcfbcb8a1", + "frontend-modern/src/features/proxmox/proxmoxPageModel.ts": "8e509651270f4bfd9a018dcd7a74a8cebe6b778f8c22661cd399c0be26bd7353", + "frontend-modern/src/hooks/useResources.ts": "b294d468567099c25ce5bf6ee68d14b36ce353dff8b36310de990fbf87a551cb", + "frontend-modern/src/types/resource.ts": "09c242b4ad770623d402a5687ee69bc0fe67da8989ae4172b7833a9a373e1bde", + "frontend-modern/src/utils/alerts.ts": "acc29b3d358fc1909255e1fe015da9587c627b69f76b74c8eb37bce52b8f5ca8" + }, + "routes": [ + "/standalone/machines", + "/docker", + "/vmware/storage", + "/vmware/networks" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (embedded build of this tree, base 13d604e1c7) with a real backend-raised open host-agent memory alert on Apollo-114 (agent:host-linux-1, agent memory trigger set to 60% on the private stack; mock mode skips CheckHost, so the private binary added that call through a go build overlay that is not part of this commit)", + "Machines drawer for Apollo-114: Needs attention lists \"Agent memory at 70.7%\" with subject Apollo-114, metric Memory and CRITICAL severity (previously empty because resources never carried alerts)", + "Machines drawer for Meridian-848 with no open alert: no Needs attention section", + "Docker host drawer for Ops Services 01: lists its three nested service alerts (docker:orion-2-mock/service/...) with service names as subjects, most severe first", + "Docker host drawer for auth-service-01 (earlier pass on the same code): lists its two nested container-state alerts; edge-proxy-01 lists its docker-host-offline alert", + "vSphere datastore archive-tier drawer: one row for the storage-incident alert whose text joins the incident summary and its impact; neither health reason repeats beside it", + "vSphere network Edge Stateful drawer: one alert row, the matching health reason deduped", + "No page errors at either viewport" + ], + "interactions": [ + "Open Machines tab and expand the Apollo-114 row drawer (Overview tab)", + "Expand the Meridian-848 row drawer and confirm no attention section", + "Open Docker hosts and expand the Ops Services 01 row drawer", + "Open vSphere storage and expand the archive-tier row drawer", + "Open vSphere networks and expand the Edge Stateful row drawer", + "Repeat the Machines and Docker drawer checks at the 390px viewport" + ] +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 1a18a78b0c..dc927cdc7f 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -7,6 +7,10 @@ import type { ResourceChangeSourceAdapter, ResourceChangeSourceType, } from '@/types/resource'; +import { useWebSocket } from '@/contexts/appRuntime'; +import { alertTypeDisplayLabel } from '@/features/alerts/helpers'; +import { useAlertsActivation } from '@/stores/alertsActivation'; +import { getAlertsForUnifiedResource } from '@/utils/alerts'; import { formatUptime, formatRelativeTime } from '@/utils/format'; import { SystemInfoCard } from '@/components/shared/cards/SystemInfoCard'; import { HardwareCard } from '@/components/shared/cards/HardwareCard'; @@ -35,7 +39,10 @@ import { TechnicalDetailsDisclosure, TechnicalDetailsSection, } from '@/components/shared/TechnicalDetailsDisclosure'; -import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection'; +import { + DrawerAttentionSection, + type DrawerAttentionItem, +} from '@/components/shared/DrawerAttentionSection'; import { RESOURCE_CHANGE_KIND_ORDER, RESOURCE_CHANGE_SOURCE_ADAPTER_ORDER, @@ -364,29 +371,46 @@ export const ResourceDetailDrawerOverviewTab: Component 0 || drawer.resourceTimelineCount() > 0 || Boolean(drawer.facetBundleError()); + const { activeAlerts } = useWebSocket(); + const alertsActivation = useAlertsActivation(); const attentionItems = () => { - const items = (resource().alerts ?? []).map((alert) => ({ + const alerts = getAlertsForUnifiedResource( + resource(), + activeAlerts, + alertsActivation.detectionEnabled(), + ); + const items: DrawerAttentionItem[] = alerts.map((alert) => ({ id: alert.id, message: alert.message, + subject: alert.resourceName?.trim() || resource().displayName || resource().name, + metric: alertTypeDisplayLabel(alert.type), severity: alert.level, + acknowledged: alert.acknowledged, })); const healthIssue = drawer.healthIssue(); if (healthIssue) { // Skip only the reasons an open alert already states. One matching alert // must not hide the other reasons (a pool's own degraded state beside a - // copied disk alert). + // copied disk alert). An incident alert may join the reason and its + // impact as sentences ("... above threshold. Affects 2 dependent + // resources: ..."), so a reason that is a whole sentence of an alert is + // already stated. const messageKey = (message: string) => message .trim() .toLowerCase() .replace(/[.\s]+$/, ''); - const stated = new Set(items.map((item) => messageKey(item.message))); + const alertSentences = alerts.map((alert) => `. ${messageKey(alert.message)}. `); + const isStated = (message: string) => { + const sentence = `. ${messageKey(message)}. `; + return alertSentences.some((stated) => stated.includes(sentence)); + }; const severity = /critical|failed|faulted|error/i.test(resource().status) ? 'critical' : 'warning'; items.unshift( ...[healthIssue.primary, ...healthIssue.details] - .filter((message) => !stated.has(messageKey(message))) + .filter((message) => !isStated(message)) .map((message, index) => ({ id: `resource-health-issue-${index}`, message, diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx index 9fb3469ab2..e643f5122d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx @@ -4,6 +4,11 @@ import { ResourceActionsAPI } from '@/api/resourceActions'; import type { Resource } from '@/types/resource'; import { ResourceDetailDrawer } from '../ResourceDetailDrawer'; +vi.mock('@/contexts/appRuntime', async (importOriginal) => ({ + ...(await importOriginal()), + useWebSocket: () => ({ activeAlerts: {} }), +})); + vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({ GuestDrawerHistory: (props: { target: { resourceType: string; resourceId: string } | null; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx index 6651b5f197..7af4828a9d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx @@ -9,6 +9,11 @@ import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSusp import * as discoveryApi from '@/api/discovery'; import { getShippedDocUrl } from '@/utils/docsLinks'; +vi.mock('@/contexts/appRuntime', async (importOriginal) => ({ + ...(await importOriginal()), + useWebSocket: () => ({ activeAlerts: {} }), +})); + vi.mock('@/api/discovery', async (importOriginal) => ({ ...(await importOriginal()), getDiscovery: vi.fn(async () => null), diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 26c4f65899..800c3f0b7f 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -100,6 +100,7 @@ const actionAuditMock = vi.hoisted(() => ({ vi.mock('@/contexts/appRuntime', () => ({ useWebSocket: () => ({ + activeAlerts: {}, state: { pmg: [] as any[] }, connected: () => true, initialDataReceived: () => true, diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index 5e835255e0..651f482880 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -32,6 +32,7 @@ const reconnectSpy = vi.hoisted(() => vi.fn()); vi.mock('@/contexts/appRuntime', () => ({ useWebSocket: () => ({ + activeAlerts: {}, state: wsState, connected: () => true, initialDataReceived: () => true, diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.k8s-capabilities.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.k8s-capabilities.test.tsx index e954d4d555..8a11fa6364 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.k8s-capabilities.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.k8s-capabilities.test.tsx @@ -4,6 +4,11 @@ import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import type { Resource } from '@/types/resource'; import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer'; +vi.mock('@/contexts/appRuntime', async (importOriginal) => ({ + ...(await importOriginal()), + useWebSocket: () => ({ activeAlerts: {} }), +})); + const navigateSpy = vi.hoisted(() => vi.fn()); vi.mock('@solidjs/router', async () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx index 4bae8028c8..1e6866c1bf 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx @@ -5,6 +5,11 @@ import type { Resource } from '@/types/resource'; import { ResourceDetailDrawer } from '../ResourceDetailDrawer'; import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; +vi.mock('@/contexts/appRuntime', async (importOriginal) => ({ + ...(await importOriginal()), + useWebSocket: () => ({ activeAlerts: {} }), +})); + const expandPlatformDetails = (): void => { const details = screen.getByTestId('resource-platform-details') as HTMLDetailsElement; details.open = true; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx index 02a2120f33..adc6132498 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, within } from '@solidjs/testing-library'; import type { Resource } from '@/types/resource'; @@ -11,11 +11,13 @@ const expandPlatformDetails = (getByTestId: (id: string) => HTMLElement): void = }; const wsState = vi.hoisted(() => ({ pmg: [] as any[] })); +const wsActiveAlerts = vi.hoisted(() => ({}) as Record); const reconnectSpy = vi.hoisted(() => vi.fn()); vi.mock('@/contexts/appRuntime', () => ({ useWebSocket: () => ({ state: wsState, + activeAlerts: wsActiveAlerts, connected: () => true, initialDataReceived: () => true, reconnecting: () => false, @@ -77,6 +79,26 @@ const baseResource = (overrides: Partial): Resource => ({ ...overrides, }); +const openAlert = (overrides: Record) => ({ + id: 'alert-1', + type: 'cpu', + level: 'warning', + resourceId: 'resource-1', + resourceName: 'host-1', + node: 'host-1', + instance: 'agent', + message: 'Agent CPU at 97%', + value: 97, + threshold: 90, + startTime: '2026-10-06T10:00:00Z', + acknowledged: false, + ...overrides, +}); + +afterEach(() => { + for (const id of Object.keys(wsActiveAlerts)) delete wsActiveAlerts[id]; +}); + describe('ResourceDetailDrawer service cards', () => { it('renders PBS card with compact summary and job breakdown section', () => { const resource = baseResource({ @@ -465,16 +487,15 @@ describe('ResourceDetailDrawer service cards', () => { summary: 'Device /dev/sdc has SMART test failures.', }, ], - alerts: [ - { - id: 'smart-sdc', - type: 'smart', - level: 'warning', - // TrueNAS alert text can differ from the incident by its full stop. - message: 'Device /dev/sdc has SMART test failures', - }, - ], } as Partial); + wsActiveAlerts['smart-sdc'] = openAlert({ + id: 'smart-sdc', + type: 'smart', + resourceId: 'resource-1', + resourceName: 'archive', + // TrueNAS alert text can differ from the incident by its full stop. + message: 'Device /dev/sdc has SMART test failures', + }); const { getAllByText, getByText } = render(() => ); @@ -484,4 +505,93 @@ describe('ResourceDetailDrawer service cards', () => { ).toBeInTheDocument(); expect(getAllByText(/Device \/dev\/sdc has SMART test failures/)).toHaveLength(1); }); + + it('keeps one row when an incident alert states the reason and its impact', () => { + // Unified incident alerts on storage with consumers join the reason and its + // impact as two sentences; neither health reason may repeat beside it. + const resource = baseResource({ + id: 'storage-213567e8568ac638', + type: 'storage', + name: 'archive-tier', + displayName: 'archive-tier', + status: 'degraded', + incidentSummary: 'Datastore latency above threshold', + incidentImpactSummary: 'Affects 2 dependent resources: warehouse-db-01, etl-batch-01', + storage: { + postureSummary: 'Affects 2 dependent resources: warehouse-db-01, etl-batch-01', + }, + incidents: [ + { + code: 'vmware_alarm', + severity: 'warning', + summary: 'Datastore latency above threshold', + }, + ], + } as Partial); + wsActiveAlerts['datastore-latency'] = openAlert({ + id: 'datastore-latency', + type: 'storage-incident', + resourceId: 'storage-213567e8568ac638', + resourceName: 'archive-tier', + message: + 'Datastore latency above threshold. Affects 2 dependent resources: warehouse-db-01, etl-batch-01', + }); + + const { getByTestId } = render(() => ); + + const attention = within(getByTestId('drawer-attention-section')); + expect(attention.getByText('1 active')).toBeInTheDocument(); + expect( + attention.getByText( + 'Datastore latency above threshold. Affects 2 dependent resources: warehouse-db-01, etl-batch-01', + ), + ).toBeInTheDocument(); + }); + + it('lists the open alerts of a machine and its components under Needs attention', () => { + // Host agent alerts key on "agent:"; disk alerts nest under it. + // Resources carry no embedded alert list, so the drawer reads the live map. + const resource = baseResource({ + id: 'agent-acdfdee2953587fb', + name: 'Apollo-114', + displayName: 'Apollo-114', + agent: { agentId: 'host-linux-1' }, + canonicalIdentity: { + displayName: 'Apollo-114', + primaryId: 'agent:host-linux-1', + aliases: ['agent:host-linux-1', 'host-linux-1', 'apollo-114'], + }, + } as Partial); + wsActiveAlerts['agent:host-linux-1-cpu'] = openAlert({ + id: 'agent:host-linux-1-cpu', + resourceId: 'agent:host-linux-1', + resourceName: 'Apollo-114', + message: 'Agent CPU at 97%', + }); + wsActiveAlerts['agent:host-linux-1/disk:data-disk'] = openAlert({ + id: 'agent:host-linux-1/disk:data-disk', + type: 'disk', + level: 'critical', + resourceId: 'agent:host-linux-1/disk:data', + resourceName: 'Apollo-114 (/data)', + message: 'Disk /data at 96%', + }); + wsActiveAlerts['agent:host-linux-10-cpu'] = openAlert({ + id: 'agent:host-linux-10-cpu', + resourceId: 'agent:host-linux-10', + resourceName: 'Other host', + message: 'Other host CPU at 99%', + }); + + const { getByTestId } = render(() => ); + + const attention = within(getByTestId('drawer-attention-section')); + expect(attention.getByText('2 active')).toBeInTheDocument(); + const rows = attention.getAllByRole('listitem'); + // Most severe first. + expect(rows[0]).toHaveTextContent('Disk /data at 96%'); + expect(rows[0]).toHaveTextContent('Apollo-114 (/data)'); + expect(rows[1]).toHaveTextContent('Agent CPU at 97%'); + expect(attention.queryByText('Other host CPU at 99%')).not.toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.snapshot.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.snapshot.test.tsx index e10cb232a1..21c20a470c 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.snapshot.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.snapshot.test.tsx @@ -15,7 +15,7 @@ const requests = vi.hoisted(() => ({ })); vi.mock('@/contexts/appRuntime', () => ({ - useWebSocket: () => ({ state: { pmg: [] }, connected: () => true }), + useWebSocket: () => ({ state: { pmg: [] }, activeAlerts: {}, connected: () => true }), useDarkMode: () => () => false, })); vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({ diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx index 39677acb26..a65ad5e765 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx @@ -6,6 +6,7 @@ import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetail vi.mock('@/contexts/appRuntime', () => ({ useWebSocket: () => ({ + activeAlerts: {}, state: { pmg: [] as any[] }, connected: () => true, initialDataReceived: () => true, diff --git a/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx b/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx index 829a756097..1baf5ce68c 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx @@ -9,6 +9,13 @@ vi.mock('@/components/Discovery/DiscoveryTab', () => ({ DiscoveryTab: () =>
, })); +const wsActiveAlerts = vi.hoisted(() => ({}) as Record); + +vi.mock('@/contexts/appRuntime', async (importOriginal) => ({ + ...(await importOriginal()), + useWebSocket: () => ({ activeAlerts: wsActiveAlerts }), +})); + vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({ GuestDrawerHistory: () =>
, GuestDrawerHistoryRangeSelect: () =>