mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Show open alerts in the Machines and Docker host drawers (#2575)
The Machines/Infrastructure drawer and the Docker host drawer built "Needs attention" from the alerts embedded in each resource. Nothing in the backend 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; a real dev Pulse showed `alerts: []` on a host with an open temperature alert). **What changes** - Both drawers read the websocket `activeAlerts` map, the source the Proxmox node, guest and Mail Gateway drawers already use, through a new `getAlertsForUnifiedResource` in `frontend-modern/src/utils/alerts.ts`. It matches the ids the backend writes for a resource (unified id, canonical primary/superseded ids, Proxmox source id, metrics target) and, for agent and Docker hosts, `agent:<id>` and `docker:<host source id>` plus the disk, RAID, container and service alerts nested under them. Rows show resource and metric like the other drawers, most severe first. - The dead embedded path is removed: frontend `ResourceAlert` and the unused `hasAlerts` filter, the Proxmox summary `alertCount` nothing rendered, and backend `ResourceFrontend.alerts` / `ResourceAlertInput` (which also drops `"alerts":[]` from every resource in each broadcast). - With incident alerts now reaching 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. - `utils/__tests__/alerts.test.ts` and `internal/models/converters_additional_test.go` are registered as proof files for the policies that own `utils/alerts.ts` and the resource converters. **Verification** - Unit tests for the matcher across agent, Proxmox-linked agent, Docker host, PBS/PMG and pod identity shapes copied from a live `/api/state`; drawer tests for a host plus a nested disk alert, a Docker host with nested service alerts, and the incident-sentence dedupe (each fails on the previous code). - Private mock stack on this tree at 1440 and 390 px: Apollo-114's backend-raised memory alert appears in its Machines drawer, a machine with no alert shows no section, Ops Services 01 lists its three service alerts, and the vSphere `archive-tier` and `Edge Stateful` drawers show one row each. Receipt: `frontend-modern/browser-verification/89dcce7f1458e2ba.json`.
This commit is contained in:
@@ -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:<id>` and
|
||||
`docker:<host source id>` 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:<id>` and
|
||||
`docker:<host source id>` 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;
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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<ResourceDetailDrawerOver
|
||||
drawer.sortedResourceTimeline().length > 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,
|
||||
|
||||
+5
@@ -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<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
|
||||
GuestDrawerHistory: (props: {
|
||||
target: { resourceType: string; resourceId: string } | null;
|
||||
|
||||
+5
@@ -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<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/discovery', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/api/discovery')>()),
|
||||
getDiscovery: vi.fn(async () => null),
|
||||
|
||||
+1
@@ -100,6 +100,7 @@ const actionAuditMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@/contexts/appRuntime', () => ({
|
||||
useWebSocket: () => ({
|
||||
activeAlerts: {},
|
||||
state: { pmg: [] as any[] },
|
||||
connected: () => true,
|
||||
initialDataReceived: () => true,
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ const reconnectSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/contexts/appRuntime', () => ({
|
||||
useWebSocket: () => ({
|
||||
activeAlerts: {},
|
||||
state: wsState,
|
||||
connected: () => true,
|
||||
initialDataReceived: () => true,
|
||||
|
||||
+5
@@ -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<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
const navigateSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@solidjs/router', async () => {
|
||||
|
||||
+5
@@ -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<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
const expandPlatformDetails = (): void => {
|
||||
const details = screen.getByTestId('resource-platform-details') as HTMLDetailsElement;
|
||||
details.open = true;
|
||||
|
||||
+120
-10
@@ -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<string, any>);
|
||||
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>): Resource => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const openAlert = (overrides: Record<string, unknown>) => ({
|
||||
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<Resource>);
|
||||
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(() => <ResourceDetailDrawer resource={resource} />);
|
||||
|
||||
@@ -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<Resource>);
|
||||
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(() => <ResourceDetailDrawer resource={resource} />);
|
||||
|
||||
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:<agent id>"; 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<Resource>);
|
||||
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(() => <ResourceDetailDrawer resource={resource} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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', () => ({
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetail
|
||||
|
||||
vi.mock('@/contexts/appRuntime', () => ({
|
||||
useWebSocket: () => ({
|
||||
activeAlerts: {},
|
||||
state: { pmg: [] as any[] },
|
||||
connected: () => true,
|
||||
initialDataReceived: () => true,
|
||||
|
||||
@@ -9,6 +9,13 @@ vi.mock('@/components/Discovery/DiscoveryTab', () => ({
|
||||
DiscoveryTab: () => <div data-testid="docker-host-discovery" />,
|
||||
}));
|
||||
|
||||
const wsActiveAlerts = vi.hoisted(() => ({}) as Record<string, unknown>);
|
||||
|
||||
vi.mock('@/contexts/appRuntime', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: wsActiveAlerts }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
|
||||
GuestDrawerHistory: () => <div data-testid="docker-host-history" />,
|
||||
GuestDrawerHistoryRangeSelect: () => <select aria-label="History range" />,
|
||||
@@ -44,6 +51,53 @@ afterEach(() => {
|
||||
cleanup();
|
||||
resetAIRuntimeState();
|
||||
vi.clearAllMocks();
|
||||
for (const id of Object.keys(wsActiveAlerts)) delete wsActiveAlerts[id];
|
||||
});
|
||||
|
||||
const openAlert = (id: string, resourceId: string, message: string, level = 'warning') => ({
|
||||
id,
|
||||
type: 'docker-service-health',
|
||||
level,
|
||||
resourceId,
|
||||
resourceName: resourceId,
|
||||
node: 'docker-1',
|
||||
instance: 'Docker',
|
||||
message,
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
startTime: '2026-10-06T10:00:00Z',
|
||||
acknowledged: false,
|
||||
});
|
||||
|
||||
describe('DockerHostDrawer open alerts', () => {
|
||||
it('lists the open alerts of the host and its containers under Needs attention', () => {
|
||||
// Docker alerts key on "docker:<host source id>" and nest containers and
|
||||
// services under it. Resources carry no embedded alert list.
|
||||
wsActiveAlerts.offline = openAlert(
|
||||
'offline',
|
||||
'docker:docker-source-1',
|
||||
"Docker host 'Docker 1' is offline",
|
||||
'critical',
|
||||
);
|
||||
wsActiveAlerts.service = openAlert(
|
||||
'service',
|
||||
'docker:docker-source-1/service/svc-1',
|
||||
'Service backend-sftp has 0/2 running tasks',
|
||||
);
|
||||
wsActiveAlerts.elsewhere = openAlert(
|
||||
'elsewhere',
|
||||
'docker:docker-source-10/service/svc-1',
|
||||
'Another host service is down',
|
||||
);
|
||||
|
||||
render(() => <DockerHostDrawer host={host()} />);
|
||||
|
||||
const section = screen.getByTestId('drawer-attention-section');
|
||||
expect(section).toHaveTextContent('2 active');
|
||||
expect(section).toHaveTextContent("Docker host 'Docker 1' is offline");
|
||||
expect(section).toHaveTextContent('Service backend-sftp has 0/2 running tasks');
|
||||
expect(section).not.toHaveTextContent('Another host service is down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerHostDrawer Discovery availability', () => {
|
||||
|
||||
@@ -16,10 +16,13 @@ import {
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
import { useResourceDetailDrawerDockerActionsState } from '@/components/Infrastructure/useResourceDetailDrawerDockerActionsState';
|
||||
import { useWebSocket } from '@/contexts/appRuntime';
|
||||
import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
|
||||
import { alertTypeDisplayLabel } from '@/features/alerts/helpers';
|
||||
import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getAlertsForUnifiedResource } from '@/utils/alerts';
|
||||
import {
|
||||
formatBytes,
|
||||
formatRelativeTime,
|
||||
@@ -169,6 +172,7 @@ export function DockerHostDrawerManagement(props: DockerHostDrawerOverviewProps)
|
||||
}
|
||||
|
||||
export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
|
||||
const { activeAlerts } = useWebSocket();
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const docker = () => props.host.docker;
|
||||
const agent = () => props.host.agent;
|
||||
@@ -363,10 +367,17 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(props.host.alerts ?? []).map((alert) => ({
|
||||
...getAlertsForUnifiedResource(
|
||||
props.host,
|
||||
activeAlerts,
|
||||
alertsActivation.detectionEnabled(),
|
||||
).map((alert) => ({
|
||||
id: alert.id,
|
||||
message: alert.message,
|
||||
subject: cleanText(alert.resourceName) || props.host.displayName || props.host.name,
|
||||
metric: alertTypeDisplayLabel(alert.type),
|
||||
severity: alert.level,
|
||||
acknowledged: alert.acknowledged,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
|
||||
+5
@@ -7,6 +7,11 @@ import type { Resource } from '@/types/resource';
|
||||
import { DockerContainersTable } from '../DockerContainersTable';
|
||||
import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from '../dockerCpuPresentation';
|
||||
|
||||
vi.mock('@/contexts/appRuntime', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/license', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/stores/license')>()),
|
||||
loadRuntimeCapabilities: vi.fn(async () => undefined),
|
||||
|
||||
@@ -17,6 +17,11 @@ import { DockerSwarmNodesTable } from '../DockerSwarmNodesTable';
|
||||
import { DockerTasksTable } from '../DockerTasksTable';
|
||||
import { DockerVolumesTable } from '../DockerVolumesTable';
|
||||
|
||||
vi.mock('@/contexts/appRuntime', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/resourceActions', () => ({
|
||||
ResourceActionsAPI: {
|
||||
planAction: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/contexts/appRuntime', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/resources', () => ({
|
||||
ResourceAPI: {
|
||||
getFacetBundle: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
PlatformType,
|
||||
Resource,
|
||||
ResourceAlert,
|
||||
ResourceMetric,
|
||||
ResourceType,
|
||||
} from '@/types/resource';
|
||||
import type { PlatformType, Resource, ResourceMetric, ResourceType } from '@/types/resource';
|
||||
|
||||
import {
|
||||
buildProxmoxPageModel,
|
||||
@@ -35,17 +29,6 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
|
||||
...resource,
|
||||
});
|
||||
|
||||
const alert = (overrides: Partial<ResourceAlert> = {}): ResourceAlert => ({
|
||||
id: 'alert-1',
|
||||
type: 'cpu',
|
||||
level: 'warning',
|
||||
message: 'high load',
|
||||
value: 95,
|
||||
threshold: 90,
|
||||
startTime: 1_700_000_000_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getPlatformSources — module-private, exercised transitively through
|
||||
// resolveProxmoxPlatformScope (its only call site).
|
||||
@@ -599,19 +582,16 @@ describe('buildProxmoxPageModel empty input', () => {
|
||||
pbsCount: 0,
|
||||
pmgCount: 0,
|
||||
cephCount: 0,
|
||||
alertCount: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProxmoxPageModel estate classification, status counts, and alert sum', () => {
|
||||
describe('buildProxmoxPageModel estate classification and status counts', () => {
|
||||
const nodeA = makeResource({
|
||||
id: 'node-a',
|
||||
type: 'agent',
|
||||
proxmox: { nodeName: 'node-a', clusterName: 'cluster-x' },
|
||||
alerts: [alert()],
|
||||
incidentCount: 2,
|
||||
});
|
||||
|
||||
const guests = [
|
||||
@@ -653,7 +633,7 @@ describe('buildProxmoxPageModel estate classification, status counts, and alert
|
||||
}),
|
||||
];
|
||||
|
||||
it('classifies resources, excludes non-proxmox, and aggregates summary + alerts', () => {
|
||||
it('classifies resources, excludes non-proxmox, and aggregates the summary', () => {
|
||||
const model = buildProxmoxPageModel([
|
||||
makeResource({ id: 'dh', type: 'docker-host', platformType: 'docker' }),
|
||||
nodeA,
|
||||
@@ -677,7 +657,6 @@ describe('buildProxmoxPageModel estate classification, status counts, and alert
|
||||
stoppedGuestCount: 2,
|
||||
nodeCount: 1,
|
||||
clusterCount: 1,
|
||||
alertCount: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ export type ProxmoxPageSummary = {
|
||||
pbsCount: number;
|
||||
pmgCount: number;
|
||||
cephCount: number;
|
||||
alertCount: number;
|
||||
};
|
||||
|
||||
export type ProxmoxPageModel = {
|
||||
@@ -415,11 +414,6 @@ export function buildProxmoxPageModel(resources: Resource[]): ProxmoxPageModel {
|
||||
return a.label.localeCompare(b.label) || a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
const alertCount = proxmoxResources.reduce(
|
||||
(total, resource) => total + (resource.alerts?.length ?? 0) + (resource.incidentCount ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
resources: proxmoxResources,
|
||||
pveNodes,
|
||||
@@ -448,7 +442,6 @@ export function buildProxmoxPageModel(resources: Resource[]): ProxmoxPageModel {
|
||||
pbsCount: pbs.length,
|
||||
pmgCount: pmg.length,
|
||||
cephCount: ceph.length,
|
||||
alertCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import type { ProbeAgentOption } from '@/utils/availabilityProbeAgents';
|
||||
import { AvailabilityHistoryAPI } from '@/api/availabilityHistory';
|
||||
import { AvailabilityChecksTable, resolveAvailabilityChecksView } from '../AvailabilityChecksTable';
|
||||
|
||||
vi.mock('@/contexts/appRuntime', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/availabilityHistory', () => ({
|
||||
AvailabilityHistoryAPI: { batch: vi.fn(async () => ({ targets: [] })) },
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/contexts/appRuntime', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Workloads/StackedDiskBar', () => ({
|
||||
StackedDiskBar: () => <div data-testid="stacked-disk-bar" />,
|
||||
}));
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VsphereNetworksTable } from '@/features/vmware/VsphereNetworksTable';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
vi.mock('@/contexts/appRuntime', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/contexts/appRuntime')>()),
|
||||
useWebSocket: () => ({ activeAlerts: {} }),
|
||||
}));
|
||||
|
||||
const makeNetwork = (overrides: Partial<Resource> & Pick<Resource, 'id'>): Resource =>
|
||||
({
|
||||
type: 'network',
|
||||
|
||||
@@ -206,11 +206,6 @@ export function useResources(storeOverride?: ResourceStoreLike): UseResourcesRet
|
||||
result = result.filter((r) => r.clusterId === filter.clusterId);
|
||||
}
|
||||
|
||||
// Filter by alerts
|
||||
if (filter.hasAlerts) {
|
||||
result = result.filter((r) => r.alerts && r.alerts.length > 0);
|
||||
}
|
||||
|
||||
// Search filter (name, displayName)
|
||||
if (filter.search && filter.search.trim()) {
|
||||
const term = filter.search.toLowerCase().trim();
|
||||
|
||||
@@ -867,25 +867,6 @@ describe('Resource Interface', () => {
|
||||
expect(resource.labels).toEqual({ env: 'prod', role: 'frontend' });
|
||||
});
|
||||
|
||||
it('supports alerts array', () => {
|
||||
const resource = createResource({
|
||||
alerts: [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'cpu',
|
||||
level: 'warning',
|
||||
message: 'High CPU usage',
|
||||
value: 85,
|
||||
threshold: 80,
|
||||
startTime: Date.now(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(resource.alerts).toHaveLength(1);
|
||||
expect(resource.alerts![0].type).toBe('cpu');
|
||||
});
|
||||
|
||||
it('supports identity for deduplication', () => {
|
||||
const resource = createResource({
|
||||
identity: {
|
||||
|
||||
@@ -139,17 +139,6 @@ export interface ResourceDiskIO {
|
||||
writeRate?: number; // Absent directions remain unavailable.
|
||||
}
|
||||
|
||||
// Alert associated with a resource
|
||||
export interface ResourceAlert {
|
||||
id: string;
|
||||
type: string; // cpu, memory, disk, temperature, etc.
|
||||
level: string; // warning, critical
|
||||
message: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
startTime: number; // Unix milliseconds
|
||||
}
|
||||
|
||||
// Provider-native incident associated with a unified resource.
|
||||
export interface ResourceIncident {
|
||||
provider?: string;
|
||||
@@ -1659,7 +1648,6 @@ export interface Resource {
|
||||
customUrl?: string;
|
||||
lastSeen: number; // Unix milliseconds
|
||||
health?: ResourceHealth;
|
||||
alerts?: ResourceAlert[];
|
||||
incidents?: ResourceIncident[];
|
||||
incidentCount?: number;
|
||||
incidentCode?: string;
|
||||
@@ -1764,7 +1752,6 @@ export interface ResourceFilter {
|
||||
statuses?: ResourceStatus[];
|
||||
parentId?: string;
|
||||
clusterId?: string;
|
||||
hasAlerts?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getAlertsForResource, getAlertStyles } from '@/utils/alerts';
|
||||
import { getAlertsForResource, getAlertsForUnifiedResource, getAlertStyles } from '@/utils/alerts';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
describe('getAlertStyles', () => {
|
||||
const createAlert = (overrides: Partial<Alert> = {}): Alert => ({
|
||||
@@ -223,3 +224,155 @@ describe('getAlertStyles', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertsForUnifiedResource', () => {
|
||||
// Identity shapes below are copied from a mock-mode /api/state payload.
|
||||
const alert = (id: string, resourceId: string, overrides: Partial<Alert> = {}): Alert => ({
|
||||
id,
|
||||
type: 'cpu',
|
||||
level: 'warning',
|
||||
resourceId,
|
||||
resourceName: resourceId,
|
||||
node: '',
|
||||
instance: '',
|
||||
message: id,
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
startTime: '2026-10-06T10:00:00Z',
|
||||
acknowledged: false,
|
||||
...overrides,
|
||||
});
|
||||
const byId = (...alerts: Alert[]): Record<string, Alert> =>
|
||||
Object.fromEntries(alerts.map((entry) => [entry.id, entry]));
|
||||
const resource = (overrides: Partial<Resource>): Resource =>
|
||||
({
|
||||
id: 'resource-1',
|
||||
type: 'agent',
|
||||
name: 'resource-1',
|
||||
displayName: 'resource-1',
|
||||
platformId: 'resource-1',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
lastSeen: 0,
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
const ids = (alerts: Alert[]) => alerts.map((entry) => entry.id);
|
||||
|
||||
it('matches a standalone agent and the components nested under its agent key', () => {
|
||||
const host = resource({
|
||||
id: 'agent-acdfdee2953587fb',
|
||||
agent: { agentId: 'host-linux-1' },
|
||||
metricsTarget: { resourceType: 'agent', resourceId: 'host-linux-1' },
|
||||
canonicalIdentity: {
|
||||
displayName: 'Apollo-114',
|
||||
primaryId: 'agent:host-linux-1',
|
||||
aliases: ['agent:host-linux-1', 'host-linux-1', 'apollo-114'],
|
||||
},
|
||||
} as Partial<Resource>);
|
||||
const active = byId(
|
||||
alert('cpu', 'agent:host-linux-1'),
|
||||
alert('disk', 'agent:host-linux-1/disk:data', { level: 'critical' }),
|
||||
alert('raid', 'agent:host-linux-1/raid:md0'),
|
||||
alert('sibling', 'agent:host-linux-10'),
|
||||
alert('sibling-disk', 'agent:host-linux-10/disk:data'),
|
||||
alert('hostname', 'apollo-114'),
|
||||
);
|
||||
|
||||
expect(ids(getAlertsForUnifiedResource(host, active, true))).toEqual(['disk', 'cpu', 'raid']);
|
||||
expect(getAlertsForUnifiedResource(host, active, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches a Proxmox node agent by node id and by its linked agent key', () => {
|
||||
const node = resource({
|
||||
id: 'agent-85d79ff9b7a2cc0e',
|
||||
platformType: 'proxmox-pve',
|
||||
agent: { agentId: 'host-node-mock-cluster-1-pve2' },
|
||||
proxmox: { sourceId: 'mock-cluster-1-pve2', nodeName: 'pve2' },
|
||||
canonicalIdentity: {
|
||||
displayName: 'West Production B',
|
||||
primaryId: 'node:mock-cluster-1-pve2',
|
||||
aliases: ['node:mock-cluster-1-pve2', 'pve2', 'agent:host-node-mock-cluster-1-pve2'],
|
||||
},
|
||||
} as Partial<Resource>);
|
||||
const active = byId(
|
||||
alert('node-memory', 'mock-cluster-1-pve2'),
|
||||
alert('agent-temp', 'agent:host-node-mock-cluster-1-pve2'),
|
||||
alert('pool', 'mock-cluster-1-pve2-local-zfs/zfs-pool:local-zfs', { node: 'pve2' }),
|
||||
alert('bare-node-name', 'pve2'),
|
||||
);
|
||||
|
||||
expect(ids(getAlertsForUnifiedResource(node, active, true))).toEqual([
|
||||
'node-memory',
|
||||
'agent-temp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches a Docker host, its containers and services, and its reporting agent', () => {
|
||||
const dockerHost = resource({
|
||||
id: 'agent-aa22ff2b2bc3257d',
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
docker: { hostSourceId: 'orion-2-mock' },
|
||||
metricsTarget: { resourceType: 'docker-host', resourceId: 'orion-2-mock' },
|
||||
canonicalIdentity: {
|
||||
displayName: 'Ops Services 01',
|
||||
primaryId: 'docker-host:orion-2-mock',
|
||||
aliases: ['docker-host:orion-2-mock', 'orion-2-mock', 'agent:agent-5d2100295642fccc'],
|
||||
},
|
||||
} as Partial<Resource>);
|
||||
const active = byId(
|
||||
alert('offline', 'docker:orion-2-mock', { level: 'critical' }),
|
||||
alert('service', 'docker:orion-2-mock/service/svc-backend-1'),
|
||||
alert('agent-cpu', 'agent:agent-5d2100295642fccc'),
|
||||
alert('other-host', 'docker:orion-20-mock/abc'),
|
||||
);
|
||||
|
||||
expect(ids(getAlertsForUnifiedResource(dockerHost, active, true))).toEqual([
|
||||
'offline',
|
||||
'service',
|
||||
'agent-cpu',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches PBS and PMG instances by their metrics target', () => {
|
||||
const pbs = resource({
|
||||
id: 'pbs-4fcc01f0e6db7b83',
|
||||
type: 'pbs',
|
||||
metricsTarget: { resourceType: 'agent', resourceId: 'pbs-pbs-docker' },
|
||||
canonicalIdentity: { displayName: 'pbs-docker', primaryId: 'agent:pbs-pbs-docker' },
|
||||
} as Partial<Resource>);
|
||||
const pmg = resource({
|
||||
id: 'pmg-abe41cf2c6a8ff47',
|
||||
type: 'pmg',
|
||||
metricsTarget: { resourceType: 'agent', resourceId: 'pmg-main' },
|
||||
canonicalIdentity: { displayName: 'mail-gateway-eu', primaryId: 'agent:pmg-main' },
|
||||
} as Partial<Resource>);
|
||||
const active = byId(
|
||||
alert('pbs-memory', 'pbs-pbs-docker'),
|
||||
alert('pmg-queue', 'pmg-main'),
|
||||
alert('pbs-datastore', 'pbs-pbs-docker-arr-secondary'),
|
||||
);
|
||||
|
||||
expect(ids(getAlertsForUnifiedResource(pbs, active, true))).toEqual(['pbs-memory']);
|
||||
expect(ids(getAlertsForUnifiedResource(pmg, active, true))).toEqual(['pmg-queue']);
|
||||
});
|
||||
|
||||
it('matches provider incidents on the unified id without borrowing a parent agent key', () => {
|
||||
const pod = resource({
|
||||
id: 'pod-9bd9679901d26654',
|
||||
type: 'pod',
|
||||
canonicalIdentity: {
|
||||
displayName: 'checkout-api',
|
||||
primaryId: 'pod:k8s:k8s-production-1:pod:nginx',
|
||||
aliases: ['k8s-production-1', 'agent:k8s-production-1-agent'],
|
||||
},
|
||||
} as Partial<Resource>);
|
||||
const active = byId(
|
||||
alert('pod-incident', 'pod-9bd9679901d26654'),
|
||||
alert('cluster-agent', 'agent:k8s-production-1-agent'),
|
||||
);
|
||||
|
||||
expect(ids(getAlertsForUnifiedResource(pod, active, true))).toEqual(['pod-incident']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getActionableAgentIdFromResource } from '@/utils/agentResources';
|
||||
import { isAlertsDetectionEnabled } from '@/utils/alertsActivation';
|
||||
|
||||
const noAlertStyles = {
|
||||
@@ -147,6 +149,77 @@ export function getAlertsForResource(
|
||||
);
|
||||
}
|
||||
|
||||
const ALERT_LEVEL_RANK: Record<Alert['level'], number> = { critical: 0, warning: 1, info: 2 };
|
||||
|
||||
// The alert keys one unified resource answers to. Alerts raised on the unified
|
||||
// resource itself (provider incidents, availability, TrueNAS, vSphere,
|
||||
// Kubernetes) carry its id; poller alerts carry the canonical primary id, the
|
||||
// Proxmox source id or the metrics target (PBS, PMG, storage). A machine also
|
||||
// answers to its agent ("agent:<id>") and Docker runtime ("docker:<id>") keys,
|
||||
// and its components' alerts nest under those: "agent:<id>/disk:<mount>",
|
||||
// "agent:<id>/raid:<device>", "docker:<id>/<container>".
|
||||
const getUnifiedResourceAlertKeys = (
|
||||
resource: Resource,
|
||||
): { exact: Set<string>; machine: string[] } => {
|
||||
const exact = new Set<string>();
|
||||
const add = (value: string | undefined) => {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed) exact.add(trimmed);
|
||||
};
|
||||
add(resource.id);
|
||||
add(resource.canonicalIdentity?.primaryId);
|
||||
resource.canonicalIdentity?.supersededIds?.forEach(add);
|
||||
add(resource.proxmox?.sourceId);
|
||||
add(resource.metricsTarget?.resourceId);
|
||||
|
||||
const machine = new Set<string>();
|
||||
if (resource.type === 'agent' || resource.type === 'docker-host') {
|
||||
const agentId = getActionableAgentIdFromResource(resource);
|
||||
if (agentId) machine.add(agentId.startsWith('agent:') ? agentId : `agent:${agentId}`);
|
||||
// The backend writes the agent key of a machine reported through a linked
|
||||
// agent (a Proxmox node, a Docker host) into the canonical aliases.
|
||||
resource.canonicalIdentity?.aliases
|
||||
?.filter((alias) => alias.startsWith('agent:'))
|
||||
.forEach((alias) => machine.add(alias));
|
||||
// Docker hosts are "docker-host" resources or agents with a Docker runtime;
|
||||
// the backend keys their alerts on the Docker host's source id.
|
||||
const dockerHostId =
|
||||
resource.docker?.hostSourceId?.trim() ||
|
||||
(resource.metricsTarget?.resourceType === 'docker-host'
|
||||
? resource.metricsTarget.resourceId?.trim()
|
||||
: undefined);
|
||||
if (dockerHostId) machine.add(`docker:${dockerHostId}`);
|
||||
}
|
||||
machine.forEach(add);
|
||||
return { exact, machine: [...machine] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Open alerts for one unified resource, read from the websocket's active
|
||||
* alert map, most severe first. This is the canonical source for a resource
|
||||
* drawer's "Needs attention" list: resources do not embed their alerts.
|
||||
*/
|
||||
export function getAlertsForUnifiedResource(
|
||||
resource: Resource,
|
||||
activeAlerts: Record<string, Alert>,
|
||||
alertsEnabled: boolean | undefined = isAlertsDetectionEnabled(),
|
||||
): Alert[] {
|
||||
if (!alertsEnabled) return [];
|
||||
const { exact, machine } = getUnifiedResourceAlertKeys(resource);
|
||||
const machinePrefixes = machine.map((key) => `${key}/`);
|
||||
return Object.values(activeAlerts)
|
||||
.filter(
|
||||
(alert) =>
|
||||
exact.has(alert.resourceId) ||
|
||||
machinePrefixes.some((prefix) => alert.resourceId?.startsWith(prefix)),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(ALERT_LEVEL_RANK[a.level] ?? 3) - (ALERT_LEVEL_RANK[b.level] ?? 3) ||
|
||||
Date.parse(a.startTime) - Date.parse(b.startTime),
|
||||
);
|
||||
}
|
||||
|
||||
// Alert types representing binary or enumerated state conditions rather
|
||||
// than a metric crossing a threshold. For these, "current value vs
|
||||
// threshold" is meaningless (both come through as 0 from the backend) and
|
||||
|
||||
@@ -1174,7 +1174,6 @@ type ResourceConvertInput struct {
|
||||
CustomURL string
|
||||
LastSeenUnix int64
|
||||
Health json.RawMessage
|
||||
Alerts []ResourceAlertInput
|
||||
IncidentCount int
|
||||
IncidentCode string
|
||||
IncidentSeverity string
|
||||
@@ -1224,16 +1223,6 @@ type ResourceMetricInput struct {
|
||||
Observation MemoryObservation
|
||||
}
|
||||
|
||||
type ResourceAlertInput struct {
|
||||
ID string
|
||||
Type string
|
||||
Level string
|
||||
Message string
|
||||
Value float64
|
||||
Threshold float64
|
||||
StartTimeUnix int64
|
||||
}
|
||||
|
||||
type ResourceIdentityInput struct {
|
||||
Hostname string
|
||||
MachineID string
|
||||
@@ -1353,22 +1342,6 @@ func ConvertResourceToFrontend(input ResourceConvertInput) ResourceFrontend {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert alerts
|
||||
if len(input.Alerts) > 0 {
|
||||
rf.Alerts = make([]ResourceAlertFrontend, len(input.Alerts))
|
||||
for i, a := range input.Alerts {
|
||||
rf.Alerts[i] = ResourceAlertFrontend{
|
||||
ID: a.ID,
|
||||
Type: a.Type,
|
||||
Level: a.Level,
|
||||
Message: a.Message,
|
||||
Value: a.Value,
|
||||
Threshold: a.Threshold,
|
||||
StartTime: a.StartTimeUnix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert identity
|
||||
if input.Identity != nil {
|
||||
rf.Identity = &ResourceIdentityFrontend{
|
||||
|
||||
@@ -162,9 +162,6 @@ func TestConvertResourceToFrontend(t *testing.T) {
|
||||
Labels: map[string]string{"env": "prod"},
|
||||
CustomURL: "https://resource.internal",
|
||||
LastSeenUnix: 12345,
|
||||
Alerts: []ResourceAlertInput{
|
||||
{ID: "a1", Type: "cpu", Level: "warn", Message: "high", Value: 90, Threshold: 80, StartTimeUnix: 111},
|
||||
},
|
||||
Identity: identity,
|
||||
PlatformData: json.RawMessage(`{"key":"value"}`),
|
||||
}
|
||||
@@ -179,9 +176,6 @@ func TestConvertResourceToFrontend(t *testing.T) {
|
||||
if frontend.Network == nil || frontend.Network.RXBytes != 1000 || frontend.Network.TXBytes != 2000 {
|
||||
t.Fatalf("Network = %#v, want RX/TX set", frontend.Network)
|
||||
}
|
||||
if len(frontend.Alerts) != 1 || frontend.Alerts[0].ID != "a1" {
|
||||
t.Fatalf("Alerts = %#v, want 1 alert", frontend.Alerts)
|
||||
}
|
||||
if frontend.CustomURL != input.CustomURL {
|
||||
t.Fatalf("CustomURL = %q, want %q", frontend.CustomURL, input.CustomURL)
|
||||
}
|
||||
@@ -201,7 +195,7 @@ func TestConvertResourceToFrontend_UsesCanonicalEmptyCollections(t *testing.T) {
|
||||
Identity: &ResourceIdentityInput{},
|
||||
})
|
||||
|
||||
if frontend.Tags == nil || frontend.Labels == nil || frontend.Alerts == nil {
|
||||
if frontend.Tags == nil || frontend.Labels == nil {
|
||||
t.Fatalf("expected resource collections to normalize, got %#v", frontend)
|
||||
}
|
||||
if frontend.Identity == nil || frontend.Identity.IPs == nil {
|
||||
@@ -536,11 +530,16 @@ func TestHostResourceAndStorageFrontend_JSONUsesCanonicalEmptyCollections(t *tes
|
||||
}
|
||||
|
||||
resource := decoded["resource"].(map[string]any)
|
||||
for _, key := range []string{"tags", "labels", "alerts"} {
|
||||
for _, key := range []string{"tags", "labels"} {
|
||||
if _, ok := resource[key]; !ok {
|
||||
t.Fatalf("resource JSON missing %q in canonical empty shape: %#v", key, resource)
|
||||
}
|
||||
}
|
||||
// Open alerts reach the frontend once, through the activeAlerts map;
|
||||
// resources do not embed a copy.
|
||||
if _, ok := resource["alerts"]; ok {
|
||||
t.Fatalf("resource JSON must not embed alerts: %#v", resource)
|
||||
}
|
||||
identity := resource["identity"].(map[string]any)
|
||||
if _, ok := identity["ips"]; !ok {
|
||||
t.Fatalf("resource identity JSON missing ips in canonical empty shape: %#v", identity)
|
||||
|
||||
@@ -1060,12 +1060,11 @@ type ResourceFrontend struct {
|
||||
Uptime *int64 `json:"uptime,omitempty"`
|
||||
|
||||
// Metadata
|
||||
Tags []string `json:"tags"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
CustomURL string `json:"customUrl,omitempty"`
|
||||
LastSeen int64 `json:"lastSeen"` // Unix milliseconds
|
||||
Health json.RawMessage `json:"health,omitempty"`
|
||||
Alerts []ResourceAlertFrontend `json:"alerts"`
|
||||
Tags []string `json:"tags"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
CustomURL string `json:"customUrl,omitempty"`
|
||||
LastSeen int64 `json:"lastSeen"` // Unix milliseconds
|
||||
Health json.RawMessage `json:"health,omitempty"`
|
||||
|
||||
IncidentCount int `json:"incidentCount,omitempty"`
|
||||
IncidentCode string `json:"incidentCode,omitempty"`
|
||||
@@ -1128,9 +1127,6 @@ func (r ResourceFrontend) NormalizeCollections() ResourceFrontend {
|
||||
if r.Labels == nil {
|
||||
r.Labels = map[string]string{}
|
||||
}
|
||||
if r.Alerts == nil {
|
||||
r.Alerts = []ResourceAlertFrontend{}
|
||||
}
|
||||
if r.Identity != nil {
|
||||
identity := r.Identity.NormalizeCollections()
|
||||
r.Identity = &identity
|
||||
@@ -1159,17 +1155,6 @@ type ResourceDiskIOFrontend struct {
|
||||
WriteRate *int64 `json:"writeRate,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceAlertFrontend represents an alert on a resource.
|
||||
type ResourceAlertFrontend struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Value float64 `json:"value"`
|
||||
Threshold float64 `json:"threshold"`
|
||||
StartTime int64 `json:"startTime"` // Unix milliseconds
|
||||
}
|
||||
|
||||
// ResourceIdentityFrontend contains identity info for deduplication.
|
||||
type ResourceIdentityFrontend struct {
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
|
||||
@@ -642,7 +642,7 @@ func TestFrontendNormalizeCollections_BranchCov0718(t *testing.T) {
|
||||
t.Fatalf("Resources not preserved")
|
||||
}
|
||||
r := out.Resources[0]
|
||||
if r.Tags == nil || r.Labels == nil || r.Alerts == nil {
|
||||
if r.Tags == nil || r.Labels == nil {
|
||||
t.Fatalf("nested ResourceFrontend collections not normalized: %+v", r)
|
||||
}
|
||||
})
|
||||
@@ -651,8 +651,8 @@ func TestFrontendNormalizeCollections_BranchCov0718(t *testing.T) {
|
||||
t.Run("ResourceFrontend_nil_and_nil_identity", func(t *testing.T) {
|
||||
r := ResourceFrontend{ID: "res-1"}
|
||||
out := r.NormalizeCollections()
|
||||
if out.Tags == nil || out.Labels == nil || out.Alerts == nil {
|
||||
t.Fatalf("Tags/Labels/Alerts should be non-nil after normalize")
|
||||
if out.Tags == nil || out.Labels == nil {
|
||||
t.Fatalf("Tags/Labels should be non-nil after normalize")
|
||||
}
|
||||
if out.Identity != nil {
|
||||
t.Fatalf("Identity must remain nil when nil on input")
|
||||
@@ -663,7 +663,6 @@ func TestFrontendNormalizeCollections_BranchCov0718(t *testing.T) {
|
||||
ID: "res-1",
|
||||
Tags: []string{"env:prod", "env:prod"}, // duplicate on purpose
|
||||
Labels: map[string]string{"team": "infra"},
|
||||
Alerts: []ResourceAlertFrontend{{ID: "al-1", Level: "warn"}},
|
||||
Identity: &ResourceIdentityFrontend{Hostname: "host-1"}, // IPs nil → recursion observable
|
||||
}
|
||||
out := r.NormalizeCollections()
|
||||
@@ -673,9 +672,6 @@ func TestFrontendNormalizeCollections_BranchCov0718(t *testing.T) {
|
||||
if len(out.Labels) != 1 || out.Labels["team"] != "infra" {
|
||||
t.Fatalf("Labels not preserved: %+v", out.Labels)
|
||||
}
|
||||
if len(out.Alerts) != 1 || out.Alerts[0].ID != "al-1" || out.Alerts[0].Level != "warn" {
|
||||
t.Fatalf("Alerts not preserved: %+v", out.Alerts)
|
||||
}
|
||||
// The `if r.Identity != nil` branch must have run and recursed.
|
||||
if out.Identity == nil {
|
||||
t.Fatalf("Identity must be preserved when non-nil on input")
|
||||
|
||||
@@ -1048,9 +1048,6 @@ func TestMonitor_ResourcesForBroadcast_Extra(t *testing.T) {
|
||||
if res[0].CPU == nil || res[0].Memory == nil || res[0].Network == nil {
|
||||
t.Fatalf("expected cpu/memory/network payloads, got %#v", res[0])
|
||||
}
|
||||
if len(res[0].Alerts) != 0 {
|
||||
t.Fatalf("expected no direct alert payload, got %#v", res[0].Alerts)
|
||||
}
|
||||
if res[0].Identity == nil || res[0].Identity.Hostname != "node1" {
|
||||
t.Fatalf("expected identity payload to be preserved, got %#v", res[0].Identity)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user