mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:<id> and docker:<host source id> 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.
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