mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 14:07:31 +00:00
Preserve reviewed monitoring maintenance with current upstream
Compose the exact current upstream shared-clock, last-known guest readings and platform-alert ownership repairs without rewriting the reviewed batch. Source proofs, final-content browser evidence and hosted checks remain separate publication prerequisites. Change-source: pulse-maintainer
This commit is contained in:
@@ -1923,6 +1923,23 @@ well as its alerts.
|
||||
`internal/alerts/host_unraid_lifecycle_test.go` pins the clear, the holds, the
|
||||
restarted count and the pending-run and count cleanup.
|
||||
|
||||
### Proxmox node cleanup keeps platform metric alerts
|
||||
|
||||
`CleanupAlertsForNodes` runs on every Proxmox node poll (and every mock alert
|
||||
tick) and removes alerts whose `Node` is not a current Proxmox node or PBS
|
||||
instance. Kubernetes, TrueNAS and vSphere metric alerts carry a cluster or
|
||||
platform host in `Node` and a display label such as `TrueNAS Disk` or
|
||||
`Kubernetes Pod` in `metadata.resourceType`, which the preserve list's
|
||||
platform words never matched. The cleanup used to delete them silently on
|
||||
each poll while their reducer incident stayed firing, so the next evaluation
|
||||
re-created each one and handed it to the alert callback again as a new
|
||||
alert. `shouldPreserveAlertOutsideNodeCleanup` now keeps any alert carrying
|
||||
`metadata.platformType`, or whose label maps through
|
||||
`CanonicalAlertResourceType` to a Kubernetes, TrueNAS or vSphere type.
|
||||
`TestCleanupAlertsForNodesKeepsPlatformMetricAlerts` in
|
||||
`internal/alerts/alerts_test.go` pins both signals, and its
|
||||
sibling pins that a guest alert on a removed Proxmox node is still removed.
|
||||
|
||||
### Configured flapping thresholds remain reachable
|
||||
|
||||
Every accepted positive `FlappingThreshold`, including values above ten, must
|
||||
@@ -2835,6 +2852,22 @@ derived alert read-model and Last 24 Hours stat refresh for
|
||||
shared acknowledgement owner instead of keeping its own alert mutation fork.
|
||||
Future overview action behavior should extend that shared acknowledgement hook
|
||||
instead of putting acknowledge mutations back into render shells.
|
||||
Overview ages and bands read the shared clock. `AlertOverviewAlertCard.tsx`
|
||||
passes its own `useRelativeTimeNow` reading as `now` to
|
||||
`formatAlertOverviewStartedAgo` and `getMetricAlertPresentation`, and the Last
|
||||
24 Hours count in `useAlertOverviewState.ts` reads the same clock, so all three
|
||||
measure from the wall clock. The hook no longer exposes a minute `tick`: read
|
||||
as "now", that tick measured a card mounted between ticks from up to a minute
|
||||
earlier, so an alert raised 70 seconds ago read "this minute", an alert
|
||||
raised since the last tick stayed out of the 24h count until the next one, a
|
||||
reading already past the 10-minute stale cut-off led the card as live, and the
|
||||
stale flip and 24h drop landed up to a minute late. A mounted card now turns stale,
|
||||
and an alert leaves the 24h count, within one 30-second tick with no data
|
||||
change. The delivery diagnoses refresh is a server read and keeps its own
|
||||
minute interval rather than following the 30-second clock.
|
||||
`AlertOverviewAlertCard.clock.test.tsx` pins the mid-tick mount, the stale flip
|
||||
and the 24h drop under fake timers, and `useAlertOverviewState.test.tsx` pins
|
||||
the diagnoses cadence.
|
||||
Render-heavy alert overview ownership now routes through
|
||||
`frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`,
|
||||
`frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`,
|
||||
|
||||
@@ -7272,6 +7272,12 @@ shell stays in `frontend-modern/src/features/alerts/OverviewTab.tsx`, while
|
||||
alert stats, filtered ordering, and single/bulk acknowledge runtime behavior.
|
||||
Future overview control flow should extend that hook rather than restoring
|
||||
action timers or acknowledge mutations to the tab shell.
|
||||
The overview follows the shared-clock rule for relative ages: the alert card
|
||||
reads `useRelativeTimeNow` for its started age and the live reading's stale
|
||||
cut-off, and the hook reads it for the Last 24 Hours count. A hook-local
|
||||
minute signal used as "now" is a table-local interval under that rule; the
|
||||
hook keeps a minute interval only to refresh delivery diagnoses, a server read
|
||||
whose cadence is not tied to the age clock.
|
||||
Render-heavy overview ownership now lives in
|
||||
`frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`,
|
||||
`frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`,
|
||||
|
||||
@@ -2759,6 +2759,24 @@ must remain distinguishable without hover. Workloads supplies this row metadata,
|
||||
not local table CSS or a duplicate renderer; non-filesystem rows keep the compact
|
||||
default. Verification covers the opt-in at this composition boundary and actual
|
||||
long-path wrapping in the shared renderer's browser proof.
|
||||
A guest's linked Pulse Agent can stop reporting while Proxmox keeps polling the
|
||||
VM. `useWorkloads` copies `resource.agent.stale` onto the row as `agentStale`,
|
||||
and sets `disksFromAgent` when Proxmox reported no guest filesystems and the
|
||||
agent's own `disks` filled in. When that agent is stale, `GuestDrawerOverview.tsx`
|
||||
reads the Guest RAID card as last known through the shared `RaidCard`
|
||||
`lastKnownReason` prop with `HOST_AGENT_STOPPED_REPORTING_REASON`, puts a status
|
||||
line over muted Network rows, reads agent-supplied Filesystems rows through the
|
||||
existing `last-known` disk read state, and marks the Pulse Agent row as stopped
|
||||
reporting. Agent-supplied filesystem rows follow the agent's freshness rather
|
||||
than the Proxmox `diskStatusReason`, which describes Proxmox's own guest reads,
|
||||
so a live agent's rows keep their usage and bars. Those rows read as
|
||||
unavailable once the guest is stopped, whether its status or the Proxmox
|
||||
`vm-stopped` reason says so, and Proxmox's own guest filesystems keep their
|
||||
Proxmox read state. The two flags ride the existing row mapping and
|
||||
add no query or poll; `GuestDrawer.test.tsx` and `useWorkloads.test.ts` pin
|
||||
both sources. The workload table's disk cell and the drawer History still read
|
||||
the aggregate disk metric and the Proxmox reason, and the agent's OS, addresses
|
||||
and physical disk health carry no stale cue yet.
|
||||
Guest, node, and Docker-host drawer headers follow the same frontend-primitives dependency
|
||||
boundary for collapse: Workloads owns which inline row is selected and the
|
||||
close handler, while
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "bab38a25b92684c978c4273200765152040624ed",
|
||||
"verified_at": "2026-10-07T14:02:51Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx",
|
||||
"frontend-modern/src/features/alerts/useAlertOverviewState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "bb98293f7a7b7b857b40ec71a38e1e32163aa937405278dd07af2a8c9dac1118",
|
||||
"frontend-modern/src/features/alerts/useAlertOverviewState.ts": "3e2c91ba930a1d3faf3965a5fd68f5bb44e27465a88cbb06c23aabe500d25668"
|
||||
},
|
||||
"routes": [
|
||||
"/alerts/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (backend :7697, Vite :5217 serving this tree on base bab38a25b9) with Playwright page.clock installed before load and an init script recording when each setInterval starts, so the shared 30s clock and the overview hook's minute interval have known phases",
|
||||
"Three producer-shaped copies of a live disk threshold alert (metricStatus breaching) sent in one websocket frame 15s after a shared tick and 45s after the hook's minute, then /ws data frames held (data-free pong per step) and /api reads stalled, so only the clock moves",
|
||||
"Late-mounted card (raised 70s ago, reading 10m05s old) reads '1 min. ago' and 'Last reading: Disk 92%, 10 mins ago' at mount; held card (reading goes stale 5s after the hook's next minute) is live at mount and 1s before the cut-off, and reads 'Last reading: ..., 10 mins ago' 31s after it; Triggered (24h) drops 85 -> 84 31s after the third copy turns 24h old; no page errors; same at 1440 and 390",
|
||||
"Same-session control with both runtime files swapped to main under the running Vite: the late card read 'this minute' and led with the old reading as live, the held card was still live 31s after the cut-off, and the 24h count rose 84 -> 85 because the just-raised copy read as future; fix restored and diff compared byte-for-byte",
|
||||
"tsc clean; src/features/alerts, src/components/Alerts and src/utils suites pass except the Mac-only agentInstallCommand lifecycle tests"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded /alerts/overview, injected the alert copies between ticks, held the stream and stepped the page clock past the stale cut-off and the 24h boundary, at both viewports, before and after the fix"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "43a80dafd91e92d3079a039cdb08929281812eef",
|
||||
"verified_at": "2026-10-07T13:06:25Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts",
|
||||
"frontend-modern/src/types/workloads.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f984547722c304cd3c80610a3e5ef743b3e6ad268493016ddc649c858e54e6de",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts": "1439f5bc51ad5e362eb21b65b9d95b870f84cdae823770f4ae41dd848905703d",
|
||||
"frontend-modern/src/types/workloads.ts": "4eebb6b801a056900e7fc309679cdca891b48bdd2672e40dbb58eaffce4ca0c6"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox?q=analytics-batch-310",
|
||||
"/proxmox?q=analytics-batch-118",
|
||||
"/proxmox?q=analytics-batch-58",
|
||||
"/proxmox?q=analytics-batch-262"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack built from origin/main 43a80dafd9 plus this change, with a local-only go build -overlay of internal/mock/generator.go: host agents apollo-114 and meridian-731 held past their reporting lease (offline, LastSeen frozen 14 minutes back, readings retained); all four standalone agents seeded with md0 raid1 clean and md1 raid5 'clean, degraded, recovering' at 42.4% / 118M/sec (sdd1 faulty, sde1 spare rebuilding); analytics-batch-310 and analytics-batch-58 forced to report no Proxmox guest filesystems with diskStatusReason agent-not-running",
|
||||
"Agents linked into VMs through the real POST /api/resources/{vm}/link: apollo-114 -> analytics-batch-118, meridian-731 -> analytics-batch-310 (both silent), spectrum-709 -> analytics-batch-262 and meridian-848 -> analytics-batch-58 (both live). /api/resources serves agent.stale true with the agent's raid, disks and networkInterfaces on the two silent VMs and no stale flag on the two live ones",
|
||||
"Before this change (same stack, PR 2652 head, real browser run): silent VMs showed Guest RAID with emerald 'clean', amber 'clean, degraded, recovering', 'Rebuild: 42% \u00b7 118M/sec' and coloured member badges, network rows and 'Pulse 0.2.0-alpha' as current; the live analytics-batch-58 showed its agent filesystems as 'Usage unavailable \u00b7 ?/328 GB' under 'Guest agent not running. Install and start qemu-guest-agent in the VM.'",
|
||||
"After, silent analytics-batch-310 (agent filesystems) at 1440 and 390: Pulse Agent row 'Pulse 0.2.0-alpha \u00b7 stopped reporting' in amber with title 'Pulse Agent 0.2.0-alpha has stopped reporting. Its readings here are its last report.'; Filesystems status 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.' with 'Last known 75%' / 'Last known 42%' rows and no bars; Network status 'Using last known interface details. The Pulse Agent in this guest stopped reporting.' over a muted eth0 row; both RAID arrays data-raid-reading=last-known, '(last known)' states in muted text titled 'Last known reading, not current: host agent stopped reporting', 'Rebuild was at 42%', neutral badges 'sdd1 \u00b7 faulty' and 'sde1 \u00b7 spare rebuilding'",
|
||||
"After, silent analytics-batch-118 with Proxmox guest filesystems (earlier run of the same change): RAID, network and agent row read as above while the Proxmox filesystem rows kept their usage and bars",
|
||||
"After, live analytics-batch-58 (agent filesystems, Proxmox agent-not-running): '/ 65%' and '/data 28%' with progress bars, no status line, no qemu-guest-agent advice; RAID current with emerald/amber states and 'Rebuild: 42% \u00b7 118M/sec'",
|
||||
"After, live control analytics-batch-262 (Proxmox filesystems): unchanged at both widths",
|
||||
"No horizontal page overflow at either width; no page errors. Stopped guests were not exercised in the browser: their agent filesystems read 'Usage unavailable' under the stopped-VM message whether the guest status or the Proxmox vm-stopped reason says stopped, and the stale flip on an open drawer, are covered by GuestDrawer.test.tsx. The final stopped-status gate changes only stopped guests, so the running-guest states above were observed on this code path"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened each guest's inline drawer from the Proxmox workloads table filtered by name (name cell click at 1440, first visible cell tap at 390)",
|
||||
"Scrolled the phone drawer to the Network section and Guest RAID card and captured both",
|
||||
"Read computed text colour, titles and data-raid-reading of every Filesystems, Network, System and RAID row via Playwright"
|
||||
]
|
||||
}
|
||||
@@ -1602,6 +1602,157 @@ describe('GuestDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── In-guest Pulse Agent readings ──
|
||||
|
||||
describe('In-guest Pulse Agent readings', () => {
|
||||
// Proxmox read no guest filesystems, so these rows came from the agent.
|
||||
const makeAgentGuest = (overrides: Partial<WorkloadGuest> = {}) =>
|
||||
makeGuest({
|
||||
agentVersion: '6.4.5',
|
||||
agentKind: 'pulse',
|
||||
disksFromAgent: true,
|
||||
diskStatusReason: 'agent-not-running',
|
||||
disks: [{ total: 100, used: 75, free: 25, usage: 75, mountpoint: '/' }],
|
||||
networkInterfaces: [{ name: 'eth0', addresses: ['192.168.1.5'], rxBytes: 1024 }],
|
||||
agentRaid: [
|
||||
{
|
||||
device: '/dev/md1',
|
||||
name: 'data',
|
||||
level: 'raid5',
|
||||
state: 'clean, degraded, recovering',
|
||||
totalDevices: 3,
|
||||
activeDevices: 2,
|
||||
workingDevices: 3,
|
||||
failedDevices: 1,
|
||||
spareDevices: 0,
|
||||
devices: [{ device: 'sdd1', state: 'faulty', slot: 1 }],
|
||||
rebuildPercent: 42,
|
||||
rebuildSpeed: '118M/sec',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("reads a silent in-guest agent's RAID, network and filesystems as last known", () => {
|
||||
render(() => <GuestDrawer guest={makeAgentGuest({ agentStale: true })} onClose={vi.fn()} />);
|
||||
|
||||
expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toHaveAttribute(
|
||||
'title',
|
||||
'Pulse Agent 6.4.5 has stopped reporting. Its readings here are its last report.',
|
||||
);
|
||||
expect(
|
||||
technicalDetails().getByText(
|
||||
'Using last known disk stats. The Pulse Agent in this guest stopped reporting.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument();
|
||||
expect(
|
||||
technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
technicalDetails().getByText(
|
||||
'Using last known interface details. The Pulse Agent in this guest stopped reporting.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
technicalDetails()
|
||||
.getByText(/^192\.168\.1\.5/)
|
||||
.closest('td'),
|
||||
).toHaveClass('text-muted');
|
||||
// Proxmox's guest-read reason does not describe rows the agent supplied.
|
||||
expect(technicalDetails().queryByText(/qemu-guest-agent/)).toBeNull();
|
||||
|
||||
const array = screen.getByTestId('raid-card-array');
|
||||
expect(array).toHaveAttribute('data-raid-reading', 'last-known');
|
||||
expect(within(array).getByTestId('raid-card-state')).toHaveAttribute(
|
||||
'title',
|
||||
'Last known reading, not current: host agent stopped reporting',
|
||||
);
|
||||
expect(within(array).getByTestId('raid-card-rebuild')).toHaveTextContent(
|
||||
'Rebuild was at 42%',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a live in-guest agent current when Proxmox has no guest reads', () => {
|
||||
render(() => <GuestDrawer guest={makeAgentGuest()} onClose={vi.fn()} />);
|
||||
|
||||
expect(technicalDetails().getByText('Pulse 6.4.5')).toBeInTheDocument();
|
||||
expect(
|
||||
technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }),
|
||||
).toHaveAttribute('aria-valuenow', '75');
|
||||
expect(technicalDetails().queryByText(/Last known|qemu-guest-agent/)).toBeNull();
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
|
||||
expect(screen.getByTestId('raid-card-rebuild')).toHaveTextContent('Rebuild: 42% · 118M/sec');
|
||||
});
|
||||
|
||||
it("keeps Proxmox's own guest filesystems current under a silent agent", () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeAgentGuest({
|
||||
agentStale: true,
|
||||
disksFromAgent: undefined,
|
||||
diskStatusReason: undefined,
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }),
|
||||
).toHaveAttribute('aria-valuenow', '75');
|
||||
expect(technicalDetails().queryByText(/Using last known disk stats/)).toBeNull();
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute(
|
||||
'data-raid-reading',
|
||||
'last-known',
|
||||
);
|
||||
});
|
||||
|
||||
// Proxmox gives vm-stopped only for a guest with allocated disk, so the
|
||||
// guest's own stopped status must hold the rows too.
|
||||
it.each([
|
||||
['with the Proxmox reason', 'vm-stopped'],
|
||||
['without a Proxmox reason', undefined],
|
||||
])(
|
||||
"reads a stopped guest's agent filesystems as unavailable before the lease lapses, %s",
|
||||
(_label, diskStatusReason) => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeAgentGuest({ status: 'stopped', diskStatusReason })}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
technicalDetails().getByText(
|
||||
'Guest filesystem stats unavailable while the VM is stopped.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText(/^Usage unavailable/)).toBeInTheDocument();
|
||||
expect(
|
||||
technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('follows the agent between silent and reporting while the drawer stays open', () => {
|
||||
const [guest, setGuest] = createSignal(makeAgentGuest());
|
||||
render(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
|
||||
|
||||
setGuest({ ...guest(), agentStale: true });
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute(
|
||||
'data-raid-reading',
|
||||
'last-known',
|
||||
);
|
||||
expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument();
|
||||
|
||||
setGuest({ ...guest(), agentStale: undefined });
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
|
||||
expect(technicalDetails().queryByText(/stopped reporting|Last known/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WebInterfaceUrlField ──
|
||||
|
||||
describe('WebInterfaceUrlField', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDis
|
||||
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
|
||||
import type { Alert } from '@/types/api';
|
||||
import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard';
|
||||
import { HOST_AGENT_STOPPED_REPORTING_REASON } from '@/components/Infrastructure/resourceDetailMappers';
|
||||
import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { getShippedDocUrl } from '@/utils/docsLinks';
|
||||
@@ -80,6 +81,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
ageClass: props.backupPresentation?.ageClass,
|
||||
});
|
||||
const guestReadPresentation = () => getGuestDrawerGuestReadPresentation(props.guest);
|
||||
// A linked Pulse agent past its reporting lease leaves its last report on
|
||||
// the guest, so its RAID, network and filesystem rows read as last known.
|
||||
const agentLastKnownReason = () =>
|
||||
props.guest.agentStale ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined;
|
||||
const coverageLabel = () => {
|
||||
if (props.hasWorkloadActionAgent) return WORKLOAD_ACTION_AGENT_LABEL;
|
||||
if (props.hasAgentInfo) return `${props.agentHeading} observed`;
|
||||
@@ -148,9 +153,22 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
props.guest.vmware?.datacenterName ||
|
||||
props.guest.vmware?.clusterName,
|
||||
);
|
||||
const diskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined;
|
||||
const diskReadState =
|
||||
props.guest.telemetryAvailability?.disk === false
|
||||
// Rows the linked Pulse agent supplied follow that agent's freshness, not
|
||||
// the Proxmox reason for its own guest reads. A stopped guest has no
|
||||
// current filesystems from either source, with or without the Proxmox
|
||||
// vm-stopped reason, which it gives only for a guest with allocated disk.
|
||||
const vmDiskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined;
|
||||
const agentDisks = props.guest.disksFromAgent === true;
|
||||
const agentDisksStopped =
|
||||
agentDisks && (props.guest.status === 'stopped' || vmDiskReason === 'vm-stopped');
|
||||
const diskReason = agentDisks ? undefined : vmDiskReason;
|
||||
const diskReadState = agentDisks
|
||||
? agentDisksStopped
|
||||
? 'unavailable'
|
||||
: agentLastKnownReason()
|
||||
? 'last-known'
|
||||
: 'current'
|
||||
: props.guest.telemetryAvailability?.disk === false
|
||||
? 'unavailable'
|
||||
: diskReason
|
||||
? diskReason.startsWith('prev-')
|
||||
@@ -160,11 +178,19 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
const diskRows = (props.guest.disks ?? []).map((disk, index) =>
|
||||
buildWorkloadsDiskPresentation(disk, index, props.diskThresholds, diskReadState),
|
||||
);
|
||||
const diskStatusMessage = diskReason
|
||||
? getWorkloadGuestDiskStatusMessage(diskReason)
|
||||
: diskReadState === 'unavailable'
|
||||
? 'Filesystem usage is unavailable.'
|
||||
: null;
|
||||
const diskStatusMessage = agentDisks
|
||||
? agentDisksStopped
|
||||
? isGuestDrawerVM(props.guest)
|
||||
? getWorkloadGuestDiskStatusMessage('vm-stopped')
|
||||
: 'Filesystem usage is unavailable.'
|
||||
: agentLastKnownReason()
|
||||
? 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.'
|
||||
: null
|
||||
: diskReason
|
||||
? getWorkloadGuestDiskStatusMessage(diskReason)
|
||||
: diskReadState === 'unavailable'
|
||||
? 'Filesystem usage is unavailable.'
|
||||
: null;
|
||||
|
||||
return compactDetailSections([
|
||||
discovery
|
||||
@@ -187,7 +213,13 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
rows: compactDetailRows([
|
||||
props.guest.cpus ? makeDetailRow('CPUs', `${props.guest.cpus}`) : null,
|
||||
props.hasAgentInfo
|
||||
? makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle })
|
||||
? agentLastKnownReason()
|
||||
? makeDetailRow(props.agentHeading, `${props.agentLabel} · stopped reporting`, {
|
||||
title: `${props.agentTitle} has stopped reporting. Its readings here are its last report.`,
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
})
|
||||
: makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle })
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
@@ -282,8 +314,16 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
props.hasNetworkInterfaces
|
||||
? {
|
||||
label: 'Network',
|
||||
rows: compactDetailRows(
|
||||
props.networkInterfaces.slice(0, 4).map((iface, index) => {
|
||||
rows: compactDetailRows([
|
||||
// Only the linked Pulse agent reports guest interfaces.
|
||||
makeDetailRow(
|
||||
'Status',
|
||||
agentLastKnownReason()
|
||||
? 'Using last known interface details. The Pulse Agent in this guest stopped reporting.'
|
||||
: null,
|
||||
{ layout: 'stacked', wrap: true },
|
||||
),
|
||||
...props.networkInterfaces.slice(0, 4).map((iface, index) => {
|
||||
const addresses = iface.addresses ?? [];
|
||||
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
|
||||
const detail = [
|
||||
@@ -301,11 +341,12 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
iface.name || `Interface ${index + 1}`,
|
||||
detail || 'No details',
|
||||
{
|
||||
tone: agentLastKnownReason() ? 'muted' : undefined,
|
||||
wrap: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
]),
|
||||
}
|
||||
: null,
|
||||
]);
|
||||
@@ -348,7 +389,11 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={props.guest.agentRaid?.length}>
|
||||
<RaidCard arrays={props.guest.agentRaid} title="Guest RAID" />
|
||||
<RaidCard
|
||||
arrays={props.guest.agentRaid}
|
||||
title="Guest RAID"
|
||||
lastKnownReason={agentLastKnownReason()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -36,6 +36,7 @@ import { AlertSnoozeAction } from './AlertSnoozeAction';
|
||||
import { isAlertSnoozed } from './useAlertSnoozeState';
|
||||
import { isPulseSystemAlert } from '@/utils/alertScope';
|
||||
import { formatTemperature } from '@/utils/temperature';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import {
|
||||
normalizeSourcePlatformQueryValue,
|
||||
resolvePlatformTypeFromSources,
|
||||
@@ -110,6 +111,9 @@ const platformPagePath = (platform: string | undefined): string | undefined => {
|
||||
};
|
||||
|
||||
export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
|
||||
// The started age and the live reading's stale cut-off and ages measure
|
||||
// from the wall clock, so a card that mounts between ticks is current.
|
||||
const now = useRelativeTimeNow();
|
||||
const alertKey = () => getCanonicalAlertId(props.alert);
|
||||
const hasResource = () =>
|
||||
!isPulseSystemAlert(props.alert) && Boolean(props.alert.resourceId?.trim());
|
||||
@@ -138,9 +142,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
|
||||
// A threshold alert can stay open below its trigger, so the card leads with
|
||||
// the reading Pulse is evaluating now; the message keeps the last breach.
|
||||
const metricPresentation = () =>
|
||||
isPulseSystemAlert(props.alert)
|
||||
? null
|
||||
: getMetricAlertPresentation(props.alert, props.state.tick());
|
||||
isPulseSystemAlert(props.alert) ? null : getMetricAlertPresentation(props.alert, now());
|
||||
const alertLevels = (): { alert: string; clear?: string } | null => {
|
||||
const presentation = metricPresentation();
|
||||
if (presentation) {
|
||||
@@ -318,7 +320,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
|
||||
new Date(props.alert.startTime).toLocaleString(),
|
||||
)}
|
||||
>
|
||||
{formatAlertOverviewStartedAgo(props.alert.startTime, props.state.tick())}
|
||||
{formatAlertOverviewStartedAgo(props.alert.startTime, now())}
|
||||
</span>
|
||||
</div>
|
||||
<Show
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n';
|
||||
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
|
||||
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
|
||||
import { METRIC_ALERT_STATUS_STALE_MS } from '../metricAlertPresentation';
|
||||
|
||||
vi.mock('@solidjs/router', () => ({
|
||||
useLocation: () => ({ hash: '', pathname: '/alerts', search: '', query: {} }),
|
||||
A: (props: Record<string, unknown>) => props.children,
|
||||
}));
|
||||
|
||||
const getDeliveryDiagnoses = vi.fn<() => Promise<AlertDeliveryDiagnosis[]>>();
|
||||
|
||||
vi.mock('@/api/alerts', () => ({
|
||||
AlertsAPI: {
|
||||
get getDeliveryDiagnoses() {
|
||||
return getDeliveryDiagnoses;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({
|
||||
InvestigateAlertButton: () => null,
|
||||
}));
|
||||
|
||||
import { OverviewTab } from '../OverviewTab';
|
||||
|
||||
const OPENED_AT = Date.parse('2026-10-07T12:00:00Z');
|
||||
// Between the shared clock's first and second ticks, and before the overview's
|
||||
// own minute.
|
||||
const BETWEEN_TICKS_MS = RELATIVE_TIME_TICK_MS + 15_000;
|
||||
|
||||
function cpuAlert(id: string, startTime: number, observedAt: number): Alert {
|
||||
return {
|
||||
id,
|
||||
resourceId: `vm-${id}`,
|
||||
resourceName: `VM ${id}`,
|
||||
type: 'cpu',
|
||||
level: 'warning',
|
||||
message: `CPU on VM ${id} at 92%`,
|
||||
value: 92,
|
||||
threshold: 80,
|
||||
startTime: new Date(startTime).toISOString(),
|
||||
lastSeen: new Date(observedAt).toISOString(),
|
||||
acknowledged: false,
|
||||
node: 'node1',
|
||||
metricStatus: {
|
||||
phase: 'breaching',
|
||||
value: 91,
|
||||
unit: '%',
|
||||
observedAt: new Date(observedAt).toISOString(),
|
||||
trigger: 80,
|
||||
recovery: 75,
|
||||
},
|
||||
} as Alert;
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const [activeAlerts, setActiveAlerts] = createSignal<Record<string, Alert>>({});
|
||||
render(() => (
|
||||
<OverviewTab
|
||||
overrides={[]}
|
||||
activeAlerts={activeAlerts()}
|
||||
updateAlert={vi.fn()}
|
||||
showQuickTip={() => false}
|
||||
dismissQuickTip={vi.fn()}
|
||||
showAcknowledged={() => true}
|
||||
setShowAcknowledged={vi.fn()}
|
||||
alertsDisabled={() => false}
|
||||
/>
|
||||
));
|
||||
return setActiveAlerts;
|
||||
}
|
||||
|
||||
const triggered24h = () =>
|
||||
screen
|
||||
.getByText('Triggered (24h)')
|
||||
.closest('[data-alert-overview-stat]')
|
||||
?.querySelector('[data-testid="alert-overview-stat-value"]')?.textContent;
|
||||
|
||||
describe('Alert overview card clock', () => {
|
||||
beforeEach(() => {
|
||||
setActiveLocale(DEFAULT_LOCALE);
|
||||
getDeliveryDiagnoses.mockReset();
|
||||
getDeliveryDiagnoses.mockResolvedValue([]);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(OPENED_AT);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('measures a card that mounts between ticks from the wall clock', () => {
|
||||
const setActiveAlerts = renderOverview();
|
||||
vi.advanceTimersByTime(BETWEEN_TICKS_MS);
|
||||
|
||||
// Raised 70 seconds ago; last evaluated just past the stale cut-off. Read
|
||||
// from the time of the last tick, the card would call it "this minute"
|
||||
// and lead with the old reading as the live one.
|
||||
const now = Date.now();
|
||||
setActiveAlerts({
|
||||
late: cpuAlert('late', now - 70_000, now - METRIC_ALERT_STATUS_STALE_MS - 5_000),
|
||||
});
|
||||
|
||||
expect(screen.getByText('1 min. ago')).toBeInTheDocument();
|
||||
expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('turns stale at the next shared tick after the cut-off with no data change', () => {
|
||||
const setActiveAlerts = renderOverview();
|
||||
vi.advanceTimersByTime(BETWEEN_TICKS_MS);
|
||||
|
||||
// The reading goes stale 5 seconds after the shared clock's second tick.
|
||||
const staleAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000;
|
||||
const now = Date.now();
|
||||
setActiveAlerts({ held: cpuAlert('held', now, staleAt - METRIC_ALERT_STATUS_STALE_MS) });
|
||||
expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument();
|
||||
|
||||
vi.advanceTimersByTime(OPENED_AT + 2 * RELATIVE_TIME_TICK_MS - Date.now());
|
||||
expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument();
|
||||
|
||||
// The next tick lands within one shared tick of the cut-off, not on the
|
||||
// overview's next minute.
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(screen.queryByText('CPU 91%, above the 80% alert level')).toBeNull();
|
||||
expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops an alert from the 24h count at the next shared tick after it turns 24h old', () => {
|
||||
const setActiveAlerts = renderOverview();
|
||||
vi.advanceTimersByTime(BETWEEN_TICKS_MS);
|
||||
|
||||
const leavesWindowAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000;
|
||||
setActiveAlerts({ old: cpuAlert('old', leavesWindowAt - 86_400_000, Date.now()) });
|
||||
expect(triggered24h()).toBe('1');
|
||||
|
||||
vi.advanceTimersByTime(OPENED_AT + 3 * RELATIVE_TIME_TICK_MS - Date.now());
|
||||
expect(triggered24h()).toBe('0');
|
||||
});
|
||||
});
|
||||
-1
@@ -26,7 +26,6 @@ import { AlertOverviewAlertCard } from '../AlertOverviewAlertCard';
|
||||
const acknowledge = vi.fn();
|
||||
const toggleTimeline = vi.fn();
|
||||
const state = {
|
||||
tick: () => Date.now(),
|
||||
processingAlerts: () => new Set(),
|
||||
snoozeProcessingAlerts: () => new Set(),
|
||||
deliveryDiagnoses: () => ({}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AlertsAPI } from '@/api/alerts';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
|
||||
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
|
||||
|
||||
import { useAlertOverviewState } from '../useAlertOverviewState';
|
||||
|
||||
@@ -118,6 +119,22 @@ describe('useAlertOverviewState', () => {
|
||||
expect(result.deliveryDiagnoses()).toEqual({ a1: retained });
|
||||
});
|
||||
|
||||
it('refreshes diagnoses once a minute, not on the 30-second age clock', async () => {
|
||||
renderHook(() =>
|
||||
useAlertOverviewState({
|
||||
activeAlerts: () => ({ a1: makeAlert('a1', new Date().toISOString()) }),
|
||||
overrides: () => [],
|
||||
showAcknowledged: () => true,
|
||||
updateAlert: vi.fn(),
|
||||
}),
|
||||
);
|
||||
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(RELATIVE_TIME_TICK_MS);
|
||||
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(60_000 - RELATIVE_TIME_TICK_MS);
|
||||
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ignores pending responses and stops periodic reads after disposal', async () => {
|
||||
let finish!: (value: AlertDeliveryDiagnosis[]) => void;
|
||||
vi.mocked(AlertsAPI.getDeliveryDiagnoses).mockReturnValueOnce(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Accessor } from 'solid-js';
|
||||
|
||||
import { AlertsAPI } from '@/api/alerts';
|
||||
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import type { Override } from './types';
|
||||
import { useAlertAcknowledgementState } from './useAlertAcknowledgementState';
|
||||
import { useAlertSnoozeState } from './useAlertSnoozeState';
|
||||
@@ -34,8 +35,7 @@ export interface UseAlertOverviewStateProps {
|
||||
}
|
||||
|
||||
export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
|
||||
const [tick, setTick] = createSignal(Date.now());
|
||||
const tickInterval = setInterval(() => setTick(Date.now()), 60_000);
|
||||
const now = useRelativeTimeNow();
|
||||
const activeAlerts = createMemo(() => Object.values(props.activeAlerts()));
|
||||
const {
|
||||
unacknowledgedAlerts,
|
||||
@@ -56,10 +56,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
|
||||
handleUnsnooze,
|
||||
} = useAlertSnoozeState({ alerts: activeAlerts, updateAlert: props.updateAlert });
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(tickInterval);
|
||||
});
|
||||
|
||||
// Delivery diagnoses answer "did/will this alert notify?" per card. One
|
||||
// bulk request covers every active alert; a failed refresh keeps the last
|
||||
// snapshot and the card simply shows no delivery line for unknown alerts.
|
||||
@@ -98,19 +94,24 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
|
||||
.join('\n'),
|
||||
);
|
||||
createEffect(() => {
|
||||
// Refresh when the active alert set changes and on the shared minute
|
||||
// tick, so time-based holds (cooldown, quiet hours) stay current.
|
||||
// Refresh when the active alert set changes.
|
||||
activeAlertIdsKey();
|
||||
tick();
|
||||
void refreshDeliveryDiagnoses();
|
||||
});
|
||||
// And once a minute, so time-based holds (cooldown, quiet hours) stay
|
||||
// current. This is a server read, so it keeps its own minute rather than
|
||||
// following the 30-second clock the ages read.
|
||||
const diagnosisInterval = setInterval(() => void refreshDeliveryDiagnoses(), 60_000);
|
||||
onCleanup(() => {
|
||||
clearInterval(diagnosisInterval);
|
||||
});
|
||||
|
||||
const alertStats = createMemo(() => {
|
||||
const alerts = effectiveAlerts();
|
||||
const recent = alerts.filter((alert) => {
|
||||
const ts = new Date(alert.startTime).getTime();
|
||||
if (Number.isNaN(ts)) return true;
|
||||
const age = tick() - ts;
|
||||
const age = now() - ts;
|
||||
return age >= 0 && age < 86_400_000;
|
||||
});
|
||||
const unacknowledged = alerts.filter((alert) => !alert.acknowledged);
|
||||
@@ -169,7 +170,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
|
||||
});
|
||||
|
||||
return {
|
||||
tick,
|
||||
alertStats,
|
||||
filteredAlerts,
|
||||
groupedAlerts,
|
||||
|
||||
@@ -142,6 +142,54 @@ describe('useWorkloads', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('marks a silent linked agent and the guest filesystems it supplied', async () => {
|
||||
const disk = (mountpoint: string) => ({
|
||||
mountpoint,
|
||||
type: 'ext4',
|
||||
total: 100,
|
||||
used: 40,
|
||||
free: 60,
|
||||
usage: 40,
|
||||
});
|
||||
const [snapshot] = createSignal([
|
||||
// Proxmox read no guest filesystems, so the agent's own report fills in.
|
||||
{
|
||||
...sampleResource,
|
||||
id: 'vm-agent-disks',
|
||||
vmid: 102,
|
||||
sources: ['proxmox', 'agent'],
|
||||
agent: { agentVersion: '6.4.5', stale: true, disks: [disk('/agent')] },
|
||||
},
|
||||
// Proxmox guest filesystems win over a live agent's.
|
||||
{
|
||||
...sampleResource,
|
||||
id: 'vm-platform-disks',
|
||||
vmid: 103,
|
||||
sources: ['proxmox', 'agent'],
|
||||
proxmox: { disks: [disk('/platform')] },
|
||||
agent: { agentVersion: '6.4.5', stale: false, disks: [disk('/agent')] },
|
||||
},
|
||||
] as any);
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<UseWorkloadsModule['useWorkloads']> | undefined;
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
result = useWorkloads(() => true, { resourceSnapshot: snapshot });
|
||||
});
|
||||
try {
|
||||
await flushAsync();
|
||||
const byId = (id: string) =>
|
||||
result!.workloads().find((workload) => workload.canonicalResourceId === id);
|
||||
expect(byId('vm-agent-disks')).toMatchObject({ agentStale: true, disksFromAgent: true });
|
||||
expect(byId('vm-agent-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/agent']);
|
||||
expect(byId('vm-platform-disks')?.agentStale).toBeUndefined();
|
||||
expect(byId('vm-platform-disks')?.disksFromAgent).toBeUndefined();
|
||||
expect(byId('vm-platform-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/platform']);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -146,6 +146,7 @@ type APIResource = {
|
||||
networkInterfaces?: APINetworkInterface[];
|
||||
disks?: APIDiskInfo[];
|
||||
raid?: HostRAIDArray[];
|
||||
stale?: boolean;
|
||||
};
|
||||
docker?: {
|
||||
containerId?: string;
|
||||
@@ -592,6 +593,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
|
||||
})(),
|
||||
disk: buildMetric(resource.metrics?.disk),
|
||||
disks: normalizeDiskArray(resource.proxmox?.disks ?? resource.agent?.disks),
|
||||
disksFromAgent: (resource.proxmox?.disks == null && resource.agent?.disks != null) || undefined,
|
||||
// Both the API and owning platform snapshot pass through this mapper.
|
||||
// A retained numeric metric must not lose the provider's read deferral.
|
||||
diskStatusReason: resource.proxmox?.diskStatusReason,
|
||||
@@ -612,6 +614,9 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
|
||||
agentId: resource.agent?.agentId,
|
||||
agentRaid: resource.agent?.raid,
|
||||
networkInterfaces: mapNetworkInterfaces(resource.agent?.networkInterfaces),
|
||||
// A linked agent past its reporting lease keeps its last report on the
|
||||
// resource, so its RAID, network and filesystem rows are not current.
|
||||
agentStale: resource.agent?.stale === true || undefined,
|
||||
networkIn: finiteMetricNumber(resource.metrics?.netIn?.value) ?? 0,
|
||||
networkOut: finiteMetricNumber(resource.metrics?.netOut?.value) ?? 0,
|
||||
diskRead: finiteMetricNumber(resource.metrics?.diskRead?.value) ?? 0,
|
||||
|
||||
@@ -60,6 +60,16 @@ export type WorkloadGuest = (VM | Container) & {
|
||||
agentId?: string;
|
||||
/** RAID arrays reported by the linked Pulse agent, not Proxmox virtual disks. */
|
||||
agentRaid?: HostRAIDArray[];
|
||||
/**
|
||||
* Set when the linked Pulse agent is past its reporting lease. Its RAID,
|
||||
* network and filesystem readings are then its last report, not current.
|
||||
*/
|
||||
agentStale?: boolean;
|
||||
/**
|
||||
* Set when `disks` came from the linked Pulse agent because the platform
|
||||
* reported no guest filesystems, so the rows follow that agent's freshness.
|
||||
*/
|
||||
disksFromAgent?: boolean;
|
||||
updateStatus?: DockerContainerUpdateStatus;
|
||||
// Server-evaluated capability refusals from the unified resource. The
|
||||
// update button reads this to disable itself with the refusal reason
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
alertconfig "github.com/rcourtman/pulse-go-rewrite/internal/alerts/config"
|
||||
alertspecs "github.com/rcourtman/pulse-go-rewrite/internal/alerts/specs"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -264,6 +265,15 @@ func shouldPreserveAlertOutsideNodeCleanup(alertID string, alert *Alert) bool {
|
||||
if alert.CanonicalKind == string(alertspecs.AlertSpecKindProviderIncident) {
|
||||
return true
|
||||
}
|
||||
// Kubernetes, TrueNAS and vSphere metric alerts carry a display label such
|
||||
// as "TrueNAS Disk" or "Kubernetes Pod" in resourceType, which the platform
|
||||
// words below never matched, and a cluster or host in Node rather than a
|
||||
// Proxmox node. Every Proxmox poll used to delete them silently, and the
|
||||
// next evaluation raised them again as new alerts.
|
||||
if metadataStringValue(alert.Metadata, alertPlatformTypeKey) != "" ||
|
||||
isUnifiedModernPlatformAlertType(alertconfig.CanonicalAlertResourceType(alertMetadataResourceType(alert))) {
|
||||
return true
|
||||
}
|
||||
|
||||
switch alertMetadataResourceType(alert) {
|
||||
case string(unifiedresources.ResourceTypeAgent),
|
||||
|
||||
@@ -12628,6 +12628,82 @@ func TestCleanupAlertsForNodes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestCleanupAlertsForNodesKeepsPlatformMetricAlerts pins that the Proxmox
|
||||
// node cleanup, which runs on every Proxmox poll, leaves Kubernetes, TrueNAS
|
||||
// and vSphere metric alerts alone. Their Node is a cluster or platform host,
|
||||
// never a Proxmox node, and their resourceType is a display label.
|
||||
func TestCleanupAlertsForNodesKeepsPlatformMetricAlerts(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
|
||||
|
||||
inputs := []*UnifiedResourceInput{
|
||||
{ID: "physical-disk:truenas-main/sda", Type: "truenas-disk", Name: "sda", Node: "truenas-main", Instance: "TrueNAS", Temperature: &UnifiedResourceMetric{Value: 80, Percent: 80}},
|
||||
{ID: "storage:truenas-main/pool:tank", Type: "truenas-pool", Name: "tank", Node: "truenas-main", Instance: "TrueNAS", Disk: &UnifiedResourceMetric{Percent: 95}},
|
||||
{ID: "k8s:prod:pod:api", Type: "pod", Name: "api", Node: "k8s-node-1", Instance: "prod", CPU: &UnifiedResourceMetric{Percent: 99}},
|
||||
{ID: "vmware:vc:vm:app-01", Type: "vmware-vm", Name: "app-01", Node: "esxi-01", Instance: "vc", CPU: &UnifiedResourceMetric{Percent: 99}},
|
||||
}
|
||||
for _, input := range inputs {
|
||||
m.CheckUnifiedResource(input)
|
||||
}
|
||||
platformAlerts := alertKeys(m)
|
||||
if len(platformAlerts) != len(inputs) {
|
||||
t.Fatalf("expected one alert per platform resource, got %v", platformAlerts)
|
||||
}
|
||||
|
||||
m.CleanupAlertsForNodes(map[string]bool{"pve1": true})
|
||||
|
||||
for _, alertID := range platformAlerts {
|
||||
if _, exists := testLookupActiveAlert(t, m, alertID); !exists {
|
||||
t.Fatalf("Proxmox node cleanup removed platform alert %q; remaining %v", alertID, alertKeys(m))
|
||||
}
|
||||
}
|
||||
|
||||
// The platform id alone keeps an alert whose label is not recognised.
|
||||
m.mu.Lock()
|
||||
labels := make(map[*Alert]interface{}, len(m.activeAlerts))
|
||||
for _, alert := range m.activeAlerts {
|
||||
labels[alert] = alert.Metadata["resourceType"]
|
||||
alert.Metadata["resourceType"] = "Unrecognised"
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.CleanupAlertsForNodes(map[string]bool{"pve1": true})
|
||||
if got := alertKeys(m); len(got) != len(platformAlerts) {
|
||||
t.Fatalf("platform alerts removed despite their platformType: %v", got)
|
||||
}
|
||||
m.mu.Lock()
|
||||
for alert, label := range labels {
|
||||
alert.Metadata["resourceType"] = label
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// A legacy-shaped alert whose metadata predates platformType is still kept
|
||||
// by its display label.
|
||||
m.mu.Lock()
|
||||
for _, alert := range m.activeAlerts {
|
||||
delete(alert.Metadata, alertPlatformTypeKey)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.CleanupAlertsForNodes(map[string]bool{"pve1": true})
|
||||
if got := alertKeys(m); len(got) != len(platformAlerts) {
|
||||
t.Fatalf("display-label platform alerts removed without platformType: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The cleanup still removes a Proxmox guest alert whose node left the estate.
|
||||
func TestCleanupAlertsForNodesStillRemovesMissingProxmoxNodeAlerts(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
|
||||
|
||||
m.CheckUnifiedResource(&UnifiedResourceInput{ID: "pve-gone:node-gone:101", Type: "vm", Name: "vm-101", Node: "node-gone", Instance: "pve-gone", CPU: &UnifiedResourceMetric{Percent: 99}})
|
||||
if len(alertKeys(m)) != 1 {
|
||||
t.Fatalf("expected the guest CPU alert, got %v", alertKeys(m))
|
||||
}
|
||||
m.CleanupAlertsForNodes(map[string]bool{"pve1": true})
|
||||
if got := alertKeys(m); len(got) != 0 {
|
||||
t.Fatalf("guest alert on a removed node survived cleanup: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckZFSPoolHealth(t *testing.T) {
|
||||
// t.Parallel()
|
||||
poolStateAlertID := buildCanonicalStateID("local-zfs/zfs-pool:rpool", "local-zfs/zfs-pool:rpool-state")
|
||||
|
||||
Reference in New Issue
Block a user