diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 443f3bb4df..a4ad773973 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -8304,3 +8304,34 @@ chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS, 403/503 controls and pending recovery. Unit controls separately cover current request ownership and disposal. No mutation, route, navigation entry, queue policy, permission or recipient-delivery contract is added by these states. + +### Drawer History does not interpolate across missing observations + +The shared `GuestDrawerHistory` renderer connects a series only across +consecutive stored times in that configured panel. If another series has a +stored observation at an intervening time and this series does not, its line +stops. Each isolated observation, including a measured zero, remains visible +as a point; separate continuous segments retain all their original values, +timestamps and common-window coordinates. Independently sampled series must +not imply simultaneous observations or a continuous trend through absent data. + +A visible, SVG-described explanation names the affected configured series and +states the limit: missing readings do not identify an outage's cause or duration. +There is no elapsed-time/cadence heuristic, synthetic zero, invented observation, +new API field or collector change. Times outside the configured panel cannot +split its series. Entirely absent series retain the existing missing/fallback +policy. Live or retained snapshot carriers cannot repair a stored gap; only a +matching stored response can. Source/range/access isolation, transient retained +reads and exact-time keyboard/pointer/touch inspection remain in force. + +`GuestDrawerHistory.gaps.test.tsx` and `guestHistorySegments.test.ts` cover +split lines, singleton recovery, zero rates, interleaved sampling, multiple +segments, configured-group isolation, invalid points, elapsed-time controls, +live updates, stored refresh and denied/transient reads. `GuestDrawer.test.tsx` +checks the full drawer's live-state recovery and unchanged request budget. +Updated pointer/sparse +regressions preserve their original missing-value and fallback obligations. +`browser-tests/history-observation-gaps.cjs` exercises the production full guest +drawer with synthetic APIs in Chromium/WebKit desktop and 390/320-pixel light +and dark views. This is presentation acceptance, not native backup/QGA safety, +provider outage attribution, installed recovery or release availability. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 4d6f1ac82f..4cbf715b53 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -3661,3 +3661,17 @@ backend, metrics-store, performance threshold or latency claim changes here. including selection retention while a same-source window advances. Browser receipts exercise the actual PBS table/drawer renderer with synthetic APIs, not installed collection or a performance benchmark. + +### Stored drawer History segmentation stays bounded + +Missing-observation rendering uses the existing normalized, configured-group +timestamps and the unchanged maximum of 240 requested points per metric. The +segment builder indexes those timestamps once per series and walks its stored +points without nested nearest-point searches. It adds no request, polling timer, +collector or synthetic reading. Separate singletons/segments retain the source +data and shared time window; live updates cannot fill absent stored evidence. +The mounted `GuestDrawerHistory.gaps.test.tsx` controls pin one initial read and +only explicit matching refreshes; the full `GuestDrawer.test.tsx` pins the +unchanged cap and no live-recovery read. `guestHistorySegments.test.ts` pins +complete point preservation at the existing request bound. Neither claims a +timing benchmark, fleet CPU attribution or native incident relief. diff --git a/frontend-modern/browser-tests/history-observation-gaps.cjs b/frontend-modern/browser-tests/history-observation-gaps.cjs new file mode 100644 index 0000000000..8fa4c98ba9 --- /dev/null +++ b/frontend-modern/browser-tests/history-observation-gaps.cjs @@ -0,0 +1,339 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/history-observation-gaps/browser'; +const origin = 'http://127.0.0.1:5317'; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx', + 'frontend-modern/src/components/Workloads/guestDrawerModel.ts', +]; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const settle = (page) => + page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const result = { + result: 'incomplete', + playwright: require('playwright/package.json').version, + runtime_hashes: Object.fromEntries(runtime.map((file) => [file, hash('/workspace/' + file)])), + cases: [], + screenshots: [], + limits: + 'Production full GuestDrawer/History, client and CSS with synthetic local APIs. Not native guest/QGA/backup/thaw, installed recovery, physical-phone or release acceptance.', + }; + assert.equal( + result.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + process.chdir(root); + 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: output + '/cache', + server: { host: '127.0.0.1', port: 5317, strictPort: true }, + }); + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['chromium-desktop-light', chromium, 1365, false], + ['chromium-desktop-dark', chromium, 1365, true], + ['webkit-phone-dark', webkit, 390, true], + ['webkit-narrow-light', webkit, 320, false], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width < 768 ? 844 : 900 }, + isMobile: width < 768, + hasTouch: width < 768, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(12000); + page.setDefaultNavigationTimeout(60000); + const record = { + name, + width, + dark, + browser: browser.version(), + errors: [], + requests: [], + checks: [], + }; + result.cases.push(record); + let mode = 'gaps'; + const start = Date.UTC(2026, 9, 4, 12); + const point = (minute, value) => ({ + timestamp: start + minute * 60_000, + value, + min: value, + max: value, + }); + const times = [0, 15, 30, 45, 60]; + const metrics = () => ({ + cpu: times.map((minute, index) => point(minute, 10 + index * 5)), + memory: (mode === 'complete' ? times : [0, 15, 60]).map((minute) => + point(minute, 20 + minute / 6), + ), + disk: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 50)), + netin: times.map((minute) => point(minute, 100)), + netout: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 0)), + diskread: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 0)), + diskwrite: times.map((minute) => point(minute, 200)), + }); + page.on('pageerror', (error) => record.errors.push(error.message)); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== origin) { + record.requests.push({ origin: url.origin, blocked: true }); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + record.requests.push({ path: url.pathname, method: route.request().method(), mode }); + if (url.pathname === '/api/metrics-store/history') { + assert.equal(route.request().method(), 'GET'); + assert.equal(url.searchParams.get('resourceId'), 'fixture:pve:101'); + assert.equal(url.searchParams.get('maxPoints'), '240'); + if (mode === '503' || mode === '403') + return route.fulfill({ + status: Number(mode), + json: { error: 'private-fixture-detail' }, + }); + return route.fulfill({ + json: { + resourceType: url.searchParams.get('resourceType'), + resourceId: url.searchParams.get('resourceId'), + range: url.searchParams.get('range'), + start, + end: start + 60 * 60_000, + metrics: metrics(), + source: 'store', + }, + }); + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 90, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { success: true, data: [], alerts: [], anomalies: [], config: null }, + }); + }); + await page.addInitScript((isDark) => { + document.addEventListener('DOMContentLoaded', () => + document.documentElement.classList.toggle('dark', isDark), + ); + window.__trustedInputs = []; + for (const type of ['click', 'input', 'keydown']) + document.addEventListener( + type, + (event) => window.__trustedInputs.push({ type, trusted: event.isTrusted }), + true, + ); + }, dark); + await page.goto(origin + '/browser-tests/history-observation-gaps.html'); + const activate = async (locator) => (width < 768 ? locator.tap() : locator.click()); + await activate(page.getByRole('tab', { name: 'History', exact: true })); + const chart = page.locator('[data-history-group="utilization"]'); + const slider = chart.getByRole('slider', { name: 'Inspect Utilization history' }); + await slider.waitFor(); + await settle(page); + await page.evaluate(() => { + window.__originalHistoryChart = document.querySelector( + '[data-history-group="utilization"]', + ); + }); + const memoryPath = chart.locator('path[stroke="#f59e0b"]'); + assert.equal(await memoryPath.count(), 1); + assert.equal((await memoryPath.getAttribute('d')).match(/L/g).length, 1); + assert.equal(await chart.locator('[data-history-observation="memory"]').count(), 1); + assert.equal(await chart.locator('path[stroke="#10b981"]').count(), 0); + assert.equal(await chart.locator('[data-history-observation="disk"]').count(), 2); + assert.equal( + (await chart.locator('path[stroke="#8b5cf6"]').getAttribute('d')).match(/L/g).length, + 4, + ); + assert.match( + await chart.locator('[data-history-gaps]').innerText(), + /Missing observations: Memory, Disk\./, + ); + assert.equal(await page.locator('[data-history-gaps]').count(), 3); + const description = await chart + .getByRole('img', { name: 'Utilization history' }) + .evaluate((el) => + el + .getAttribute('aria-describedby') + .split(' ') + .map((id) => document.getElementById(id).textContent) + .join(' '), + ); + assert.match(description, /Missing observations: Memory, Disk\./); + assert.match(description, /does not identify the cause or duration/); + const noteGeometry = await chart.locator('[data-history-gaps]').evaluate((el) => ({ + width: el.clientWidth, + scrollWidth: el.scrollWidth, + colour: getComputedStyle(el).color, + })); + assert.ok(noteGeometry.scrollWidth <= noteGeometry.width + 1); + record.noteGeometry = noteGeometry; + record.checks.push( + 'Actual stored gap splits only affected metrics; recovered singleton/zero samples stay visible; visible/accessibly linked explanation fits.', + ); + const screenshot = async (suffix) => { + await settle(page); + const file = `${name}-${suffix}.png`; + await page.screenshot({ path: path.join(output, file), fullPage: true }); + result.screenshots.push({ + file, + sha256: hash(path.join(output, file)), + name, + state: suffix, + }); + }; + await screenshot('gaps'); + + await slider.focus(); + await page.keyboard.press('Home'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + assert.equal(await slider.inputValue(), '2'); + assert.match( + await slider.getAttribute('aria-valuetext'), + /Memory no observation\. Disk no observation\./, + ); + assert.match(await chart.innerText(), /Memory\s*-/); + assert.equal(await chart.locator('circle[r="3"]').count(), 1); + record.checks.push( + 'Trusted Home/Arrow keys inspect the missing stored time without borrowing nearby/live memory or disk.', + ); + if (width < 768) { + await slider.scrollIntoViewIfNeeded(); + const box = await slider.boundingBox(); + await page.touchscreen.tap(box.x + box.width * 0.75, box.y + box.height / 2); + assert.notEqual(await slider.inputValue(), '2'); + record.checks.push('Trusted touch operates the native inspector.'); + } else { + await page.getByRole('heading', { name: 'Drawer History missing observations' }).click(); + const box = await chart.getByRole('img', { name: 'Utilization history' }).boundingBox(); + await page.mouse.move(box.x + box.width * 0.537, box.y + box.height / 2); + assert.match( + await chart.locator('[data-testid="guest-history-hover-time"]').innerText(), + /12:30/, + ); + assert.equal(await chart.locator('circle[r="3"]').count(), 1); + record.checks.push( + 'Trusted pointer matches the exact stored time, not a synthetic line value.', + ); + } + const reads = () => + record.requests.filter((r) => r.path === '/api/metrics-store/history').length; + const before = reads(); + await activate(page.getByRole('button', { name: 'Simulate live recovery' })); + assert.equal(reads(), before); + assert.equal(await chart.locator('[data-history-gaps]').count(), 1); + assert.equal(await chart.locator('[data-history-observation="memory"]').count(), 1); + assert.equal( + await page.evaluate( + () => + window.__originalHistoryChart === + document.querySelector('[data-history-group="utilization"]'), + ), + true, + ); + record.checks.push( + 'Live status recovery cannot fill missing stored evidence, refetch or remount the chart.', + ); + + mode = '503'; + await activate(page.getByRole('button', { name: 'Refresh history' })); + await page.getByRole('button', { name: 'Retry history' }).waitFor(); + assert.equal(await chart.locator('[data-history-gaps]').count(), 1); + assert.equal(await chart.locator('path[stroke="#10b981"]').count(), 0); + assert.doesNotMatch(await page.locator('body').innerText(), /private-fixture-detail/); + record.checks.push('Transient read failure retains the gap and does not leak the raw error.'); + mode = 'complete'; + await activate(page.getByRole('button', { name: 'Retry history' })); + await page.waitForFunction( + () => document.querySelectorAll('[data-history-gaps]').length === 0, + ); + assert.equal(await chart.locator('[data-history-observation]').count(), 0); + assert.equal((await memoryPath.getAttribute('d')).match(/L/g).length, 4); + assert.equal( + await page.evaluate( + () => + window.__originalHistoryChart === + document.querySelector('[data-history-group="utilization"]'), + ), + true, + ); + record.checks.push( + 'Matching complete stored refresh joins actual consecutive observations; chart identity stays stable.', + ); + await screenshot('complete'); + mode = '403'; + await activate(page.getByRole('button', { name: 'Refresh history' })); + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="guest-history-group-chart"]').length === 0, + ); + assert.equal(await page.locator('[data-history-gaps]').count(), 0); + assert.doesNotMatch(await page.locator('body').innerText(), /private-fixture-detail/); + record.checks.push('Access denial withdraws all stored chart/gap/inspection evidence.'); + assert.equal(reads(), before + 3); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), + true, + ); + assert.equal( + record.requests.some((r) => r.method && r.method !== 'GET'), + false, + ); + assert.equal( + record.requests.some((r) => r.blocked), + false, + ); + assert.deepEqual(record.errors, []); + assert.equal( + await page.evaluate(() => window.__trustedInputs.every((entry) => entry.trusted)), + true, + ); + record.checks.push( + 'No page error, horizontal overflow, external request or mutation; unchanged user-request read budget.', + ); + await page.close(); + await browser.close(); + browser = null; + } + result.result = 'passed'; + } catch (error) { + result.failure = error.stack || String(error); + throw error; + } finally { + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + if (browser) await browser.close(); + await server.close(); + } + process.stdout.write(JSON.stringify(result) + '\n'); +})().catch((error) => { + process.stderr.write(error.stack + '\n'); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/history-observation-gaps.html b/frontend-modern/browser-tests/history-observation-gaps.html new file mode 100644 index 0000000000..e73184c18e --- /dev/null +++ b/frontend-modern/browser-tests/history-observation-gaps.html @@ -0,0 +1,12 @@ + + + + + + Drawer History missing-observation verification + + +
+ + + diff --git a/frontend-modern/browser-tests/history-observation-gaps.tsx b/frontend-modern/browser-tests/history-observation-gaps.tsx new file mode 100644 index 0000000000..20eac8f05d --- /dev/null +++ b/frontend-modern/browser-tests/history-observation-gaps.tsx @@ -0,0 +1,87 @@ +// Real production drawer/CSS. The browser supplies synthetic stored readings; +// no collector, guest-agent, backup, thaw or installation operation is run. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { WorkloadGuest } from '../src/types/workloads'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: false }); + +function Fixture() { + const [recovered, setRecovered] = createSignal(false); + const guest = (): WorkloadGuest => ({ + id: 'fixture:pve:101', + vmid: 101, + name: 'backup-guest', + node: 'pve', + instance: 'fixture', + status: 'running', + type: 'qemu', + cpu: 0.1, + cpus: 2, + memory: { + total: 1024 ** 3, + used: 256 * 1024 ** 2, + free: 768 * 1024 ** 2, + usage: 25, + observation: { + state: recovered() ? 'current' : 'last-known', + source: 'status-mem', + observedAt: '2026-10-04T12:01:00Z', + }, + }, + disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 }, + diskStatusReason: recovered() ? '' : 'prev-vm-locked', + lock: recovered() ? '' : 'backup', + guestAgentStatus: recovered() ? 'available' : 'deferred', + backupInProgress: !recovered(), + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: 0, + tags: [], + lastSeen: '2026-10-04T12:04:00Z', + }); + return ( +
+

