mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
Qualify drawer composition through reading and access consumers
Cover closed-Details OS and filesystem guidance plus stable update help across readings, relative ages and evidence changes. Add a compiled safe-observation browser fixture for the combined drawers and shipped Docs. Contract-Neutral: Connected composition regression and browser fixtures retain existing source behaviour and document the merge outcomes. Change-source: pulse-maintainer
This commit is contained in:
@@ -20,6 +20,28 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Alert-first drawer composition retains usable evidence guidance (13 October 2026)
|
||||
|
||||
The guest's full OS caption moves to the visible, wrapping facts strip; it is
|
||||
not shortened or made tooltip-only when Summary moves into closed Details.
|
||||
Filesystem evidence and its existing reading-guidance link stay outside that
|
||||
disclosure, including unknown and last-known readings. The link keeps its
|
||||
44px phone target, compact desktop target and keyboard outline.
|
||||
|
||||
The node's denied-update explanation and HelpIcon are owned by the drawer,
|
||||
outside the demoted Summary and the lifted warning rows. An ordinary same-node
|
||||
reading or relative-age update must not replace the open help dialog or its
|
||||
trigger. Update evidence wraps in Worth checking. Alert-first 24h cards,
|
||||
metric-owned alert colours, on-demand History, closed inventory Details and
|
||||
unchanged advice/actions remain. This is presentation/access continuity, not
|
||||
native QGA/backup or update-permission recovery.
|
||||
|
||||
`NodeDrawerOverview.updateEvidence.test.tsx` and
|
||||
`GuestDrawerOverview.filesystems.test.tsx` cover the connected merge outcomes;
|
||||
`browser-tests/drawer-composition.cjs` consumes the actual adapter, drawers,
|
||||
shared history query and Docs with synthetic observations at desktop/phone
|
||||
widths, including keyboard/touch guidance and same-node updates.
|
||||
|
||||
### History honours explicit selected-source staleness (13 October 2026)
|
||||
|
||||
The shared memory presentation reads the server's explicit `agent.stale`
|
||||
|
||||
@@ -33,12 +33,12 @@ try {
|
||||
if (file.startsWith(root + '/') && !file.includes('/node_modules/') && fs.existsSync(file) && fs.statSync(file).isFile())
|
||||
binding.sources[path.relative(root, file)] = hash(fs.readFileSync(file));
|
||||
}
|
||||
for (const file of ['src/components/shared/InlineNotice.tsx', 'src/components/Workloads/GuestDrawer.tsx', 'src/components/Infrastructure/ResourceDetailDrawer.tsx', 'src/components/Settings/RolesPanel.tsx', 'src/components/Settings/UserAssignmentsPanel.tsx', 'src/pages/Docs.tsx'])
|
||||
for (const file of ['src/components/shared/InlineNotice.tsx', 'src/components/Workloads/GuestDrawer.tsx', 'src/components/Infrastructure/ResourceDetailDrawer.tsx', 'src/components/Settings/RolesPanel.tsx', 'src/components/Settings/UserAssignmentsPanel.tsx', 'src/pages/Docs.tsx', 'src/components/Workloads/NodeDrawer.tsx'])
|
||||
if (!binding.sources[file]) throw new Error(`Required consumer not compiled: ${file}`);
|
||||
},
|
||||
}, tailwindcss(), solid()],
|
||||
resolve: { alias: { '@': path.join(root, 'src') }, conditions: ['import', 'browser', 'default'] },
|
||||
build: { target: 'esnext', outDir, emptyOutDir: true, rollupOptions: { input: path.join(root, 'browser-tests/inline-notice-access.html') } },
|
||||
build: { target: 'esnext', outDir, emptyOutDir: true, rollupOptions: { input: [path.join(root, 'browser-tests/inline-notice-access.html'), path.join(root, 'browser-tests/drawer-composition.html')] } },
|
||||
});
|
||||
const bind = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
@@ -51,7 +51,8 @@ try {
|
||||
for (const file of ['index.html', 'package.json', 'package-lock.json', 'tsconfig.json', 'public/docs/VM_DISK_MONITORING.md'])
|
||||
binding.sources[file] = hash(fs.readFileSync(path.join(root, file)));
|
||||
fs.mkdirSync(path.join(outDir, 'docs'), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, 'public/docs/VM_DISK_MONITORING.md'), path.join(outDir, 'docs/VM_DISK_MONITORING.md'));
|
||||
for (const doc of ['VM_DISK_MONITORING.md', 'TROUBLESHOOTING.md'])
|
||||
fs.copyFileSync(path.join(root, 'public/docs', doc), path.join(outDir, 'docs', doc));
|
||||
const artifacts = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Exact compiled producer/consumer proof; no external or mutating requests.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const crypto = require('node:crypto');
|
||||
const assert = require('node:assert/strict');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
const root = '/workspace', origin = 'http://127.0.0.1:5335';
|
||||
const compiled = root + '/tmp/inline-notice-access/final-static';
|
||||
const output = root + '/tmp/drawer-composition';
|
||||
const hash = (b) => crypto.createHash('sha256').update(b).digest('hex');
|
||||
async function box(locator) {
|
||||
return locator.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect(), css = getComputedStyle(element);
|
||||
return { width: rect.width, height: rect.height, left: rect.left, right: rect.right,
|
||||
client: element.clientWidth, scroll: element.scrollWidth, text: element.textContent,
|
||||
focused: element === document.activeElement, focusVisible: element.matches(':focus-visible'),
|
||||
whiteSpace: css.whiteSpace, overflowWrap: css.overflowWrap, colour: css.color,
|
||||
outline: css.outlineStyle, outlineWidth: css.outlineWidth, outlineOffset: css.outlineOffset };
|
||||
});
|
||||
}
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const binding = JSON.parse(fs.readFileSync(compiled + '/binding.json'));
|
||||
const result = { result: 'incomplete', source: binding.source_sha, driver: hash(fs.readFileSync(__filename)),
|
||||
playwright: require('playwright/package.json').version, binding, cases: [], captures: [],
|
||||
cleanup: { browser: false, server: false },
|
||||
limits: 'Synthetic observation/API data through actual adapter, rows, drawers, history and Docs; no native thaw, monitoring restoration, QGA restart, permissions, deployed or release acceptance.' };
|
||||
let server, browser;
|
||||
try {
|
||||
assert.match(result.source, /^[0-9a-f]{40}$/);
|
||||
assert.equal(result.playwright, JSON.parse(fs.readFileSync(root + '/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version);
|
||||
for (const [file, digest] of Object.entries(binding.sources))
|
||||
assert.equal(hash(fs.readFileSync(root + '/frontend-modern/' + file)), digest, file);
|
||||
const assets = new Map();
|
||||
for (const [file, digest] of Object.entries(binding.artifacts)) {
|
||||
const bytes = fs.readFileSync(compiled + '/' + file); assert.equal(hash(bytes), digest, file); assets.set('/' + file, bytes);
|
||||
}
|
||||
for (const doc of ['VM_DISK_MONITORING.md', 'TROUBLESHOOTING.md'])
|
||||
assert.equal(hash(fs.readFileSync(root + '/docs/' + doc)), binding.artifacts['docs/' + doc]);
|
||||
server = http.createServer((request, response) => {
|
||||
if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405); return response.end(); }
|
||||
let pathname = new URL(request.url, origin).pathname;
|
||||
if (['/', '/guest', '/node', '/docs/VM_DISK_MONITORING', '/docs/TROUBLESHOOTING'].includes(pathname)) pathname = '/browser-tests/drawer-composition.html';
|
||||
const bytes = assets.get(pathname);
|
||||
if (!bytes) { response.writeHead(404); return response.end(); }
|
||||
response.setHeader('Content-Type', { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.md': 'text/markdown; charset=utf-8' }[path.extname(pathname)] || 'application/octet-stream');
|
||||
response.setHeader('Cache-Control', 'no-store'); response.end(request.method === 'HEAD' ? undefined : bytes);
|
||||
});
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(5335, '127.0.0.1', resolve); });
|
||||
for (const [engine, width, theme] of [[chromium, 1365, 'light'], [chromium, 1365, 'dark'], [webkit, 390, 'light'], [webkit, 390, 'dark']]) {
|
||||
const name = `${engine === chromium ? 'chromium' : 'webkit'}-${width}-${theme}`;
|
||||
browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
|
||||
const context = await browser.newContext({ viewport: { width, height: 900 }, isMobile: width < 768, hasTouch: width < 768, colorScheme: theme, locale: 'en-GB', reducedMotion: 'reduce' });
|
||||
await context.addInitScript((theme) => {
|
||||
window.__trustedInputs = []; document.addEventListener('click', (event) => window.__trustedInputs.push({ trusted: event.isTrusted, label: event.target.closest('a,button,summary')?.textContent }), true);
|
||||
document.addEventListener('DOMContentLoaded', () => document.documentElement.classList.toggle('dark', theme === 'dark'));
|
||||
}, theme);
|
||||
const page = await context.newPage(); page.setDefaultTimeout(10000);
|
||||
const record = { name, browser: browser.version(), width, theme, requests: [], errors: [], states: [] }; result.cases.push(record);
|
||||
page.on('pageerror', (error) => record.errors.push(error.message));
|
||||
await page.clock.install({ time: new Date('2026-10-13T13:00:00Z') });
|
||||
await page.route('**/*', (route) => {
|
||||
const request = route.request(), url = new URL(request.url());
|
||||
record.requests.push({ method: request.method(), path: url.pathname, search: url.search });
|
||||
assert.equal(url.origin, origin); assert.equal(request.method(), 'GET');
|
||||
assert(!/\/(?:diagnostics|run-command|guest-agent|qemu)(?:\/|$)/.test(url.pathname));
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/metrics/history') {
|
||||
const end = Date.parse('2026-10-13T13:00:00Z'), start = end - 86400000;
|
||||
const metrics = Object.fromEntries(['cpu', 'memory', 'disk', 'temperature'].map((metric) => [metric, [
|
||||
{ timestamp: start, value: 20, min: 10, max: 40 }, { timestamp: end, value: 30, min: 20, max: 90 },
|
||||
]]));
|
||||
return route.fulfill({ json: { resourceType: url.searchParams.get('resourceType'), resourceId: url.searchParams.get('resourceId'), range: '24h', start, end, metrics, source: 'mock_synthetic' } });
|
||||
}
|
||||
if (url.pathname === '/api/license/runtime-capabilities') return route.fulfill({ json: { capabilities: [], hosted_mode: false, max_history_days: 7, runtime: { build: 'free' }, blocked_capabilities: [] } });
|
||||
return route.fulfill({ json: { success: true, data: [], alerts: [], anomalies: [], config: null } });
|
||||
});
|
||||
const capture = async (suffix) => {
|
||||
const file = `${name}-${suffix}.png`; await page.screenshot({ path: output + '/' + file, fullPage: true });
|
||||
result.captures.push({ file, bytes: fs.statSync(output + '/' + file).size, sha256: hash(fs.readFileSync(output + '/' + file)) });
|
||||
};
|
||||
const noOverflow = async () => assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth), 0);
|
||||
const activate = async (control) => { if (width < 768) await control.tap(); else { await control.focus(); await page.keyboard.press('Enter'); } };
|
||||
await page.goto(origin + '/guest', { waitUntil: 'networkidle' });
|
||||
await activate(page.getByText('composition-guest', { exact: true }).first());
|
||||
const overview = page.getByTestId('guest-drawer-overview'); await overview.waitFor();
|
||||
const details = page.getByTestId('guest-technical-details'); assert.equal(await details.getAttribute('open'), null);
|
||||
const os = (await page.evaluate(() => window.__composition.evidence())).os;
|
||||
const fact = page.getByTestId('guest-drawer-facts').getByText(os, { exact: true }); await fact.waitFor();
|
||||
record.os = await box(fact); assert.equal(record.os.text, os); assert(record.os.scroll <= record.os.client); assert.equal(record.os.whiteSpace, 'normal');
|
||||
assert(record.os.right <= width && record.os.left >= 0); await noOverflow();
|
||||
const reading = overview.getByRole('link', { name: 'Filesystem reading guidance' });
|
||||
await reading.scrollIntoViewIfNeeded(); await page.keyboard.press('Tab'); await reading.focus();
|
||||
record.reading = await box(reading); assert.equal(record.reading.focusVisible, true); assert.equal(record.reading.outline, 'solid'); assert.equal(record.reading.outlineWidth, '2px');
|
||||
assert.equal(record.reading.height, width < 768 ? 44 : 16); await capture('guest-unknown');
|
||||
await activate(reading); await page.waitForURL('**/docs/VM_DISK_MONITORING#*');
|
||||
await page.getByRole('heading', { name: 'A missing reading is not an installation diagnosis' }).waitFor();
|
||||
record.filesystemDoc = page.url();
|
||||
const digest = await page.evaluate(async () => { const b = await (await fetch('/docs/VM_DISK_MONITORING.md')).arrayBuffer(); return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', b))).map((x) => x.toString(16).padStart(2, '0')).join(''); });
|
||||
assert.equal(digest, binding.artifacts['docs/VM_DISK_MONITORING.md']);
|
||||
await page.goto(origin + '/guest', { waitUntil: 'networkidle' }); await activate(page.getByText('composition-guest', { exact: true }).first());
|
||||
await page.evaluate(() => window.__composition.mode('retained'));
|
||||
await overview.getByText(/Using last known/).waitFor(); await overview.getByText('/data', { exact: true }).waitFor();
|
||||
assert.equal(await details.getAttribute('open'), null); await noOverflow(); await capture('guest-retained');
|
||||
record.states.push('Unknown reading and full OS outside closed Details; retained /data and last-known notice preserved');
|
||||
await page.evaluate(() => window.__composition.mode('fresh'));
|
||||
await overview.getByText(/Using last known/).waitFor({ state: 'hidden' });
|
||||
assert.equal(await overview.getByRole('link', { name: 'Filesystem reading guidance' }).count(), 0);
|
||||
const memoryCard = overview.getByTestId('drawer-trend-card-memory'); assert.match(await memoryCard.getAttribute('class'), /amber/);
|
||||
await memoryCard.locator('[data-testid="drawer-trend-threshold"]').waitFor();
|
||||
await activate(page.getByRole('button', { name: 'History', exact: true }));
|
||||
await page.getByTestId('guest-history-group-chart').first().waitFor();
|
||||
await activate(page.getByRole('button', { name: 'Overview', exact: true }));
|
||||
record.states.push('Fresh read withdraws deferral; metric alert trend threshold and on-demand History retained');
|
||||
|
||||
await page.goto(origin + '/node', { waitUntil: 'networkidle' });
|
||||
const node = page.getByTestId('node-drawer-overview'), nodeDetails = page.getByTestId('node-technical-details');
|
||||
const trigger = node.getByRole('button', { name: 'Why update checks are unavailable' }); await trigger.waitFor();
|
||||
assert.equal(await nodeDetails.getAttribute('open'), null);
|
||||
await node.getByText(/7 pending.*stale/).waitFor();
|
||||
await node.getByText(/Other monitoring can still work/).waitFor();
|
||||
const plain = node.getByTestId('drawer-trend-card-memory'); assert(!/amber|red-/.test(await plain.getAttribute('class')), 'guest alert must not colour node trend');
|
||||
await trigger.scrollIntoViewIfNeeded(); await activate(trigger);
|
||||
const help = page.getByRole('dialog', { name: 'Proxmox update-check access' }); await help.waitFor();
|
||||
assert.match(await help.textContent(), /Sys.Modify/); assert.match(await help.textContent(), /broader than read-only monitoring/);
|
||||
await help.evaluate((element) => { window.__retainedHelp = element; });
|
||||
await trigger.evaluate((element) => { window.__retainedTrigger = element; });
|
||||
await page.evaluate(() => window.__composition.update({ cpu: 0.2 }));
|
||||
await page.clock.runFor(60001);
|
||||
assert.equal(await help.evaluate((element) => window.__retainedHelp === element), true);
|
||||
assert.equal(await trigger.evaluate((element) => window.__retainedTrigger === element), true);
|
||||
await node.getByText(/7 pending.*stale.*checked 2 mins ago/).waitFor();
|
||||
await page.evaluate(() => window.__composition.update({ pendingUpdatesStatus: 'unavailable' }));
|
||||
await node.getByText('Unavailable · Update check access denied', { exact: true }).waitFor();
|
||||
assert.equal(await help.evaluate((element) => window.__retainedHelp === element), true);
|
||||
record.trigger = await box(trigger); assert(record.trigger.height >= (width < 768 ? 44 : 32));
|
||||
record.help = await box(help); assert(record.help.right <= width && record.help.left >= 0);
|
||||
await noOverflow(); await capture('node-help');
|
||||
await page.keyboard.press('Escape'); await help.waitFor({ state: 'hidden' }); assert.equal(await trigger.evaluate((e) => e === document.activeElement), true);
|
||||
await activate(nodeDetails.locator('summary')); assert.notEqual(await nodeDetails.getAttribute('open'), null);
|
||||
await activate(nodeDetails.locator('summary')); assert.equal(await nodeDetails.getAttribute('open'), null);
|
||||
await page.evaluate(() => window.__composition.update({ pendingUpdates: 0, pendingUpdatesStatus: 'checked', pendingUpdatesReason: undefined }));
|
||||
await trigger.waitFor({ state: 'hidden' });
|
||||
record.states.push('Denied evidence and usable guidance visible with Details closed; same dialog/trigger survives readings, age and verdict changes; Escape restores focus; actual success removes denial help');
|
||||
record.inputs = await page.evaluate(() => window.__trustedInputs); assert(record.inputs.some((event) => event.trusted && event.label?.includes('Why update checks')));
|
||||
assert.deepEqual(record.errors, []);
|
||||
await context.close(); await browser.close(); browser = null;
|
||||
}
|
||||
result.result = 'passed'; result.verified_at = new Date().toISOString();
|
||||
} catch (error) { result.error = String(error.stack || error); throw error; }
|
||||
finally {
|
||||
if (browser) await browser.close(); result.cleanup.browser = true;
|
||||
if (server) await new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); server.closeAllConnections(); });
|
||||
result.cleanup.server = true;
|
||||
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en-GB"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Drawer composition proof</title></head><body><div id="root"></div><script type="module" src="/browser-tests/drawer-composition.tsx"></script></body></html>
|
||||
@@ -0,0 +1,71 @@
|
||||
// Synthetic readings through the real adapter, row, drawers, history and Docs.
|
||||
// This fixture never operates on a guest, credential, service or backup.
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { DarkModeContext } from '../src/contexts/appRuntime';
|
||||
import { useWorkloads } from '../src/hooks/useWorkloads';
|
||||
import { GuestRow } from '../src/components/Workloads/GuestRow';
|
||||
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
|
||||
import { NodeDrawer } from '../src/components/Workloads/NodeDrawer';
|
||||
import Docs from '../src/pages/Docs';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import type { Alert, Node } from '../src/types/api';
|
||||
import '../src/index.css';
|
||||
|
||||
const os = 'Microsoft Windows Server 2025 Datacenter Evaluation with retained build identity 26100.1742';
|
||||
const capacity = 10 * 1024 ** 3;
|
||||
const alert = (id: string, resourceId: string, type = 'memory'): Alert => ({
|
||||
id, resourceId, resourceName: 'composition-guest', type, level: 'warning', value: 92,
|
||||
threshold: 85, message: 'Synthetic metric warning', startTime: new Date().toISOString(),
|
||||
} as Alert);
|
||||
function GuestFixture() {
|
||||
const [mode, setMode] = createSignal('unknown');
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const snapshot = (): Resource[] => [{
|
||||
id: 'fixture:pve1:101', type: 'vm', name: 'composition-guest', displayName: 'composition-guest',
|
||||
platformId: 'fixture', platformType: 'proxmox-pve', platformScopes: ['proxmox-pve'],
|
||||
sourceType: 'api', sources: ['proxmox'], status: 'online', lastSeen: Date.now(),
|
||||
metricsTarget: { resourceType: 'vm', resourceId: 'fixture:pve1:101' },
|
||||
cpu: { current: 10 }, memory: { current: 92, total: capacity, used: capacity * 0.92 },
|
||||
...(mode() !== 'unknown' ? { disk: { current: 50, used: capacity / 2, total: capacity } } : {}),
|
||||
proxmox: {
|
||||
vmid: 101, nodeName: 'pve1', instance: 'fixture', runtimeStatus: 'running', osName: os,
|
||||
guestAgentStatus: mode() === 'unknown' ? 'not-running' : mode() === 'retained' ? 'deferred' : 'available',
|
||||
diskStatusReason: mode() === 'unknown' ? 'agent-not-running' : mode() === 'retained' ? 'prev-vm-locked' : '',
|
||||
lock: mode() === 'retained' ? 'backup' : '',
|
||||
disks: mode() === 'unknown' ? [] : [{ mountpoint: '/data', type: 'ext4', total: capacity, used: capacity / 2, usage: 50 }],
|
||||
},
|
||||
}];
|
||||
const state = useWorkloads(() => true, { resourceSnapshot: snapshot });
|
||||
const guest = () => state.workloads()[0];
|
||||
(window as any).__composition = { mode: setMode, evidence: () => ({ mode: mode(), projected: guest(), os }) };
|
||||
return <main class="mx-auto min-h-screen max-w-4xl space-y-3 bg-surface p-3 text-base-content">
|
||||
<h1>Combined guest drawer</h1><p class="text-xs text-muted">Synthetic observations; no guest commands.</p>
|
||||
<Show when={guest()}>
|
||||
<table class="w-full table-fixed"><tbody><GuestRow guest={guest()} visibleColumnIds={['name', 'disk']}
|
||||
isExpanded={open()} workloadTableLayoutMode={window.innerWidth < 768 ? 'phone' : 'wide'} onClick={() => setOpen(!open())} /></tbody></table>
|
||||
<Show when={open()}><GuestDrawer guest={guest()} alerts={[alert('fixture-warning', 'fixture:pve1:101')]} onClose={() => setOpen(false)} /></Show>
|
||||
</Show>
|
||||
</main>;
|
||||
}
|
||||
function NodeFixture() {
|
||||
const [node, setNode] = createSignal<Node>({
|
||||
id: 'fixture-pve1', name: 'pve1', instance: 'fixture', host: 'pve1', status: 'online', type: 'node',
|
||||
cpu: 0.1, memory: { total: capacity, used: capacity / 4, free: capacity * 0.75, usage: 25 },
|
||||
disk: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 },
|
||||
uptime: 3600, loadAverage: [0.1, 0.2, 0.3], kernelVersion: '6.8.12', pveVersion: 'pve-manager/9.2.21',
|
||||
cpuInfo: { model: 'Fixture CPU', cores: 4, sockets: 1, mhz: '2400' },
|
||||
lastSeen: new Date().toISOString(), connectionHealth: 'healthy',
|
||||
pendingUpdates: 7, pendingUpdatesStatus: 'stale', pendingUpdatesReason: 'permission_denied',
|
||||
pendingUpdatesCheckedAt: new Date(Date.now() - 60000).toISOString(),
|
||||
});
|
||||
(window as any).__composition = { update: (delta: Partial<Node>) => setNode((previous) => ({ ...previous, ...delta })), evidence: node };
|
||||
return <main class="mx-auto min-h-screen max-w-4xl space-y-3 bg-surface p-3 text-base-content">
|
||||
<h1>Combined node drawer</h1><p class="text-xs text-muted">Synthetic observations; no permissions changed.</p>
|
||||
<NodeDrawer node={node()} alerts={[alert('fixture-guest-warning', 'fixture:pve1:101')]} />
|
||||
</main>;
|
||||
}
|
||||
render(() => <DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
|
||||
<Router><Route path="/docs/*docPath" component={Docs} /><Route path="/node" component={NodeFixture} /><Route path="*" component={GuestFixture} /></Router>
|
||||
</DarkModeContext.Provider>, document.getElementById('root')!);
|
||||
+27
@@ -56,6 +56,33 @@ const props: ComponentProps<typeof GuestDrawerOverview> = {
|
||||
describe('GuestDrawerOverview filesystem labels', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('keeps full OS facts and phone reading guidance outside the closed Details', () => {
|
||||
const os = 'Microsoft Windows Server 2025 Datacenter Evaluation long-build-identity';
|
||||
render(() => (
|
||||
<GuestDrawerOverview
|
||||
{...props}
|
||||
guestOsSummary={os}
|
||||
hasFilesystemDetails={false}
|
||||
guest={{ ...props.guest, type: 'qemu', disks: [], diskStatusReason: 'agent-not-running' }}
|
||||
/>
|
||||
));
|
||||
const facts = screen.getByTestId('guest-drawer-facts');
|
||||
expect(facts).toHaveTextContent(os);
|
||||
const system = screen.getByText(os);
|
||||
expect(system.closest('details')).toBeNull();
|
||||
expect(system).toHaveClass('whitespace-normal', 'wrap-anywhere');
|
||||
expect(system).not.toHaveClass('truncate');
|
||||
expect(screen.getByTestId('guest-technical-details')).not.toHaveAttribute('open');
|
||||
const guidance = screen.getByRole('link', { name: 'Filesystem reading guidance' });
|
||||
expect(guidance.closest('details')).toBeNull();
|
||||
expect(guidance).toHaveClass('min-h-11', 'sm:min-h-0', 'text-base-content');
|
||||
expect(guidance).toHaveClass('focus-visible:outline-2');
|
||||
expect(guidance).toHaveAttribute(
|
||||
'href',
|
||||
'/docs/VM_DISK_MONITORING#a-missing-reading-is-not-an-installation-diagnosis',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(guestDiskDeferrals)(
|
||||
'explains %s in the existing drawer even before any disk sample',
|
||||
(reason, message) => {
|
||||
|
||||
+16
@@ -43,6 +43,8 @@ describe('NodeDrawerOverview update evidence', () => {
|
||||
...(status === 'stale' ? { pendingUpdatesReason: 'check_failed' as const } : {}),
|
||||
});
|
||||
render(() => <NodeDrawerOverview node={node} />);
|
||||
// The alert-first Overview demotes healthy inventory, not warning rows.
|
||||
(screen.getByTestId('node-technical-details') as HTMLDetailsElement).open = true;
|
||||
expect(screen.getByText(/No pending updates.*checked 1 min ago/)).toBeInTheDocument();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(screen.getByText(/No pending updates.*checked 2 mins ago/)).toHaveAttribute(
|
||||
@@ -66,6 +68,7 @@ describe('NodeDrawerOverview update evidence', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
(screen.getByTestId('node-technical-details') as HTMLDetailsElement).open = true;
|
||||
expect(screen.getByText('Updates')).toBeInTheDocument();
|
||||
expect(screen.getByText(/No pending updates · checked/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -81,9 +84,14 @@ describe('NodeDrawerOverview update evidence', () => {
|
||||
));
|
||||
|
||||
const status = screen.getByText('Unavailable · Update check access denied');
|
||||
expect(screen.getByTestId('node-technical-details')).not.toHaveAttribute('open');
|
||||
expect(status.closest('details')).toBeNull();
|
||||
expect(status).toHaveClass('whitespace-normal');
|
||||
expect(status).not.toHaveClass('truncate');
|
||||
expect(screen.getByText(/Other monitoring can still work/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Why update checks are unavailable' }).closest('details'),
|
||||
).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Why update checks are unavailable' }));
|
||||
const help = screen.getByRole('dialog', { name: 'Proxmox update-check access' });
|
||||
expect(help).toHaveTextContent('Sys.Modify');
|
||||
@@ -109,9 +117,17 @@ describe('NodeDrawerOverview update evidence', () => {
|
||||
render(() => <NodeDrawerOverview node={node()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Why update checks are unavailable' }));
|
||||
const help = screen.getByRole('dialog', { name: 'Proxmox update-check access' });
|
||||
const trigger = screen.getByRole('button', { name: 'Why update checks are unavailable' });
|
||||
const details = screen.getByTestId('node-technical-details') as HTMLDetailsElement;
|
||||
details.open = true;
|
||||
details.open = false;
|
||||
setNode({ ...node(), cpu: 0.2 });
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(screen.getByRole('dialog', { name: 'Proxmox update-check access' })).toBe(help);
|
||||
expect(screen.getByRole('button', { name: 'Why update checks are unavailable' })).toBe(trigger);
|
||||
expect(screen.getByText(/7 pending.*stale.*checked 2 mins ago/)).toBeInTheDocument();
|
||||
setNode({ ...node(), pendingUpdatesStatus: 'unavailable' });
|
||||
expect(screen.getByRole('dialog', { name: 'Proxmox update-check access' })).toBe(help);
|
||||
expect(screen.getByText('Unavailable · Update check access denied')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user