mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
Integrate reviewed History interpretation help and browser proof
Preserve the exact reviewedc036461dand594f5025history. Retained source and production Docs desktop/narrow proof covers the unchanged guides; no monitoring, licence or release behaviour changes. Change-source: pulse-maintainer
This commit is contained in:
+69
-1
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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; });
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user