mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Keep History readings correctly attributed and continuing alerts visible (#2357)
## What changed and why Make existing drawer History readable with keyboard and touch, and keep every inspected metric attributed to one actual stored time. Missing sibling observations remain unavailable rather than borrowing neighbouring or live values. Selection survives same-source refreshes and clears at resource/range boundaries; refresh-failure warnings remain intact. Keep continuing unacknowledged alerts through retention cleanup. Age-only cleanup could remove an observed condition and cause another notification on the next failing poll. Cleanup now also requires observation inactivity, preserving configured acknowledgement and affirmative recovery behavior. These repairs follow reproduced History attribution/accessibility and alert-retention defects. They improve existing behavior, not new product scope. They do not establish complete #1723 PBS/VirtualBox acceptance or diagnose all of #2353's reported daily cycle. ## Included source Complete range `6f4e2f25c558d7c69717984c47d06a94c179c4ca..00f82e2afc3cd75035c997e9e245c6e0eeffffa9`: - History inspection candidate `d38e2ec61a933b1621895cc71c17ec366a8e99ce`, followed by common-time correction `02090fe3515e324159225acab0130f4b82e444fb`. - Alert retention candidate `7cbde2e3bf368e9ee36a34b4fc9dcf87d3089497`. - Reviewed additive integrations `0dc858cb366a394b159d2c911c5d85b3020e7881`, `3e01f02cfca620bd0777db97257d52d63fc32ef7`, `00f82e2afc3cd75035c997e9e245c6e0eeffffa9`, and upstream incorporation `cf1e9b4ca3ea0c4277731a366d6fdd0daf8e24a8`. All reviewed commit identities and ancestry are retained. ## Validation and limits Recorded source proof `source-fcbetfv8` passes offline installation, type-check, lint, 21,592 tests (three existing skips), build, bundle budgets and formatting/header/duplication checks. Its final frontend content matches the integrated tree. Chromium/Firefox desktop and Chromium/WebKit phone proofs cover eight width/engine/theme cases with 16 screenshots recorded as inspected. Earlier keyboard/touch proof `source-0cyn9bst` passed 21,567 tests; its behavior is included in the later proof. Baseline `source-5g7tw1c0` records eight failures and two passing controls before the pointer correction. Alert proof `source-lh_dko1x` passes ten focused race repetitions, full alerts/notifications/monitoring race suites, vet and affected builds; baseline `source-rz64y65n` reproduces five callbacks where the corrected behavior emits one. Final alert source matches the reviewed candidate. Complete-range provenance, whitespace, canonical contract and parent-bound browser receipt checks pass. Existing sufficient evidence was reused, not repeated solely for batching. Proof Go was 1.26.7, not the declared 1.26.8; matching toolchain preparation remains separate. Required exact-head CI and independent review still govern landing. Synthetic source/browser checks are not installed recovery, complete reporter acceptance or release qualification. No release, tag or deployment is changed here. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
@@ -795,6 +795,26 @@ inspectability, or convert missing/stale evidence into health.
|
||||
|
||||
## Current State
|
||||
|
||||
### Continuing unacknowledged alerts survive age-based cleanup
|
||||
|
||||
`MaxAlertAgeDays` removes an unacknowledged alert only when both its occurrence
|
||||
start and last observation predate the configured limit. A still-observed
|
||||
condition keeps its occurrence identity, age and dispatch timestamp; cleanup
|
||||
must not hide it or manufacture another firing on the next poll. Legacy alerts
|
||||
without `LastSeen` use `StartTime` for inactivity. Zero disables this retention
|
||||
rule. Acknowledged-alert cleanup, automatic acknowledgement, explicit clears,
|
||||
confirmed recovery and notification eligibility remain unchanged.
|
||||
|
||||
`TestCleanupRetentionRequiresObservationInactivity` in `alerts_test.go` covers
|
||||
continuing/inactive, legacy, disabled, acknowledged and inconsistent-timestamp
|
||||
controls. `TestCleanupContinuingDockerUpdateKeepsOccurrence` in
|
||||
`cleanup_observation_retention_test.go` exercises the real Docker update
|
||||
detector with cached, absent and failed registry observations through four
|
||||
cleanup/next-poll cycles, JSON and durable active-state restart, callback counts,
|
||||
history and affirmative recovery. This is source-fixture lifecycle proof, not
|
||||
an installed registry/destination result or a diagnosis of a reporter's daily
|
||||
resolve/reopen cycle.
|
||||
|
||||
### Confirmed empty storage is recovery evidence
|
||||
|
||||
Static storage capacity evaluation must admit a zero usage observation when
|
||||
|
||||
@@ -7556,3 +7556,44 @@ catalog, the header metadata and every locale catalog; the product name is not
|
||||
translated. Its route id, feature gate and read capability are unchanged.
|
||||
Relay never provided remote access to the web UI, so no settings chrome may
|
||||
label it Remote Access.
|
||||
|
||||
### Drawer History is inspectable without pointer hover
|
||||
|
||||
`GuestDrawerHistory` exposes a labelled native range input for groups with
|
||||
multiple stored observation times. Native arrow keys, Home/End and touch input
|
||||
select real stored timestamps; the control's value text includes the full local
|
||||
date/time and separately formatted series values. A series without a sample at
|
||||
the selected time remains missing rather than borrowing a neighbouring or live
|
||||
reading. The SVG has a linked textual description, including lone observations
|
||||
and the absence of stored data; a lone observation does not fabricate a trend.
|
||||
|
||||
Selection follows its timestamp across same-source refreshes, not an ordinal
|
||||
index that shifts when samples arrive. An expired selection snaps to an actual
|
||||
remaining observation. Changing resource type, id or range clears pointer and
|
||||
keyboard selection even when matching cached data arrives immediately. Empty,
|
||||
failed initial, absent-target and licence-locked views expose no inspection
|
||||
control. Existing matching-point retention and scoped retry remain unchanged.
|
||||
Mounted inspection regressions cover these boundaries. The direct-renderer
|
||||
browser fixture verifies native keys, focus, pointer coexistence and touch at
|
||||
desktop/phone widths across Chromium, Firefox and WebKit; it is not installed
|
||||
PBS collection or a screen-reader announcement-quality claim.
|
||||
|
||||
### Drawer History pointer values share one observed time
|
||||
|
||||
Pointer inspection snaps to the nearest actual stored timestamp across the
|
||||
group's series, with equidistant observations resolved to the earlier time.
|
||||
Every displayed value and marker must belong to that timestamp. Missing series
|
||||
remain unavailable at the inspected time, not a nearest neighbour, latest
|
||||
observation or current reading. A lone stored sample, including zero, can be
|
||||
inspected without fabricating a trend. The SVG's dated description follows the
|
||||
same active time as the visible legend. Leaving the plot restores the normal
|
||||
latest/current legend; focused native inspection still takes precedence.
|
||||
|
||||
Matching refreshes reconcile the pointer with the current set of actual
|
||||
observations; resource/range replacement still clears pointer state. Mounted
|
||||
`GuestDrawerHistory.pointer.test.tsx` regressions cover sparse and disjoint
|
||||
series, zero rates, single observations, ties, focus precedence and replacement.
|
||||
`browser-tests/history-pointer.cjs` verifies the production renderer and query
|
||||
in Chromium/Firefox desktop and Chromium/WebKit phone emulation in both themes,
|
||||
including failed refreshes and source replacement. Scripted APIs establish
|
||||
presentation accuracy, not real PBS collection or installed #1723 acceptance.
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// Direct production History/query + CSS with synthetic API data. Not installed PBS acceptance.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { chromium, firefox, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
const engine =
|
||||
process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'chromium';
|
||||
assert.ok(['chromium', 'firefox', 'webkit'].includes(engine));
|
||||
const width = process.argv.includes('--phone') ? 390 : 1365;
|
||||
const artifacts = path.join(root, 'node_modules', `history-inspection-${engine}-${width}`);
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-cache-')),
|
||||
server: { host: '127.0.0.1', port: 5223, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
const observations = [];
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await { chromium, firefox, webkit }[engine].launch(
|
||||
engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
for (const theme of ['light', 'dark']) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
...(width === 390 ? { isMobile: true, hasTouch: true } : {}),
|
||||
});
|
||||
page.setDefaultTimeout(20_000);
|
||||
const requests = [],
|
||||
errors = [];
|
||||
let action = 'original',
|
||||
held;
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const time = 1_700_000_000_000;
|
||||
const point = (offset, value) => ({
|
||||
timestamp: time + offset,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
const metrics = (kind) => {
|
||||
if (kind === 'single') return { cpu: [point(0, 11)] };
|
||||
if (kind === 'empty') return {};
|
||||
if (kind === 'service')
|
||||
return {
|
||||
cpu: [point(0, 77), point(120_000, 88)],
|
||||
memory: [point(0, 33), point(120_000, 44)],
|
||||
};
|
||||
return {
|
||||
cpu:
|
||||
kind === 'updated'
|
||||
? [
|
||||
point(-60_000, 8),
|
||||
point(0, 11),
|
||||
point(60_000, 22),
|
||||
point(120_000, 43),
|
||||
point(180_000, 66),
|
||||
]
|
||||
: [point(120_000, 43), point(0, 11), point(60_000, 22)],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
// Zero throughput is an observation, not missing history.
|
||||
netin: [point(0, 0), point(120_000, 2048)],
|
||||
netout: [point(0, 1024), point(120_000, 4096)],
|
||||
diskread: [point(0, 2048), point(120_000, 8192)],
|
||||
diskwrite: [point(0, 0), point(120_000, 0)],
|
||||
};
|
||||
};
|
||||
const response = (id, range, kind) => ({
|
||||
resourceType: 'agent',
|
||||
resourceId: id,
|
||||
range,
|
||||
start: time,
|
||||
end: time + 180_000,
|
||||
source: 'store',
|
||||
metrics: metrics(kind),
|
||||
});
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5223') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
const id = url.searchParams.get('resourceId'),
|
||||
range = url.searchParams.get('range');
|
||||
assert.ok(['agent-three', 'pbs-three'].includes(id));
|
||||
const kind = action;
|
||||
action = id === 'pbs-three' ? 'service' : 'original';
|
||||
requests.push({ id, range, kind, method: route.request().method() });
|
||||
if (kind === 'hold') {
|
||||
held = route;
|
||||
return;
|
||||
}
|
||||
if (kind === 'failure')
|
||||
return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } });
|
||||
return route.fulfill({ json: response(id, range, kind) });
|
||||
}
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community', label: 'Pulse Community runtime' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
});
|
||||
return route.fulfill({ json: { data: [], enabled: false } });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5223/browser-tests/pbs-history-refresh.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.evaluate(
|
||||
(dark) => document.documentElement.classList.toggle('dark', dark),
|
||||
theme === 'dark',
|
||||
);
|
||||
const fixture = page.getByTestId('history-refresh-fixture');
|
||||
const utilization = fixture.locator('[data-history-group="utilization"]');
|
||||
const slider = fixture.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
await slider.waitFor();
|
||||
const countBeforeInspection = requests.length;
|
||||
const value = () => slider.getAttribute('aria-valuetext');
|
||||
const screenshot = (state) =>
|
||||
fixture.screenshot({ path: path.join(artifacts, `${state}-${theme}.png`) });
|
||||
const assertValue = async (pattern) => assert.match(await value(), pattern);
|
||||
const layout = async () => {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
inner: innerWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
|
||||
const box = await slider.boundingBox();
|
||||
assert.ok(box && box.x >= 0 && box.x + box.width <= width + 1);
|
||||
if (width === 390) assert.ok(box.height >= 44, JSON.stringify(box));
|
||||
return dimensions;
|
||||
};
|
||||
// Tab reaches a native slider after the existing range/refresh controls.
|
||||
await fixture.getByTestId('guest-history-range-control').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await fixture
|
||||
.getByRole('button', { name: 'Refresh history' })
|
||||
.evaluate((el) => document.activeElement === el),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await slider.evaluate((el) => document.activeElement === el), true);
|
||||
const focusStyle = await slider.evaluate((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return {
|
||||
visible: el.matches(':focus-visible'),
|
||||
colour: style.outlineColor,
|
||||
width: style.outlineWidth,
|
||||
style: style.outlineStyle,
|
||||
background: getComputedStyle(el.closest('section')).backgroundColor,
|
||||
};
|
||||
});
|
||||
assert.equal(focusStyle.visible, true);
|
||||
assert.equal(focusStyle.colour, 'rgb(59, 130, 246)');
|
||||
assert.equal(focusStyle.style, 'solid');
|
||||
assert.ok(parseFloat(focusStyle.width) >= 2);
|
||||
const luminance = (colour) => {
|
||||
const channels = colour
|
||||
.match(/\d+/g)
|
||||
.slice(0, 3)
|
||||
.map((n) => {
|
||||
const s = Number(n) / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
};
|
||||
const a = luminance(focusStyle.colour),
|
||||
b = luminance(focusStyle.background);
|
||||
focusStyle.contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
assert.ok(focusStyle.contrast >= 3, JSON.stringify(focusStyle));
|
||||
await assertValue(/CPU 43\.0%\. Memory 55\.0%\. Disk no observation\./);
|
||||
await slider.press('Home');
|
||||
assert.equal(await slider.inputValue(), '0');
|
||||
await assertValue(/CPU 11\.0%\. Memory no observation\. Disk no observation\./);
|
||||
await slider.press('ArrowRight');
|
||||
assert.equal(await slider.inputValue(), '1');
|
||||
await assertValue(/CPU no observation\. Memory 30\.0%\. Disk no observation\./);
|
||||
assert.match(await utilization.innerText(), /CPU\s*-/);
|
||||
assert.doesNotMatch(await utilization.innerText(), /53\.0%/);
|
||||
await layout();
|
||||
await screenshot('selected-sparse-time');
|
||||
await slider.press('End');
|
||||
assert.equal(await slider.inputValue(), '3');
|
||||
await slider.press('ArrowLeft');
|
||||
await assertValue(/CPU 22\.0%\. Memory no observation/);
|
||||
assert.equal(
|
||||
requests.length,
|
||||
countBeforeInspection,
|
||||
'inspection must not start another API read',
|
||||
);
|
||||
const selectedBeforePoll = await value();
|
||||
// Exercise an actual same-source refresh without moving focus, as a background poll would.
|
||||
action = 'updated';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).evaluate((el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('input[aria-label="Inspect Utilization history"]')?.max === '5',
|
||||
);
|
||||
assert.equal(await slider.inputValue(), '3');
|
||||
assert.equal(await value(), selectedBeforePoll);
|
||||
assert.equal(await slider.evaluate((el) => document.activeElement === el), true);
|
||||
const plot = utilization.getByTestId('guest-history-plot');
|
||||
const box = await plot.boundingBox();
|
||||
await page.mouse.move(box.x + box.width - 4, box.y + box.height / 2);
|
||||
await page.mouse.move(1, 1);
|
||||
assert.equal(await value(), selectedBeforePoll);
|
||||
assert.match(await utilization.innerText(), /CPU\s*22\.0%/);
|
||||
// Native blur restores the latest legend; mouse inspection still works.
|
||||
await slider.press('Tab');
|
||||
assert.match(await utilization.innerText(), /CPU\s*66\.0%/);
|
||||
await page.mouse.move(box.x + (34 / 360) * box.width, box.y + box.height / 2);
|
||||
assert.match(await utilization.innerText(), /CPU\s*8\.0%/);
|
||||
await page.mouse.move(1, 1);
|
||||
|
||||
if (width === 390) {
|
||||
await slider.scrollIntoViewIfNeeded();
|
||||
const touchBox = await slider.boundingBox();
|
||||
await page.touchscreen.tap(touchBox.x + 3, touchBox.y + touchBox.height / 2);
|
||||
await assertValue(/CPU 8\.0%\. Memory no observation/);
|
||||
assert.match(await utilization.innerText(), /CPU\s*8\.0%/);
|
||||
await page.touchscreen.tap(
|
||||
touchBox.x + touchBox.width - 3,
|
||||
touchBox.y + touchBox.height / 2,
|
||||
);
|
||||
await assertValue(/CPU 66\.0%\. Memory no observation/);
|
||||
assert.match(await utilization.innerText(), /CPU\s*66\.0%/);
|
||||
}
|
||||
action = 'failure';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await fixture
|
||||
.getByRole('status', { name: 'History refresh status' })
|
||||
.getByText('History refresh failed. Showing previously loaded history.', { exact: true })
|
||||
.waitFor();
|
||||
await slider.focus();
|
||||
await slider.press('Home');
|
||||
await assertValue(/CPU 8\.0%\. Memory no observation/);
|
||||
assert.doesNotMatch(await fixture.innerText(), /Private fixture detail/);
|
||||
await layout();
|
||||
await screenshot('failed-refresh-inspection');
|
||||
const semantics = await slider.ariaSnapshot();
|
||||
action = 'single';
|
||||
await fixture.getByRole('button', { name: 'Retry history' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('input[type="range"]').length === 0,
|
||||
);
|
||||
const description = await utilization
|
||||
.getByRole('img')
|
||||
.evaluate((el) => document.getElementById(el.getAttribute('aria-describedby')).textContent);
|
||||
assert.match(description, /1 stored observation time\. .*CPU 11\.0%/);
|
||||
assert.equal(await utilization.locator('path').count(), 0);
|
||||
action = 'empty';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector('[data-history-group="utilization"] svg[role="img"]');
|
||||
return (
|
||||
document.getElementById(image.getAttribute('aria-describedby')).textContent ===
|
||||
'No stored history observations.'
|
||||
);
|
||||
});
|
||||
assert.equal(await fixture.getByRole('slider').count(), 0);
|
||||
// Changing resource cannot carry an inspected former-host point into the new target.
|
||||
action = 'original';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await slider.waitFor();
|
||||
await slider.focus();
|
||||
await slider.press('Home');
|
||||
action = 'hold';
|
||||
await page.getByRole('button', { name: 'Switch to service target' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('input[type="range"]').length === 0,
|
||||
);
|
||||
assert.equal(await fixture.locator('svg path').count(), 0);
|
||||
assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0);
|
||||
assert.ok(held, 'new target read must be held');
|
||||
await held.fulfill({ json: response('pbs-three', '24h', 'service') });
|
||||
await slider.waitFor();
|
||||
assert.equal(await slider.inputValue(), '1');
|
||||
await assertValue(/CPU 88\.0%\. Memory 44\.0%\. Disk no observation/);
|
||||
assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0);
|
||||
const readsBeforeLock = requests.length;
|
||||
await fixture.getByTestId('guest-history-range-control').selectOption('14d');
|
||||
await fixture.getByText(/14 days history requires a higher license plan/).waitFor();
|
||||
assert.equal(await fixture.getByRole('slider').count(), 0);
|
||||
assert.equal(requests.length, readsBeforeLock);
|
||||
assert.deepEqual(errors, []);
|
||||
observations.push({
|
||||
engine,
|
||||
version: browser.version(),
|
||||
width,
|
||||
theme,
|
||||
requests,
|
||||
errors,
|
||||
semantics,
|
||||
layout: { width },
|
||||
singleDescription: description,
|
||||
focusStyle,
|
||||
nativeKeys: ['Tab', 'Home', 'ArrowRight', 'End', 'ArrowLeft'],
|
||||
touch: width === 390,
|
||||
});
|
||||
console.log(JSON.stringify({ engine, width, theme, result: 'passed' }));
|
||||
await page.close();
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'result.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
result: 'passed',
|
||||
engine,
|
||||
version: browser.version(),
|
||||
playwright: require('playwright/package.json').version,
|
||||
runtime_sha256: createHash('sha256')
|
||||
.update(
|
||||
fs.readFileSync(path.join(root, 'src/components/Workloads/GuestDrawerHistory.tsx')),
|
||||
)
|
||||
.digest('hex'),
|
||||
observations,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// Production History/query/CSS with synthetic API data, not installed PBS acceptance.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { chromium, firefox, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
const engine =
|
||||
process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'chromium';
|
||||
assert.ok(['chromium', 'firefox', 'webkit'].includes(engine));
|
||||
const width = process.argv.includes('--phone') ? 390 : 1365;
|
||||
const artifacts = path.join(root, 'node_modules', `history-pointer-${engine}-${width}`);
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-cache-')),
|
||||
server: { host: '127.0.0.1', port: 5223, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
const observations = [];
|
||||
const sourceFile = path.join(root, 'src/components/Workloads/GuestDrawerHistory.tsx');
|
||||
const sourceHash = createHash('sha256').update(fs.readFileSync(sourceFile)).digest('hex');
|
||||
const playwrightVersion = require('playwright/package.json').version;
|
||||
const integrationVersion = JSON.parse(
|
||||
fs.readFileSync('/workspace/tests/integration/package-lock.json'),
|
||||
).packages['node_modules/@playwright/test'].version;
|
||||
assert.equal(playwrightVersion, integrationVersion);
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await { chromium, firefox, webkit }[engine].launch(
|
||||
engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
for (const theme of ['light', 'dark']) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
...(width === 390 ? { isMobile: true, hasTouch: true } : {}),
|
||||
});
|
||||
page.setDefaultTimeout(20_000);
|
||||
const requests = [],
|
||||
errors = [];
|
||||
let kind = 'original',
|
||||
held;
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const time = 1_700_000_000_000;
|
||||
const point = (offset, value) => ({
|
||||
timestamp: time + offset,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
const metrics = (state) => {
|
||||
if (state === 'empty') return {};
|
||||
if (state === 'service') return { cpu: [point(0, 77), point(120_000, 88)] };
|
||||
return {
|
||||
cpu: [point(120_000, 43), point(0, 11), point(60_000, 22)],
|
||||
memory: [point(30_000, state === 'updated' ? 31 : 30), point(120_000, 55)],
|
||||
disk: [point(45_000, 0)],
|
||||
netin: [point(0, 0), point(120_000, 2048)],
|
||||
netout: [point(30_000, 4096)],
|
||||
diskread: [point(0, 2048), point(120_000, 8192)],
|
||||
diskwrite: [point(0, 0), point(120_000, 0)],
|
||||
temperature: [point(0, 42), point(120_000, 57)],
|
||||
};
|
||||
};
|
||||
const response = (id, range, state) => ({
|
||||
resourceType: 'agent',
|
||||
resourceId: id,
|
||||
range,
|
||||
start: time,
|
||||
end: time + 120_000,
|
||||
source: 'store',
|
||||
metrics: metrics(state),
|
||||
});
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5223') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
const id = url.searchParams.get('resourceId'),
|
||||
range = url.searchParams.get('range');
|
||||
assert.ok(['agent-three', 'pbs-three'].includes(id));
|
||||
const state = kind;
|
||||
requests.push({ id, range, state, method: route.request().method() });
|
||||
if (state === 'hold') {
|
||||
held = route;
|
||||
return;
|
||||
}
|
||||
if (state === 'failure')
|
||||
return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } });
|
||||
return route.fulfill({ json: response(id, range, state) });
|
||||
}
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community', label: 'Pulse Community runtime' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
});
|
||||
return route.fulfill({ json: { data: [], enabled: false } });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5223/browser-tests/pbs-history-refresh.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.evaluate(
|
||||
(dark) => document.documentElement.classList.toggle('dark', dark),
|
||||
theme === 'dark',
|
||||
);
|
||||
const fixture = page.getByTestId('history-refresh-fixture');
|
||||
const utilization = fixture.locator('[data-history-group="utilization"]');
|
||||
const slider = utilization.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
await slider.waitFor();
|
||||
const readsBefore = requests.length;
|
||||
const plot = utilization.getByTestId('guest-history-plot');
|
||||
const hover = async (chart, offset) => {
|
||||
await chart.scrollIntoViewIfNeeded();
|
||||
const box = await chart.getByTestId('guest-history-plot').boundingBox();
|
||||
assert.ok(box);
|
||||
await page.mouse.move(
|
||||
box.x + ((34 + (offset / 120_000) * 318) / 360) * box.width,
|
||||
box.y + box.height / 2,
|
||||
);
|
||||
};
|
||||
const text = async () => (await utilization.innerText()).replace(/\s+/g, ' ');
|
||||
const sparseTime = async (offset, expected, absent) => {
|
||||
await hover(utilization, offset);
|
||||
assert.match(await text(), expected);
|
||||
assert.match(await text(), absent);
|
||||
const markers = await plot
|
||||
.locator('circle[r="3"]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({ x: node.getAttribute('cx'), y: node.getAttribute('cy') })),
|
||||
);
|
||||
assert.equal(new Set(markers.map((marker) => marker.x)).size, 1);
|
||||
return markers;
|
||||
};
|
||||
// Capture the exact base misattribution too, before assertions fail.
|
||||
await hover(utilization, 30_000);
|
||||
const observed = { theme, sourceHash, text: await text(), browser: browser.version() };
|
||||
console.log(JSON.stringify({ observedSparsePointer: observed }));
|
||||
// Element screenshots scroll a tall phone fixture and end its hover.
|
||||
// Capture the current viewport without disturbing the active pointer.
|
||||
await page.screenshot({ path: path.join(artifacts, `sparse-pointer-${theme}.png`) });
|
||||
assert.match(await text(), /CPU\s*- Memory\s*30\.0% Disk\s*-/);
|
||||
assert.equal(await plot.locator('circle[r="3"]').count(), 1);
|
||||
assert.equal(await utilization.getByTestId('guest-history-hover-time').innerText(), '22:13');
|
||||
const descriptionId = await plot.getAttribute('aria-describedby');
|
||||
assert.match(
|
||||
await page.locator(`[id="${descriptionId}"]`).innerText(),
|
||||
/14\/11\/2023, 22:13:50\. CPU no observation\. Memory 30\.0%\. Disk no observation\./,
|
||||
);
|
||||
await sparseTime(0, /CPU\s*11\.0%/, /Memory\s*- Disk\s*-/);
|
||||
await sparseTime(45_000, /Disk\s*0\.0%/, /CPU\s*- Memory\s*-/);
|
||||
assert.equal(
|
||||
await plot.locator('path').count(),
|
||||
2,
|
||||
'lone disk sample must not become a path',
|
||||
);
|
||||
await hover(utilization, 120_000);
|
||||
assert.match(await text(), /CPU\s*43\.0% Memory\s*55\.0% Disk\s*-/);
|
||||
assert.equal(await plot.locator('circle[r="3"]').count(), 2);
|
||||
assert.equal(await utilization.getByTestId('guest-history-hover-time').innerText(), '22:15');
|
||||
const network = fixture.locator('[data-history-group="network"]');
|
||||
await hover(network, 0);
|
||||
assert.match((await network.innerText()).replace(/\s+/g, ' '), /In\s*0 B\/s Out\s*-/);
|
||||
await hover(network, 30_000);
|
||||
assert.match((await network.innerText()).replace(/\s+/g, ' '), /In\s*- Out\s*4\.00 KB\/s/);
|
||||
await page.mouse.move(1, 1);
|
||||
assert.match(await text(), /CPU\s*43\.0% Memory\s*55\.0% Disk\s*0\.0%/);
|
||||
assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0);
|
||||
assert.equal(requests.length, readsBefore, 'pointer inspection must not request data');
|
||||
// Native keyboard selection remains authoritative even with the pointer on another time.
|
||||
await slider.focus();
|
||||
await slider.press('Home');
|
||||
await hover(utilization, 30_000);
|
||||
assert.match(await text(), /CPU\s*11\.0% Memory\s*- Disk\s*-/);
|
||||
await slider.press('ArrowRight');
|
||||
assert.match(
|
||||
await slider.getAttribute('aria-valuetext'),
|
||||
/CPU no observation\. Memory 30\.0%\. Disk no observation/,
|
||||
);
|
||||
await slider.press('Tab');
|
||||
await hover(utilization, 45_000);
|
||||
assert.match(await text(), /CPU\s*- Memory\s*- Disk\s*0\.0%/);
|
||||
if (width === 390) {
|
||||
const box = await slider.boundingBox();
|
||||
assert.ok(box.height >= 44);
|
||||
await page.touchscreen.tap(box.x + 3, box.y + box.height / 2);
|
||||
assert.match(
|
||||
await slider.getAttribute('aria-valuetext'),
|
||||
/CPU 11\.0%\. Memory no observation/,
|
||||
);
|
||||
await page.touchscreen.tap(box.x + box.width - 3, box.y + box.height / 2);
|
||||
assert.match(await slider.getAttribute('aria-valuetext'), /CPU 43\.0%\. Memory 55\.0%/);
|
||||
await slider.press('Tab');
|
||||
}
|
||||
await page.mouse.move(1, 1);
|
||||
kind = 'failure';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).evaluate((el) => el.click());
|
||||
await fixture
|
||||
.getByText('History refresh failed. Showing previously loaded history.')
|
||||
.waitFor();
|
||||
await sparseTime(30_000, /Memory\s*30\.0%/, /CPU\s*-/);
|
||||
await page.screenshot({
|
||||
path: path.join(artifacts, `retained-sparse-pointer-${theme}.png`),
|
||||
});
|
||||
assert.equal((await fixture.innerText()).includes('Private fixture detail'), false);
|
||||
kind = 'updated';
|
||||
await fixture.getByRole('button', { name: 'Retry history' }).evaluate((el) => el.click());
|
||||
await page.waitForFunction(() =>
|
||||
[...document.querySelectorAll('[data-history-group="utilization"] span')].some(
|
||||
(el) => el.textContent === '31.0%',
|
||||
),
|
||||
);
|
||||
assert.match(await text(), /CPU\s*- Memory\s*31\.0% Disk\s*-/);
|
||||
kind = 'hold';
|
||||
await page
|
||||
.getByRole('button', { name: 'Switch to service target' })
|
||||
.evaluate((el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
[...document.querySelectorAll('[data-history-group="utilization"] path')].length === 0,
|
||||
);
|
||||
assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0);
|
||||
assert.equal(await plot.locator('circle[r="3"]').count(), 0);
|
||||
assert.match(await text(), /CPU\s*42\.0% Memory\s*53\.0% Disk\s*-/);
|
||||
assert.ok(held);
|
||||
kind = 'service';
|
||||
await held.fulfill({ json: response('pbs-three', '24h', 'service') });
|
||||
await slider.waitFor();
|
||||
await hover(utilization, 0);
|
||||
assert.match(await text(), /CPU\s*77\.0% Memory\s*- Disk\s*-/);
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
inner: innerWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
|
||||
assert.deepEqual(errors, []);
|
||||
observations.push({ theme, requests, dimensions, sourceHash });
|
||||
await page.close();
|
||||
}
|
||||
const result = {
|
||||
engine,
|
||||
width,
|
||||
browserVersion: browser.version(),
|
||||
playwrightVersion,
|
||||
sourceHash,
|
||||
observations,
|
||||
result: 'passed',
|
||||
};
|
||||
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,18 +1,16 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c",
|
||||
"verified_at": "2026-09-30T22:39:35Z",
|
||||
"base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881",
|
||||
"verified_at": "2026-10-01T00:37:51Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts"
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx": "d51df7a98a379e313f306b68529c8db0687ff91176af23869411c4b46feb2599",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts": "b25bf82441fc6572b95891af48a1ed2a0e8ea1f4e583c171ea9352a1e67d76ce"
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/update-progress-modal.html (production UpdateProgressModal with a scripted update stream and scripted /api/updates/status and /api/version)"
|
||||
"/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -25,45 +23,199 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"stream delivers one downloading 10% event then stays open and silent; fallback polling advances the modal through 35, 70, 80 and 90% (applying)",
|
||||
"stream closed during download with a single unreachable status poll between two successful ones; download progress continues and the restart phase is never entered",
|
||||
"restarting status from polling, then /api/version unreachable twice, then the new version reported; Pulse is restarting shown, then exactly one page reload",
|
||||
"pre-update version unavailable (503), late polls failing and a new version plus idle answering without a restarting or completed status; modal stays on Pulse is restarting with no reload and no completion claim",
|
||||
"stream delivers downloading, verifying, extracting, backing-up, applying and completed; old version answers once, becomes unreachable, then the new version; exactly one reload and no fallback polls"
|
||||
"light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp",
|
||||
"a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data",
|
||||
"zero-valued lone observations can be inspected without a synthetic trend",
|
||||
"genuinely aligned series share cursor geometry and their own observed values",
|
||||
"network directions preserve independently missing and zero rates",
|
||||
"native keyboard/touch selection takes precedence over pointer movement",
|
||||
"failed same-source refresh retains correctly attributed observations and a visible warning",
|
||||
"a successful refresh updates the hovered timestamp values without choosing other latest metrics",
|
||||
"held service-target replacement has no former-host observations, cursor or dated pointer description"
|
||||
],
|
||||
"interactions": [
|
||||
"emit scripted SSE statuses and hold the stream silent past the 6s silence window; assert the first fallback poll lands after it and the stream stays open",
|
||||
"fail the stream and abort one status poll mid-download; assert no restart text ever rendered (MutationObserver) and the do-not-close warning remains",
|
||||
"count document loads of the harness to assert a single reload after real restart evidence, and none without a baseline or backend confirmation",
|
||||
"inspect screenshots at desktop and phone widths; assert no page errors, no horizontal overflow and the dialog inside the viewport"
|
||||
"move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry",
|
||||
"leave the plot to restore latest/current legends; verify pointer inspection makes no API reads",
|
||||
"focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection",
|
||||
"Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area",
|
||||
"fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response"
|
||||
],
|
||||
"command": "NODE_PATH=<playwright 1.56.1 node_modules> PULSE_BROWSER_ARTIFACTS=<proof dir> PULSE_CHROMIUM_PATH=<cached chrome-headless-shell 151.0.7922.34> node frontend-modern/browser-tests/update-progress-modal.cjs from the worktree root; Vite dev server on 127.0.0.1:5208, Playwright 1.56.1, Chromium headless shell 151.0.7922.34",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).",
|
||||
"browser_versions": {
|
||||
"chromium": "141.0.7390.37",
|
||||
"firefox": "142.0.1",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/harness-rebased-3.log",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-390.png"
|
||||
],
|
||||
"notes": [
|
||||
"Mock-backed UI proof of the production modal; all five scenarios passed at 1365x900 and 390x844 and the screenshots were inspected. The same harness run against the pre-fix UpdateProgressModal and updateReadinessModel (fa1d9eb36) fails scenario (a), staying on 10% (pre-fix/run.log). This does not exercise the backend SSE endpoint, a real proxy, or an installed update. Re-run after rebasing onto 8d5969fb3 (lockfile-only brace-expansion/DOMPurify floors); both full runs passed all five scenarios at both viewports with unchanged component content hashes.",
|
||||
"Re-run after rebasing onto f3c92a242; all five scenarios passed at both viewports with unchanged component content hashes."
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log"
|
||||
],
|
||||
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability.",
|
||||
"prior_verifications": [
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f",
|
||||
"verified_at": "2026-09-30T23:28:26Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"light/dark ordered and sparse stored series",
|
||||
"native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface",
|
||||
"missing series at a selected timestamp stay missing, not nearby or live values",
|
||||
"same-source refresh adds samples without changing the selected timestamp or keyboard focus",
|
||||
"failed refresh retains inspectable matching observations and honest warning",
|
||||
"single stored observation has a dated description but no fabricated trend or pointless slider",
|
||||
"empty store/current readings expose no inspection control",
|
||||
"held replacement target has no former-host points or selection; new target starts at its own latest observation",
|
||||
"licence-locked range has no reads or inspection action"
|
||||
],
|
||||
"interactions": [
|
||||
"Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads",
|
||||
"refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp",
|
||||
"move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works",
|
||||
"Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values",
|
||||
"fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-inspection.cjs --engine=chromium; the same command with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).",
|
||||
"browser_versions": {
|
||||
"chromium": "141.0.7390.37",
|
||||
"firefox": "142.0.1",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log"
|
||||
],
|
||||
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c",
|
||||
"verified_at": "2026-09-30T22:39:35Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx": "d51df7a98a379e313f306b68529c8db0687ff91176af23869411c4b46feb2599",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts": "b25bf82441fc6572b95891af48a1ed2a0e8ea1f4e583c171ea9352a1e67d76ce"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/update-progress-modal.html (production UpdateProgressModal with a scripted update stream and scripted /api/updates/status and /api/version)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"stream delivers one downloading 10% event then stays open and silent; fallback polling advances the modal through 35, 70, 80 and 90% (applying)",
|
||||
"stream closed during download with a single unreachable status poll between two successful ones; download progress continues and the restart phase is never entered",
|
||||
"restarting status from polling, then /api/version unreachable twice, then the new version reported; Pulse is restarting shown, then exactly one page reload",
|
||||
"pre-update version unavailable (503), late polls failing and a new version plus idle answering without a restarting or completed status; modal stays on Pulse is restarting with no reload and no completion claim",
|
||||
"stream delivers downloading, verifying, extracting, backing-up, applying and completed; old version answers once, becomes unreachable, then the new version; exactly one reload and no fallback polls"
|
||||
],
|
||||
"interactions": [
|
||||
"emit scripted SSE statuses and hold the stream silent past the 6s silence window; assert the first fallback poll lands after it and the stream stays open",
|
||||
"fail the stream and abort one status poll mid-download; assert no restart text ever rendered (MutationObserver) and the do-not-close warning remains",
|
||||
"count document loads of the harness to assert a single reload after real restart evidence, and none without a baseline or backend confirmation",
|
||||
"inspect screenshots at desktop and phone widths; assert no page errors, no horizontal overflow and the dialog inside the viewport"
|
||||
],
|
||||
"command": "NODE_PATH=<playwright 1.56.1 node_modules> PULSE_BROWSER_ARTIFACTS=<proof dir> PULSE_CHROMIUM_PATH=<cached chrome-headless-shell 151.0.7922.34> node frontend-modern/browser-tests/update-progress-modal.cjs from the worktree root; Vite dev server on 127.0.0.1:5208, Playwright 1.56.1, Chromium headless shell 151.0.7922.34",
|
||||
"artifacts": [
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/harness-rebased-3.log",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-390.png"
|
||||
],
|
||||
"notes": [
|
||||
"Mock-backed UI proof of the production modal; all five scenarios passed at 1365x900 and 390x844 and the screenshots were inspected. The same harness run against the pre-fix UpdateProgressModal and updateReadinessModel (fa1d9eb36) fails scenario (a), staying on 10% (pre-fix/run.log). This does not exercise the backend SSE endpoint, a real proxy, or an installed update. Re-run after rebasing onto 8d5969fb3 (lockfile-only brace-expansion/DOMPurify floors); both full runs passed all five scenarios at both viewports with unchanged component content hashes.",
|
||||
"Re-run after rebasing onto f3c92a242; all five scenarios passed at both viewports with unchanged component content hashes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "21a35494aecb335755de773dd099e1364e65b3e0",
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { For, Show, createMemo, createSignal, onMount, type Component } from 'solid-js';
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
on,
|
||||
onMount,
|
||||
type Component,
|
||||
} from 'solid-js';
|
||||
|
||||
import {
|
||||
ChartsAPI,
|
||||
@@ -57,6 +67,7 @@ interface GuestDrawerHistoryGroupChartProps {
|
||||
loading: boolean;
|
||||
metrics: Record<string, AggregatedMetricPoint[] | undefined>;
|
||||
range: HistoryTimeRange;
|
||||
sourceKey: string;
|
||||
}
|
||||
|
||||
const GUEST_DRAWER_HISTORY_MAX_POINTS = 240;
|
||||
@@ -176,18 +187,18 @@ const getGuestDrawerHistoryY = (
|
||||
return GUEST_DRAWER_HISTORY_PLOT_TOP + (1 - (bounded - scale.minValue) / valueSpan) * plotHeight;
|
||||
};
|
||||
|
||||
const findClosestGuestDrawerHistoryPoint = (
|
||||
points: readonly AggregatedMetricPoint[],
|
||||
const findClosestGuestDrawerHistoryTimestamp = (
|
||||
timestamps: readonly number[],
|
||||
timestamp: number,
|
||||
): AggregatedMetricPoint | null => {
|
||||
if (points.length === 0) return null;
|
||||
): number | null => {
|
||||
if (timestamps.length === 0) return null;
|
||||
|
||||
let closest = points[0];
|
||||
let closestDistance = Math.abs(points[0].timestamp - timestamp);
|
||||
for (const point of points.slice(1)) {
|
||||
const distance = Math.abs(point.timestamp - timestamp);
|
||||
let closest = timestamps[0];
|
||||
let closestDistance = Math.abs(closest - timestamp);
|
||||
for (const candidate of timestamps.slice(1)) {
|
||||
const distance = Math.abs(candidate - timestamp);
|
||||
if (distance < closestDistance) {
|
||||
closest = point;
|
||||
closest = candidate;
|
||||
closestDistance = distance;
|
||||
}
|
||||
}
|
||||
@@ -196,24 +207,82 @@ const findClosestGuestDrawerHistoryPoint = (
|
||||
|
||||
const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps> = (props) => {
|
||||
const [hoverTimestamp, setHoverTimestamp] = createSignal<number | null>(null);
|
||||
const [selectedTimestamp, setSelectedTimestamp] = createSignal<number | null>(null);
|
||||
const inspectionId = `history-inspection-${createUniqueId()}`;
|
||||
createEffect(
|
||||
on(
|
||||
() => props.sourceKey,
|
||||
() => {
|
||||
setHoverTimestamp(null);
|
||||
setSelectedTimestamp(null);
|
||||
},
|
||||
),
|
||||
);
|
||||
const series = createMemo(() =>
|
||||
props.group.series.map((config) => ({
|
||||
...config,
|
||||
points: normalizeGuestDrawerHistoryPoints(props.metrics[config.metric], config.unit),
|
||||
})),
|
||||
);
|
||||
// Step through actual stored times, not synthetic points between samples.
|
||||
// Keep the selected time across polls even if its ordinal index changes.
|
||||
const observationTimes = createMemo(() =>
|
||||
[...new Set(series().flatMap((item) => item.points.map((point) => point.timestamp)))].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
);
|
||||
const selectedIndex = createMemo(() => {
|
||||
const times = observationTimes();
|
||||
const selected = selectedTimestamp();
|
||||
if (selected === null) return Math.max(0, times.length - 1);
|
||||
const closest = findClosestGuestDrawerHistoryTimestamp(times, selected);
|
||||
return closest === null ? 0 : times.indexOf(closest);
|
||||
});
|
||||
const inspectionTimestamp = createMemo(() =>
|
||||
selectedTimestamp() === null ? null : (observationTimes()[selectedIndex()] ?? null),
|
||||
);
|
||||
// Resolve the pointer to one real time across the whole group. Independent
|
||||
// nearest-series reads would attribute values from different times to the
|
||||
// first metric's timestamp, and can even borrow a live fallback reading.
|
||||
const activeTimestamp = createMemo(() => {
|
||||
const inspected = inspectionTimestamp();
|
||||
if (inspected !== null) return inspected;
|
||||
const hovered = hoverTimestamp();
|
||||
return hovered === null
|
||||
? null
|
||||
: findClosestGuestDrawerHistoryTimestamp(observationTimes(), hovered);
|
||||
});
|
||||
const observationValueText = (timestamp: number | undefined): string => {
|
||||
if (timestamp === undefined) return 'No stored history observations.';
|
||||
const values = series().map((item) => {
|
||||
const point = item.points.find((point) => point.timestamp === timestamp);
|
||||
return `${item.label} ${point ? formatHistoryChartTooltipValue(point.value, item.unit) : 'no observation'}`;
|
||||
});
|
||||
return `${new Date(timestamp).toLocaleString()}. ${values.join('. ')}.`;
|
||||
};
|
||||
const inspectionValueText = createMemo(() =>
|
||||
observationValueText(observationTimes()[selectedIndex()]),
|
||||
);
|
||||
const chartDescription = createMemo(() => {
|
||||
if (props.loading) return 'Loading history.';
|
||||
const timestamp = activeTimestamp();
|
||||
if (timestamp !== null) return observationValueText(timestamp);
|
||||
const count = observationTimes().length;
|
||||
if (count === 0) return inspectionValueText();
|
||||
return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`;
|
||||
});
|
||||
const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2));
|
||||
const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit));
|
||||
const bounds = createMemo(() => getGuestDrawerHistoryRangeBounds(series()));
|
||||
const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null);
|
||||
const hoveredSeries = createMemo(() => {
|
||||
const timestamp = hoverTimestamp();
|
||||
const timestamp = activeTimestamp();
|
||||
const rangeBounds = bounds();
|
||||
if (timestamp === null || !rangeBounds) return [];
|
||||
|
||||
return drawableSeries()
|
||||
return series()
|
||||
.map((item) => {
|
||||
const point = findClosestGuestDrawerHistoryPoint(item.points, timestamp);
|
||||
const point = item.points.find((point) => point.timestamp === timestamp);
|
||||
if (!point) return null;
|
||||
return {
|
||||
...item,
|
||||
@@ -240,20 +309,23 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
...item,
|
||||
valueLabel: hovered
|
||||
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
|
||||
: item.points.length > 0
|
||||
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
|
||||
: typeof currentValue === 'number' && Number.isFinite(currentValue)
|
||||
? formatHistoryChartTooltipValue(currentValue, item.unit)
|
||||
: '-',
|
||||
: activeTimestamp() !== null
|
||||
? '-'
|
||||
: item.points.length > 0
|
||||
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
|
||||
: typeof currentValue === 'number' && Number.isFinite(currentValue)
|
||||
? formatHistoryChartTooltipValue(currentValue, item.unit)
|
||||
: '-',
|
||||
};
|
||||
}),
|
||||
);
|
||||
const hoverTimeLabel = createMemo(() => {
|
||||
const point = hoveredSeries()[0]?.point;
|
||||
return point ? formatHistoryChartTimeLabel(point.timestamp, props.range) : '';
|
||||
const timestamp = activeTimestamp();
|
||||
return timestamp === null ? '' : formatHistoryChartTimeLabel(timestamp, props.range);
|
||||
});
|
||||
|
||||
const handleHoverMove = (event: MouseEvent & { currentTarget: SVGSVGElement }) => {
|
||||
if (inspectionTimestamp() !== null) return;
|
||||
const rangeBounds = bounds();
|
||||
if (!rangeBounds) return;
|
||||
|
||||
@@ -321,6 +393,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
</For>
|
||||
<svg
|
||||
aria-label={`${props.group.label} history`}
|
||||
aria-describedby={`${inspectionId}-description`}
|
||||
class="absolute inset-0 h-full w-full cursor-crosshair"
|
||||
data-testid="guest-history-plot"
|
||||
onMouseMove={handleHoverMove}
|
||||
@@ -407,6 +480,43 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<p id={`${inspectionId}-description`} class="sr-only">
|
||||
{chartDescription()}
|
||||
</p>
|
||||
<Show when={observationTimes().length > 1}>
|
||||
<div class="mt-2">
|
||||
<label for={inspectionId} class="flex justify-between gap-2 text-[10px] text-muted">
|
||||
<span>Inspect history</span>
|
||||
<span aria-hidden="true">
|
||||
{selectedIndex() + 1} / {observationTimes().length}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={inspectionId}
|
||||
type="range"
|
||||
aria-label={`Inspect ${props.group.label} history`}
|
||||
aria-valuetext={inspectionValueText()}
|
||||
aria-describedby={`${inspectionId}-help`}
|
||||
min="0"
|
||||
max={observationTimes().length - 1}
|
||||
step="1"
|
||||
value={selectedIndex()}
|
||||
class="h-11 w-full cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 sm:h-6"
|
||||
onFocus={() => {
|
||||
setHoverTimestamp(null);
|
||||
setSelectedTimestamp(observationTimes()[selectedIndex()] ?? null);
|
||||
}}
|
||||
onBlur={() => setSelectedTimestamp(null)}
|
||||
onInput={(event) => {
|
||||
const index = event.currentTarget.valueAsNumber;
|
||||
if (Number.isFinite(index)) setSelectedTimestamp(observationTimes()[index] ?? null);
|
||||
}}
|
||||
/>
|
||||
<p id={`${inspectionId}-help`} class="sr-only">
|
||||
Use arrow keys, Home or End to inspect stored observations.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -517,6 +627,7 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
|
||||
metrics={metrics()}
|
||||
currentMetrics={props.currentMetrics}
|
||||
range={props.range}
|
||||
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChartsAPI, type AllMetricsHistoryResponse, type HistoryTimeRange } from '@/api/charts';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { GuestDrawerHistory } from '../GuestDrawerHistory';
|
||||
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
|
||||
import type { GuestDrawerHistoryTarget } from '../guestDrawerModel';
|
||||
|
||||
vi.mock('@/stores/license', () => ({
|
||||
loadRuntimeCapabilities: vi.fn(async () => undefined),
|
||||
maxHistoryDays: () => 7,
|
||||
isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const time = 1_700_000_000_000;
|
||||
const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'pbs-host' };
|
||||
const point = (offset: number, value: number) => ({
|
||||
timestamp: time + offset,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
const response = (
|
||||
metrics: AllMetricsHistoryResponse['metrics'] = {
|
||||
cpu: [point(120_000, 43), point(0, 11), point(60_000, 22)],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
},
|
||||
): AllMetricsHistoryResponse => ({
|
||||
...target,
|
||||
range: '24h',
|
||||
start: time,
|
||||
end: time + 120_000,
|
||||
source: 'store',
|
||||
metrics,
|
||||
});
|
||||
const inspect = () => screen.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
const choose = (slider: HTMLElement, index: number) =>
|
||||
fireEvent.input(slider, { target: { value: String(index) } });
|
||||
|
||||
// Native keyboard/touch stepping is exercised in the offline browser; jsdom
|
||||
// tests the renderer's input/focus contract rather than simulating browser defaults.
|
||||
describe('GuestDrawerHistory stored-observation inspection', () => {
|
||||
it('exposes an ordered native control, dated values and chart description without another read', async () => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" currentMetrics={{ disk: 88 }} />);
|
||||
await waitFor(() => expect(inspect()).toHaveAttribute('max', '3'));
|
||||
const slider = inspect();
|
||||
expect(slider).toHaveAttribute('type', 'range');
|
||||
expect(slider).toHaveAttribute('min', '0');
|
||||
expect(slider).toHaveAttribute('step', '1');
|
||||
expect(slider).toHaveValue('3');
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time + 120_000).toLocaleString()}. CPU 43.0%. Memory 55.0%. Disk no observation.`,
|
||||
);
|
||||
expect(
|
||||
within(utilization()).getByRole('img', { name: 'Utilization history' }),
|
||||
).toHaveAccessibleDescription(/4 stored observation times/);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('inspects exact stored times and does not borrow nearby or current values for missing series', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => (
|
||||
<GuestDrawerHistory
|
||||
target={target}
|
||||
range="24h"
|
||||
currentMetrics={{ cpu: 99, memory: 98, disk: 88 }}
|
||||
/>
|
||||
));
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
const slider = inspect();
|
||||
slider.focus();
|
||||
choose(slider, 0);
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
expect(utilization()).toHaveTextContent('Memory-');
|
||||
expect(utilization()).not.toHaveTextContent('88.0%');
|
||||
choose(slider, 1);
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time + 30_000).toLocaleString()}. CPU no observation. Memory 30.0%. Disk no observation.`,
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU-');
|
||||
expect(utilization()).toHaveTextContent('Memory30.0%');
|
||||
expect(slider).toHaveFocus();
|
||||
});
|
||||
|
||||
it('keeps focused selection independent of plot pointer movement, then restores normal hover on blur', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
const slider = inspect();
|
||||
const plot = within(utilization()).getByTestId('guest-history-plot');
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ left: 0, width: 360 } as DOMRect);
|
||||
slider.focus();
|
||||
choose(slider, 0);
|
||||
fireEvent.pointerMove(plot, { clientX: 352 });
|
||||
fireEvent.pointerLeave(plot);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
fireEvent.blur(slider);
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
fireEvent.mouseMove(plot, { clientX: 34 });
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
});
|
||||
|
||||
it('preserves the selected stored timestamp, not its index, when a poll adds observations', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
cpu: [
|
||||
point(-60_000, 8),
|
||||
point(0, 11),
|
||||
point(60_000, 22),
|
||||
point(120_000, 43),
|
||||
point(180_000, 66),
|
||||
],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
}),
|
||||
);
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const slider = inspect();
|
||||
slider.focus();
|
||||
choose(slider, 2);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(inspect()).toBe(slider);
|
||||
expect(slider).toHaveFocus();
|
||||
expect(slider).toHaveValue('3');
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time + 60_000).toLocaleString()}. CPU 22.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU22.0%');
|
||||
});
|
||||
|
||||
it('snaps an expired inspected timestamp to an actual remaining observation on refresh', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(response({ cpu: [point(120_000, 43), point(180_000, 66)] }));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const slider = inspect();
|
||||
slider.focus();
|
||||
choose(slider, 0);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(slider).toHaveValue('0');
|
||||
expect(slider.getAttribute('aria-valuetext')).toContain(
|
||||
new Date(time + 120_000).toLocaleString(),
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
});
|
||||
|
||||
it.each(['target', 'resource type', 'range'] as const)(
|
||||
'clears selection on a cached %s replacement even when its timestamps coincide',
|
||||
async (change) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(response({ cpu: [point(0, 77), point(120_000, 88)] }))
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
const [current, setTarget] = createSignal(target);
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>('24h');
|
||||
render(() => <GuestDrawerHistory target={current()} range={range()} />);
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
if (change === 'target') setTarget({ ...target, resourceId: 'other-pbs' });
|
||||
else if (change === 'resource type') setTarget({ ...target, resourceType: 'vm' });
|
||||
else setRange('6h');
|
||||
await waitFor(() => expect(inspect()).toHaveValue('1'));
|
||||
inspect().focus();
|
||||
choose(inspect(), 0);
|
||||
expect(utilization()).toHaveTextContent('CPU77.0%');
|
||||
if (change !== 'range') setTarget(target);
|
||||
else setRange('24h');
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
expect(
|
||||
within(utilization()).queryByTestId('guest-history-hover-time'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('inspects zero throughput and temperature using the existing host metric units', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response({
|
||||
netin: [point(0, 0), point(60_000, 2048)],
|
||||
temperature: [point(0, 42), point(60_000, 57)],
|
||||
}),
|
||||
);
|
||||
render(() => (
|
||||
<GuestDrawerHistory target={target} range="24h" groups={HOST_METRICS_HISTORY_GROUPS} />
|
||||
));
|
||||
const network = await screen.findByRole('slider', { name: 'Inspect Network I/O history' });
|
||||
network.focus();
|
||||
choose(network, 0);
|
||||
expect(network.getAttribute('aria-valuetext')).toContain('In 0 B/s. Out no observation.');
|
||||
const thermal = screen.getByRole('slider', { name: 'Inspect Thermals history' });
|
||||
thermal.focus();
|
||||
choose(thermal, 1);
|
||||
expect(thermal.getAttribute('aria-valuetext')).toContain('CPU 57°C.');
|
||||
});
|
||||
|
||||
it('keeps stored observations inspectable after a failed poll without exposing the error', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockRejectedValueOnce(new Error('private backend detail'));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(screen.getByRole('status', { name: 'History refresh status' })).toHaveTextContent(
|
||||
'Showing previously loaded history.',
|
||||
);
|
||||
inspect().focus();
|
||||
choose(inspect(), 0);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
expect(utilization()).not.toHaveTextContent('private backend detail');
|
||||
});
|
||||
|
||||
it('describes a lone stored observation without manufacturing a trend or pointless slider', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ cpu: [point(0, 11)] }));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await waitFor(() =>
|
||||
expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription(
|
||||
/1 stored observation time/,
|
||||
),
|
||||
);
|
||||
expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription(
|
||||
`1 stored observation time. ${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(utilization().querySelector('path')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['empty', 'invalid'] as const)(
|
||||
'does not offer inspection for %s stored history or live readings',
|
||||
async (kind) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response(kind === 'empty' ? {} : { cpu: [point(0, NaN)] }),
|
||||
);
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" currentMetrics={{ cpu: 42 }} />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading history')).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription(
|
||||
'No stored history observations.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('offers no inspection or reads for an absent target or locked range', async () => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory');
|
||||
const [current, setTarget] = createSignal<GuestDrawerHistoryTarget | null>(null);
|
||||
render(() => <GuestDrawerHistory target={current()} range="14d" />);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
setTarget(target);
|
||||
await screen.findByText(/14 days history requires/);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChartsAPI, type AllMetricsHistoryResponse, type HistoryTimeRange } from '@/api/charts';
|
||||
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
|
||||
import { formatHistoryChartTimeLabel } from '@/components/shared/historyChartModel';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { GuestDrawerHistory } from '../GuestDrawerHistory';
|
||||
import type { GuestDrawerHistoryTarget } from '../guestDrawerModel';
|
||||
|
||||
vi.mock('@/stores/license', () => ({
|
||||
loadRuntimeCapabilities: vi.fn(async () => undefined),
|
||||
maxHistoryDays: () => 7,
|
||||
isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const time = 1_700_000_000_000;
|
||||
const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'pbs-host' };
|
||||
const point = (offset: number, value: number) => ({
|
||||
timestamp: time + offset,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
const response = (
|
||||
metrics: AllMetricsHistoryResponse['metrics'] = {
|
||||
cpu: [point(120_000, 43), point(0, 11), point(60_000, 22)],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
disk: [point(45_000, 0)],
|
||||
},
|
||||
): AllMetricsHistoryResponse => ({
|
||||
...target,
|
||||
range: '24h',
|
||||
start: time,
|
||||
end: time + 120_000,
|
||||
source: 'store',
|
||||
metrics,
|
||||
});
|
||||
const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
const hover = (chart: HTMLElement, offset: number, duration = 120_000) => {
|
||||
const plot = within(chart).getByTestId('guest-history-plot');
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ left: 0, width: 360 } as DOMRect);
|
||||
fireEvent.mouseMove(plot, { clientX: 34 + (offset / duration) * 318 });
|
||||
return plot;
|
||||
};
|
||||
|
||||
describe('GuestDrawerHistory common-time pointer inspection', () => {
|
||||
it('chooses the nearest actual observation across all series, not the first metric', async () => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
|
||||
hover(utilization(), 30_000);
|
||||
expect(within(utilization()).getByTestId('guest-history-hover-time')).toHaveTextContent(
|
||||
formatHistoryChartTimeLabel(time + 30_000, '24h'),
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU-');
|
||||
expect(utilization()).toHaveTextContent('Memory30.0%');
|
||||
expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not borrow neighbouring or current readings for a missing metric at the chosen time', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => (
|
||||
<GuestDrawerHistory
|
||||
target={target}
|
||||
range="24h"
|
||||
currentMetrics={{ cpu: 99, memory: 98, disk: 88 }}
|
||||
/>
|
||||
));
|
||||
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
|
||||
const plot = hover(utilization(), 0);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
expect(utilization()).toHaveTextContent('Memory-');
|
||||
expect(utilization()).toHaveTextContent('Disk-');
|
||||
expect(utilization()).not.toHaveTextContent('98.0%');
|
||||
expect(
|
||||
within(utilization()).getByRole('img', { name: 'Utilization history' }),
|
||||
).toHaveAccessibleDescription(
|
||||
`${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
fireEvent.pointerLeave(plot);
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
expect(utilization()).toHaveTextContent('Memory55.0%');
|
||||
expect(within(utilization()).queryByTestId('guest-history-hover-time')).toBeNull();
|
||||
});
|
||||
|
||||
it('can inspect a lone zero observation without drawing a synthetic trend', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
|
||||
hover(utilization(), 45_000);
|
||||
expect(utilization()).toHaveTextContent('CPU-');
|
||||
expect(utilization()).toHaveTextContent('Memory-');
|
||||
expect(utilization()).toHaveTextContent('Disk0.0%');
|
||||
expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(1);
|
||||
expect(utilization().querySelectorAll('path')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders all metrics that really share a timestamp at the same cursor position', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
|
||||
hover(utilization(), 120_000);
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
expect(utilization()).toHaveTextContent('Memory55.0%');
|
||||
expect(utilization()).toHaveTextContent('Disk-');
|
||||
const markers = [...utilization().querySelectorAll('circle[r="3"]')];
|
||||
expect(markers).toHaveLength(2);
|
||||
expect(markers[0].getAttribute('cx')).toBe(markers[1].getAttribute('cx'));
|
||||
});
|
||||
|
||||
it('snaps between observations and resolves a tie to the earlier actual time', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
|
||||
hover(utilization(), 37_500);
|
||||
expect(utilization()).toHaveTextContent('CPU-');
|
||||
expect(utilization()).toHaveTextContent('Memory30.0%');
|
||||
expect(utilization()).toHaveTextContent('Disk-');
|
||||
});
|
||||
|
||||
it('keeps keyboard selection authoritative and restores pointer inspection after blur', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
const slider = await screen.findByRole('slider', { name: 'Inspect Utilization history' });
|
||||
slider.focus();
|
||||
fireEvent.input(slider, { target: { value: '0' } });
|
||||
hover(utilization(), 30_000);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
expect(utilization()).toHaveTextContent('Memory-');
|
||||
fireEvent.blur(slider);
|
||||
hover(utilization(), 30_000);
|
||||
expect(utilization()).toHaveTextContent('CPU-');
|
||||
expect(utilization()).toHaveTextContent('Memory30.0%');
|
||||
});
|
||||
|
||||
it('reconciles a hovered time with a refreshed series instead of substituting every latest metric', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(response({ memory: [point(30_000, 31)], cpu: [point(120_000, 88)] }));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
hover(utilization(), 30_000);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(utilization()).toHaveTextContent('CPU-');
|
||||
expect(utilization()).toHaveTextContent('Memory31.0%');
|
||||
expect(utilization().querySelectorAll('path')).toHaveLength(0);
|
||||
expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(['target', 'range'] as const)(
|
||||
'clears pointer state on %s replacement',
|
||||
async (change) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
const [current, setTarget] = createSignal(target);
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>('24h');
|
||||
render(() => <GuestDrawerHistory target={current()} range={range()} />);
|
||||
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
|
||||
hover(utilization(), 30_000);
|
||||
if (change === 'target') setTarget({ ...target, resourceId: 'replacement' });
|
||||
else setRange('6h');
|
||||
await waitFor(() =>
|
||||
expect(within(utilization()).queryByTestId('guest-history-hover-time')).toBeNull(),
|
||||
);
|
||||
expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('inspects real zero rates and temperatures while leaving missing sibling directions unavailable', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response({
|
||||
netin: [point(0, 0), point(120_000, 2048)],
|
||||
netout: [point(30_000, 4096)],
|
||||
temperature: [point(0, 42), point(120_000, 57)],
|
||||
}),
|
||||
);
|
||||
render(() => (
|
||||
<GuestDrawerHistory target={target} range="24h" groups={HOST_METRICS_HISTORY_GROUPS} />
|
||||
));
|
||||
await screen.findByRole('slider', { name: 'Inspect Network I/O history' });
|
||||
const groups = screen.getAllByTestId('guest-history-group-chart');
|
||||
const network = groups.find((group) => group.dataset.historyGroup === 'network')!;
|
||||
hover(network, 0);
|
||||
expect(network).toHaveTextContent('In0 B/s');
|
||||
expect(network).toHaveTextContent('Out-');
|
||||
hover(network, 30_000);
|
||||
expect(network).toHaveTextContent('In-');
|
||||
expect(network).toHaveTextContent('Out4.00 KB/s');
|
||||
const thermals = groups.find((group) => group.dataset.historyGroup === 'thermals')!;
|
||||
hover(thermals, 0);
|
||||
expect(thermals).toHaveTextContent('CPU42°C');
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,11 @@ func (m *Manager) Cleanup(maxAge time.Duration) {
|
||||
if m.config.MaxAlertAgeDays > 0 {
|
||||
alertTTL := time.Duration(m.config.MaxAlertAgeDays) * 24 * time.Hour
|
||||
for id, alert := range m.activeAlerts {
|
||||
if !alert.Acknowledged && now.Sub(alert.StartTime) > alertTTL {
|
||||
// Retention is housekeeping for inactive alerts, not recovery
|
||||
// evidence. As with acknowledged alerts, keep a condition still
|
||||
// observed; deleting it would re-fire on the next failing poll.
|
||||
if !alert.Acknowledged && now.Sub(alert.StartTime) > alertTTL &&
|
||||
lastSeenTooOld(alert, alertTTL) {
|
||||
log.Info().
|
||||
Str("alertID", id).
|
||||
Dur("age", now.Sub(alert.StartTime)).
|
||||
|
||||
@@ -21639,3 +21639,53 @@ func TestAsyncActiveCheckpointBurstIsBounded(t *testing.T) {
|
||||
t.Fatalf("restart lost latest pending intent: %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupRetentionRequiresObservationInactivity(t *testing.T) {
|
||||
now := time.Now()
|
||||
old := now.Add(-48 * time.Hour)
|
||||
recent := now.Add(-5 * time.Minute)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
start, seen time.Time
|
||||
acknowledged bool
|
||||
ttl int
|
||||
keep bool
|
||||
}{
|
||||
{name: "continuing unacknowledged condition", start: old, seen: recent, ttl: 1, keep: true},
|
||||
{name: "inactive unacknowledged condition", start: old, seen: old, ttl: 1},
|
||||
{name: "legacy inactive without last seen", start: old, ttl: 1},
|
||||
{name: "legacy recent without last seen", start: recent, ttl: 1, keep: true},
|
||||
{name: "recent occurrence with inconsistent old last seen", start: recent, seen: old, ttl: 1, keep: true},
|
||||
{name: "disabled unacknowledged retention", start: old, seen: old, keep: true},
|
||||
{name: "continuing acknowledged condition", start: old, seen: recent, acknowledged: true, ttl: 1, keep: true},
|
||||
{name: "inactive acknowledged condition", start: old, seen: old, acknowledged: true, ttl: 1},
|
||||
{name: "legacy without either timestamp", ttl: 1},
|
||||
{name: "future observation does not expire", start: old, seen: now.Add(time.Hour), ttl: 1, keep: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := newCleanupRetentionManager(t, t.TempDir(), false)
|
||||
alert := &Alert{
|
||||
ID: "retention-resource::metric-threshold:usage", ResourceID: "retention-resource",
|
||||
CanonicalSpecID: "metric-threshold:usage", Type: "usage", Level: AlertLevelWarning,
|
||||
StartTime: tc.start, LastSeen: tc.seen, Acknowledged: tc.acknowledged,
|
||||
}
|
||||
if tc.acknowledged {
|
||||
ackTime := old
|
||||
alert.AckTime = &ackTime
|
||||
alert.AckUser = "operator"
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.config.MaxAlertAgeDays = tc.ttl
|
||||
m.setActiveAlertNoLock(alert.ID, alert)
|
||||
m.mu.Unlock()
|
||||
m.Cleanup(time.Hour)
|
||||
active := m.GetActiveAlerts()
|
||||
if kept := len(active) == 1; kept != tc.keep {
|
||||
t.Fatalf("cleanup kept = %v, want %v (start %s, last seen %s)", kept, tc.keep, tc.start, tc.seen)
|
||||
}
|
||||
if tc.keep && (!active[0].StartTime.Equal(tc.start) || !active[0].LastSeen.Equal(tc.seen) || active[0].Acknowledged != tc.acknowledged) {
|
||||
t.Fatalf("cleanup changed the retained occurrence: %+v", active[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts/eventlog"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
func newCleanupRetentionManager(t *testing.T, dir string, durable bool) *Manager {
|
||||
t.Helper()
|
||||
var options []ManagerOption
|
||||
if durable {
|
||||
options = append(options, WithDurableAlertStore())
|
||||
}
|
||||
m := NewManagerWithDataDir(dir, options...)
|
||||
t.Cleanup(m.Stop)
|
||||
m.mu.Lock()
|
||||
m.config.Enabled = true
|
||||
m.config.ActivationState = ActivationActive
|
||||
m.config.AutoAcknowledgeAfterHours = 0
|
||||
m.config.MaxAlertAgeDays = 1
|
||||
m.config.MaxAcknowledgedAgeDays = 1
|
||||
m.config.DockerDefaults.UpdateAlertDelayHours = 24
|
||||
m.config.Schedule.Cooldown = 0 // Only the first notification per occurrence.
|
||||
m.config.FlappingEnabled = false
|
||||
m.mu.Unlock()
|
||||
return m
|
||||
}
|
||||
|
||||
// Exercise the actual detector, callbacks, history, checkpoint and restart,
|
||||
// not just a hand-seeded active map. A cached registry observation is a current
|
||||
// report; missing/failed checks must not be invented as recovery either.
|
||||
func TestCleanupContinuingDockerUpdateKeepsOccurrence(t *testing.T) {
|
||||
for _, durable := range []bool{false, true} {
|
||||
name := "JSON recovery"
|
||||
if durable {
|
||||
name = "durable active authority"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := newCleanupRetentionManager(t, dir, durable)
|
||||
host := models.DockerHost{ID: "retention-host", Hostname: "retention-host"}
|
||||
pending := &models.DockerContainerUpdateStatus{
|
||||
UpdateAvailable: true, CurrentDigest: "sha256:old", LatestDigest: "sha256:new",
|
||||
LastChecked: time.Now().Add(-6 * time.Hour),
|
||||
}
|
||||
container := models.DockerContainer{ID: "retention-container", Name: "web", Image: "mongo:7", UpdateStatus: pending}
|
||||
resourceID := DockerResourceID(host.ID, container.ID)
|
||||
trackingKey := dockerUpdateTrackingKey(host, container)
|
||||
first := time.Now().Add(-25 * time.Hour)
|
||||
m.mu.Lock()
|
||||
m.dockerUpdateFirstSeen[resourceID] = first
|
||||
m.dockerUpdateFirstSeenByIdentity[trackingKey] = first
|
||||
m.mu.Unlock()
|
||||
var deliveries, fired, resolved atomic.Int32
|
||||
m.SetAlertCallback(func(*Alert) { deliveries.Add(1) })
|
||||
m.SubscribeLifecycleCallback(func(event LifecycleEvent) {
|
||||
switch event.Type {
|
||||
case eventlog.TypeFired, eventlog.TypeRefired:
|
||||
fired.Add(1)
|
||||
case eventlog.TypeResolved:
|
||||
resolved.Add(1)
|
||||
}
|
||||
})
|
||||
evaluate := func() {
|
||||
m.checkDockerContainerImageUpdate(host, container, resourceID, container.Name, host.Hostname, host.Hostname)
|
||||
}
|
||||
evaluate()
|
||||
active := m.GetActiveAlerts()
|
||||
if len(active) != 1 || deliveries.Load() != 1 || fired.Load() != 1 {
|
||||
t.Fatalf("initial update did not fire once: active %d, callbacks %d, firings %d", len(active), deliveries.Load(), fired.Load())
|
||||
}
|
||||
initial := active[0]
|
||||
for _, status := range []*models.DockerContainerUpdateStatus{pending, nil, {Error: "registry unavailable"}, pending} {
|
||||
container.UpdateStatus = status
|
||||
evaluate()
|
||||
m.Cleanup(time.Hour)
|
||||
if got := len(m.GetActiveAlerts()); got != 1 {
|
||||
t.Errorf("cleanup hid the continuing condition: %d active alerts", got)
|
||||
}
|
||||
container.UpdateStatus = pending
|
||||
evaluate() // The next poll must not fire a replacement occurrence.
|
||||
}
|
||||
if got := deliveries.Load(); got != 1 {
|
||||
t.Errorf("continuing update dispatched %d notifications, want one", got)
|
||||
}
|
||||
if got := fired.Load(); got != 1 || resolved.Load() != 0 {
|
||||
t.Errorf("continuing lifecycle has %d firings / %d resolutions, want 1 / 0", got, resolved.Load())
|
||||
}
|
||||
active = m.GetActiveAlerts()
|
||||
if len(active) != 1 || active[0].ID != initial.ID || !active[0].StartTime.Equal(first) || active[0].Acknowledged || active[0].LastNotified == nil || !active[0].LastNotified.Equal(*initial.LastNotified) {
|
||||
t.Fatalf("continuing occurrence/notification identity changed: %+v", active)
|
||||
}
|
||||
if got := len(m.GetAlertHistory(0)); got != 1 {
|
||||
t.Errorf("continuing condition produced %d history rows, want one", got)
|
||||
}
|
||||
if durable {
|
||||
events := queryAlertEvents(t, m, eventlog.Filter{Types: []string{eventlog.TypeFired, eventlog.TypeRefired, eventlog.TypeResolved}})
|
||||
if len(events) != 1 || events[0].Type != eventlog.TypeFired {
|
||||
t.Errorf("durable lifecycle invented transitions during cleanup: %+v", events)
|
||||
}
|
||||
}
|
||||
t.Logf("four cleanup/next-poll cycles: %d firing callbacks, %d dispatch callbacks", fired.Load(), deliveries.Load())
|
||||
lastSeen := active[0].LastSeen
|
||||
m.Stop() // Includes the real final active-state checkpoint.
|
||||
|
||||
restarted := newCleanupRetentionManager(t, dir, durable)
|
||||
active = restarted.GetActiveAlerts()
|
||||
if len(active) != 1 || active[0].ID != initial.ID || !active[0].StartTime.Equal(first) || !active[0].LastSeen.Equal(lastSeen) || active[0].Acknowledged || active[0].LastNotified == nil || !active[0].LastNotified.Equal(*initial.LastNotified) {
|
||||
t.Fatalf("restart did not preserve the continuing unacknowledged occurrence: %+v", active)
|
||||
}
|
||||
var afterRestartDeliveries, afterRestartFirings, afterRestartResolutions atomic.Int32
|
||||
restarted.SetAlertCallback(func(*Alert) { afterRestartDeliveries.Add(1) })
|
||||
restarted.SubscribeLifecycleCallback(func(event LifecycleEvent) {
|
||||
switch event.Type {
|
||||
case eventlog.TypeFired, eventlog.TypeRefired:
|
||||
afterRestartFirings.Add(1)
|
||||
case eventlog.TypeResolved:
|
||||
afterRestartResolutions.Add(1)
|
||||
}
|
||||
})
|
||||
container.UpdateStatus = nil
|
||||
restarted.checkDockerContainerImageUpdate(host, container, resourceID, container.Name, host.Hostname, host.Hostname)
|
||||
restarted.Cleanup(time.Hour)
|
||||
if len(restarted.GetActiveAlerts()) != 1 || afterRestartFirings.Load() != 0 || afterRestartDeliveries.Load() != 0 {
|
||||
t.Fatal("restart/unknown report lost or re-fired the occurrence")
|
||||
}
|
||||
container.UpdateStatus = &models.DockerContainerUpdateStatus{LastChecked: time.Now()}
|
||||
restarted.checkDockerContainerImageUpdate(host, container, resourceID, container.Name, host.Hostname, host.Hostname)
|
||||
if len(restarted.GetActiveAlerts()) != 0 || afterRestartResolutions.Load() != 1 || afterRestartDeliveries.Load() != 0 {
|
||||
t.Fatal("affirmative clear did not resolve once without another firing notification")
|
||||
}
|
||||
history := restarted.GetAlertHistory(0)
|
||||
if len(history) != 1 || history[0].OperationalRecord == nil || history[0].OperationalRecord.ResolvedAt == nil {
|
||||
t.Fatalf("affirmative clear did not preserve one resolved history occurrence: %+v", history)
|
||||
}
|
||||
t.Log("persisted restart preserved identity, age, last observation and dispatch time; affirmative clear resolved once")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -381,7 +381,7 @@ type AlertConfig struct {
|
||||
TimeThresholds map[string]int `json:"timeThresholds"` // Per-type delays: guest, node, agent, storage, pbs
|
||||
MetricTimeThresholds map[string]map[string]int `json:"metricTimeThresholds"` // Optional per-metric delays keyed by resource type
|
||||
MetricEvaluationWindows map[string]map[string]int `json:"metricEvaluationWindows"` // Rolling-average seconds keyed by resource type and metric; explicit zero uses the current value
|
||||
MaxAlertAgeDays int `json:"maxAlertAgeDays"` // Maximum age for alerts before auto-cleanup (0 = disabled)
|
||||
MaxAlertAgeDays int `json:"maxAlertAgeDays"` // Inactivity limit in days for unacknowledged alert cleanup (0 = disabled)
|
||||
MaxAcknowledgedAgeDays int `json:"maxAcknowledgedAgeDays"` // Maximum age for acknowledged alerts (0 = disabled)
|
||||
AutoAcknowledgeAfterHours int `json:"autoAcknowledgeAfterHours"` // Auto-acknowledge alerts after X hours (0 = disabled)
|
||||
FlappingEnabled bool `json:"flappingEnabled"` // Enable flapping detection
|
||||
|
||||
@@ -194,7 +194,7 @@ func defaultAlertConfig() AlertConfig {
|
||||
},
|
||||
},
|
||||
// Alert TTL defaults
|
||||
MaxAlertAgeDays: 7, // Auto-cleanup alerts older than 7 days
|
||||
MaxAlertAgeDays: 7, // Cleanup unacknowledged alerts inactive for 7 days
|
||||
MaxAcknowledgedAgeDays: 1, // Auto-cleanup acknowledged alerts older than 1 day
|
||||
AutoAcknowledgeAfterHours: 24, // Auto-acknowledge alerts after 24 hours
|
||||
// Flapping detection defaults
|
||||
|
||||
Reference in New Issue
Block a user