mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Keep deferred filesystem detail values distinct from current health
Label retained guest usage beside each mount and remove current utilization bars and threshold colours until a fresh read. Do not promote numeric carriers on failed or explicitly unavailable reads; retain identity and known capacity, current/LXC controls and the existing safe read explanation. Production drawer browser proof, mounted regressions and pure builder controls preserve the local request budget. Change-source: pulse-maintainer
This commit is contained in:
@@ -249,6 +249,24 @@ Fresh linked Pulse-agent disk evidence with no reason remains unmarked, and
|
||||
non-VM rows do not inherit QGA guidance. The cue adds no action or tab stop;
|
||||
touch on it continues through the existing row disclosure to the full reason.
|
||||
|
||||
The opened guest Overview applies the same admission to every filesystem, not
|
||||
only the aggregate row or its status sentence. A valid `prev-` value is labelled
|
||||
**Last known** beside its mount identity and used/capacity bytes; retained values
|
||||
do not receive current utilization bars or threshold colours. Other VM read
|
||||
reasons and explicit disk telemetry unavailability show **Usage unavailable**,
|
||||
retaining identity and known capacity without promoting old used bytes. Missing
|
||||
or invalid retained usage is unavailable, not an empty healthy filesystem. The
|
||||
read explanation precedes the filesystem breakdown and remains visible before
|
||||
any sample. Clearing a lock alone cannot clear a still-supplied reason. A fresh
|
||||
same-guest read restores the normal accessible utilization bar; independent LXC
|
||||
and linked-agent readings with no VM reason retain their existing semantics.
|
||||
|
||||
Verification: `GuestDrawerOverview.filesystem-provenance.test.tsx`, the existing
|
||||
filesystem regressions and builder controls in `DiskList.test.tsx`, plus
|
||||
`browser-tests/guest-filesystem-provenance.cjs` using the full production guest
|
||||
drawer on desktop/light/dark and touch WebKit. These are synthetic presentation
|
||||
and input proofs, not native backup/thaw, covered-filesystem writes or resumption.
|
||||
|
||||
Verification: mounted `GuestRow.test.tsx` and `MetricMiniSparkline.test.tsx`
|
||||
regressions and `browser-tests/guest-disk-provenance.cjs` exercise retained and
|
||||
pre-sample states, display/lens changes, current-value semantics and same-row
|
||||
|
||||
@@ -1671,6 +1671,15 @@ without contention or cross-delivery.
|
||||
lifecycle. Request handlers may reuse the cached manager but must not reopen
|
||||
or remigrate SQLite on each role or assignment request, and shutdown must
|
||||
close the owned manager set.
|
||||
8. Guest Overview filesystem provenance is local presentation over the already
|
||||
supplied snapshot: no additional query, timer, poll, History fetch or guest
|
||||
command may be added to establish it. Its existing linear filesystem mapping
|
||||
may label retained values and omit current utilization bars; it must preserve
|
||||
ordinary current-value builder behaviour and must not mutate the snapshot.
|
||||
`DiskList.test.tsx` verifies the pure builder's retained/unavailable/current
|
||||
controls; mounted drawer regressions and the full-drawer browser proof cover
|
||||
the shipped path. No performance or native workload improvement follows from
|
||||
the presentation proof alone.
|
||||
|
||||
## Current State
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
const root = '/workspace/frontend-modern';
|
||||
const output = '/workspace/tmp/filesystem-read-provenance/browser-final';
|
||||
const origin = 'http://127.0.0.1:5324';
|
||||
const runtime = [
|
||||
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
|
||||
'frontend-modern/src/components/Workloads/diskListModel.ts',
|
||||
];
|
||||
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const settle = page => page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const result = {
|
||||
result: 'incomplete', playwright: require('playwright/package.json').version,
|
||||
runtime_hashes: Object.fromEntries(runtime.map(file => [file, hash('/workspace/' + file)])),
|
||||
cases: [], screenshots: [], cleanup: { browser: false, server: false },
|
||||
limits: 'Real GuestRow/full GuestDrawer/DetailSectionTable and CSS; synthetic guest observations and APIs. No native QGA, backup, cause, thaw, writes, installed/reporter or release acceptance.',
|
||||
};
|
||||
assert.equal(result.playwright, JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version);
|
||||
process.chdir(root);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({root, configFile: path.join(root, 'vite.config.ts'), cacheDir: output + '/cache', server: { host: '127.0.0.1', port: 5324, strictPort: true }});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, dark] of [
|
||||
['chromium-desktop-light', chromium, 1365, false],
|
||||
['chromium-desktop-dark', chromium, 1365, true],
|
||||
['webkit-phone-dark', webkit, 390, true],
|
||||
['webkit-narrow-light', webkit, 320, false],
|
||||
]) {
|
||||
browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
|
||||
const context = await browser.newContext({viewport: { width, height: width <= 768 ? 844 : 900 }, isMobile: width <= 768, hasTouch: width <= 768, locale: 'en-GB', timezoneId: 'UTC'});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
const record = {name, browser: browser.version(), width, dark, errors: [], requests: [], checks: [], states: []};
|
||||
result.cases.push(record);
|
||||
page.on('pageerror', error => record.errors.push(error.message));
|
||||
await page.route('**/*', route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (url.origin !== origin) { record.requests.push({ method: request.method(), path: url.pathname, disposition: 'off-origin blocked' }); return route.abort(); }
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
record.requests.push({ method: request.method(), path: url.pathname, disposition: 'synthetic' });
|
||||
return route.fulfill({status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: [], alerts: [], anomalies: [], config: null })});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.addInitScript(isDark => {
|
||||
document.addEventListener('DOMContentLoaded', () => { if (isDark) document.documentElement.classList.add('dark'); });
|
||||
window.__trustedInput = [];
|
||||
document.addEventListener('click', event => window.__trustedInput.push({ type: 'click', trusted: event.isTrusted }), true);
|
||||
document.addEventListener('keydown', event => window.__trustedInput.push({ type: event.key, trusted: event.isTrusted }), true);
|
||||
}, dark);
|
||||
await page.goto(`${origin}/browser-tests/guest-disk-provenance.html`);
|
||||
await page.waitForFunction(() => window.__guestDiskProvenance);
|
||||
const apply = async next => { await page.evaluate(next => window.__guestDiskProvenance.apply(next), next); await settle(page); };
|
||||
await apply({ reason: 'prev-vm-locked', data: true, usage: 90, lock: 'backup', kind: 'qemu' });
|
||||
const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]');
|
||||
const disclosure = row.locator('[data-row-action]');
|
||||
if (width <= 768) await row.locator('[data-workload-disk-read-status]').tap();
|
||||
else { await disclosure.focus(); await page.keyboard.press('Enter'); }
|
||||
const drawer = page.getByRole('region', {name: 'Guest details'});
|
||||
await drawer.waitFor({state: 'visible'});
|
||||
const technical = drawer.getByTestId('guest-technical-details');
|
||||
const filesystem = technical.getByText('/data', {exact: true}).locator('xpath=ancestor::tr');
|
||||
const progress = filesystem.getByRole('progressbar');
|
||||
const text = async () => (await filesystem.innerText()).replace(/\s+/g, ' ').trim();
|
||||
const capture = async state => {
|
||||
await technical.scrollIntoViewIfNeeded(); await settle(page);
|
||||
const file = `${name}-${state}.png`;
|
||||
await technical.screenshot({path: path.join(output, file)});
|
||||
result.screenshots.push({file, sha256: hash(path.join(output, file)), name, state});
|
||||
};
|
||||
assert.match(await text(), /Last known 90% · 9\.00 GB\/10\.0 GB · EXT4/);
|
||||
assert.equal(await progress.count(), 0);
|
||||
record.checks.push('Retained quantitative values are labelled last known beside their filesystem identity, with no current utilization bar.');
|
||||
record.states.push({state: 'retained', text: await text()});
|
||||
await capture('retained');
|
||||
await apply({ lock: '' });
|
||||
assert.match(await text(), /Last known 90%/); assert.equal(await progress.count(), 0);
|
||||
record.checks.push('Clearing only the operation lock cannot promote a deferred reading.');
|
||||
const reasons = await page.evaluate(() => window.__guestDiskProvenance.deferrals.map(item => item[0]));
|
||||
for (const reason of reasons) {
|
||||
await apply({reason: 'prev-' + reason});
|
||||
assert.match(await text(), /Last known 90%/); assert.equal(await progress.count(), 0);
|
||||
await apply({reason});
|
||||
assert.match(await text(), /Usage unavailable · \?\/10\.0 GB · EXT4/);
|
||||
assert.doesNotMatch(await text(), /90%|9\.00 GB/); assert.equal(await progress.count(), 0);
|
||||
}
|
||||
record.checks.push('All eight fixed read reasons distinguish retained data from unavailable numeric carriers.');
|
||||
record.states.push({state: 'unavailable', text: await text()});
|
||||
await capture('unavailable');
|
||||
await apply({reason: '', usage: 30, lock: ''});
|
||||
assert.match(await text(), /30% · 3\.00 GB\/10\.0 GB · EXT4/);
|
||||
assert.equal(await progress.getAttribute('aria-valuenow'), '30');
|
||||
assert.equal(await progress.getAttribute('aria-label'), 'Filesystem /data utilization');
|
||||
assert.equal(await drawer.isVisible(), true);
|
||||
record.checks.push('A same-guest fresh read restores the correct numeric value and accessible utilization bar without closing the drawer.');
|
||||
record.states.push({state: 'fresh', text: await text()});
|
||||
await capture('fresh');
|
||||
await apply({reason: 'prev-vm-locked', kind: 'lxc'});
|
||||
assert.match(await text(), /30% · 3\.00 GB\/10\.0 GB · EXT4/); assert.equal(await progress.getAttribute('aria-valuenow'), '30');
|
||||
record.checks.push('Independent LXC filesystem evidence does not inherit a VM-only reason.');
|
||||
await apply({kind: 'qemu', data: false, reason: 'vm-locked'});
|
||||
assert.equal(await technical.getByText('/data', {exact: true}).count(), 0);
|
||||
assert.match(await technical.innerText(), /Guest reads paused while Proxmox reports a VM operation lock/);
|
||||
record.checks.push('With no prior filesystem sample, the readable explanation remains and no filesystem value is invented.');
|
||||
const overflow = await technical.evaluate(el => ({width: el.clientWidth, scrollWidth: el.scrollWidth, documentWidth: document.documentElement.clientWidth, documentScroll: document.documentElement.scrollWidth}));
|
||||
assert.ok(overflow.scrollWidth <= overflow.width + 1, JSON.stringify(overflow));
|
||||
record.layout = overflow;
|
||||
await drawer.getByRole('button', {name: 'Collapse backup-guest details'}).click();
|
||||
assert.equal(await drawer.count(), 0);
|
||||
record.input = await page.evaluate(() => window.__trustedInput);
|
||||
assert.ok(record.input.some(event => event.trusted));
|
||||
assert.deepEqual(record.errors, []);
|
||||
assert.equal(record.requests.filter(request => !['GET','HEAD'].includes(request.method)).length, 0);
|
||||
record.checks.push('Trusted keyboard/touch disclosure and close work; no uncaught page error, overflow or mutating request.');
|
||||
await context.close(); await browser.close(); browser = null;
|
||||
}
|
||||
result.result = 'passed';
|
||||
} catch (error) {
|
||||
result.error = String(error.stack || error); throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close(); result.cleanup.browser = true;
|
||||
await server.close(); result.cleanup.server = true;
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"version": 1,
|
||||
"result": "passed",
|
||||
"base_sha": "664b29f930b19f91b74f673914c85e3b806b54f7",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
|
||||
"frontend-modern/src/components/Workloads/diskListModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "16ccfd4c8a865c02d4ab3580d7872518cb599715245fb10ebbe3dfbe375cb104",
|
||||
"frontend-modern/src/components/Workloads/diskListModel.ts": "8b43eeb3e08dff191039ee19ea74baa89703d6b8f89d801a5ff0f8cdef09dffc"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/guest-disk-provenance.html \u2014 production Workloads GuestRow/full GuestDrawer; synthetic APIs, not the full App route"
|
||||
],
|
||||
"states": [
|
||||
"Retained filesystem usage with each of eight prev- reasons: labelled last known, no current bars/threshold colours",
|
||||
"Same eight non-retained read failures: unavailable used bytes, identity/capacity retained",
|
||||
"Lock-only clearance retains the label; same-guest fresh read restores accessible 30% utilization",
|
||||
"LXC independent reading and no-sample readable VM status"
|
||||
],
|
||||
"interactions": [
|
||||
"Trusted desktop Enter disclosure and close",
|
||||
"Trusted WebKit phone/narrow touch on the row provenance cue and close",
|
||||
"Synthetic same-guest poll replacements while keeping the drawer open",
|
||||
"Inspected every retained/unavailable/fresh screenshot in all four browser/theme/width groups"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900,
|
||||
"browser": "chromium-desktop-light",
|
||||
"theme": "light"
|
||||
},
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900,
|
||||
"browser": "chromium-desktop-dark",
|
||||
"theme": "dark"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"browser": "webkit-phone-dark",
|
||||
"theme": "dark"
|
||||
},
|
||||
{
|
||||
"width": 320,
|
||||
"height": 844,
|
||||
"browser": "webkit-narrow-light",
|
||||
"theme": "light"
|
||||
}
|
||||
],
|
||||
"verified_at": "2026-10-04T21:47:04Z",
|
||||
"playwright_version": "1.56.1",
|
||||
"checks": 28,
|
||||
"screenshots_inspected": 12,
|
||||
"script": "frontend-modern/browser-tests/guest-filesystem-provenance.cjs",
|
||||
"limits": "Real GuestRow/full GuestDrawer/DetailSectionTable and CSS; synthetic guest observations and APIs. No native QGA, backup, cause, thaw, writes, installed/reporter or release acceptance."
|
||||
}
|
||||
@@ -138,12 +138,22 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
props.guest.vmware?.datacenterName ||
|
||||
props.guest.vmware?.clusterName,
|
||||
);
|
||||
const diskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined;
|
||||
const diskReadState =
|
||||
props.guest.telemetryAvailability?.disk === false
|
||||
? 'unavailable'
|
||||
: diskReason
|
||||
? diskReason.startsWith('prev-')
|
||||
? 'last-known'
|
||||
: 'unavailable'
|
||||
: 'current';
|
||||
const diskRows = (props.guest.disks ?? []).map((disk, index) =>
|
||||
buildWorkloadsDiskPresentation(disk, index, props.diskThresholds),
|
||||
buildWorkloadsDiskPresentation(disk, index, props.diskThresholds, diskReadState),
|
||||
);
|
||||
const diskStatusMessage =
|
||||
isGuestDrawerVM(props.guest) && props.guest.diskStatusReason
|
||||
? getWorkloadGuestDiskStatusMessage(props.guest.diskStatusReason)
|
||||
const diskStatusMessage = diskReason
|
||||
? getWorkloadGuestDiskStatusMessage(diskReason)
|
||||
: diskReadState === 'unavailable'
|
||||
? 'Filesystem usage is unavailable.'
|
||||
: null;
|
||||
|
||||
return compactDetailSections([
|
||||
@@ -217,6 +227,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
? {
|
||||
label: 'Filesystems',
|
||||
rows: compactDetailRows([
|
||||
makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true }),
|
||||
...diskRows.map((disk) =>
|
||||
makeDetailRow(
|
||||
disk.label,
|
||||
@@ -238,7 +249,6 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
},
|
||||
),
|
||||
),
|
||||
makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true }),
|
||||
]),
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -28,6 +28,38 @@ afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('filesystem presentation read admission', () => {
|
||||
it.each(['last-known', 'unavailable'] as const)(
|
||||
'keeps %s readings out of threshold-coloured utilization',
|
||||
(state) => {
|
||||
const disk = makeDisk();
|
||||
const reading = buildWorkloadsDiskPresentation(disk, 0, undefined, state);
|
||||
expect(reading.progressValue).toBeNull();
|
||||
expect(reading.progressWidth).toBe('0%');
|
||||
expect(reading.progressClass).toBe('bg-surface-hover');
|
||||
expect(reading.label).toBe('/');
|
||||
expect(reading.typeLabel).toBe('EXT4');
|
||||
expect(reading.usagePercentLabel).toBe(
|
||||
state === 'last-known' ? 'Last known 50%' : 'Usage unavailable',
|
||||
);
|
||||
expect(reading.usageText).toBe(state === 'last-known' ? '50.0 GB/100 GB' : '?/100 GB');
|
||||
expect(disk.used).toBe(53687091200);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not infer a retained value from capacity or free bytes', () => {
|
||||
const reading = buildWorkloadsDiskPresentation(
|
||||
makeDisk({ used: undefined }),
|
||||
0,
|
||||
undefined,
|
||||
'last-known',
|
||||
);
|
||||
expect(reading.usagePercentLabel).toBe('Usage unavailable');
|
||||
expect(reading.usageText).toBe('?/100 GB');
|
||||
expect(reading.progressValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiskList', () => {
|
||||
describe('fallback when no disks', () => {
|
||||
it('renders fallback "-" when disks array is empty', () => {
|
||||
|
||||
+12
@@ -111,6 +111,18 @@ describe('filesystem details do not promote retained usage to current health', (
|
||||
expect(within(filesystemRow()).queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains explicit unavailability even before any filesystem sample', () => {
|
||||
render(() => (
|
||||
<GuestDrawerOverview
|
||||
{...props(guest({ disks: [], telemetryAvailability: { disk: false } }))}
|
||||
hasFilesystemDetails={false}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByText('Filesystems')).toBeVisible();
|
||||
expect(screen.getByText('Filesystem usage is unavailable.')).toBeVisible();
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps retained values until the reason clears, then resumes the same drawer', () => {
|
||||
const [value, setValue] = createSignal(
|
||||
guest({ diskStatusReason: 'prev-vm-locked', lock: 'backup', backupInProgress: true }),
|
||||
|
||||
+3
@@ -85,6 +85,9 @@ describe('GuestDrawerOverview filesystem labels', () => {
|
||||
).toHaveAttribute('colspan', '2');
|
||||
expect(screen.queryByText('prev-vm-locked')).not.toBeInTheDocument();
|
||||
for (const path of paths) expect(screen.getByText(path)).toBeInTheDocument();
|
||||
expect(screen.getByText('Last known 50% · 5.00 GB/10.0 GB · MP0')).toBeVisible();
|
||||
expect(screen.getByText('Usage unavailable · ?/10.0 GB · MP1')).toBeVisible();
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
setGuest({
|
||||
...guest(),
|
||||
diskStatusReason: '',
|
||||
|
||||
@@ -92,18 +92,31 @@ export const buildWorkloadsDiskPresentation = (
|
||||
disk: Disk,
|
||||
index: number,
|
||||
thresholds?: MetricDisplayThresholds | null,
|
||||
readState: 'current' | 'last-known' | 'unavailable' = 'current',
|
||||
): WorkloadsDiskPresentation => {
|
||||
const label = getWorkloadsDiskLabel(disk);
|
||||
// Retention preserves identity and capacity, not a current utilization
|
||||
// measurement. Only an explicitly retained read may display old used bytes.
|
||||
const reading = readState === 'unavailable' ? { ...disk, used: undefined } : disk;
|
||||
const percentLabel = getWorkloadsDiskUsagePercentLabel(reading);
|
||||
|
||||
return {
|
||||
key: `${disk.mountpoint ?? ''}:${disk.device ?? ''}:${index}`,
|
||||
label,
|
||||
labelTitle: getWorkloadsDiskLabelTitle(label),
|
||||
progressClass: getWorkloadsDiskProgressClass(disk, thresholds),
|
||||
progressValue: getWorkloadsDiskProgressValue(disk),
|
||||
progressWidth: getWorkloadsDiskProgressWidth(disk),
|
||||
progressClass:
|
||||
readState === 'current'
|
||||
? getWorkloadsDiskProgressClass(reading, thresholds)
|
||||
: 'bg-surface-hover',
|
||||
progressValue: readState === 'current' ? getWorkloadsDiskProgressValue(reading) : null,
|
||||
progressWidth: readState === 'current' ? getWorkloadsDiskProgressWidth(reading) : '0%',
|
||||
typeLabel: getWorkloadsDiskTypeLabel(disk),
|
||||
usageText: getWorkloadsDiskUsageText(disk),
|
||||
usagePercentLabel: getWorkloadsDiskUsagePercentLabel(disk),
|
||||
usageText: getWorkloadsDiskUsageText(reading),
|
||||
usagePercentLabel:
|
||||
readState === 'current'
|
||||
? percentLabel
|
||||
: percentLabel === '—'
|
||||
? 'Usage unavailable'
|
||||
: `Last known ${percentLabel}`,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user