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: () =>