diff --git a/docs/VM_DISK_MONITORING.md b/docs/VM_DISK_MONITORING.md index f2854d396d..fe61cdf2a2 100644 --- a/docs/VM_DISK_MONITORING.md +++ b/docs/VM_DISK_MONITORING.md @@ -313,10 +313,15 @@ custom monitoring role if the required read privileges are absent. ### Current, retained and unavailable disk readings -In **Workloads**, read the disk cell's explanation or open the guest's details -and look under **Filesystems → Status**. Compact rows use **Prior** and **N/A**; -wider rows use **Last known** and **Unavailable**. Where an observation time is -shown, keep it with the reading; an unavailable time leaves freshness unknown. +In **Workloads**, select the disk reading indicator for its explanation, or +open the same guest's details and look under **Filesystems → Status**. Use the +details on a phone or with a keyboard rather than relying on a hover tooltip. +Retained readings use **Prior** in compact rows and **Last known** in wider rows. +An unavailable reading may show a dash or an unavailable label: the presence or +absence of a caption does not establish freshness or explain the cause. Read +the same guest's explanation before treating it as an agent setup problem. +Where an observation time is shown, keep it with the reading; an unavailable +time leaves freshness unknown. | Reading | What it establishes | | --- | --- | diff --git a/frontend-modern/browser-tests/guest-disk-docs-context.cjs b/frontend-modern/browser-tests/guest-disk-docs-context.cjs new file mode 100644 index 0000000000..81f77a3303 --- /dev/null +++ b/frontend-modern/browser-tests/guest-disk-docs-context.cjs @@ -0,0 +1,124 @@ +// Exercise the shipped guidance through the production row, drawer and Docs +// renderer. Synthetic observations only; no guest request or setup action. +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const assert = require('node:assert/strict'); +const { chromium, webkit } = require('playwright'); +const { createPublicationFixtureServer } = require('/workspace/frontend-modern/browser-tests/publication-fixture-server.cjs'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/guest-disk-docs-context'; +const origin = 'http://127.0.0.1:5331'; +const hash = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const result = { + result: 'incomplete', + playwright: require('playwright/package.json').version, + driver_sha256: hash(fs.readFileSync(__filename)), + cases: [], + captures: [], + cleanup: { browser: false, server: false }, + limits: 'Current-source presentation and guidance with safe fixtures; not PR #2893, native recovery, installed use or release acceptance.', + }; + let server, browser; + try { + assert.equal(result.playwright, JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version); + server = createPublicationFixtureServer(root, 5331, 'browser-tests/guest-reading-help.html'); + result.compiled = server.binding; + const docHash = hash(fs.readFileSync(root + '/public/docs/VM_DISK_MONITORING.md')); + assert.equal(server.binding.artifacts['docs/VM_DISK_MONITORING.md'], docHash); + assert.equal(hash(fs.readFileSync('/workspace/docs/VM_DISK_MONITORING.md')), docHash); + result.document_sha256 = docHash; + await server.listen(); + for (const [name, engine, width] of [ + ['chromium-desktop', chromium, 1365], + ['webkit-phone', webkit, 390], + ]) { + browser = await engine.launch(engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const context = await browser.newContext({ + viewport: { width, height: 900 }, + isMobile: width <= 768, + hasTouch: width <= 768, + locale: 'en-GB', + }); + const page = await context.newPage(); + page.setDefaultTimeout(12000); + const record = { name, browser: browser.version(), width, errors: [], requests: [], docs: [] }; + result.cases.push(record); + page.on('pageerror', (error) => record.errors.push(error.message)); + page.on('response', (response) => { + if (new URL(response.url()).pathname === '/docs/VM_DISK_MONITORING.md') { + record.docs.push(response.body().then((body) => ({ status: response.status(), sha256: hash(body) }))); + } + }); + await page.route('**/*', (route) => { + const request = route.request(), url = new URL(request.url()); + record.requests.push({ method: request.method(), path: url.pathname, external: url.origin !== origin }); + if (url.origin !== origin) return route.abort(); + if (url.pathname.startsWith('/api/')) return route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ success: true, data: [], alerts: [], anomalies: [], config: null }), + }); + return route.continue(); + }); + await page.goto(origin, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__readingHelp); + const disclosure = page.locator('[data-guest-id="fixture-pve:pve-a:101"] [data-row-action]').first(); + if (width <= 768) await page.getByText('reading-guest', { exact: true }).tap(); + else { await disclosure.focus(); await page.keyboard.press('Enter'); } + const drawer = page.getByRole('region', { name: 'Guest details' }); + await drawer.waitFor({ state: 'visible' }); + const details = drawer.getByTestId('guest-technical-details'); + assert.match(await details.innerText(), /does not prove it is absent or stopped/); + const guidance = details.getByRole('link', { name: 'Filesystem reading guidance' }); + if (width <= 768) await guidance.tap(); + else { await guidance.focus(); await page.keyboard.press('Enter'); } + await page.locator('#a-missing-reading-is-not-an-installation-diagnosis').waitFor({ state: 'visible' }); + const contextLink = page.getByRole('link', { name: "Check the reading's context", exact: true }); + if (width <= 768) await contextLink.tap(); + else { await contextLink.focus(); await page.keyboard.press('Enter'); } + const heading = page.locator('#current-retained-and-unavailable-disk-readings'); + await heading.waitFor({ state: 'visible' }); + await heading.scrollIntoViewIfNeeded(); + const text = (await page.locator('article').innerText()).replace(/\s+/g, ' '); + for (const expected of [ + 'select the disk reading indicator', 'phone or with a keyboard', + 'presence or absence of a caption does not establish freshness', + "same guest's explanation before treating it as an agent setup problem", + 'unknown, not zero', 'not current free space', + 'not a monitoring-coverage check', 'does not prove that the QEMU-only path recovered', + 'Do not bypass a guest-read pause or widen token access', + ]) assert.ok(text.includes(expected), `Missing rendered guidance: ${expected}`); + assert.doesNotMatch(text, /wider rows use Last known and Unavailable/); + record.guide = { url: page.url(), heading: await heading.innerText(), checks: 9 }; + record.layout = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth })); + assert.ok(record.layout.scrollWidth <= record.layout.width + 1, JSON.stringify(record.layout)); + const file = name + '-reading-context.png'; + await page.screenshot({ path: output + '/' + file }); + result.captures.push({ file, sha256: hash(fs.readFileSync(output + '/' + file)) }); + record.docs = await Promise.all(record.docs); + assert.ok(record.docs.length > 0, 'Docs did not consume the changed producer'); + for (const doc of record.docs) assert.deepEqual(doc, { status: 200, sha256: docHash }); + assert.deepEqual(record.errors, []); + assert.equal(record.requests.filter((request) => !['GET', 'HEAD'].includes(request.method)).length, 0); + assert.equal(record.requests.filter((request) => request.external).length, 0); + await context.close(); + await browser.close(); + browser = null; + } + result.result = 'passed'; + result.verified_at = new Date().toISOString(); + } catch (error) { + result.error = String(error.stack || error); + throw error; + } finally { + if (browser) await browser.close(); + result.cleanup.browser = true; + if (server) await server.close(); + result.cleanup.server = true; + fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n'); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/VM_DISK_MONITORING.md b/frontend-modern/public/docs/VM_DISK_MONITORING.md index f2854d396d..fe61cdf2a2 100644 --- a/frontend-modern/public/docs/VM_DISK_MONITORING.md +++ b/frontend-modern/public/docs/VM_DISK_MONITORING.md @@ -313,10 +313,15 @@ custom monitoring role if the required read privileges are absent. ### Current, retained and unavailable disk readings -In **Workloads**, read the disk cell's explanation or open the guest's details -and look under **Filesystems → Status**. Compact rows use **Prior** and **N/A**; -wider rows use **Last known** and **Unavailable**. Where an observation time is -shown, keep it with the reading; an unavailable time leaves freshness unknown. +In **Workloads**, select the disk reading indicator for its explanation, or +open the same guest's details and look under **Filesystems → Status**. Use the +details on a phone or with a keyboard rather than relying on a hover tooltip. +Retained readings use **Prior** in compact rows and **Last known** in wider rows. +An unavailable reading may show a dash or an unavailable label: the presence or +absence of a caption does not establish freshness or explain the cause. Read +the same guest's explanation before treating it as an agent setup problem. +Where an observation time is shown, keep it with the reading; an unavailable +time leaves freshness unknown. | Reading | What it establishes | | --- | --- | diff --git a/scripts/tests/test_guest_disk_reading_docs.py b/scripts/tests/test_guest_disk_reading_docs.py index 7f0ce11691..f3f5c2a7f2 100644 --- a/scripts/tests/test_guest_disk_reading_docs.py +++ b/scripts/tests/test_guest_disk_reading_docs.py @@ -10,21 +10,41 @@ HEADING = "### Current, retained and unavailable disk readings\n" ANCHOR = "#current-retained-and-unavailable-disk-readings" +def assert_disk_reason_wiring(test, row, presentation): + # An unavailable caption is optional presentation, not the reading's state + # or explanation. Bind the actual disk cell (not another metric's N/A) to + # its reason, and keep the current/retained/unavailable producer distinct. + disk_cell = row.split("{/* Disk */}", 1)[1].split("{/* IP Address", 1)[0] + test.assertIn("title={diskReadStatus()?.message}", disk_cell) + test.assertIn("title={getDiskStatusTooltip()}", disk_cell) + test.assertIn("{status().message}", disk_cell) + test.assertIn("state: reason.startsWith('prev-') ? 'last-known' : 'unavailable'", presentation) + test.assertIn("message: getWorkloadGuestDiskStatusMessage(reason)", presentation) + + class GuestDiskReadingDocsTest(unittest.TestCase): def section(self): guide = (ROOT / "docs/VM_DISK_MONITORING.md").read_text(encoding="utf-8") self.assertEqual(guide.count(HEADING), 1) return " ".join(guide.split(HEADING, 1)[1].split("\n### ", 1)[0].split()) - def test_explanations_use_existing_labels_without_claiming_freshness(self): + def test_explanations_distinguish_reading_states_without_requiring_an_empty_caption(self): text = self.section() row = (ROOT / "frontend-modern/src/components/Workloads/GuestRow.tsx").read_text() drawer = (ROOT / "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx").read_text() presentation = (ROOT / "frontend-modern/src/utils/workloadGuestPresentation.ts").read_text() - for label in ("Prior", "N/A", "Last known", "Unavailable"): + for label in ("Prior", "Last known"): with self.subTest(label=label): self.assertIn(label, text) self.assertIn(f"'{label}'", row) + for empty_state in ("N/A", "Unavailable", "a dash"): + self.assertIn(empty_state, text) + assert_disk_reason_wiring(self, row, presentation) + for distinction in ("select the disk reading indicator", "same guest's details", + "phone or with a keyboard", "rather than relying on a hover tooltip", + "presence or absence of a caption does not establish freshness", + "same guest's explanation before treating it as an agent setup problem"): + self.assertIn(distinction, text) self.assertIn("Filesystems → Status", text) self.assertIn("label: 'Filesystems'", drawer) self.assertIn("makeDetailRow('Status'", drawer) @@ -35,6 +55,22 @@ class GuestDiskReadingDocsTest(unittest.TestCase): "A cooldown ending does not establish a successful new read"): self.assertIn(distinction, text) + def test_reason_contract_accepts_caption_removal_not_reason_removal(self): + row = (ROOT / "frontend-modern/src/components/Workloads/GuestRow.tsx").read_text() + presentation = (ROOT / "frontend-modern/src/utils/workloadGuestPresentation.ts").read_text() + # A future caption-only edit must not require false documentation or + # pass just because another metric still contains the old label. + assert_disk_reason_wiring(self, row.replace("'N/A'", "null").replace("'Unavailable'", "null"), + presentation) + for required in ("title={diskReadStatus()?.message}", "title={getDiskStatusTooltip()}", + "{status().message}"): + with self.subTest(missing=required), self.assertRaises(AssertionError): + assert_disk_reason_wiring(self, row.replace(required, ""), presentation) + for required in ("state: reason.startsWith('prev-') ? 'last-known' : 'unavailable'", + "message: getWorkloadGuestDiskStatusMessage(reason)"): + with self.subTest(missing=required), self.assertRaises(AssertionError): + assert_disk_reason_wiring(self, row, presentation.replace(required, "")) + def test_inventory_workflow_does_not_confuse_selection_with_coverage(self): text = self.section() for distinction in ("not a monitoring-coverage check", "clear numeric disk conditions",