Explain dated History readings before storage and licence changes

Make the existing guide distinguish same-time stored observations, missing metrics, independent panel times and qualified fallback. Link it from the guest-memory workflow, separate retained data from runtime access and explain the retired Relay compatibility limit without offering a new plan. Preserve read-only evidence and guest recovery boundaries.

Contract-Neutral: Existing help, mirrors and regression/browser checks only; collectors, rendering behaviour, runtime grants, retention defaults and product routes are unchanged.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-12 17:43:22 +01:00
parent 0d8ade2b3b
commit c036461de7
6 changed files with 432 additions and 2 deletions
+69 -1
View File
@@ -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.
+5
View File
@@ -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; });
+69 -1
View File
@@ -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',
);
});
});