diff --git a/docs/METRICS_HISTORY.md b/docs/METRICS_HISTORY.md index 9165500d6d..1555f2b2fb 100644 --- a/docs/METRICS_HISTORY.md +++ b/docs/METRICS_HISTORY.md @@ -2,6 +2,72 @@ Pulse persists metrics history to disk so trend views and sparklines survive restarts. +## Read a History panel + +Start with the existing chart, not a restart or a storage change. A current +reading does not prove that it was stored, and a selected time range does not +prove continuous coverage. **Window start** and **Window end** bound the +requested interval; they are not the first and last successful observations. + +VM and LXC drawer panels can show these labels. Other charts and older Pulse +versions may differ; if a time or source is not shown, keep it unknown rather +than borrowing one from the current overview. + +| Display | How to read it | What it does not establish | +| --- | --- | --- | +| **Latest stored** | The newest stored observation time in this panel. Each value belongs to that time; a dash means no stored observation for that metric at that time. | Not necessarily now, every metric's newest value, or another panel's time. | +| **Stored** while inspecting | Values at the selected stored observation time. | Not a live reading or an interpolated observation between points. | +| **current**, **last known** or **freshness unknown** with no stored history | A separately qualified fallback reading. Keep its label and explanation. | Not proof that the value was persisted or belongs to a past incident. | +| **Single observation. No trend yet.** | One stored time in that panel. | Not a flat trend or sustained recovery. | +| **Missing observations** | Some series lack readings at other stored times in that panel; lines stop across those gaps. | Not the cause or duration of a monitoring outage. Missing is not measured zero. | + +For example, CPU **22% at 12:02** and memory **95% at 12:01** are not one +observation. At **Latest stored · 12:02**, memory is a dash if there is no +memory point at 12:02; the earlier 95% remains inspectable. Network and disk +I/O panels have their own times, not a shared snapshot of the guest. + +1. Keep the same resource, metric and range when comparing existing readings. + **Inspect history**, when offered, steps through stored times: focus the + slider and use the arrow keys, **Home** or **End**, or move it by touch. + Hover is another way to inspect; it is not required. Leaving the slider + returns to the latest stored display. +2. Keep the displayed observation time and qualification with the value. + Stored points can be aggregated or downsampled; they are not a measurement + of every instant in the selected range. A page refresh time is not the + observation time. If an absolute time is unavailable, retain that limit. +3. Compare an alert or Patrol finding with the original episode, not just + today's overview. Today's source cannot identify the source of a past + warning. Follow [guest memory interpretation](TROUBLESHOOTING.md#guest-memory-is-high-but-available-memory-is-plentiful) + when unlike readings suggest memory pressure. + +Use already available observations; do not run Diagnostics or guest-agent +probes, restart a guest, repeat a backup, clear History or change retention to +make a comparison agree. An unresponsive guest or an uncertain freeze/thaw +operation needs [Backup safety](VM_DISK_MONITORING.md#backup-safety), not chart +inspection. When reporting a display disagreement, keep the panel, time, +range, labels and relevant values with consistent private aliases; do not +share raw responses, credentials or a full network export. + +## Retention and accessible range + +Storage retention and the range your running instance can serve are separate. +Community supports up to **7 days**; Pro and legacy Pro+ entitlements support +up to **90 days**. The panel uses the active runtime's history limit, not the +length of time the database has existed. A longer retention setting does not +grant access, and a licence cannot recover observations that were never stored. + +**Relay is no longer sold.** Continuing Relay subscribers receive Pro features, +including 90-day history, at their existing price. Older Relay payloads can +still show a 14-day compatibility limit; that is not a plan to buy or proof +that a continuing subscription has received its current entitlement. See +[plans and legacy subscriptions](PULSE_PRO.md#self-hosted-commercial-model). + +An API request beyond the active limit returns **402 Payment Required**, not +an empty history or a failed collector. Use an allowed range and the existing +licence/range display first; do not buy another licence, reset history or +reinstall merely to diagnose it. Storage defaults below do not guarantee that +every resource has data for every allowed range. + ## Storage Location Metrics history is stored in a SQLite database named `metrics.db` under the Pulse data directory: @@ -178,7 +244,9 @@ durable historical coverage. An HTTP 200 with no points proves neither zero usage nor successful collection, and a non-zero current value does not prove every historical interval is present. -> **License**: Requests beyond Community's `7d` floor require the paid `long_term_metrics` entitlement. Relay unlocks `14d`, Pro and legacy Pro+ unlock `90d`, and requests beyond the active tier's limit return `402 Payment Required`. +> **History limit**: Requests beyond the active runtime's allowed range return +> `402 Payment Required`. See [retention and accessible range](#retention-and-accessible-range); +> stored retention is not access or proof of collected observations. > **Older examples**: `container`, `dockerHost`, `dockerContainer`, `guest` and > `docker` are not current `resourceType` query values. Use the chart's current > type rather than a display label or internal storage name. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ced4188f9f..00b3f1e3b9 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -680,6 +680,11 @@ same units and memory allocation. The Proxmox node's available memory is not the VM's available memory, and a single application's RSS is not total guest usage. Do not sum process RSS to infer guest pressure: processes can share pages. +For History comparisons, keep each panel's stored time and qualification with +the value; a range endpoint, page refresh or today's live source does not date +a past reading. Use [History panel interpretation](METRICS_HISTORY.md#read-a-history-panel) +to distinguish a missing observation from zero, and stored data from fallback. + | Reading | What it describes | What it does not establish | | --- | --- | --- | | Linux `available` / `MemAvailable` | An estimate of memory usable for new allocations without swapping, including some reclaimable cache. | It is not just `free`, a guarantee of every allocation succeeding, or a reading for another guest. | diff --git a/frontend-modern/browser-tests/metrics-history-help.cjs b/frontend-modern/browser-tests/metrics-history-help.cjs new file mode 100644 index 0000000000..d64df3f744 --- /dev/null +++ b/frontend-modern/browser-tests/metrics-history-help.cjs @@ -0,0 +1,164 @@ +// Exercise only existing help: real Docs fetch, renderer, routes and CSS. +// No monitored system, diagnostics, licence change or History collector runs. +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 sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); +(async () => { + const root = '/workspace/frontend-modern'; + const artifacts = path.join(root, 'node_modules', 'metrics-history-help-browser'); + const sourceDocs = Object.fromEntries( + ['METRICS_HISTORY', 'TROUBLESHOOTING', 'PULSE_PRO', 'VM_DISK_MONITORING'].map((name) => { + const bytes = fs.readFileSync(path.join(root, `public/docs/${name}.md`)); + assert.deepEqual(bytes, fs.readFileSync(`/workspace/docs/${name}.md`)); + return [name, { bytes: bytes.length, sha256: sha256(bytes) }]; + }), + ); + process.chdir(root); + 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: path.join(root, 'node_modules', '.vite-metrics-history-help'), + server: { host: '127.0.0.1', port: 5257, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, dark] of [['chromium', 1280, false], ['webkit', 390, true]]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = [], backendRequests = [], mutations = [], requests = [], captures = []; + await page.addInitScript(() => { + const fetch = window.fetch.bind(window); + window.__historyHelpDocuments = []; + window.fetch = async (...args) => { + const response = await fetch(...args); + const pathname = new URL(response.url).pathname; + if (/^\/docs\/.+\.md$/.test(pathname)) { + const bytes = Array.from(new Uint8Array(await response.clone().arrayBuffer())); + window.__historyHelpDocuments.push({ pathname, status: response.status, bytes }); + } + return response; + }; + }); + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + page.on('request', (request) => { + requests.push(new URL(request.url()).pathname); + if (!['GET', 'HEAD'].includes(request.method())) mutations.push(request.method()); + }); + await page.route('**/api/**', async (route) => { + backendRequests.push(new URL(route.request().url()).pathname); + await route.abort(); + }); + const navigate = async (route, fragment) => { + await page.evaluate((href) => { + history.pushState({}, '', href); + dispatchEvent(new PopStateEvent('popstate')); + }, route); + await focused(fragment); + }; + const focused = async (id) => { + await page.waitForFunction((fragment) => { + const target = document.getElementById(fragment); + const rect = target?.getBoundingClientRect(); + return target && document.activeElement === target && rect.top >= 0 && rect.top < innerHeight; + }, id); + }; + const activate = async (name, id) => { + const link = page.getByRole('link', { name, exact: true }); + await link.focus(); + await page.keyboard.press('Enter'); + await focused(id); + }; + const capture = async (name) => { + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.waitForFunction(() => document.getAnimations().length === 0); + const filename = `${engine}-${name}.png`; + const filepath = path.join(artifacts, filename); + await page.screenshot({ path: filepath }); + captures.push({ filename, bytes: fs.statSync(filepath).size, sha256: sha256(fs.readFileSync(filepath)) }); + }; + await page.goto('http://127.0.0.1:5257/browser-tests/docs-fragment-navigation.html', { + waitUntil: 'domcontentloaded', timeout: 60000, + }); + if (dark) await page.evaluate(() => document.documentElement.classList.add('dark')); + await navigate('/docs/TROUBLESHOOTING#guest-memory-is-high-but-available-memory-is-plentiful', + 'guest-memory-is-high-but-available-memory-is-plentiful'); + assert.ok((await page.locator('article').innerText()).includes("today's live source does not date a past reading")); + await capture('entry'); + await activate('History panel interpretation', 'read-a-history-panel'); + const section = await page.evaluate(() => { + const nodes = []; + for (let node = document.getElementById('read-a-history-panel').nextElementSibling; + node && node.tagName !== 'H2'; node = node.nextElementSibling) nodes.push(node.textContent); + return nodes.join(' ').replace(/\s+/g, ' '); + }); + for (const phrase of [ + 'Latest stored', 'Single observation. No trend yet.', 'Missing is not measured zero', + 'CPU 22% at 12:02 and memory 95% at 12:01 are not one observation', + 'arrow keys, Home or End, or move it by touch', + "Today's source cannot identify the source of a past warning", + 'do not run Diagnostics or guest-agent probes', 'do not share raw responses, credentials', + ]) assert.ok(section.includes(phrase), `${engine}: ${phrase}`); + await capture('observations'); + await page.getByText('For example, CPU', { exact: false }).scrollIntoViewIfNeeded(); + await capture('comparison'); + await navigate('/docs/METRICS_HISTORY#retention-and-accessible-range', 'retention-and-accessible-range'); + const rendered = await page.locator('article').innerText(); + for (const phrase of [ + 'Community supports up to 7 days', 'Relay is no longer sold', + '14-day compatibility limit', 'not a plan to buy', '402 Payment Required', + 'do not buy another licence, reset history or reinstall', + ]) assert.ok(rendered.replace(/\s+/g, ' ').includes(phrase), `${engine}: ${phrase}`); + assert.ok(!rendered.includes('Relay unlocks')); + await capture('range'); + await activate('plans and legacy subscriptions', 'self-hosted-commercial-model'); + assert.ok((await page.locator('article').innerText()).includes('not the entitlement for continuing Relay subscribers')); + await navigate('/docs/METRICS_HISTORY#read-a-history-panel', 'read-a-history-panel'); + await activate('Backup safety', 'backup-safety'); + assert.ok((await page.locator('article').innerText()).includes('every filesystem covered by the backup')); + const documents = await page.evaluate(() => window.__historyHelpDocuments); + const consumed = documents.filter((doc) => sourceDocs[doc.pathname.split('/').at(-1).replace('.md', '')]) + .map((doc) => ({ pathname: doc.pathname, status: doc.status, + bytes: doc.bytes.length, sha256: sha256(Buffer.from(doc.bytes)) })); + for (const [name, identity] of Object.entries(sourceDocs)) { + const responses = consumed.filter((doc) => doc.pathname === `/docs/${name}.md`); + assert.ok(responses.length >= 1, `${engine}: missing ${name} consumer`); + for (const response of responses) { + assert.equal(response.status, 200); + assert.equal(response.bytes, identity.bytes); + assert.equal(response.sha256, identity.sha256); + } + } + assert.deepEqual(backendRequests, []); + assert.deepEqual(mutations, []); + assert.deepEqual(errors, []); + results.push({ engine, browserVersion: browser.version(), width, dark, consumed, captures, + memoryEntryKeyboardNavigation: true, plansKeyboardNavigation: true, safetyKeyboardNavigation: true, + directRangeFragmentFocus: true, noHorizontalPageOverflow: true, backendRequests, mutations, errors, + requestedSourcePaths: [...new Set(requests.filter((url) => url.startsWith('/src/')))].sort() }); + await browser.close(); + browser = undefined; + } + } finally { + if (browser) await browser.close(); + await server.close(); + } + const result = { result: 'passed', playwright: require('playwright/package.json').version, + sourceDocs, results, browserAndServerClosed: true, + scope: 'Production Docs consumer and exact shipped help bytes; no native guest, collector, licence migration, full application or release acceptance.' }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/browser-verification/5be6ce455290494f.json b/frontend-modern/browser-verification/5be6ce455290494f.json new file mode 100644 index 0000000000..a98c938015 --- /dev/null +++ b/frontend-modern/browser-verification/5be6ce455290494f.json @@ -0,0 +1,131 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "b54a2fa3f70d7df05e5d3fe1d3733326c2332da8", + "verified_at": "2026-10-12T18:23:23.779839Z", + "changed_paths": [ + "docs/METRICS_HISTORY.md", + "docs/TROUBLESHOOTING.md", + "frontend-modern/public/docs/METRICS_HISTORY.md", + "frontend-modern/public/docs/TROUBLESHOOTING.md" + ], + "content_sha256": { + "docs/METRICS_HISTORY.md": "2f3e78e9d066f2b7e221b6ed351c4cd8f7b40567a6d7b5e47332376ade0c6b11", + "docs/TROUBLESHOOTING.md": "cf128b8d4a3edb4595e57536d3370de0be636dd3300736efa4a6501c53861967", + "frontend-modern/public/docs/METRICS_HISTORY.md": "2f3e78e9d066f2b7e221b6ed351c4cd8f7b40567a6d7b5e47332376ade0c6b11", + "frontend-modern/public/docs/TROUBLESHOOTING.md": "cf128b8d4a3edb4595e57536d3370de0be636dd3300736efa4a6501c53861967" + }, + "routes": [ + "/docs/TROUBLESHOOTING#guest-memory-is-high-but-available-memory-is-plentiful", + "/docs/METRICS_HISTORY#read-a-history-panel", + "/docs/METRICS_HISTORY#retention-and-accessible-range", + "/docs/PULSE_PRO#self-hosted-commercial-model", + "/docs/VM_DISK_MONITORING#backup-safety" + ], + "states": [ + "Stored-time interpretation table: latest stored, selected stored, qualified fallback, single observation and missing observations", + "Unequal CPU/memory observation times and keyboard/touch History guidance", + "Stored retention versus active accessible range, retired Relay sale and continuing Pro subscriber terms", + "Guest-memory entry and linked backup-safety help" + ], + "interactions": [ + "Keyboard activation of the existing guest-memory History interpretation link, with fragment focus and scroll", + "Direct range fragment navigation, plans link and backup-safety link with target focus", + "Scroll the comparison example at the narrow touch viewport", + "Inspect eight retained capture files natively; all table columns and new links remain readable without horizontal page overflow" + ], + "viewports": [ + { + "width": 1280, + "height": 900, + "browser": "Chromium 141.0.7390.37", + "theme": "light" + }, + { + "width": 390, + "height": 900, + "browser": "WebKit 26.0", + "theme": "dark", + "touch": true + } + ], + "browser": { + "client": "Playwright 1.56.1", + "driver": "frontend-modern/browser-tests/metrics-history-help.cjs" + }, + "source_help_commit": "c036461de74c0e71932e7e481dc35ead26bbe685", + "containing_source_sha256": { + "docs/METRICS_HISTORY.md": "2f3e78e9d066f2b7e221b6ed351c4cd8f7b40567a6d7b5e47332376ade0c6b11", + "docs/PULSE_PRO.md": "776235ec660c1c53fef781e99b024bd2030d218afb8a601929e4bb5cf6b2dffb", + "docs/TROUBLESHOOTING.md": "cf128b8d4a3edb4595e57536d3370de0be636dd3300736efa4a6501c53861967", + "docs/VM_DISK_MONITORING.md": "88da3f28184e78b724f2bf59e39cbb1b7be65f8094ca2a3197c3d88875155b8e", + "frontend-modern/browser-tests/docs-fragment-navigation.html": "8303e101060aabd3247446676624d0f83b2cf81b9f6b1b71f180ea3432f66208", + "frontend-modern/browser-tests/docs-fragment-navigation.tsx": "2de6b8c853e5cd87790dcb6e582fefae07661d449835366cb72f2623b1bfe053", + "frontend-modern/browser-tests/metrics-history-help.cjs": "45b4189d641b6ec75fdb0dfd9889ef0d6ba6ca14edeac2f55e263cb4f7d2e674", + "frontend-modern/package-lock.json": "230d8a9ef15f68577ba07691753a52b4baad05b36b9d6058993955d3fe480ecb", + "frontend-modern/package.json": "e00d2edb9f70b43c7e43ef3d79195fc2148b58772c00a3e130fe721f0eec76ee", + "frontend-modern/public/docs/METRICS_HISTORY.md": "2f3e78e9d066f2b7e221b6ed351c4cd8f7b40567a6d7b5e47332376ade0c6b11", + "frontend-modern/public/docs/PULSE_PRO.md": "776235ec660c1c53fef781e99b024bd2030d218afb8a601929e4bb5cf6b2dffb", + "frontend-modern/public/docs/TROUBLESHOOTING.md": "cf128b8d4a3edb4595e57536d3370de0be636dd3300736efa4a6501c53861967", + "frontend-modern/public/docs/VM_DISK_MONITORING.md": "88da3f28184e78b724f2bf59e39cbb1b7be65f8094ca2a3197c3d88875155b8e", + "frontend-modern/src/components/shared/PageHeader.tsx": "428d4c02380ba13a38d1ee7404c56b538562df904aecb66f11786ef0dbb8d259", + "frontend-modern/src/features/docs/docMarkdown.ts": "572a3b4bc5bc1114adef5aa64bb0ea833c0dbe5865bbd57d3dc767fd1965aa55", + "frontend-modern/src/hooks/useKioskMode.ts": "31a30251cb828b2a32977143707f5cc1b57d980aa2228d6e09b26b000162cc0b", + "frontend-modern/src/index.css": "df9316666c4dab61acd0528944690c3f8f08f890fd49bfa9efcbfea68524664c", + "frontend-modern/src/pages/Docs.tsx": "2ddb5a1daef50d23a99a7d1cb510c73cd7a85600f07ea9f69ad72f741f3b5229", + "frontend-modern/src/utils/url.ts": "94c3a37354a53b38169ed41188f1c4d18d2d04c829b6c8f9573d2fb2d28a93dc", + "frontend-modern/vite.config.ts": "de649c3466c8369b7ceff630b72f3733d1123417d6ef1964903450d64d796fb2", + "tests/integration/package-lock.json": "d6241c8c3ad5ccd1c97c749ea70e0b707a2dc5eabd8601faf19c78b8f79b975a" + }, + "captures": [ + { + "filename": "chromium-entry.png", + "bytes": 212900, + "sha256": "c40718b94d55645aa8389c815d793f9b673e3c7f3928af55ab5341873ea04d87" + }, + { + "filename": "chromium-observations.png", + "bytes": 177877, + "sha256": "f993e99b0d895a8999642430718ee358220cfcc9dc52ea70650e5d996307c572" + }, + { + "filename": "chromium-comparison.png", + "bytes": 177877, + "sha256": "f993e99b0d895a8999642430718ee358220cfcc9dc52ea70650e5d996307c572" + }, + { + "filename": "chromium-range.png", + "bytes": 146986, + "sha256": "8a3102b040206bb83062c4a81befc7e3deb60118923551998a11510751ba09a0" + }, + { + "filename": "webkit-entry.png", + "bytes": 129297, + "sha256": "120dce35f9200582d2a56957981b96dd239810bdc230308ac9e5da00a4b0c435" + }, + { + "filename": "webkit-observations.png", + "bytes": 110289, + "sha256": "92ddf9b74d610be0f8bbd47de520b73af413c6a8ca3630441580bef5e00778dd" + }, + { + "filename": "webkit-comparison.png", + "bytes": 125240, + "sha256": "fad1d40830e85f736ae5a983719cf38ec52f7635bbe41e2795cee024916a884d" + }, + { + "filename": "webkit-range.png", + "bytes": 137572, + "sha256": "7b164d7ccdd59544af4f4df4d99ff9f4686ba1e200ae6f30c4f9e5add8b4eac5" + } + ], + "pixel_observations": [ + "Desktop: focus outlines identify the guest-memory, History and range headings; the three-column table, 12:02/12:01 example, plan qualification and underlined links are legible and unclipped. The two desktop History captures are byte-identical because the comparison example was already visible.", + "Narrow dark WebKit: table columns wrap within 390 pixels; focus heading, missing-not-zero qualification, unequal-time example, History link and retired-Relay/range text remain legible. Vertical scroll is needed for the longer table and page, not horizontal scrolling." + ], + "backend_requests": [], + "mutations": [], + "console_and_page_errors": [], + "browser_and_server_closed": true, + "scope": "Existing production Docs/router/Markdown/CSS consumer and exact help bytes. No native guest, monitoring collector, licence migration, full authenticated app, release or installed-recovery acceptance." +} diff --git a/frontend-modern/public/docs/METRICS_HISTORY.md b/frontend-modern/public/docs/METRICS_HISTORY.md index 9165500d6d..1555f2b2fb 100644 --- a/frontend-modern/public/docs/METRICS_HISTORY.md +++ b/frontend-modern/public/docs/METRICS_HISTORY.md @@ -2,6 +2,72 @@ Pulse persists metrics history to disk so trend views and sparklines survive restarts. +## Read a History panel + +Start with the existing chart, not a restart or a storage change. A current +reading does not prove that it was stored, and a selected time range does not +prove continuous coverage. **Window start** and **Window end** bound the +requested interval; they are not the first and last successful observations. + +VM and LXC drawer panels can show these labels. Other charts and older Pulse +versions may differ; if a time or source is not shown, keep it unknown rather +than borrowing one from the current overview. + +| Display | How to read it | What it does not establish | +| --- | --- | --- | +| **Latest stored** | The newest stored observation time in this panel. Each value belongs to that time; a dash means no stored observation for that metric at that time. | Not necessarily now, every metric's newest value, or another panel's time. | +| **Stored** while inspecting | Values at the selected stored observation time. | Not a live reading or an interpolated observation between points. | +| **current**, **last known** or **freshness unknown** with no stored history | A separately qualified fallback reading. Keep its label and explanation. | Not proof that the value was persisted or belongs to a past incident. | +| **Single observation. No trend yet.** | One stored time in that panel. | Not a flat trend or sustained recovery. | +| **Missing observations** | Some series lack readings at other stored times in that panel; lines stop across those gaps. | Not the cause or duration of a monitoring outage. Missing is not measured zero. | + +For example, CPU **22% at 12:02** and memory **95% at 12:01** are not one +observation. At **Latest stored · 12:02**, memory is a dash if there is no +memory point at 12:02; the earlier 95% remains inspectable. Network and disk +I/O panels have their own times, not a shared snapshot of the guest. + +1. Keep the same resource, metric and range when comparing existing readings. + **Inspect history**, when offered, steps through stored times: focus the + slider and use the arrow keys, **Home** or **End**, or move it by touch. + Hover is another way to inspect; it is not required. Leaving the slider + returns to the latest stored display. +2. Keep the displayed observation time and qualification with the value. + Stored points can be aggregated or downsampled; they are not a measurement + of every instant in the selected range. A page refresh time is not the + observation time. If an absolute time is unavailable, retain that limit. +3. Compare an alert or Patrol finding with the original episode, not just + today's overview. Today's source cannot identify the source of a past + warning. Follow [guest memory interpretation](TROUBLESHOOTING.md#guest-memory-is-high-but-available-memory-is-plentiful) + when unlike readings suggest memory pressure. + +Use already available observations; do not run Diagnostics or guest-agent +probes, restart a guest, repeat a backup, clear History or change retention to +make a comparison agree. An unresponsive guest or an uncertain freeze/thaw +operation needs [Backup safety](VM_DISK_MONITORING.md#backup-safety), not chart +inspection. When reporting a display disagreement, keep the panel, time, +range, labels and relevant values with consistent private aliases; do not +share raw responses, credentials or a full network export. + +## Retention and accessible range + +Storage retention and the range your running instance can serve are separate. +Community supports up to **7 days**; Pro and legacy Pro+ entitlements support +up to **90 days**. The panel uses the active runtime's history limit, not the +length of time the database has existed. A longer retention setting does not +grant access, and a licence cannot recover observations that were never stored. + +**Relay is no longer sold.** Continuing Relay subscribers receive Pro features, +including 90-day history, at their existing price. Older Relay payloads can +still show a 14-day compatibility limit; that is not a plan to buy or proof +that a continuing subscription has received its current entitlement. See +[plans and legacy subscriptions](PULSE_PRO.md#self-hosted-commercial-model). + +An API request beyond the active limit returns **402 Payment Required**, not +an empty history or a failed collector. Use an allowed range and the existing +licence/range display first; do not buy another licence, reset history or +reinstall merely to diagnose it. Storage defaults below do not guarantee that +every resource has data for every allowed range. + ## Storage Location Metrics history is stored in a SQLite database named `metrics.db` under the Pulse data directory: @@ -178,7 +244,9 @@ durable historical coverage. An HTTP 200 with no points proves neither zero usage nor successful collection, and a non-zero current value does not prove every historical interval is present. -> **License**: Requests beyond Community's `7d` floor require the paid `long_term_metrics` entitlement. Relay unlocks `14d`, Pro and legacy Pro+ unlock `90d`, and requests beyond the active tier's limit return `402 Payment Required`. +> **History limit**: Requests beyond the active runtime's allowed range return +> `402 Payment Required`. See [retention and accessible range](#retention-and-accessible-range); +> stored retention is not access or proof of collected observations. > **Older examples**: `container`, `dockerHost`, `dockerContainer`, `guest` and > `docker` are not current `resourceType` query values. Use the chart's current > type rather than a display label or internal storage name. diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index ced4188f9f..00b3f1e3b9 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -680,6 +680,11 @@ same units and memory allocation. The Proxmox node's available memory is not the VM's available memory, and a single application's RSS is not total guest usage. Do not sum process RSS to infer guest pressure: processes can share pages. +For History comparisons, keep each panel's stored time and qualification with +the value; a range endpoint, page refresh or today's live source does not date +a past reading. Use [History panel interpretation](METRICS_HISTORY.md#read-a-history-panel) +to distinguish a missing observation from zero, and stored data from fallback. + | Reading | What it describes | What it does not establish | | --- | --- | --- | | Linux `available` / `MemAvailable` | An estimate of memory usable for new allocations without swapping, including some reclaimable cache. | It is not just `free`, a guarantee of every allocation succeeding, or a reading for another guest. | diff --git a/frontend-modern/src/utils/__tests__/metricsHistoryHelp.test.ts b/frontend-modern/src/utils/__tests__/metricsHistoryHelp.test.ts new file mode 100644 index 0000000000..082f646dc9 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/metricsHistoryHelp.test.ts @@ -0,0 +1,120 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { renderDocMarkdown } from '@/features/docs/docMarkdown'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const read = (file: string) => readFileSync(path.join(root, file), 'utf8'); +const guide = read('docs/METRICS_HISTORY.md'); +const render = (markdown: string, name: string) => { + const article = document.createElement('article'); + article.innerHTML = renderDocMarkdown(markdown, name); + return article; +}; +const article = () => render(guide, 'METRICS_HISTORY'); +const interpretation = () => + render(guide.split('## Read a History panel')[1].split('\n## ')[0], 'METRICS_HISTORY'); +const text = (element: Element) => element.textContent?.replace(/\s+/g, ' '); + +describe('History interpretation help', () => { + it('ships both changed guides and puts safe interpretation before storage tuning', () => { + for (const name of ['METRICS_HISTORY', 'TROUBLESHOOTING']) { + expect(read(`frontend-modern/public/docs/${name}.md`)).toBe(read(`docs/${name}.md`)); + } + const headings = [...article().querySelectorAll('h2')].map((heading) => heading.id); + expect(headings.slice(0, 3)).toEqual([ + 'read-a-history-panel', + 'retention-and-accessible-range', + 'storage-location', + ]); + expect(interpretation().querySelector('pre')).toBeNull(); + }); + + it('explains real drawer labels without treating older or fallback readings as current', () => { + const rows = [...interpretation().querySelectorAll('tbody tr')].map((row) => text(row)); + expect(rows).toHaveLength(5); + expect(rows[0]).toContain("Not necessarily now, every metric's newest value"); + expect(rows[1]).toContain('Not a live reading or an interpolated observation'); + expect(rows[2]).toContain('Not proof that the value was persisted'); + expect(rows[3]).toContain('Not a flat trend or sustained recovery'); + expect(rows[4]).toContain('Not the cause or duration of a monitoring outage'); + const renderer = read('frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx'); + for (const label of [ + 'Latest stored', + 'Stored', + 'Inspect history', + 'Single observation. No trend yet.', + ]) { + expect(renderer).toContain(label); + expect(text(interpretation())).toContain(label); + } + expect(text(interpretation())).toContain('Other charts and older Pulse versions may differ'); + expect(text(interpretation())).toContain('keep it unknown rather than borrowing one'); + }); + + it('keeps same-time values, missing observations, panel-local times and access to earlier points distinct', () => { + expect(text(interpretation())).toContain( + 'CPU 22% at 12:02 and memory 95% at 12:01 are not one observation', + ); + expect(text(interpretation())).toContain('the earlier 95% remains inspectable'); + expect(text(interpretation())).toContain('Network and disk I/O panels have their own times'); + expect(text(interpretation())).toContain('Missing is not measured zero'); + const steps = [...interpretation().querySelectorAll('ol > li')].map((step) => text(step)); + expect(steps).toHaveLength(3); + expect(steps[0]).toContain('arrow keys, Home or End, or move it by touch'); + expect(steps[1]).toContain('aggregated or downsampled'); + expect(steps[2]).toContain("Today's source cannot identify the source of a past warning"); + }); + + it('links from the existing memory workflow to working interpretation and safety headings', () => { + const troubleshooting = render(read('docs/TROUBLESHOOTING.md'), 'TROUBLESHOOTING'); + const href = '/docs/METRICS_HISTORY#read-a-history-panel'; + expect(troubleshooting.querySelector(`a[href="${href}"]`)?.textContent).toBe( + 'History panel interpretation', + ); + expect(article().querySelector('#read-a-history-panel')).not.toBeNull(); + const safety = '/docs/VM_DISK_MONITORING#backup-safety'; + expect(interpretation().querySelector(`a[href="${safety}"]`)?.textContent).toBe( + 'Backup safety', + ); + expect( + render(read('docs/VM_DISK_MONITORING.md'), 'VM_DISK_MONITORING').querySelector( + '#backup-safety', + ), + ).not.toBeNull(); + expect(text(interpretation())).toContain('do not run Diagnostics or guest-agent probes'); + expect(text(interpretation())).toContain('clear History or change retention'); + expect(text(interpretation())).toContain( + 'do not share raw responses, credentials or a full network export', + ); + }); + + it('separates storage from runtime access and does not sell the retired Relay compatibility tier', () => { + const section = guide.split('## Retention and accessible range')[1].split('\n## ')[0]; + const rendered = render(section, 'METRICS_HISTORY'); + expect(text(rendered)).toContain('Community supports up to 7 days'); + expect(text(rendered)).toContain('Pro and legacy Pro+ entitlements support up to 90 days'); + expect(text(rendered)).toContain('Relay is no longer sold'); + expect(text(rendered)).toContain('Continuing Relay subscribers receive Pro features'); + expect(text(rendered)).toContain('14-day compatibility limit'); + expect(text(rendered)).toContain('not a plan to buy'); + expect(text(rendered)).toContain('402 Payment Required, not an empty history'); + expect(text(rendered)).toContain('do not buy another licence, reset history or reinstall'); + expect(text(rendered)).toContain('cannot recover observations that were never stored'); + expect(guide).not.toContain('Relay unlocks'); + const link = rendered.querySelector('a[href="/docs/PULSE_PRO#self-hosted-commercial-model"]'); + expect(link?.textContent).toBe('plans and legacy subscriptions'); + expect( + render(read('docs/PULSE_PRO.md'), 'PULSE_PRO').querySelector('#self-hosted-commercial-model'), + ).not.toBeNull(); + const limits = read('pkg/licensing/features.go'); + expect(limits).toMatch(/TierFree:\s+7,/); + expect(limits).toMatch(/TierRelay:\s+14,/); + expect(limits).toMatch(/TierPro:\s+90,/); + expect(limits).toMatch(/TierProPlus:\s+90,/); + expect(read('frontend-modern/src/stores/license.ts')).toContain( + 'runtimeCapabilities()?.max_history_days', + ); + }); +});