Drawer History missing observations

+

+ Synthetic readings. No backup or guest-agent command is sent. +

+ +
+ {}} /> +
+
+ ); +} + +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/51fcee98af1964fd.json b/frontend-modern/browser-verification/51fcee98af1964fd.json new file mode 100644 index 0000000000..c0d206a73c --- /dev/null +++ b/frontend-modern/browser-verification/51fcee98af1964fd.json @@ -0,0 +1,59 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "0ad41e4c9a309ce459dd717d16d343b46efcac8a", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "8b94c3d9409f46bedf812b00ff9f3156da9ace6885ec04456ea5fed59d842127", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "79e176dd5640190f6bf11d3a6bbddc63fcd18c37b19e4e7098163a95d542b0a5" + }, + "routes": [ + "/browser-tests/history-observation-gaps.html \u2014 production full GuestDrawer/History/client/CSS, not the full App route" + ], + "states": [ + "Stored CPU is continuous while memory and disk miss intervening group times; preserve split segments and recovery singleton/zero readings.", + "Live recovery alone preserves stored gaps and chart identity without a read.", + "Transient 503 retains data/gaps; matching complete stored refresh restores actual contiguous lines; denied 403 withdraws all history." + ], + "interactions": [ + "Trusted desktop click, Home/Arrow keyboard inspection and pointer at exact missing stored time.", + "Trusted WebKit 390/320 px native inspector touch, keyboard arrows and refresh/retry.", + "Trusted live-recovery action does not alter stored points, query budget or chart identity.", + "All eight captures visually inspected for gaps, isolated points, contiguous recovery, light/dark readability and 390/320 px wrapping." + ], + "viewports": [ + { + "width": 1365, + "height": 900, + "browser": "chromium-desktop-light", + "theme": "light" + }, + { + "width": 1365, + "height": 900, + "browser": "chromium-desktop-dark", + "theme": "dark" + }, + { + "width": 390, + "height": 844, + "browser": "webkit-phone-dark", + "theme": "dark" + }, + { + "width": 320, + "height": 844, + "browser": "webkit-narrow-light", + "theme": "light" + } + ], + "verified_at": "2026-10-04T22:39:45.443984Z", + "playwright_version": "1.56.1", + "checks": 32, + "screenshots_inspected": 8, + "script": "frontend-modern/browser-tests/history-observation-gaps.cjs", + "limits": "Production full GuestDrawer/History, client and CSS with synthetic local APIs. Not native guest/QGA/backup/thaw, installed recovery, physical-phone or release acceptance." +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index d7d9b65dfe..6f6986b054 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -220,6 +220,61 @@ afterEach(() => { // ── Tests ────────────────────────────────────────────────────────────── describe('GuestDrawer', () => { + it('keeps stored History gaps through live guest recovery without another read', async () => { + const points = makeHistoryPoints(10); + const payload = { + resourceType: 'vm', + resourceId: 'inst1:node1:100', + range: '24h', + start: 1, + end: 3, + source: 'store', + metrics: { cpu: points, memory: [points[0], points[2]] }, + }; + chartsApiMocks.getMetricsHistory.mockResolvedValue(payload); + const [guest, setGuest] = createSignal( + makeGuest({ lock: 'backup', diskStatusReason: 'prev-vm-locked' }), + ); + render(() => ); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const chart = screen.getAllByTestId('guest-history-group-chart')[0]; + expect(chart.querySelectorAll('path')).toHaveLength(1); + expect(chart.querySelectorAll('[data-history-observation="memory"]')).toHaveLength(2); + expect(chart.querySelector('[data-history-gaps]')).toHaveTextContent( + 'Missing observations: Memory.', + ); + setGuest({ + ...guest(), + lock: '', + diskStatusReason: '', + memory: { + ...guest().memory, + usage: 99, + observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T12:00:00Z' }, + }, + }); + expect(screen.getAllByTestId('guest-history-group-chart')[0]).toBe(chart); + expect(chart.querySelectorAll('[data-history-observation="memory"]')).toHaveLength(2); + expect(chart).not.toHaveTextContent('99.0%'); + expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(1); + expect(chartsApiMocks.getMetricsHistory.mock.calls[0][0]).toMatchObject({ + resourceType: 'vm', + resourceId: 'inst1:node1:100', + range: '24h', + maxPoints: 240, + }); + chartsApiMocks.getMetricsHistory.mockResolvedValue({ + ...payload, + metrics: { cpu: points, memory: points }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Refresh history' })); + await waitFor(() => expect(chart.querySelector('[data-history-gaps]')).toBeNull()); + expect(chart.querySelectorAll('path')).toHaveLength(2); + expect(chart.querySelector('[data-history-observation="memory"]')).toBeNull(); + expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(2); + }); + it('keeps the backup precaution visible across drawer tabs without starting a guest check', async () => { const [guest, setGuest] = createSignal( makeGuest({ diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 1f1ace5910..d0c984b89c 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -38,6 +38,7 @@ import { buildGuestDrawerHistoryPath, getGuestDrawerHistoryRangeBounds, getGuestDrawerHistoryScale, + getGuestDrawerHistorySegments, getGuestDrawerHistoryValueLabel, normalizeGuestDrawerHistoryPoints, type GuestDrawerHistoryGroupConfig, @@ -277,8 +278,30 @@ const GuestDrawerHistoryGroupChart: Component 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 singlePointSeries = createMemo(() => series().filter((item) => item.points.length === 1)); + const segmentedSeries = createMemo(() => + series().map((item) => ({ + ...item, + segments: getGuestDrawerHistorySegments(item.points, observationTimes()), + })), + ); + const gapMessage = createMemo(() => { + const labels = segmentedSeries() + .filter((item) => item.segments.length > 1) + .map((item) => item.label); + return labels.length > 0 + ? `Missing observations: ${labels.join(', ')}. Lines stop where a series has no reading at another stored time in this panel. This does not identify the cause or duration of a monitoring outage.` + : null; + }); + const drawableSeries = createMemo(() => + segmentedSeries().flatMap((item) => + item.segments.filter((points) => points.length >= 2).map((points) => ({ ...item, points })), + ), + ); + const singlePointSeries = createMemo(() => + segmentedSeries().flatMap((item) => + item.segments.filter((points) => points.length === 1).map((points) => ({ ...item, points })), + ), + ); const singleObservation = createMemo(() => observationTimes().length === 1 ? { timestamp: observationTimes()[0] } : null, ); @@ -443,7 +466,7 @@ const GuestDrawerHistoryGroupChart: Component

{chartDescription()}

+ + {(message) => ( +

+ {message()} +

+ )} +
{(observation) => (

diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx index ef5cc1c7e9..7474afe6be 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx @@ -84,7 +84,7 @@ describe('GuestDrawerHistory common-time pointer inspection', () => { expect( within(utilization()).getByRole('img', { name: 'Utilization history' }), ).toHaveAccessibleDescription( - `${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`, + `${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation. Missing observations: CPU, Memory. Lines stop where a series has no reading at another stored time in this panel. This does not identify the cause or duration of a monitoring outage.`, ); fireEvent.pointerLeave(plot); expect(utilization()).toHaveTextContent('CPU43.0%'); @@ -101,7 +101,8 @@ describe('GuestDrawerHistory common-time pointer inspection', () => { expect(utilization()).toHaveTextContent('Memory-'); expect(utilization()).toHaveTextContent('Disk0.0%'); expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(1); - expect(utilization().querySelectorAll('path')).toHaveLength(2); + expect(utilization().querySelectorAll('path')).toHaveLength(1); + expect(utilization().querySelectorAll('[data-history-observation]')).toHaveLength(4); }); it('renders all metrics that really share a timestamp at the same cursor position', async () => { diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx index 2d428eca9e..885d1078e7 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx @@ -165,7 +165,7 @@ describe('GuestDrawerHistory sparse observations and current provenance', () => }, ); - it('draws a lone series alongside a trend and labels only the live fallback', async () => { + it('preserves interleaved lone observations and labels only the live fallback', async () => { vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( response({ cpu: [point(start, 10), point(end, 20)], @@ -173,12 +173,10 @@ describe('GuestDrawerHistory sparse observations and current provenance', () => }), ); mount({ cpu: 99, memory: 98, disk: 88 }); - await waitFor(() => expect(dots()).toHaveLength(1)); - expect(group().querySelectorAll('path')).toHaveLength(1); - expect(group().querySelector('[data-history-observation]')).toHaveAttribute( - 'data-history-observation', - 'memory', - ); + await waitFor(() => expect(dots()).toHaveLength(3)); + expect(group().querySelectorAll('path')).toHaveLength(0); + expect(group().querySelectorAll('[data-history-observation="memory"]')).toHaveLength(1); + expect(group().querySelectorAll('[data-history-observation="cpu"]')).toHaveLength(2); expect(group().querySelectorAll('[data-history-current]')).toHaveLength(1); expect(group().querySelector('[data-history-current="disk"]')).toHaveTextContent( 'Disk88.0%current', @@ -191,7 +189,7 @@ describe('GuestDrawerHistory sparse observations and current provenance', () => expect(group()).toHaveTextContent('Memory0.0%'); expect(group()).toHaveTextContent('Disk-'); expect(group().querySelector('[data-history-current]')).toBeNull(); - expect(dots()).toHaveLength(1); + expect(dots()).toHaveLength(3); }); it('does not connect single observations from different series or borrow their times', async () => { diff --git a/frontend-modern/src/components/Workloads/__tests__/guestHistorySegments.test.ts b/frontend-modern/src/components/Workloads/__tests__/guestHistorySegments.test.ts new file mode 100644 index 0000000000..c488627e30 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/guestHistorySegments.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { getGuestDrawerHistorySegments } from '../guestDrawerModel'; + +const point = (timestamp: number, value = 0) => ({ timestamp, value, min: value, max: value }); + +describe('stored History segments', () => { + it('retains real points and both sides of each missing in-panel time', () => { + const points = Object.freeze([point(1), point(2), point(4), point(6), point(7)]); + const times = Object.freeze([1, 2, 3, 4, 5, 6, 7]); + const segments = getGuestDrawerHistorySegments(points, times); + expect(segments).toEqual([[points[0], points[1]], [points[2]], [points[3], points[4]]]); + expect(segments.flat()).toEqual(points); + expect(segments[1][0]).toBe(points[2]); + }); + + it('keeps duplicate-time readings in the same segment, without creating a new time', () => { + const points = [point(1, 0), point(1, 10), point(2, 20), point(4, 30)]; + expect(getGuestDrawerHistorySegments(points, [1, 2, 3, 4])).toEqual([ + points.slice(0, 3), + [points[3]], + ]); + }); + + it('cannot guess gaps from elapsed time when all stored times match', () => { + const points = [point(1), point(1_000_000)]; + expect(getGuestDrawerHistorySegments(points, [1, 1_000_000])).toEqual([points]); + }); + + it('keeps an empty series empty and never accepts a point outside the group times', () => { + expect(getGuestDrawerHistorySegments([], [1, 2, 3])).toEqual([]); + expect(getGuestDrawerHistorySegments([point(1)], [])).toEqual([]); + expect(getGuestDrawerHistorySegments([point(1), point(2), point(3)], [1, 3])).toEqual([ + [point(1), point(3)], + ]); + }); + + it('preserves every point at the existing request bound', () => { + const times = Array.from({ length: 240 }, (_, index) => index); + const points = times.filter((time) => time % 2 === 0).map((time) => point(time)); + const segments = getGuestDrawerHistorySegments(points, times); + expect(segments).toHaveLength(120); + expect(segments.every((segment) => segment.length === 1)).toBe(true); + expect(segments.flat()).toEqual(points); + }); +}); diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index e511545781..7435d99698 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -355,6 +355,29 @@ export const getGuestDrawerHistoryScale = ( return { minValue: 0, maxValue: Math.max(1, maxValue * 1.15) }; }; +// Both inputs are normalized stored observations, ordered by timestamp. A +// series cannot span a time where another series in its panel has a reading +// and this one does not. Do not infer failed polls or cadence from elapsed +// time: the API supplies neither. Keep singleton segments as real evidence. +export const getGuestDrawerHistorySegments = ( + points: readonly AggregatedMetricPoint[], + observationTimes: readonly number[], +): AggregatedMetricPoint[][] => { + const timeIndices = new Map(observationTimes.map((timestamp, index) => [timestamp, index])); + const segments: AggregatedMetricPoint[][] = []; + let previousIndex: number | undefined; + for (const point of points) { + const index = timeIndices.get(point.timestamp); + if (index === undefined) continue; + if (previousIndex === undefined || (index !== previousIndex && index !== previousIndex + 1)) { + segments.push([]); + } + segments[segments.length - 1].push(point); + previousIndex = index; + } + return segments; +}; + export const buildGuestDrawerHistoryPath = ( points: readonly AggregatedMetricPoint[], scale: GuestDrawerHistoryScale,