Merge remote-tracking branch 'origin/main' into feat/vsphere-overview-health

This commit is contained in:
rcourtman
2026-10-05 02:45:05 +01:00
96 changed files with 6592 additions and 527 deletions
+94 -28
View File
@@ -101,48 +101,114 @@ All endpoints require admin authentication.
### Testing and adding a connection (API)
The **Settings → Infrastructure → Platform connections** flow is the simplest option. For an API client,
prepare the private Pulse header file as described in the
[API authentication guide](API.md). The Pulse API token authenticates to
Pulse; the separate TrueNAS API key belongs in the connection's JSON file.
Do not put either credential in shell arguments, URLs or a public thread.
Prefer **Settings → Infrastructure → Platform connections** for setup. These
API examples are for an authorised administrator using curl 7.76 or later,
not a way to diagnose stale polling. Testing makes a live system-information
request to TrueNAS; saving creates a connection and starts ordinary polling.
Do not use either during a backup, freeze/thaw or an unresponsive-host incident.
Create or protect the connection file before opening it in a trusted editor:
First prepare a private Pulse header file using the
[API token file procedure](API.md#api-token-recommended), with the admin access
needed for connection management. The Pulse token authenticates to Pulse;
the separate TrueNAS API key belongs in the connection's JSON file. Enter
credentials only in your local editor, never in arguments, URLs or a thread.
On a trusted machine, as the account running curl, prepare the connection
file below. Stop if either file-preparation step fails. This preserves an
existing file, restricts the directory and refuses symlinked credential paths
or a non-regular connection file before opening the editor:
```bash
umask 077
mkdir -p "$HOME/.config/pulse"
touch "$HOME/.config/pulse/truenas-connection.json"
chmod 600 "$HOME/.config/pulse/truenas-connection.json"
vi "$HOME/.config/pulse/truenas-connection.json"
(
set -eu
umask 077
config_dir="$HOME/.config/pulse"
connection_file="$config_dir/truenas-connection.json"
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$connection_file" ]; then
printf 'Refusing a symlinked credential path.\n' >&2
exit 1
fi
if [ -e "$connection_file" ] && [ ! -f "$connection_file" ]; then
printf 'Connection file must be a regular file.\n' >&2
exit 1
fi
mkdir -p "$config_dir"
chmod 700 "$config_dir"
touch "$connection_file"
chmod 600 "$connection_file"
vi "$connection_file"
)
```
In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL),
`username` (the key owner) and `apiKey`. Keep both credential files private;
do not paste their contents into a terminal command or upload them.
In the editor, save a JSON object with `name`, `host` (the TrueNAS HTTPS URL),
`username` (the key owner) and `apiKey`. Keep both credential files private and
outside repositories and diagnostics. Do not upload them.
Test the connection before saving it:
The following examples run **on the Pulse host**, using its direct loopback
address. They ignore curl configuration, do not follow redirects or retry,
and retain each response in a new private local file. Responses and errors can
contain identifying details or echoed credentials; review them locally, not in
a public terminal recording or thread. Keep `--disable` first so local curl
configuration cannot enable tracing or change the request.
Test once without saving:
```bash
curl --fail-with-body --silent --show-error \
--header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' \
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
http://127.0.0.1:7655/api/truenas/connections/test
(
set -eu
umask 077
result_file=$(mktemp "$HOME/.config/pulse/truenas-test.XXXXXX")
printf 'Private response file: %s\n' "$result_file"
status=$(curl --disable --fail-with-body --silent --show-error \
--connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \
--header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' \
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
--output "$result_file" --write-out '%{http_code}' \
http://127.0.0.1:7655/api/truenas/connections/test)
printf 'HTTP %s\n' "$status"
[ "$status" = 200 ]
)
```
After checking the test response, save the connection with:
Require exit 0, HTTP **200** and `success: true` in that private test response.
A redirect, HTTP error or `000` is not a successful test. Test success is not
inventory, metric or guest-thaw acceptance.
Only after reviewing the test response, create the connection once:
```bash
curl --fail-with-body --silent --show-error \
--header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' \
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
http://127.0.0.1:7655/api/truenas/connections
(
set -eu
umask 077
result_file=$(mktemp "$HOME/.config/pulse/truenas-save.XXXXXX")
printf 'Private response file: %s\n' "$result_file"
status=$(curl --disable --fail-with-body --silent --show-error \
--connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \
--header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' \
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
--output "$result_file" --write-out '%{http_code}' \
http://127.0.0.1:7655/api/truenas/connections)
printf 'HTTP %s\n' "$status"
[ "$status" = 201 ]
)
```
These Pulse URLs are loopback-only examples. For a remote Pulse server, use
its trusted HTTPS origin without disabling certificate verification.
A successful save returns HTTP **201** and the new connection's `id`, not
proof of a complete poll. If saving times out or loses its response, it may
already have created the connection. Check **Platform connections** or
`/api/truenas/connections` in your existing signed-in Pulse browser before
trying again; do not blindly repeat POST and create duplicates. Continue with
the [ordinary polling checks](#stale-truenas-data) for collection acceptance.
For remote Pulse access, replace the loopback origin in the relevant command
with your trusted **HTTPS** origin and change `--proto '=http'` to
`--proto '=https'`. Keep certificate verification enabled. For a private CA,
add `--cacert /path/to/trusted-ca.pem` using a CA obtained independently from
your administrator, not from the failing connection. Do not add `--insecure`,
`--location`, verbose/trace flags or automatic retries. Share only the relevant
HTTP status and a manually redacted error, never the full response or headers.
## Troubleshooting
@@ -15,6 +15,35 @@
## Purpose
### Retained guest observations are not alert evidence
Guest memory marked `last-known`, `unavailable` or an unknown observation state
cannot start, advance, refresh or resolve a memory incident. A VM filesystem
read with a nonempty `diskStatusReason`, including retained `prev-*` values,
or unavailable aggregate usage is likewise not current disk evidence. It cannot
evaluate either aggregate or per-filesystem thresholds, replace an aggregate
incident with filesystem incidents, or clear filesystem alerts as if a failed
or expired inventory were authoritative removal. Metric filters do not match
these unavailable memory/disk values, including a fabricated healthy zero.
Existing incidents retain their last trusted value, occurrence, acknowledgement,
observation time and lifecycle/delivery evidence until a current observation
resumes evaluation. Successful current inventory still resolves removed
filesystems. Explicit disablement, suppression and stopped-guest behavior remain
effective independently of telemetry, including per-filesystem disable overrides
on retained identities. Legacy unannotated memory and independent
current PVE/Pulse-agent memory/disks keep their existing policy, even while QGA
is deferred; current CPU and I/O continue to evaluate. Producers own freshness;
no new age threshold, QGA request, payload, alert family or UI surface is added.
`guest_observation_test.go` pins typed/pointer VM/LXC observation admission,
delayed breaches, canonical incident/acknowledgement/ledger continuity, filters,
fresh recovery and removal, independent metrics and deliberate disablement.
`TestGuestBackupDeferralAlertObservationLifecycle` connects actual HTTP client,
poll builder, retained snapshot, unified view, History and the production alert
call. These are synthetic source proofs, not native backup/thaw, containing
release, installed delivery or external recipient acceptance.
### Pulse system alerts retain their service scope in the existing UI
The `pulse-system-` identity prefix or explicit `systemAlert: true` metadata
@@ -1118,6 +1118,18 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
renders only for a saved URL and adds no fetch, polling or per-row work.
`GuestDrawer.test.tsx` verifies its target, the unsafe-URL warning, and
that opening it does not collapse the drawer.
The workloads table header owns the bulk web-link editor. The trigger in
`WorkloadWebLinksAction.tsx` holds only the open flag and unsaved drafts
and lazy-loads `WorkloadWebLinksDialog.tsx`, whose state
(`useWorkloadWebLinksState.ts`) and derivations
(`workloadWebLinksModel.ts`) stay out of the WorkloadsSurface chunk and
run only while the panel is mounted. It edits the guests in the current
filtered view through the same guest metadata API and metadata-changed
event as the drawer field. `workloadGuestMetadataRecord.ts` is the single
lookup for a row's saved link in both the table and the editor. Panel rows
keep unchanged row objects across inventory ticks so an open editor never
remounts the input being typed in.
`WorkloadsSurface.performance.contract.test.tsx` pins that wiring.
Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`.
A current metric may remain visible in a chart legend only when labelled
`current`; it must never be expanded into synthetic timestamps or a flat
@@ -1678,6 +1690,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
@@ -3659,3 +3680,17 @@ backend, metrics-store, performance threshold or latency claim changes here.
including selection retention while a same-source window advances. Browser
receipts exercise the actual PBS table/drawer renderer with synthetic APIs,
not installed collection or a performance benchmark.
### Stored drawer History segmentation stays bounded
Missing-observation rendering uses the existing normalized, configured-group
timestamps and the unchanged maximum of 240 requested points per metric. The
segment builder indexes those timestamps once per series and walks its stored
points without nested nearest-point searches. It adds no request, polling timer,
collector or synthetic reading. Separate singletons/segments retain the source
data and shared time window; live updates cannot fill absent stored evidence.
The mounted `GuestDrawerHistory.gaps.test.tsx` controls pin one initial read and
only explicit matching refreshes; the full `GuestDrawer.test.tsx` pins the
unchanged cap and no live-recovery read. `guestHistorySegments.test.ts` pins
complete point preservation at the existing request bound. Neither claims a
timing benchmark, fleet CPU attribution or native incident relief.
@@ -171,6 +171,16 @@ backup files, never guest-local snapshots. Guest snapshots remain listed as
restore evidence but cannot make an old independent backup look current.
Archive detail uses its format (or filename fallback), preserving the full
provider volid for hover and search in both Coverage and By date.
Coverage rows carry that one counted backup age at every width; per-source
PBS, PVE-file and guest-snapshot age columns are not row columns, because a
fresh snapshot beside a stale backup read as protection that posture does not
grant. The row expansion keeps the newest point and the newest completed point
of every source inside its bounded evidence list, so neither recent snapshots
nor a running or failed job hide the latest usable backup. The Backup health
strip states what its attention, unprotected and unknown counts mean from the
server posture policy (`freshnessWindowSeconds`) using the causes
`internal/recovery/posture.go` evaluates, and renders no rule until that policy
has loaded rather than a guessed window.
Phone pool/disk rows preserve health and identity while using weighted widths,
consistent gutters and compact capacity/health labels. Full disk risk reasons
@@ -1293,6 +1293,14 @@ names remain inert identity text, while the separate accessible launch target
owns new-tab behavior and event containment. Proxmox host table columns are
governed by `proxmoxHostTableModel.ts`; that model must not reintroduce a
separate `Web` column, a linked row name, or a page-local launch implementation.
When every node shares one cluster and none is standalone, the model drops
the `cluster` column (it would repeat one value on every row) and
`ProxmoxNodesTable` names that cluster in the table header instead, visible
during search and independent of the inventory-count preference, so the
cluster identity is never lost. Workload status buckets read the canonical
health too: a running workload whose unified health names an open
`warning_alert` or `critical_alert` reason counts under Attention, while
`backup_stale` and other non-alert reasons do not.
The same presentation contract applies to Docker/Podman hosts and containers,
standalone agents, PBS/PMG instances, Kubernetes clusters/nodes/workloads, and
card or mobile variants. An absent URL is honestly absent, while an invalid or
@@ -3291,7 +3299,9 @@ metadata.
### Protection posture identity consumer
`ProxmoxCoverageTable` remains a unified-resource identity consumer while
storage/recovery owns protection truth. Live VM/LXC rows carry the exact
storage/recovery owns protection truth. Dropping the per-source age columns
from its rows changes no identity input: rows still key posture by the exact
canonical `Resource.id`. Live VM/LXC rows carry the exact
canonical `Resource.id` into one bounded posture batch; the table must not parse
its presentation key, VMID, name, node, or instance to mint a replacement
resource identity. Host and orphaned backup artifacts have no canonical live
@@ -0,0 +1,301 @@
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/discovery-ownership-proof/browser';
const origin = 'http://127.0.0.1:5333';
const runtime = [
'frontend-modern/src/components/Discovery/DiscoveryTab.tsx',
'frontend-modern/src/components/Discovery/useDiscoveryTabState.ts',
'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
];
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const saved = (id, service = `Saved Home Assistant ${id}`) => ({
id: `vm:fixture-node-agent:${id}`,
resource_type: 'vm',
resource_id: id,
target_id: 'fixture-node-agent',
hostname: `guest-${id}`,
service_type: 'home-assistant',
service_name: service,
service_version: 'test',
category: 'home_automation',
cli_access: '',
facts: [],
config_paths: [],
data_paths: [],
log_paths: [],
ports: [],
user_notes: '',
user_secrets: {},
confidence: 0.9,
ai_reasoning: 'Synthetic saved evidence',
discovered_at: '2026-10-03T12:00:00Z',
updated_at: '2026-10-03T12:00:00Z',
discovery_engine_version: 1,
});
(async () => {
fs.mkdirSync(output, { recursive: true });
const report = {
result: 'incomplete',
playwright: require('playwright/package.json').version,
runtime_sha256: Object.fromEntries(runtime.map((p) => [p, hash('/workspace/' + p)])),
groups: [],
screenshots: [],
cleanup: {},
limits:
'Production DiscoveryTab/state/CSS, synthetic API and production event-bus progress. No native QGA, automatic collector, original freeze cause, thaw, covered filesystem writes, installed, physical-phone or release acceptance.',
};
assert.equal(
report.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: 5333, strictPort: true, watch: null },
});
let browser,
phase = 'server';
try {
await server.listen();
for (const [name, engine, width, dark] of [
['desktop-light', chromium, 1365, false],
['desktop-dark', chromium, 1365, true],
['phone-dark', webkit, 390, true],
['narrow-light', webkit, 320, false],
]) {
phase = name;
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width < 500 ? 844 : 900 },
isMobile: width < 500,
hasTouch: width < 500,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(10000);
page.setDefaultNavigationTimeout(60000);
const group = {
name,
width,
dark,
browser: browser.version(),
checks: [],
requests: [],
errors: [],
offOrigin: [],
};
report.groups.push(group);
page.on('pageerror', (error) => group.errors.push(error.message));
await page.routeWebSocket('**/ws*', () => {});
await page.addInitScript((dark) => {
const apply = () => document.documentElement.classList.toggle('dark', dark);
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
let fail = false;
const releases = new Map();
const arrivals = new Map();
const stored = new Map();
await page.route('**/*', async (route) => {
const request = route.request(),
url = new URL(request.url());
if (url.origin !== origin) {
group.offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
group.requests.push({
path: url.pathname,
method: request.method(),
body: request.postData(),
});
const target = url.pathname.match(/^\/api\/discovery\/vm\/fixture-node-agent\/(101|102)$/);
if (target) {
const id = target[1];
if (request.method() === 'GET')
return route.fulfill({ json: stored.get(id) || saved(id) });
assert.equal(request.method(), 'POST');
if (fail)
return route.fulfill({
status: 409,
json: { error: 'Guest execution paused: backup lock observed' },
});
await new Promise((resolve) => {
releases.set(id, resolve);
arrivals.get(id)?.();
});
const result = saved(id, id === '101' ? 'Old guest result' : 'Current guest result');
stored.set(id, result);
return route.fulfill({ json: result });
}
assert.equal(request.method(), 'GET');
const responses = {
'/api/discovery/info/vm': {
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
commands: [],
command_categories: [],
},
'/api/ai/agents': {
count: 1,
agents: [{ agent_id: 'fixture-node-agent', hostname: 'pve1' }],
},
'/api/ai/settings': { discovery_enabled: true, enabled: false },
'/api/license/runtime-capabilities': {
capabilities: [],
limits: [],
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
},
};
return route.fulfill({ json: responses[url.pathname] || {} });
});
const capture = async (suffix) => {
await page.screenshot({ path: `${output}/${name}-${suffix}.png`, fullPage: true });
report.screenshots.push(`${name}-${suffix}.png`);
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
};
const emit = (progress) =>
page.evaluate((progress) => window.discoveryOwnership.progress(progress), progress);
const run = page.getByRole('button', { name: 'Run Discovery', exact: true });
const pressRun = async () => {
if (width >= 500) {
await run.focus();
await page.keyboard.press('Enter');
} else await run.tap();
};
await page.goto(origin + '/browser-tests/discovery-outcome-ownership.html');
await page.getByText('Saved Home Assistant 101', { exact: true }).waitFor();
await run.waitFor();
await page.waitForFunction(
() =>
!Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent.trim() === 'Run Discovery',
).disabled,
);
const before = group.requests.length;
await emit({
resource_id: 'vm:fixture-node-agent:101',
status: 'running',
current_step: 'Collecting guest evidence',
percent_complete: 20,
});
await page.getByText('Collecting guest evidence', { exact: true }).waitFor();
await emit({
resource_id: 'vm:fixture-node-agent:101',
status: 'completed',
error: 'Guest execution paused: backup lock observed',
current_step: 'Guest execution paused',
percent_complete: 20,
});
const alert = page.getByRole('alert');
await alert.waitFor();
await page.waitForTimeout(650);
assert((await alert.innerText()).includes('Guest execution paused: backup lock observed'));
assert.equal(group.requests.length, before);
assert(await page.getByText('Saved Home Assistant 101', { exact: true }).isVisible());
assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0);
await capture('retained-background-pause');
group.checks.push(
'Background completed-with-error remains an accessible failure with saved evidence and no retry or refresh',
);
const dismiss = page.getByRole('button', { name: 'Dismiss Discovery error' });
await dismiss.focus();
await page.keyboard.press('Enter');
assert.equal(await alert.count(), 0);
group.checks.push('The error dismiss action has a keyboard-accessible name');
const firstPost = new Promise((resolve) => arrivals.set('101', resolve));
await pressRun();
await firstPost;
await page.waitForFunction(() => document.body.textContent.includes('Scanning...'));
await page.evaluate(() => window.discoveryOwnership.target('102'));
await page.getByText('Saved Home Assistant 102', { exact: true }).waitFor();
const secondPost = new Promise((resolve) => arrivals.set('102', resolve));
await pressRun();
await secondPost;
await page.waitForFunction(() => document.body.textContent.includes('Scanning...'));
assert(releases.has('101'));
assert(releases.has('102'));
releases.get('101')();
await page.waitForTimeout(150);
assert.equal(await page.getByText('Old guest result', { exact: true }).count(), 0);
assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0);
assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled());
await emit({
resource_id: 'vm:fixture-node-agent:102',
status: 'completed',
percent_complete: 100,
});
assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled());
assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0);
releases.get('102')();
await page.getByText('Current guest result', { exact: true }).waitFor();
await page.getByText('Discovery complete!', { exact: true }).waitFor();
group.checks.push(
'Late old-target HTTP and scanner completion cannot replace the current guest or finish its active HTTP request',
);
const posts = group.requests.filter((r) => r.method === 'POST');
assert.equal(posts.length, 2);
assert.deepEqual(
posts.map((p) => JSON.parse(p.body)),
[
{ force: true, hostname: 'guest-101' },
{ force: true, hostname: 'guest-102' },
],
);
await capture('current-guest-result');
await emit({ resource_id: 'vm:fixture-node-agent:102', status: 'completed' });
await page.waitForTimeout(650);
assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0);
assert(await page.getByText('Current guest result', { exact: true }).isVisible());
assert.equal(group.requests.filter((r) => r.method === 'POST').length, 2);
group.checks.push(
'Later completion cannot strand the old HTTP success banner or issue an automatic scan',
);
fail = true;
await pressRun();
await alert.waitFor();
assert((await alert.innerText()).includes('Guest execution paused: backup lock observed'));
assert(await page.getByText('Current guest result', { exact: true }).isVisible());
assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0);
await page.waitForTimeout(650);
assert.equal(group.requests.filter((r) => r.method === 'POST').length, 3);
group.checks.push(
'A failed explicit run retains prior saved evidence, visible failure and no automatic retry',
);
assert.deepEqual(group.errors, []);
assert.deepEqual(group.offOrigin, []);
await browser.close();
browser = null;
}
report.result = 'passed';
report.verified_at = new Date().toISOString();
} catch (error) {
report.result = 'failed';
report.phase = phase;
report.error = String(error.stack || error);
throw error;
} finally {
if (browser) await browser.close();
report.cleanup.browser_closed = true;
await server.close();
report.cleanup.server_closed = true;
fs.writeFileSync(output + '/result.json', JSON.stringify(report, null, 2) + '\n');
}
})().catch((error) => {
console.error(error.stack || String(error));
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Discovery outcome ownership evidence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./discovery-outcome-ownership.tsx"></script>
</body>
</html>
@@ -0,0 +1,54 @@
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { DiscoveryTab } from '../src/components/Discovery/DiscoveryTab';
import { eventBus } from '../src/stores/events';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { DiscoveryProgress } from '../src/types/discovery';
import '../src/index.css';
declare global {
interface Window {
discoveryOwnership: {
target: (id: string) => void;
progress: (progress: DiscoveryProgress) => void;
enabled: (enabled: boolean) => void;
};
}
}
function Fixture() {
const [id, setId] = createSignal('101');
const enabled = (discovery_enabled: boolean) =>
syncAIRuntimeSettings({ discovery_enabled } as Parameters<typeof syncAIRuntimeSettings>[0]);
enabled(true);
window.discoveryOwnership = {
target: setId,
progress: (progress) => eventBus.emit('ai_discovery_progress', progress),
enabled,
};
return (
<main class="mx-auto min-h-screen max-w-4xl space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Discovery outcome ownership evidence</h1>
<p class="text-xs text-muted">Synthetic guest responses. No guest command is sent.</p>
<h2 class="text-sm font-medium">Guest {id()}</h2>
<DiscoveryTab
resourceType="vm"
agentId="fixture-node-agent"
resourceId={id()}
canonicalResourceId={`fixture-pve1-${id()}`}
hostname={`guest-${id()}`}
showManualRunAction
/>
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,19 @@
const { spawnSync } = require('node:child_process');
for (const script of ['guest-discovery-safety.cjs', 'discovery-outcome-ownership.cjs']) {
const result = spawnSync(
process.execPath,
['/workspace/frontend-modern/browser-tests/' + script],
{ stdio: 'inherit' },
);
if (result.status !== 0) {
console.error(
script,
'did not complete successfully',
result.status,
result.signal,
result.error?.message,
);
process.exit(result.status || 1);
}
}
console.log('Combined guest Discovery pause and outcome ownership browser proof passed.');
@@ -0,0 +1,329 @@
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/discovery-safety-proof/browser';
const runtime = [
'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
'frontend-modern/src/components/Discovery/DiscoveryTab.tsx',
'frontend-modern/src/components/Discovery/useDiscoveryTabState.ts',
];
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const initial = {
state: 'deferred',
reason: 'prev-vm-locked',
lock: 'backup',
backup: true,
version: '6.4.5',
assignment: true,
diskUsed: 5 * 1024 ** 3,
};
const healthy = { ...initial, state: 'available', reason: '', lock: '', backup: false };
const saved = {
id: 'vm:fixture-node-agent:101',
resource_type: 'vm',
resource_id: '101',
target_id: 'fixture-node-agent',
hostname: 'backup-guest',
service_type: 'home-assistant',
service_name: 'Saved Home Assistant',
service_version: 'test',
category: 'home_automation',
cli_access: '',
facts: [],
config_paths: [],
data_paths: [],
log_paths: [],
ports: [],
user_notes: '',
user_secrets: {},
confidence: 0.9,
ai_reasoning: 'Synthetic saved evidence',
discovered_at: '2026-10-03T12:00:00Z',
updated_at: '2026-10-03T12:00:00Z',
discovery_engine_version: 1,
};
(async () => {
fs.mkdirSync(output, { recursive: true });
const report = {
runtime_sha256: Object.fromEntries(runtime.map((p) => [p, hash(path.join('/workspace', p))])),
playwright: require('playwright/package.json').version,
groups: [],
screenshots: [],
cleanup: {},
};
assert.equal(
report.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: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5297, strictPort: true, watch: null },
});
const origin = 'http://127.0.0.1:5297';
let browser,
phase = 'server';
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],
]) {
phase = name;
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width < 500 ? 844 : 900 },
isMobile: width < 500,
hasTouch: width < 500,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(10000);
page.setDefaultNavigationTimeout(60000);
const errors = [],
requests = [],
offOrigin = [],
checks = [];
report.groups.push({
name,
width,
dark,
browser: browser.version(),
checks,
requests,
errors,
offOrigin,
});
await page.routeWebSocket('**/ws*', () => {});
let hold = false,
release;
page.on('pageerror', (e) => errors.push(e.message));
await page.addInitScript((dark) => {
const apply = () => document.documentElement.classList.toggle('dark', dark);
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
await page.route('**/*', async (route) => {
const req = route.request(),
url = new URL(req.url());
if (url.origin !== origin) {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push({
path: url.pathname,
query: url.search,
method: req.method(),
body: req.postData(),
});
if (url.pathname === '/api/discovery/vm/fixture-node-agent/101') {
if (req.method() === 'POST') {
if (hold)
await new Promise((resolve) => {
release = resolve;
});
return route.fulfill({ json: saved });
}
return route.fulfill({ json: saved });
}
assert.equal(req.method(), 'GET');
if (url.pathname.startsWith('/api/discovery/info/'))
return route.fulfill({
json: {
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
commands: [],
command_categories: [],
},
});
if (url.pathname === '/api/ai/agents')
return route.fulfill({
json: {
count: 1,
agents: [
{
agent_id: 'fixture-node-agent',
hostname: 'pve-a',
version: '6.4.5',
platform: 'linux',
},
],
},
});
if (url.pathname.includes('metrics-store/history'))
return route.fulfill({
json: {
resourceType: 'vm',
resourceId: url.searchParams.get('resourceId'),
range: '24h',
start: Date.UTC(2026, 9, 3, 12),
end: Date.UTC(2026, 9, 4, 12),
metrics: {},
source: 'store',
},
});
if (url.pathname === '/api/resources')
return route.fulfill({ json: { data: [], total: 0, page: 1, limit: 500 } });
if (url.pathname.includes('license'))
return route.fulfill({ json: { capabilities: [], limits: [], max_history_days: 90 } });
if (url.pathname.includes('metadata')) return route.fulfill({ json: {} });
return route.fulfill({
json: { success: true, data: [], alerts: [], anomalies: [], config: null },
});
});
await page.goto(`${origin}/browser-tests/guest-discovery-safety.html`, {
waitUntil: 'domcontentloaded',
});
await page.waitForFunction(() => Boolean(window.__guestDiscoverySafety));
const row = page.locator('[data-guest-id]').first();
if (width > 500) {
await row.getByRole('button', { name: 'Expand backup-guest', exact: true }).focus();
await page.keyboard.press('Enter');
} else await row.tap();
await page.getByRole('tab', { name: 'Discovery', exact: true }).click();
const run = page.getByRole('button', { name: 'Run Discovery', exact: true });
await run.waitFor();
const update = async (value) =>
page.evaluate((v) => window.__guestDiscoverySafety.update(v), value);
const postCount = () => requests.filter((r) => r.method === 'POST').length;
const block = page.getByTestId('discovery-run-block');
const capture = async (state) => {
const filename = `${name}-${state}.png`,
file = path.join(output, filename);
await run.scrollIntoViewIfNeeded();
await page.screenshot({ path: file, fullPage: true });
report.screenshots.push({ filename, sha256: hash(file) });
};
const layout = async () => {
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
for (const loc of [block, page.getByTestId('guest-read-precaution')]) {
const box = await loc.boundingBox();
assert(box && box.x >= 0 && box.x + box.width <= width + 1);
assert(await loc.evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
}
};
await page.getByText('Saved Home Assistant', { exact: true }).first().waitFor();
assert(await run.isDisabled());
assert.equal(postCount(), 0);
assert((await block.innerText()).includes('does not prove thaw'));
assert(await run.getAttribute('aria-describedby'));
assert.equal(await block.getAttribute('id'), await run.getAttribute('aria-describedby'));
await layout();
await capture('locked-saved');
checks.push(
'Native canonical projection, row and real drawer/Discovery: assigned node agent, saved results readable, backup blocks live run with associated wrapping reason',
);
await page.evaluate(() => {
window.__originalSafetyButton = document.querySelector(
'[aria-describedby^="discovery-run-block-"]',
);
});
for (const reason of [
'vm-locked',
'lock-unverified',
'agent-busy',
'agent-cooldown',
'agent-response-incomplete',
'agent-capacity',
'invalid-guest-key',
'agent-timeout',
]) {
for (const prefix of ['', 'prev-']) {
await update({ ...healthy, reason: prefix + reason });
assert(await run.isDisabled());
assert.equal(postCount(), 0);
assert(
await page.evaluate(
() =>
document.querySelector('[aria-describedby^="discovery-run-block-"]') ===
window.__originalSafetyButton,
),
);
}
}
await update({ ...healthy, reason: 'prev-agent-timeout' });
await capture('uncertain-saved');
await page.getByRole('tab', { name: 'History', exact: true }).click();
assert(await page.getByTestId('guest-read-precaution').isVisible());
await page.getByRole('tab', { name: 'Discovery', exact: true }).click();
assert(await run.isDisabled());
checks.push(
'All eight read deferrals, retained variants and same-ID tab/snapshot updates block without remount, POST or automatic rescan',
);
await update(healthy);
assert(await run.isEnabled());
assert.equal(await block.count(), 0);
assert.equal(postCount(), 0);
await capture('ready-manual');
await run.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => document.body.textContent.includes('Discovery complete!'));
assert.equal(postCount(), 1);
const first = requests.find((r) => r.method === 'POST');
assert.deepEqual(JSON.parse(first.body), { force: true, hostname: 'backup-guest' });
checks.push(
'Explicit healthy keyboard run sends exactly the original target/body once; clearing safety pause alone sends nothing',
);
await page.waitForFunction(() => !document.body.textContent.includes('Discovery complete!'));
hold = true;
const secondPost = page.waitForRequest(
(req) =>
req.method() === 'POST' &&
new URL(req.url()).pathname === '/api/discovery/vm/fixture-node-agent/101',
);
if (width < 500) await run.tap();
else await run.click();
await secondPost;
await page.waitForFunction(() => document.body.textContent.includes('Scanning...'));
assert.equal(postCount(), 2);
await update({ ...initial, reason: 'agent-timeout', lock: '', backup: false });
assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled());
assert(await block.isVisible());
assert((await page.locator('body').innerText()).includes('Scanning...'));
assert.equal(postCount(), 2);
assert(release);
release();
await page.waitForFunction(() => document.body.textContent.includes('Discovery complete!'));
assert(await run.isDisabled());
assert.equal(postCount(), 2);
await capture('inflight-completed-blocked');
checks.push(
'A newly blocked snapshot neither hides nor cancels dispatched work; original completion is shown and no further run is sent',
);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
await browser.close();
browser = null;
}
report.result = 'passed';
report.verified_at = new Date().toISOString();
} catch (error) {
report.result = 'failed';
report.phase = phase;
report.error = String(error.stack || error);
throw error;
} finally {
if (browser) await browser.close();
report.cleanup.browser_closed = true;
await server.close();
report.cleanup.server_closed = true;
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2) + '\n');
}
})().catch((error) => {
console.error(error.stack || String(error));
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Guest Discovery safety verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./guest-discovery-safety.tsx"></script>
</body>
</html>
@@ -0,0 +1,126 @@
// Production canonical resource projection, row, drawer, router and Docs.
// Synthetic observations only; no native QGA, diagnostic or backup operation.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { useWorkloads } from '../src/hooks/useWorkloads';
import { mergeCanonicalResourceSnapshot } from '../src/utils/resourceStateAdapters';
import { GuestRow } from '../src/components/Workloads/GuestRow';
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
import { DarkModeContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import Docs from '../src/pages/Docs';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
syncAIRuntimeSettings({ discovery_enabled: true });
interface Observation {
state?: string;
reason?: string;
lock?: string;
backup?: boolean;
version?: string;
assignment?: boolean;
diskUsed?: number;
}
const initial: Observation = {
state: 'deferred',
reason: 'prev-vm-locked',
lock: 'backup',
backup: true,
version: '6.4.5',
assignment: true,
diskUsed: 5 * 1024 ** 3,
};
const nativeResource = (value: Observation): Resource => ({
id: 'fixture-pve:pve-a:101',
type: 'vm',
name: 'backup-guest',
displayName: 'backup-guest',
platformType: 'proxmox-pve',
platformScopes: ['proxmox-pve'],
sourceType: 'api',
status: 'online',
sources: ['proxmox', 'agent'],
lastSeen: Date.UTC(2026, 9, 4, 14),
cpu: { current: 10 },
memory: { current: 25, used: 1024 ** 3 / 4, total: 1024 ** 3 },
disk: {
current: ((value.diskUsed ?? 0) / (10 * 1024 ** 3)) * 100,
used: value.diskUsed,
total: 10 * 1024 ** 3,
},
proxmox: {
instance: 'fixture-pve',
nodeName: 'pve-a',
vmid: 101,
runtimeStatus: 'running',
template: false,
guestAgentStatus: value.state,
diskStatusReason: value.reason,
lock: value.lock,
guestAgentExpected: true,
backupInProgress: value.backup ?? false,
lastBackup: '2026-10-04T06:00:00Z',
disks: [],
},
agent: {
agentId: 'fixture-pulse-agent',
agentVersion: value.version || '',
hostname: 'backup-guest',
osName: 'Linux',
},
discoveryTarget: value.assignment
? { resourceType: 'vm', agentId: 'fixture-node-agent', resourceId: '101' }
: undefined,
});
function Fixture() {
const [observation, setObservation] = createSignal(initial);
const [snapshot, setSnapshot] = createSignal<Resource[]>([nativeResource(initial)]);
const state = useWorkloads(() => true, { resourceSnapshot: snapshot });
const guest = () => state.workloads()[0]!;
const [expanded, setExpanded] = createSignal(false);
(window as any).__guestDiscoverySafety = {
update: (next: Observation) => {
setObservation(next);
setSnapshot((previous) => mergeCanonicalResourceSnapshot([nativeResource(next)], previous));
},
observation,
identity: () => guest().id,
};
return (
<main class="mx-auto min-h-screen max-w-5xl space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Guest Discovery safety evidence</h1>
<p class="text-xs text-muted">Synthetic observations. No guest check is sent.</p>
<Show when={Boolean(guest())}>
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={guest()}
visibleColumnIds={['name', 'disk']}
isExpanded={expanded()}
onClick={() => setExpanded(!expanded())}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
/>
</tbody>
</table>
<Show when={expanded()}>
<section aria-label="Guest details">
<GuestDrawer guest={guest()} onClose={() => setExpanded(false)} />
</section>
</Show>
</Show>
</main>
);
}
render(
() => (
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<Router>
<Route path="/docs/*docPath" component={Docs} />
<Route path="/*" component={Fixture} />
</Router>
</DarkModeContext.Provider>
),
document.getElementById('root')!,
);
@@ -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,339 @@
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/history-observation-gaps/browser';
const origin = 'http://127.0.0.1:5317';
const runtime = [
'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx',
'frontend-modern/src/components/Workloads/guestDrawerModel.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: [],
limits:
'Production full GuestDrawer/History, client and CSS with synthetic local APIs. Not native guest/QGA/backup/thaw, installed recovery, physical-phone 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: 5317, 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 page = await browser.newPage({
viewport: { width, height: width < 768 ? 844 : 900 },
isMobile: width < 768,
hasTouch: width < 768,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(12000);
page.setDefaultNavigationTimeout(60000);
const record = {
name,
width,
dark,
browser: browser.version(),
errors: [],
requests: [],
checks: [],
};
result.cases.push(record);
let mode = 'gaps';
const start = Date.UTC(2026, 9, 4, 12);
const point = (minute, value) => ({
timestamp: start + minute * 60_000,
value,
min: value,
max: value,
});
const times = [0, 15, 30, 45, 60];
const metrics = () => ({
cpu: times.map((minute, index) => point(minute, 10 + index * 5)),
memory: (mode === 'complete' ? times : [0, 15, 60]).map((minute) =>
point(minute, 20 + minute / 6),
),
disk: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 50)),
netin: times.map((minute) => point(minute, 100)),
netout: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 0)),
diskread: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 0)),
diskwrite: times.map((minute) => point(minute, 200)),
});
page.on('pageerror', (error) => record.errors.push(error.message));
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== origin) {
record.requests.push({ origin: url.origin, blocked: true });
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
record.requests.push({ path: url.pathname, method: route.request().method(), mode });
if (url.pathname === '/api/metrics-store/history') {
assert.equal(route.request().method(), 'GET');
assert.equal(url.searchParams.get('resourceId'), 'fixture:pve:101');
assert.equal(url.searchParams.get('maxPoints'), '240');
if (mode === '503' || mode === '403')
return route.fulfill({
status: Number(mode),
json: { error: 'private-fixture-detail' },
});
return route.fulfill({
json: {
resourceType: url.searchParams.get('resourceType'),
resourceId: url.searchParams.get('resourceId'),
range: url.searchParams.get('range'),
start,
end: start + 60 * 60_000,
metrics: metrics(),
source: 'store',
},
});
}
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 90,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: { success: true, data: [], alerts: [], anomalies: [], config: null },
});
});
await page.addInitScript((isDark) => {
document.addEventListener('DOMContentLoaded', () =>
document.documentElement.classList.toggle('dark', isDark),
);
window.__trustedInputs = [];
for (const type of ['click', 'input', 'keydown'])
document.addEventListener(
type,
(event) => window.__trustedInputs.push({ type, trusted: event.isTrusted }),
true,
);
}, dark);
await page.goto(origin + '/browser-tests/history-observation-gaps.html');
const activate = async (locator) => (width < 768 ? locator.tap() : locator.click());
await activate(page.getByRole('tab', { name: 'History', exact: true }));
const chart = page.locator('[data-history-group="utilization"]');
const slider = chart.getByRole('slider', { name: 'Inspect Utilization history' });
await slider.waitFor();
await settle(page);
await page.evaluate(() => {
window.__originalHistoryChart = document.querySelector(
'[data-history-group="utilization"]',
);
});
const memoryPath = chart.locator('path[stroke="#f59e0b"]');
assert.equal(await memoryPath.count(), 1);
assert.equal((await memoryPath.getAttribute('d')).match(/L/g).length, 1);
assert.equal(await chart.locator('[data-history-observation="memory"]').count(), 1);
assert.equal(await chart.locator('path[stroke="#10b981"]').count(), 0);
assert.equal(await chart.locator('[data-history-observation="disk"]').count(), 2);
assert.equal(
(await chart.locator('path[stroke="#8b5cf6"]').getAttribute('d')).match(/L/g).length,
4,
);
assert.match(
await chart.locator('[data-history-gaps]').innerText(),
/Missing observations: Memory, Disk\./,
);
assert.equal(await page.locator('[data-history-gaps]').count(), 3);
const description = await chart
.getByRole('img', { name: 'Utilization history' })
.evaluate((el) =>
el
.getAttribute('aria-describedby')
.split(' ')
.map((id) => document.getElementById(id).textContent)
.join(' '),
);
assert.match(description, /Missing observations: Memory, Disk\./);
assert.match(description, /does not identify the cause or duration/);
const noteGeometry = await chart.locator('[data-history-gaps]').evaluate((el) => ({
width: el.clientWidth,
scrollWidth: el.scrollWidth,
colour: getComputedStyle(el).color,
}));
assert.ok(noteGeometry.scrollWidth <= noteGeometry.width + 1);
record.noteGeometry = noteGeometry;
record.checks.push(
'Actual stored gap splits only affected metrics; recovered singleton/zero samples stay visible; visible/accessibly linked explanation fits.',
);
const screenshot = async (suffix) => {
await settle(page);
const file = `${name}-${suffix}.png`;
await page.screenshot({ path: path.join(output, file), fullPage: true });
result.screenshots.push({
file,
sha256: hash(path.join(output, file)),
name,
state: suffix,
});
};
await screenshot('gaps');
await slider.focus();
await page.keyboard.press('Home');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
assert.equal(await slider.inputValue(), '2');
assert.match(
await slider.getAttribute('aria-valuetext'),
/Memory no observation\. Disk no observation\./,
);
assert.match(await chart.innerText(), /Memory\s*-/);
assert.equal(await chart.locator('circle[r="3"]').count(), 1);
record.checks.push(
'Trusted Home/Arrow keys inspect the missing stored time without borrowing nearby/live memory or disk.',
);
if (width < 768) {
await slider.scrollIntoViewIfNeeded();
const box = await slider.boundingBox();
await page.touchscreen.tap(box.x + box.width * 0.75, box.y + box.height / 2);
assert.notEqual(await slider.inputValue(), '2');
record.checks.push('Trusted touch operates the native inspector.');
} else {
await page.getByRole('heading', { name: 'Drawer History missing observations' }).click();
const box = await chart.getByRole('img', { name: 'Utilization history' }).boundingBox();
await page.mouse.move(box.x + box.width * 0.537, box.y + box.height / 2);
assert.match(
await chart.locator('[data-testid="guest-history-hover-time"]').innerText(),
/12:30/,
);
assert.equal(await chart.locator('circle[r="3"]').count(), 1);
record.checks.push(
'Trusted pointer matches the exact stored time, not a synthetic line value.',
);
}
const reads = () =>
record.requests.filter((r) => r.path === '/api/metrics-store/history').length;
const before = reads();
await activate(page.getByRole('button', { name: 'Simulate live recovery' }));
assert.equal(reads(), before);
assert.equal(await chart.locator('[data-history-gaps]').count(), 1);
assert.equal(await chart.locator('[data-history-observation="memory"]').count(), 1);
assert.equal(
await page.evaluate(
() =>
window.__originalHistoryChart ===
document.querySelector('[data-history-group="utilization"]'),
),
true,
);
record.checks.push(
'Live status recovery cannot fill missing stored evidence, refetch or remount the chart.',
);
mode = '503';
await activate(page.getByRole('button', { name: 'Refresh history' }));
await page.getByRole('button', { name: 'Retry history' }).waitFor();
assert.equal(await chart.locator('[data-history-gaps]').count(), 1);
assert.equal(await chart.locator('path[stroke="#10b981"]').count(), 0);
assert.doesNotMatch(await page.locator('body').innerText(), /private-fixture-detail/);
record.checks.push('Transient read failure retains the gap and does not leak the raw error.');
mode = 'complete';
await activate(page.getByRole('button', { name: 'Retry history' }));
await page.waitForFunction(
() => document.querySelectorAll('[data-history-gaps]').length === 0,
);
assert.equal(await chart.locator('[data-history-observation]').count(), 0);
assert.equal((await memoryPath.getAttribute('d')).match(/L/g).length, 4);
assert.equal(
await page.evaluate(
() =>
window.__originalHistoryChart ===
document.querySelector('[data-history-group="utilization"]'),
),
true,
);
record.checks.push(
'Matching complete stored refresh joins actual consecutive observations; chart identity stays stable.',
);
await screenshot('complete');
mode = '403';
await activate(page.getByRole('button', { name: 'Refresh history' }));
await page.waitForFunction(
() => document.querySelectorAll('[data-testid="guest-history-group-chart"]').length === 0,
);
assert.equal(await page.locator('[data-history-gaps]').count(), 0);
assert.doesNotMatch(await page.locator('body').innerText(), /private-fixture-detail/);
record.checks.push('Access denial withdraws all stored chart/gap/inspection evidence.');
assert.equal(reads(), before + 3);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
true,
);
assert.equal(
record.requests.some((r) => r.method && r.method !== 'GET'),
false,
);
assert.equal(
record.requests.some((r) => r.blocked),
false,
);
assert.deepEqual(record.errors, []);
assert.equal(
await page.evaluate(() => window.__trustedInputs.every((entry) => entry.trusted)),
true,
);
record.checks.push(
'No page error, horizontal overflow, external request or mutation; unchanged user-request read budget.',
);
await page.close();
await browser.close();
browser = null;
}
result.result = 'passed';
} catch (error) {
result.failure = error.stack || String(error);
throw error;
} finally {
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
if (browser) await browser.close();
await server.close();
}
process.stdout.write(JSON.stringify(result) + '\n');
})().catch((error) => {
process.stderr.write(error.stack + '\n');
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drawer History missing-observation verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./history-observation-gaps.tsx"></script>
</body>
</html>
@@ -0,0 +1,87 @@
// Real production drawer/CSS. The browser supplies synthetic stored readings;
// no collector, guest-agent, backup, thaw or installation operation is run.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
import { DarkModeContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { WorkloadGuest } from '../src/types/workloads';
import '../src/index.css';
syncAIRuntimeSettings({ discovery_enabled: false });
function Fixture() {
const [recovered, setRecovered] = createSignal(false);
const guest = (): WorkloadGuest => ({
id: 'fixture:pve:101',
vmid: 101,
name: 'backup-guest',
node: 'pve',
instance: 'fixture',
status: 'running',
type: 'qemu',
cpu: 0.1,
cpus: 2,
memory: {
total: 1024 ** 3,
used: 256 * 1024 ** 2,
free: 768 * 1024 ** 2,
usage: 25,
observation: {
state: recovered() ? 'current' : 'last-known',
source: 'status-mem',
observedAt: '2026-10-04T12:01:00Z',
},
},
disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
diskStatusReason: recovered() ? '' : 'prev-vm-locked',
lock: recovered() ? '' : 'backup',
guestAgentStatus: recovered() ? 'available' : 'deferred',
backupInProgress: !recovered(),
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: 0,
tags: [],
lastSeen: '2026-10-04T12:04:00Z',
});
return (
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Drawer History missing observations</h1>
<p class="text-xs text-muted">
Synthetic readings. No backup or guest-agent command is sent.
</p>
<button
class="rounded-sm border border-border p-2 text-xs"
onClick={() => setRecovered(true)}
>
Simulate live recovery
</button>
<section class="mx-auto max-w-5xl" aria-label="Guest details">
<GuestDrawer guest={guest()} onClose={() => {}} />
</section>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -319,9 +319,6 @@ function Fixture() {
: new Set([...current, key]),
)
}
showPbsColumn
showArchiveColumn
showSnapshotColumn
showTaskColumn={false}
/>
<h2>By date</h2>
@@ -0,0 +1,48 @@
{
"version": 1,
"base_sha": "967e51701e215cba3522dbafa4b1b6ac8620d333",
"verified_at": "2026-10-05T00:14:02Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx",
"frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts",
"frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts",
"frontend-modern/src/components/Workloads/workloadWebLinksModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "72db08228690ba571a285771ef81b2ef9be308794da2ff1cd09be612efbbd29b",
"frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx": "9b57f62cf0de2196918f8faf9791e227fabaf960ad2ec3aa4302325379955e30",
"frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx": "e779b8210c85326fda8bc37f7213384136766f93c80f06b6ac43eb8d0340f93c",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "42169c016a13fceb359fbd72b3284101769c27519aa749deb9c7a9fc72c12221",
"frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts": "8affd5f0798fa9c4af05cf1c763d1c3176200756a95e54294553f3b09969ac27",
"frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts": "532af19b043255ebcc116faa3b2247ca828c552f2f00ad544e06a62524761099",
"frontend-modern/src/components/Workloads/workloadWebLinksModel.ts": "bab44fddeb88d842c60eddcbef834dcddc602e37a9e92af2a773ff3b3cca071a"
},
"routes": [
"/proxmox"
],
"viewports": [
{
"width": 1280,
"height": 800
},
{
"width": 375,
"height": 812
}
],
"states": [
"Follow-up to the bulk web-link editor: the CI bundle budget failed with WorkloadsSurface at 71.44 kB gzip against a 70.23 kB max (main was 68.80 kB). After moving the panel, its state and row model into a lazily loaded chunk, a local production build measured WorkloadsSurface at 69.13 kB and the new WorkloadWebLinksDialog chunk at 3.9 kB gzip; check-bundle-size reported no violations.",
"Private mock stack from this tree (backend :7701, Vite :5221, 31 Proxmox guests): Edit links opened the lazily loaded panel with all 31 rows and '7 of 31 in this view have a link', focus on the close button.",
"A draft typed into grafana-agent-01 survived Escape closing the panel; focus returned to the trigger, which showed (unsaved); reopening restored the draft; editing checkout-web-01 away and back to its saved value did not count as a change (footer stayed at 1 unsaved change).",
"Submitting saved grafana-agent-01 through PUT /api/guests/metadata, closed the panel, cleared the (unsaved) marker and the table row gained its link without reload.",
"Phone 375x812: full-width sheet, 44px inputs, no horizontal overflow; an invalid entry ('redis admin') showed the shared validator's message under its row with aria-invalid and focus. This is not physical-device, vSphere-estate or release acceptance."
],
"interactions": [
"Clicked Edit links at 1280 and 375 widths; typed into inputs across live inventory ticks; pressed Escape; reopened; submitted the form.",
"Submitted an invalid link on phone width; the shared validator's error appeared under that row, aria-invalid was set and focus moved to the field."
]
}
@@ -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."
}
@@ -0,0 +1,48 @@
{
"version": 1,
"base_sha": "53e2d27ab420c505f54694b0ccbb7d843ff6eded",
"verified_at": "2026-10-05T00:16:51Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestRow.tsx",
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/types/workloads.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestRow.tsx": "d1d60dfc1fca8e2cf94c0870ae348ad005e527656e57bac84b304151a87a808b",
"frontend-modern/src/components/Workloads/workloadSelectors.ts": "cc59933a4f75515ce1ab075e737fdd26d2523d526455dbd7765d85ce8d8b3fd0",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "9a111deb348d54c2c778595d770d6bf25752067214cec6e4d0cc363cdbd0d321",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "0088db54f683bfb8c5603480f240c05a930248442cb08c6a344db9363d62c08d",
"frontend-modern/src/hooks/useWorkloads.ts": "b662ce635aad385ab8e18334874fcc16f324566295d2d2eedfdfb8684c367d5f",
"frontend-modern/src/types/workloads.ts": "924235c100c8d78a9546cd566e2d1cf306bf92415f649aa29e0364832d445ca5"
},
"routes": [
"/proxmox/overview",
"/proxmox/overview?status=degraded",
"/docker/overview",
"/vmware/overview",
"/kubernetes/workloads"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Mock estate with the guest memory threshold lowered to 60% on the private stack so seven running guests carried warning alerts. Before (changes reverted in place): chips read Running 60 / Attention 0 / Stopped 5. After: Running 53 / Attention 7; ?status=degraded lists exactly the seven alerting guests.",
"Nodes table: the Cluster column that repeated Production West on every row is gone and the header reads Cluster Production West; it stays visible while searching (Nodes 2 of 5).",
"VM disk Unavailable label renders muted slate with the reason as a title tooltip for vm-stopped; permission-denied and agent-error stay amber (component test).",
"Other platform chips checked after the change: Docker Healthy 27 / Degraded 10 / Offline 2, vSphere Attention 0, Kubernetes workloads Degraded 2, TrueNAS VMs Attention 0, Machines Degraded 0."
],
"interactions": [
"Clicked the Attention chip and read the filtered rows; typed a search into the guest search box and confirmed the cluster label; read the disk status title attribute and computed colour."
]
}
@@ -0,0 +1,67 @@
{
"version": 1,
"result": "passed",
"base_sha": "2cb3f6a9043e0bab9c54c5b5ef57b3e1cb994781",
"changed_paths": [
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
"frontend-modern/src/components/Workloads/GuestDrawer.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "d8ba9c14cbfac0756f8df461bea07c40a39ef56ba907301b71e92286e410facc",
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "01e2d687067df69d4f3eb6ea74742c9ce478f278976ee87c96c3567ff1b82896",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "19388b7e163d5fff79416f4bcb9c47ba0718cede9a56dbdf331065ab09cc00ee"
},
"routes": [
"/browser-tests/guest-discovery-safety.html: production canonical resource projection, Workloads row, GuestDrawer and DiscoveryTab with synthetic APIs"
],
"states": [
"Desktop light/dark and 390px/320px WebKit phone: locked guest and saved results",
"Every declared deferral and retained variant; same-ID/tab changes",
"Healthy manual action; uncertain read; in-flight scan completion while further runs are blocked"
],
"interactions": [
"Keyboard expand/tab/explicit run and touch expand/run; accessible disabled reason; wrapping warning/saved service inspection",
"No POST on blocked/unlock/snapshot/tab changes; exactly one original-body POST per explicit enabled run",
"No remount, cancellation or false in-flight-stop; original completion remains visible and additional run is blocked"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 320,
"height": 844
}
],
"verified_at": "2026-10-04T15:40:18.829Z",
"script": "frontend-modern/browser-tests/guest-discovery-safety.cjs",
"browsers": [
{
"group": "chromium-desktop-light",
"version": "141.0.7390.37"
},
{
"group": "chromium-desktop-dark",
"version": "141.0.7390.37"
},
{
"group": "webkit-phone-dark",
"version": "26.0"
},
{
"group": "webkit-narrow-light",
"version": "26.0"
}
],
"limitations": [
"Synthetic backend responses, not a native guest/backup or all-path backend admission proof.",
"Vite-transformed production components; production build is separately checked in the source-proof VM.",
"Independent thaw/covered-filesystem writes remain necessary; an unlocked snapshot does not prove them."
]
}
@@ -0,0 +1,59 @@
{
"version": 1,
"result": "passed",
"base_sha": "0ad41e4c9a309ce459dd717d16d343b46efcac8a",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "8b94c3d9409f46bedf812b00ff9f3156da9ace6885ec04456ea5fed59d842127",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "79e176dd5640190f6bf11d3a6bbddc63fcd18c37b19e4e7098163a95d542b0a5"
},
"routes": [
"/browser-tests/history-observation-gaps.html \u2014 production full GuestDrawer/History/client/CSS, not the full App route"
],
"states": [
"Stored CPU is continuous while memory and disk miss intervening group times; preserve split segments and recovery singleton/zero readings.",
"Live recovery alone preserves stored gaps and chart identity without a read.",
"Transient 503 retains data/gaps; matching complete stored refresh restores actual contiguous lines; denied 403 withdraws all history."
],
"interactions": [
"Trusted desktop click, Home/Arrow keyboard inspection and pointer at exact missing stored time.",
"Trusted WebKit 390/320 px native inspector touch, keyboard arrows and refresh/retry.",
"Trusted live-recovery action does not alter stored points, query budget or chart identity.",
"All eight captures visually inspected for gaps, isolated points, contiguous recovery, light/dark readability and 390/320 px wrapping."
],
"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-04T22:39:45.443984Z",
"playwright_version": "1.56.1",
"checks": 32,
"screenshots_inspected": 8,
"script": "frontend-modern/browser-tests/history-observation-gaps.cjs",
"limits": "Production full GuestDrawer/History, client and CSS with synthetic local APIs. Not native guest/QGA/backup/thaw, installed recovery, physical-phone or release acceptance."
}
@@ -0,0 +1,42 @@
{
"version": 1,
"base_sha": "482a2614e32f875990ee84662dfcbf3010ca7181",
"verified_at": "2026-10-04T23:18:40.484Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawer.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "a1d197b12aa0bed084664472aeeca1d4b0415911a8aa94c8aa3742e59ce77789"
},
"routes": [
"/proxmox/overview (production drawer and canonical projection in offline synthetic fixture)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Combined published saved-URL header action and reviewed guest read/Discovery precaution on exact production bytes. Playwright 1.56.1 Chromium desktop light and WebKit phone dark; eight screenshots inspected.",
"Locked and uncertain retained readings keep saved results visible but live Discovery disabled; healthy clear permits only explicit dispatch. In-flight completion remains attributed when observation becomes deferred.",
"Header Open retains safe href, target _blank, noopener noreferrer, desktop 32px/phone 44px target and expanded drawer. Missing and unsafe URLs expose no link. No horizontal overflow, page errors or external requests."
],
"interactions": [
"Saved header Open retains safe href/target/rel, phone target and expansion; absent/unsafe URLs expose no link, without sending Discovery",
"Native canonical projection, row and real drawer/Discovery: assigned node agent, saved results readable, backup blocks live run with associated wrapping reason",
"All eight read deferrals, retained variants and same-ID tab/snapshot updates block without remount, POST or automatic rescan",
"Explicit healthy keyboard run sends exactly the original target/body once; clearing safety pause alone sends nothing",
"A newly blocked snapshot neither hides nor cancels dispatched work; original completion is shown and no further run is sent",
"Saved header Open retains safe href/target/rel, phone target and expansion; absent/unsafe URLs expose no link, without sending Discovery",
"Native canonical projection, row and real drawer/Discovery: assigned node agent, saved results readable, backup blocks live run with associated wrapping reason",
"All eight read deferrals, retained variants and same-ID tab/snapshot updates block without remount, POST or automatic rescan",
"Explicit healthy keyboard run sends exactly the original target/body once; clearing safety pause alone sends nothing",
"A newly blocked snapshot neither hides nor cancels dispatched work; original completion is shown and no further run is sent"
]
}
@@ -0,0 +1,52 @@
{
"version": 1,
"base_sha": "53e2d27ab420c505f54694b0ccbb7d843ff6eded",
"verified_at": "2026-10-04T23:36:36Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
"frontend-modern/src/components/Workloads/workloadWebLinksModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "f46e070a2fad03d7fe30103a8f5a9a6412604a810b3b4b65c56071baeaf6198c",
"frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx": "85af2231815206c30c34671af4b358cb9e4243df4a1fa54e7f9968491cc668fa",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "ea5b51e677ccb2a8deaeb51e18068ef7c4dd4a956f0004b4afb373e67708985a",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "d7d54e85014320bf46a3a19fd0ac47d12de3341580bd368643a9a0edc56232bc",
"frontend-modern/src/components/Workloads/workloadWebLinksModel.ts": "cec219e47e3bf46487fccdac46535cb6ae0301b5e3e165970d0bf3946f9179e8"
},
"routes": [
"/proxmox",
"/proxmox?agent=Production+West-pve2"
],
"viewports": [
{
"width": 1280,
"height": 800
},
{
"width": 1024,
"height": 768
},
{
"width": 375,
"height": 812
}
],
"states": [
"Private mock stack built from this tree (backend :7701, Vite :5221, 3 nodes, 31 Proxmox guests) with five guests already carrying saved web-interface URLs.",
"Guests table header shows an 'Edit links' action beside the guest count at 1280, 1024 and 375 widths without horizontal overflow; it opens a right-hand panel listing all 31 guests in the filtered view, name-sorted, each with type, ID and node, the saved URL or an IP-based placeholder, and a label tied to its input.",
"Node-scoped view (Show guests on West Production B) limits the editor to that node's 8 guests; the 'Without a link' filter shows the 25 guests with no saved link, including one whose link was just removed.",
"Phone 375x812: full-height sheet, name above a 44px input, footer pinned, no horizontal overflow (dark theme). Light theme checked at 1280.",
"Invalid entry 'auth14 admin page' blocked the whole save with the shared validator's message on that row and aria-invalid; native browser URL validation is disabled so Pulse's message shows. GET /api/guests/metadata confirmed nothing was written.",
"Valid save of one addition, one correction and one removal wrote all three through PUT /api/guests/metadata, showed 'Saved 3 links.', closed the panel, returned focus to the trigger, and the table link icons updated without reload. This is not physical-device, vSphere-estate or release acceptance.",
"Re-checked after renaming the row-reuse helper (identifier only): the editor opened with 31 rows, a draft survived three seconds of live ticks, and submitting saved docs-wiki-01 through PUT /api/guests/metadata and closed the panel."
],
"interactions": [
"Clicked Edit links; typed into three inputs over several live inventory ticks without losing focus or values; footer counted 3 unsaved changes and the trigger showed (unsaved).",
"Pressed Enter to submit with one invalid value, corrected it, pressed Enter again; toggled All / Without a link; closed via the header button and reopened.",
"Selected a node's guests from the nodes table, then reopened the editor to confirm scope follows the table filter."
]
}
@@ -0,0 +1,35 @@
{
"version": 1,
"base_sha": "53e2d27ab420c505f54694b0ccbb7d843ff6eded",
"verified_at": "2026-10-05T00:02:30Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx"
],
"content_sha256": {
"frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx": "b13db44a0fea2de8605bb79a1d1fd011f5023e9c8cba44f465c305eeac4f00b5"
},
"routes": [
"/proxmox/overview",
"/docker/overview",
"/kubernetes/overview"
],
"viewports": [
{
"width": 1440,
"height": 700
},
{
"width": 390,
"height": 844
}
],
"states": [
"Mock estate with five Proxmox hosts, three Docker hosts and three Kubernetes nodes on an older agent. Before: a three-line amber warning listing three names plus \"and 2 more\", a Show all button and an action row above every platform tab.",
"After at 1440: one info-tone line on Proxmox (5 hosts run an older Pulse agent. Update them to v4.26.0 for the latest agent-contributed Proxmox node detail and command support. Which hosts? Open agent upgrade commands) with the node table directly below.",
"Phone 390x844 Kubernetes: the missing-data sentence, Which nodes? toggle and upgrade link wrap within the notice above the clusters table."
],
"interactions": [
"Loaded each route signed in and inspected screenshots; the component test exercises the Which hosts? toggle expanding and collapsing the affected names and checks the inline link href."
]
}
@@ -0,0 +1,79 @@
{
"version": 1,
"base_sha": "bc3f4d81d14873caae6f769dfa670d16fa13d518",
"verified_at": "2026-10-04T20:32:45.762Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts"
],
"content_sha256": {
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "2e93209a2b3830f77a0ad94d02bd59ea01509932f8d51e594d60f55d1f5895d0",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "e600bb993b80e85b6df48a9e070f1bc2db51d7bb8e5ffd6216e3861d610def14"
},
"routes": [
"/browser-tests/guest-discovery-safety.html: production Workloads row, GuestDrawer and DiscoveryTab with synthetic canonical snapshots",
"/browser-tests/discovery-outcome-ownership.html: reused production DiscoveryTab/state, synthetic per-guest APIs and production event-bus progress"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 320,
"height": 844
}
],
"states": [
"Guest operation lock and all current guest-read deferrals with/without retained data; healthy explicit action",
"Original HTTP result retained after a same-target precaution without cancellation or another scan",
"Background completed-with-error retains an accessible failed outcome and saved evidence",
"Late old-target HTTP result cannot overwrite the current guest or stop its manual request",
"Scanner completion during HTTP does not claim saved success; later terminal event cannot strand earlier success",
"Current explicit failure remains visible without an automatic retry"
],
"interactions": [
"Keyboard and touch drawer expansion, Discovery tabs, explicit enabled runs and associated disabled reasons",
"No POST on blocked/unlock/tab/snapshot change and exactly one target/body POST per explicit healthy action",
"Keyboard-labelled error dismissal with saved content preserved",
"Change target during an in-flight request, deliver the old and current responses independently and inspect original ownership",
"Render desktop light/dark and WebKit 390px/320px, inspect wrapping and all 24 captures"
],
"script": "frontend-modern/browser-tests/guest-discovery-completion.cjs",
"browsers": [
{
"name": "desktop-light",
"browser": "141.0.7390.37",
"width": 1365,
"dark": false
},
{
"name": "desktop-dark",
"browser": "141.0.7390.37",
"width": 1365,
"dark": true
},
{
"name": "phone-dark",
"browser": "26.0",
"width": 390,
"dark": true
},
{
"name": "narrow-light",
"browser": "26.0",
"width": 320,
"dark": false
}
],
"limitations": [
"Synthetic inputs and API responses, not native QGA, backup or automatic poller acceptance.",
"Vite-transformed production components. Exact production build is checked separately in the source-proof VM.",
"No original freeze cause, independent thaw, covered-filesystem writes, installed use, physical phone or containing release is established."
]
}
@@ -0,0 +1,44 @@
{
"version": 1,
"base_sha": "4c266391c3753eaa1e6bea1cfd8339c5d40c01de",
"verified_at": "2026-10-05T01:10:06Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx",
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupsTableModel.ts",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx": "97d3f7a23a959406dd8cb9e92a8921fd38118194d5f757e31b6e0e2eec74f27f",
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "2cd4cfb0c470ba51efcdb0beafef0b630acac34373b92dd9b77de03f99b16743",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "2568f261531b4b601df7da9e1b8c1826bacc70e8a28420bbe909f2eb3524c6eb",
"frontend-modern/src/features/proxmox/proxmoxBackupsTableModel.ts": "8f7f8a80bb994404c21cce7b75c621bdf844f80b38a9def79d6dc61fb42b8dd6",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "55dd4787cd47e9ccbf056ba67809fd45638b1e3f21889c6a31ce192ce87b8e67"
},
"routes": [
"/proxmox/backups"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Coverage workspace, mock fleet of 68 targets: Backup health strip footnote explains attention, unprotected and unknown with the 7-day window from the server policy",
"Coverage table at 1440: Workload / Type / Target ID / Node / Posture / Last backup / Task, no PBS snapshot, PVE file or Guest snapshot columns",
"Coverage table at 390: Workload / Posture / Age / Job, footnote wraps under the legend without a reserved column",
"Row expansion for a Needs attention guest with a failed newer job: posture explanation, Provider evidence and Restore evidence listing PVE file and Snapshot sources",
"No console errors on either viewport"
],
"interactions": [
"Expanded and collapsed coverage rows to inspect provider and restore evidence",
"Compared before/after screenshots at both viewports"
]
}
+94 -28
View File
@@ -101,48 +101,114 @@ All endpoints require admin authentication.
### Testing and adding a connection (API)
The **Settings → Infrastructure → Platform connections** flow is the simplest option. For an API client,
prepare the private Pulse header file as described in the
[API authentication guide](API.md). The Pulse API token authenticates to
Pulse; the separate TrueNAS API key belongs in the connection's JSON file.
Do not put either credential in shell arguments, URLs or a public thread.
Prefer **Settings → Infrastructure → Platform connections** for setup. These
API examples are for an authorised administrator using curl 7.76 or later,
not a way to diagnose stale polling. Testing makes a live system-information
request to TrueNAS; saving creates a connection and starts ordinary polling.
Do not use either during a backup, freeze/thaw or an unresponsive-host incident.
Create or protect the connection file before opening it in a trusted editor:
First prepare a private Pulse header file using the
[API token file procedure](API.md#api-token-recommended), with the admin access
needed for connection management. The Pulse token authenticates to Pulse;
the separate TrueNAS API key belongs in the connection's JSON file. Enter
credentials only in your local editor, never in arguments, URLs or a thread.
On a trusted machine, as the account running curl, prepare the connection
file below. Stop if either file-preparation step fails. This preserves an
existing file, restricts the directory and refuses symlinked credential paths
or a non-regular connection file before opening the editor:
```bash
umask 077
mkdir -p "$HOME/.config/pulse"
touch "$HOME/.config/pulse/truenas-connection.json"
chmod 600 "$HOME/.config/pulse/truenas-connection.json"
vi "$HOME/.config/pulse/truenas-connection.json"
(
set -eu
umask 077
config_dir="$HOME/.config/pulse"
connection_file="$config_dir/truenas-connection.json"
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$connection_file" ]; then
printf 'Refusing a symlinked credential path.\n' >&2
exit 1
fi
if [ -e "$connection_file" ] && [ ! -f "$connection_file" ]; then
printf 'Connection file must be a regular file.\n' >&2
exit 1
fi
mkdir -p "$config_dir"
chmod 700 "$config_dir"
touch "$connection_file"
chmod 600 "$connection_file"
vi "$connection_file"
)
```
In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL),
`username` (the key owner) and `apiKey`. Keep both credential files private;
do not paste their contents into a terminal command or upload them.
In the editor, save a JSON object with `name`, `host` (the TrueNAS HTTPS URL),
`username` (the key owner) and `apiKey`. Keep both credential files private and
outside repositories and diagnostics. Do not upload them.
Test the connection before saving it:
The following examples run **on the Pulse host**, using its direct loopback
address. They ignore curl configuration, do not follow redirects or retry,
and retain each response in a new private local file. Responses and errors can
contain identifying details or echoed credentials; review them locally, not in
a public terminal recording or thread. Keep `--disable` first so local curl
configuration cannot enable tracing or change the request.
Test once without saving:
```bash
curl --fail-with-body --silent --show-error \
--header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' \
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
http://127.0.0.1:7655/api/truenas/connections/test
(
set -eu
umask 077
result_file=$(mktemp "$HOME/.config/pulse/truenas-test.XXXXXX")
printf 'Private response file: %s\n' "$result_file"
status=$(curl --disable --fail-with-body --silent --show-error \
--connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \
--header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' \
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
--output "$result_file" --write-out '%{http_code}' \
http://127.0.0.1:7655/api/truenas/connections/test)
printf 'HTTP %s\n' "$status"
[ "$status" = 200 ]
)
```
After checking the test response, save the connection with:
Require exit 0, HTTP **200** and `success: true` in that private test response.
A redirect, HTTP error or `000` is not a successful test. Test success is not
inventory, metric or guest-thaw acceptance.
Only after reviewing the test response, create the connection once:
```bash
curl --fail-with-body --silent --show-error \
--header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' \
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
http://127.0.0.1:7655/api/truenas/connections
(
set -eu
umask 077
result_file=$(mktemp "$HOME/.config/pulse/truenas-save.XXXXXX")
printf 'Private response file: %s\n' "$result_file"
status=$(curl --disable --fail-with-body --silent --show-error \
--connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \
--header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' \
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
--output "$result_file" --write-out '%{http_code}' \
http://127.0.0.1:7655/api/truenas/connections)
printf 'HTTP %s\n' "$status"
[ "$status" = 201 ]
)
```
These Pulse URLs are loopback-only examples. For a remote Pulse server, use
its trusted HTTPS origin without disabling certificate verification.
A successful save returns HTTP **201** and the new connection's `id`, not
proof of a complete poll. If saving times out or loses its response, it may
already have created the connection. Check **Platform connections** or
`/api/truenas/connections` in your existing signed-in Pulse browser before
trying again; do not blindly repeat POST and create duplicates. Continue with
the [ordinary polling checks](#stale-truenas-data) for collection acceptance.
For remote Pulse access, replace the loopback origin in the relevant command
with your trusted **HTTPS** origin and change `--proto '=http'` to
`--proto '=https'`. Keep certificate verification enabled. For a private CA,
add `--cacert /path/to/trusted-ca.pem` using a CA obtained independently from
your administrator, not from the failing connection. Do not add `--insecure`,
`--location`, verbose/trace flags or automatic retries. Share only the relevant
HTTP status and a manually redacted error, never the full response or headers.
## Troubleshooting
@@ -1,4 +1,4 @@
import { Component, For, Show, createMemo } from 'solid-js';
import { Component, For, Show, createMemo, createUniqueId } from 'solid-js';
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert';
import type { ResourceType } from '../../types/discovery';
@@ -33,6 +33,7 @@ import {
import { ButtonLink, CopyValueButton } from '@/components/shared/Button';
import { CopyableCodeRow } from '@/components/shared/CopyableCodeRow';
import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
import { InlineNotice } from '@/components/shared/InlineNotice';
import { useDiscoveryTabState } from './useDiscoveryTabState';
import { orderFactsByActionability } from './factOrdering';
import { deriveCliCommand } from './cliCommand';
@@ -54,6 +55,8 @@ interface DiscoveryTabProps {
commandsEnabled?: boolean;
/** Show the primary run action at the top of embedded drawer contexts. */
showManualRunAction?: boolean;
/** Safety pause for live manual scans, independent of command permissions. */
runBlockReason?: string | null;
}
export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
@@ -75,6 +78,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
liveElapsedSeconds,
notesText,
mutateDiscovery,
runBlockReason,
saveError,
scanError,
scanProgress,
@@ -90,6 +94,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
startEditingNotes,
validDiscovery,
} = useDiscoveryTabState(props);
const runBlockReasonId = `discovery-run-block-${createUniqueId()}`;
const suggestedURLReasonText = createMemo(() => {
const current = discovery();
@@ -225,6 +230,19 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</div>
</Show>
<Show when={runBlockReason()}>
{(reason) => (
<InlineNotice
id={runBlockReasonId}
tone="warning"
role="status"
data-testid="discovery-run-block"
>
{reason()}
</InlineNotice>
)}
</Show>
<Show when={showManualRunAction()}>
<InfoCardFrame>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -236,6 +254,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
type="button"
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning() || !canTriggerDiscovery()}
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
class="inline-flex min-h-9 items-center justify-center gap-1.5 rounded-sm border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50"
>
<Show
@@ -455,7 +474,10 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
{/* Scan Error */}
<Show when={scanError()}>
<div class="mb-4 rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/25">
<div
role="alert"
class="mb-4 rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/25"
>
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 text-red-500 dark:text-red-400 shrink-0 mt-0.5"
@@ -471,10 +493,14 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
/>
</svg>
<div>
<p class="text-sm font-medium text-red-800 dark:text-red-200">Discovery Failed</p>
<p class="text-sm font-medium text-red-800 dark:text-red-200">
Discovery did not complete
</p>
<p class="text-sm text-red-700 dark:text-red-300 mt-1">{scanError()}</p>
</div>
<button
type="button"
aria-label="Dismiss Discovery error"
onClick={() => setScanError(null)}
class="ml-auto text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
@@ -633,6 +659,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<button
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning() || !canTriggerDiscovery()}
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isScanning() ? (
@@ -708,6 +735,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<button
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning() || !canTriggerDiscovery()}
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isScanning() ? (
@@ -1192,6 +1220,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<button
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning() || !canTriggerDiscovery()}
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
class="px-3 py-1.5 bg-surface-hover text-base-content text-xs rounded-sm hover:bg-surface-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
>
<Show
@@ -0,0 +1,341 @@
import { createSignal } from 'solid-js';
import { cleanup, render, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResourceDiscovery } from '@/types/discovery';
import { eventBus } from '@/stores/events';
import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState';
import * as api from '@/api/discovery';
import { useDiscoveryTabState } from '../useDiscoveryTabState';
vi.mock('@/api/discovery', () => ({
getDiscovery: vi.fn(async () => null),
getDiscoveryInfo: vi.fn(async () => ({
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
})),
getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })),
triggerDiscovery: vi.fn(),
updateDiscoveryNotes: vi.fn(async () => undefined),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
const saved = (id = '100'): ResourceDiscovery => ({
id: `vm:node-agent:${id}`,
resource_type: 'vm',
resource_id: id,
target_id: 'node-agent',
hostname: `guest-${id}`,
service_type: 'home-assistant',
service_name: `Saved service ${id}`,
service_version: 'fixture',
category: 'home_automation',
cli_access: '',
user_secrets: {},
ai_reasoning: 'Synthetic saved evidence',
scan_duration: 0,
facts: [],
config_paths: [],
data_paths: [],
log_paths: [],
ports: [],
user_notes: '',
discovered_at: '2026-10-03T12:00:00Z',
updated_at: '2026-10-03T12:00:00Z',
confidence: 0.9,
});
async function mount(initialAgent = 'node-agent') {
const [id, setId] = createSignal('100');
const [agent, setAgent] = createSignal(initialAgent);
let state!: ReturnType<typeof useDiscoveryTabState>;
const view = render(() => {
state = useDiscoveryTabState({
resourceType: 'vm',
get agentId() {
return agent();
},
get resourceId() {
return id();
},
get hostname() {
return `guest-${id()}`;
},
});
return <span>Discovery lifecycle</span>;
});
await waitFor(() => expect(state.discoveryInfo.loading).toBe(false));
await waitFor(() => expect(state.discovery.loading).toBe(false));
return { state, setId, setAgent, dispose: view.unmount };
}
beforeEach(() => {
vi.resetAllMocks();
resetAIRuntimeState();
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
vi.mocked(api.getDiscovery).mockImplementation(async (_type, _agent, id) => saved(id));
vi.mocked(api.getDiscoveryInfo).mockResolvedValue({
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
});
vi.mocked(api.getConnectedAgents).mockResolvedValue({ count: 0, agents: [] });
vi.mocked(api.triggerDiscovery).mockResolvedValue(saved());
});
afterEach(() => {
cleanup();
resetAIRuntimeState();
vi.useRealTimers();
});
describe('Discovery outcome ownership', () => {
it.each(['success', 'failure'] as const)(
'does not apply an old target HTTP %s to the new guest or its in-flight run',
async (outcome) => {
const old = deferred<ResourceDiscovery>();
const current = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery)
.mockReturnValueOnce(old.promise)
.mockReturnValueOnce(current.promise);
const { state, setId } = await mount();
const first = state.handleTriggerDiscovery(true);
setId('101');
await waitFor(() => expect(state.discovery()?.resource_id).toBe('101'));
const second = state.handleTriggerDiscovery(true);
if (outcome === 'success') old.resolve(saved());
else old.reject(new Error('Old guest execution paused'));
await first;
expect(state.discovery()?.resource_id).toBe('101');
expect(state.isScanning()).toBe(true);
expect(state.scanError()).toBeNull();
expect(state.scanSuccess()).toBe(false);
current.resolve(saved('101'));
await second;
expect(state.scanSuccess()).toBe(true);
expect(api.triggerDiscovery).toHaveBeenNthCalledWith(2, 'vm', 'node-agent', '101', {
force: true,
hostname: 'guest-101',
});
},
);
it('invalidates an old run even when the operator leaves and returns to the same target', async () => {
const old = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise);
const { state, setId } = await mount();
const first = state.handleTriggerDiscovery(true);
setId('101');
await waitFor(() => expect(state.discovery()?.resource_id).toBe('101'));
setId('100');
await waitFor(() => expect(state.discovery()?.resource_id).toBe('100'));
old.resolve({ ...saved(), service_name: 'Old run result' });
await first;
expect(state.discovery()?.service_name).toBe('Saved service 100');
expect(state.scanSuccess()).toBe(false);
});
it('does not resurrect a run after Discovery was disabled and re-enabled', async () => {
const old = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise);
const { state } = await mount();
const first = state.handleTriggerDiscovery(true);
syncAIRuntimeSettings({ discovery_enabled: false } as Parameters<
typeof syncAIRuntimeSettings
>[0]);
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(false));
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<
typeof syncAIRuntimeSettings
>[0]);
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true));
old.resolve({ ...saved(), service_name: 'Disabled run result' });
await first;
expect(state.discovery()?.service_name).toBe('Saved service 100');
expect(state.scanSuccess()).toBe(false);
});
it('ignores HTTP completion after disposal without scheduling another success timer', async () => {
const old = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise);
const { state, dispose } = await mount();
vi.useFakeTimers();
const first = state.handleTriggerDiscovery(true);
dispose();
old.resolve(saved());
await first;
expect(state.scanSuccess()).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('does not leave a spinner running when a retained handler has no target agent', async () => {
const { state } = await mount('');
await state.handleTriggerDiscovery(true);
expect(api.triggerDiscovery).not.toHaveBeenCalled();
expect(state.scanError()).toBe('Agent identifier unavailable for discovery');
expect(state.isScanning()).toBe(false);
expect(state.scanSuccess()).toBe(false);
});
it('does not let an earlier success timer clear the success of a later explicit run', async () => {
const { state } = await mount();
vi.useFakeTimers();
await state.handleTriggerDiscovery(true);
await vi.advanceTimersByTimeAsync(1500);
await state.handleTriggerDiscovery(true);
await vi.advanceTimersByTimeAsync(600);
expect(state.scanSuccess()).toBe(true);
await vi.advanceTimersByTimeAsync(1400);
expect(state.scanSuccess()).toBe(false);
});
it('does not leave the previous HTTP success visible forever after a later completion event', async () => {
const { state } = await mount();
vi.useFakeTimers();
await state.handleTriggerDiscovery(true);
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status: 'completed',
});
await vi.advanceTimersByTimeAsync(2500);
expect(state.scanSuccess()).toBe(false);
expect(state.discovery()?.resource_id).toBe('100');
});
it.each(['failed', 'completed'] as const)(
'retains a background %s-with-error outcome and the saved evidence, not silent success',
async (status) => {
const { state } = await mount();
const before = vi.mocked(api.getDiscovery).mock.calls.length;
vi.useFakeTimers();
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status,
error: 'Guest execution paused: backup lock observed',
current_step: 'Guest execution paused',
});
await vi.advanceTimersByTimeAsync(600);
expect(state.scanError()).toBe('Guest execution paused: backup lock observed');
expect(state.scanSuccess()).toBe(false);
expect(state.discovery()?.service_name).toBe('Saved service 100');
expect(api.getDiscovery).toHaveBeenCalledTimes(before);
expect(api.triggerDiscovery).not.toHaveBeenCalled();
},
);
it('shows background progress without claiming completion of a manual HTTP request', async () => {
const http = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(http.promise);
const { state } = await mount();
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status: 'running',
current_step: 'Collecting guest evidence',
});
expect(state.isScanning()).toBe(true);
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status: 'completed',
});
expect(state.isScanning()).toBe(false);
const first = state.handleTriggerDiscovery(true);
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status: 'completed',
});
expect(state.isScanning()).toBe(true);
expect(state.scanSuccess()).toBe(false);
http.resolve(saved());
await first;
expect(state.scanSuccess()).toBe(true);
});
it('drops a delayed background refresh when its target changed before dispatch', async () => {
const { state, setId } = await mount();
vi.useFakeTimers();
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status: 'completed',
});
setId('101');
await Promise.resolve();
const before = vi.mocked(api.getDiscovery).mock.calls.length;
await vi.advanceTimersByTimeAsync(600);
expect(api.getDiscovery).toHaveBeenCalledTimes(before);
expect(state.discovery()?.resource_id).toBe('101');
expect(api.triggerDiscovery).not.toHaveBeenCalled();
});
it('does not apply a delayed background read to a newer manual result on the same guest', async () => {
const refresh = deferred<ResourceDiscovery | null>();
const { state } = await mount();
vi.mocked(api.getDiscovery).mockReturnValueOnce(refresh.promise);
vi.useFakeTimers();
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status: 'completed',
});
await vi.advanceTimersByTimeAsync(500);
vi.mocked(api.triggerDiscovery).mockResolvedValueOnce({
...saved(),
service_name: 'New manual result',
});
await state.handleTriggerDiscovery(true);
refresh.resolve({ ...saved(), service_name: 'Old background result' });
await Promise.resolve();
expect(state.discovery()?.service_name).toBe('New manual result');
expect(state.scanSuccess()).toBe(true);
});
it.each([401, 403])(
'withdraws saved evidence when a completion refresh is denied with %s',
async (status) => {
const { state } = await mount();
vi.mocked(api.getDiscovery).mockRejectedValueOnce(
Object.assign(new Error('Discovery details unavailable.'), { status }),
);
vi.useFakeTimers();
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status: 'completed',
});
await vi.advanceTimersByTimeAsync(500);
expect(state.discovery()).toBeNull();
expect(state.scanSuccess()).toBe(false);
expect(api.triggerDiscovery).not.toHaveBeenCalled();
},
);
it('keeps saved evidence without success when HTTP supplies no result', async () => {
vi.mocked(api.triggerDiscovery).mockResolvedValueOnce(null as unknown as ResourceDiscovery);
const { state } = await mount();
await state.handleTriggerDiscovery(true);
expect(state.scanError()).toBe('Discovery returned no saved result.');
expect(state.scanSuccess()).toBe(false);
expect(state.discovery()?.service_name).toBe('Saved service 100');
expect(state.isScanning()).toBe(false);
});
it('does not close another guest’s notes editor when an old save finishes', async () => {
const save = deferred<ResourceDiscovery>();
vi.mocked(api.updateDiscoveryNotes).mockReturnValueOnce(save.promise);
const { state, setId } = await mount();
state.startEditingNotes();
state.setNotesText('Original guest notes');
const first = state.handleSaveNotes();
setId('101');
await waitFor(() => expect(state.discovery()?.resource_id).toBe('101'));
state.startEditingNotes();
state.setNotesText('New guest draft');
save.resolve(saved());
await first;
expect(state.editingNotes()).toBe(true);
expect(state.notesText()).toBe('New guest draft');
expect(api.updateDiscoveryNotes).toHaveBeenCalledWith('vm', 'node-agent', '100', {
user_notes: 'Original guest notes',
});
});
});
@@ -0,0 +1,184 @@
import { createSignal, Suspense } from 'solid-js';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResourceDiscovery } from '@/types/discovery';
import { DiscoveryTab } from '../DiscoveryTab';
import { useDiscoveryTabState } from '../useDiscoveryTabState';
import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState';
import * as discoveryApi from '@/api/discovery';
vi.mock('@/api/discovery', async (importOriginal) => ({
...(await importOriginal<typeof import('@/api/discovery')>()),
getDiscovery: vi.fn(async () => null),
getDiscoveryInfo: vi.fn(async () => ({
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
commands: [],
command_categories: [],
})),
getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })),
triggerDiscovery: vi.fn(async () => null),
}));
const reason = 'Discovery paused for this guest. Saved results remain available.';
const target = {
resourceType: 'vm' as const,
agentId: 'node-agent',
resourceId: '100',
hostname: 'guest',
};
const saved: ResourceDiscovery = {
id: 'vm:node-agent:100',
resource_type: 'vm',
resource_id: '100',
target_id: 'node-agent',
hostname: 'guest',
service_type: 'home-assistant',
service_name: 'Saved service',
category: 'home_automation',
service_version: 'fixture',
cli_access: '',
user_secrets: {},
ai_reasoning: 'Synthetic saved evidence',
scan_duration: 0,
facts: [],
config_paths: [],
data_paths: [],
log_paths: [],
ports: [],
user_notes: '',
discovered_at: '2026-10-03T12:00:00Z',
updated_at: '2026-10-03T12:00:00Z',
confidence: 0.9,
};
beforeEach(() => {
resetAIRuntimeState();
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
vi.mocked(discoveryApi.triggerDiscovery).mockResolvedValue(saved);
});
afterEach(() => {
cleanup();
resetAIRuntimeState();
vi.clearAllMocks();
});
describe('manual discovery safety', () => {
it.each([
['loading', (): Promise<ResourceDiscovery | null> => new Promise(() => undefined)],
['absent', async (): Promise<ResourceDiscovery | null> => null],
[
'empty',
async (): Promise<ResourceDiscovery | null> => ({
...saved,
service_type: '',
service_name: '',
confidence: 0,
}),
],
['saved', async (): Promise<ResourceDiscovery | null> => saved],
] as const)(
'blocks all run affordances with %s discovery while retaining passive reads',
async (_name, lookup) => {
vi.mocked(discoveryApi.getDiscovery).mockImplementation(lookup);
const props = { ...target, runBlockReason: reason };
render(() => <DiscoveryTab {...props} />);
await waitFor(() => expect(discoveryApi.getDiscoveryInfo).toHaveBeenCalled());
await waitFor(() => {
const buttons = screen.getAllByRole('button', { name: /^(Run|Re-scan|Update) Discovery/ });
for (const button of buttons) expect(button).toBeDisabled();
});
expect(screen.getByTestId('discovery-run-block')).toHaveTextContent(reason);
expect(discoveryApi.getDiscovery).toHaveBeenCalledWith('vm', 'node-agent', '100');
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
},
);
it('keeps saved evidence mounted and restores only the explicit run action, not an automatic scan', async () => {
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(saved);
const [block, setBlock] = createSignal<string | null>(reason);
const props = {
...target,
get runBlockReason() {
return block();
},
showManualRunAction: true,
};
render(() => (
<Suspense>
<DiscoveryTab {...props} />
</Suspense>
));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeDisabled());
const evidence = await screen.findByText('Saved service');
expect(evidence).toBeVisible();
setBlock(null);
await waitFor(() => expect(button).toBeEnabled());
expect(screen.getByText('Saved service')).toBe(evidence);
expect(screen.queryByTestId('discovery-run-block')).toBeNull();
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
fireEvent.click(button);
await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1));
});
it('checks the current block inside the handler even when a caller retained the old enabled verdict', async () => {
const [block, setBlock] = createSignal<string | null>(null);
const props = {
...target,
get runBlockReason() {
return block();
},
};
let state!: ReturnType<typeof useDiscoveryTabState>;
render(() => {
state = useDiscoveryTabState(props);
return <span>state</span>;
});
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true));
setBlock(reason);
await state.handleTriggerDiscovery(true);
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
expect(state.isScanning()).toBe(false);
expect(state.scanSuccess()).toBe(false);
});
it('does not cancel or hide an in-flight scan when a new snapshot blocks further runs', async () => {
const finishes: Array<(value: ResourceDiscovery) => void> = [];
vi.mocked(discoveryApi.triggerDiscovery).mockImplementation(
() =>
new Promise((resolve) => {
finishes.push(resolve);
}),
);
const [block, setBlock] = createSignal<string | null>(null);
const props = {
...target,
get runBlockReason() {
return block();
},
};
let state!: ReturnType<typeof useDiscoveryTabState>;
render(() => {
state = useDiscoveryTabState(props);
return <span>state</span>;
});
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true));
const original = state.handleTriggerDiscovery(true);
expect(state.isScanning()).toBe(true);
setBlock(reason);
const second = state.handleTriggerDiscovery(true);
try {
expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1);
expect(state.isScanning()).toBe(true);
} finally {
for (const finish of finishes) finish(saved);
await Promise.all([original, second]);
}
expect(state.isScanning()).toBe(false);
expect(state.discovery()).toBe(saved);
expect(state.scanSuccess()).toBe(true);
setBlock(null);
expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1);
});
});
@@ -24,6 +24,8 @@ export interface DiscoveryTabStateProps {
resourceId: string;
hostname: string;
commandsEnabled?: boolean;
/** Snapshot-based safety pause for manual runs; saved reads remain available. */
runBlockReason?: string | null;
}
const makeResourceId = (type: ResourceType, agentId: string, resourceId: string) =>
@@ -45,6 +47,18 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
const [httpScanInProgress, setHttpScanInProgress] = createSignal(false);
const [copiedDiscoveryValue, setCopiedDiscoveryValue] = createSignal('');
let copyFeedbackTimer: ReturnType<typeof setTimeout> | undefined;
let successTimer: ReturnType<typeof setTimeout> | undefined;
let completionTimer: ReturnType<typeof setTimeout> | undefined;
let contextVersion = 0;
let runVersion = 0;
let disposed = false;
const clearScanTimers = () => {
clearTimeout(successTimer);
clearTimeout(completionTimer);
successTimer = undefined;
completionTimer = undefined;
};
const targetAgentId = createMemo(() => props.agentId || '');
const discoverySourceKey = createMemo(
@@ -57,6 +71,23 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
const { discoveryFeatureEnabled, discoveryFeatureKnownDisabled } =
useDiscoveryFeatureAvailability();
// A target key alone is insufficient: leaving and returning to the same
// guest must not admit a response from the previous visit. A precaution on
// the same target does not change this ownership or cancel dispatched work.
const captureContext = () => ({
version: contextVersion,
key: discoverySourceKey(),
type: props.resourceType,
agentId: targetAgentId(),
resourceId: props.resourceId,
hostname: props.hostname,
});
const isCurrentContext = (context: ReturnType<typeof captureContext>) =>
!disposed &&
context.version === contextVersion &&
context.key === discoverySourceKey() &&
discoveryFeatureEnabled();
const [discoveryInfo] = createResource(
() => (discoveryFeatureEnabled() ? props.resourceType : null),
async (type) => {
@@ -120,8 +151,13 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
// the tab-wide banner already tells the user to configure one before
// scanning. (Command/connectivity gaps are surfaced separately, not blocked
// here, since their backend semantics are murkier.)
const runBlockReason = createMemo(() => props.runBlockReason?.trim() || null);
const canTriggerDiscovery = createMemo(
() => discoveryFeatureEnabled() && aiProviderConfigured() && Boolean(targetAgentId()),
() =>
discoveryFeatureEnabled() &&
aiProviderConfigured() &&
Boolean(targetAgentId()) &&
!runBlockReason(),
);
const [discovery, { refetch, mutate }] = createResource(
@@ -143,6 +179,9 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
createEffect(() => {
void discoveryFeatureEnabled();
void discoverySourceKey();
contextVersion++;
runVersion++;
clearScanTimers();
setIsScanning(false);
setHttpScanInProgress(false);
setScanProgress(null);
@@ -185,11 +224,23 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
});
const handleTriggerDiscovery = async (force = false) => {
// Check the current snapshot at the dispatch boundary as well as disabling
// buttons. Do not cancel a dispatched scan when a precaution appears.
if (disposed || runBlockReason() || isScanning()) return;
if (!discoveryFeatureEnabled()) {
setScanError('Service context is disabled in Settings -> Pulse Intelligence -> Assistant.');
return;
}
const context = captureContext();
if (!context.agentId) {
setScanError('Agent identifier unavailable for discovery');
return;
}
if (!aiProviderConfigured()) return;
clearScanTimers();
const version = ++runVersion;
const ownsRun = () => isCurrentContext(context) && version === runVersion;
setIsScanning(true);
setHttpScanInProgress(true);
setScanProgress(null);
@@ -199,59 +250,58 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
setLiveElapsedSeconds(0);
try {
const agentId = targetAgentId();
if (!agentId) {
setScanError('Agent identifier unavailable for discovery');
return;
}
const result = await triggerDiscovery(props.resourceType, agentId, props.resourceId, {
const result = await triggerDiscovery(context.type, context.agentId, context.resourceId, {
force,
hostname: props.hostname,
hostname: context.hostname,
});
if (result) {
mutate(result);
}
setHttpScanInProgress(false);
setIsScanning(false);
setScanProgress(null);
setScanStartTime(null);
if (!ownsRun()) return;
if (!result) throw new Error('Discovery returned no saved result.');
mutate(result);
setScanError(null);
setScanSuccess(true);
setTimeout(() => setScanSuccess(false), 2000);
successTimer = setTimeout(() => {
successTimer = undefined;
if (ownsRun()) setScanSuccess(false);
}, 2000);
} catch (err) {
if (!ownsRun()) return;
console.error('Discovery failed:', err);
const message = err instanceof Error ? err.message : 'Discovery scan failed';
if (message.includes('no connected agent')) {
setScanError(getDiscoveryNoConnectedAgentMessage(props.commandsEnabled));
} else {
setScanError(message);
setScanError(
message.includes('no connected agent')
? getDiscoveryNoConnectedAgentMessage(props.commandsEnabled)
: message,
);
} finally {
if (ownsRun()) {
setHttpScanInProgress(false);
setIsScanning(false);
setScanProgress(null);
setScanStartTime(null);
}
setHttpScanInProgress(false);
setIsScanning(false);
setScanProgress(null);
setScanStartTime(null);
}
};
const handleSaveNotes = async () => {
if (disposed || !discoveryFeatureEnabled()) return;
setSaveError(null);
const agentId = targetAgentId();
if (!agentId) {
const context = captureContext();
if (!context.agentId) {
setSaveError('Agent identifier unavailable for discovery');
return;
}
try {
await updateDiscoveryNotes(props.resourceType, agentId, props.resourceId, {
await updateDiscoveryNotes(context.type, context.agentId, context.resourceId, {
user_notes: notesText(),
});
if (!isCurrentContext(context)) return;
setEditingNotes(false);
await refetch();
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save notes');
if (isCurrentContext(context)) {
setSaveError(err instanceof Error ? err.message : 'Failed to save notes');
}
}
};
@@ -267,14 +317,19 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
};
onCleanup(() => {
disposed = true;
contextVersion++;
runVersion++;
clearScanTimers();
clearCopyFeedbackTimer();
});
const handleCopyDiscoveryValue = async (value?: string | null) => {
const text = (value || '').trim();
if (!text) return;
const context = captureContext();
const copied = await copyToClipboard(text);
if (!copied) return;
if (!copied || !isCurrentContext(context)) return;
clearCopyFeedbackTimer();
setCopiedDiscoveryValue(text);
@@ -291,28 +346,56 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
if (!progress || progress.resource_id !== resourceId()) return;
setScanProgress(progress);
if (
(progress.status === 'completed' || progress.status === 'failed') &&
!httpScanInProgress()
) {
setIsScanning(false);
setTimeout(async () => {
const agentId = targetAgentId();
if (!agentId) return;
try {
const result = await getDiscovery(props.resourceType, agentId, props.resourceId);
if (result) {
mutate(result);
}
} catch (err) {
console.error('Failed to fetch discovery after completion:', err);
}
setScanProgress(null);
}, 500);
if (progress.status === 'running' || progress.status === 'pending') {
if (!httpScanInProgress() && !isScanning()) {
clearScanTimers();
runVersion++;
setScanError(null);
setScanSuccess(false);
const startedAt = Date.parse(progress.started_at || '');
setScanStartTime(Number.isFinite(startedAt) ? startedAt : Date.now());
}
setIsScanning(true);
return;
}
if (progress.status !== 'completed' && progress.status !== 'failed') return;
// The scanner can emit completed-with-error when QGA is paused or no
// command evidence was collected. It is not successful saved Discovery.
// Keep that reason visible even when this tab did not initiate the scan.
const error =
progress.error?.trim() || (progress.status === 'failed' ? 'Discovery scan failed.' : null);
if (error) {
setScanError(error);
setScanSuccess(false);
}
// Scanner completion can precede analysis/persistence. A manual request
// owns its final result until HTTP settles, not an intermediate event.
if (httpScanInProgress()) return;
clearScanTimers();
const version = ++runVersion;
setScanSuccess(false);
setIsScanning(false);
setScanStartTime(null);
setScanProgress(null);
if (error) return;
const context = captureContext();
const ownsRefresh = () => isCurrentContext(context) && version === runVersion;
completionTimer = setTimeout(async () => {
completionTimer = undefined;
if (!context.agentId || !ownsRefresh()) return;
try {
const result = await getDiscovery(context.type, context.agentId, context.resourceId);
if (ownsRefresh()) mutate(result);
} catch (err) {
if (!ownsRefresh()) return;
const status = (err as { status?: number } | null)?.status;
if (status === 401 || status === 403) mutate(null);
console.error('Failed to fetch discovery after completion:', err);
}
}, 500);
});
onCleanup(() => {
@@ -347,6 +430,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
notesText,
mutateDiscovery: mutate,
refetchDiscovery: refetch,
runBlockReason,
saveError,
scanError,
scanProgress,
@@ -220,6 +220,61 @@ afterEach(() => {
// ── Tests ──────────────────────────────────────────────────────────────
describe('GuestDrawer', () => {
it('keeps stored History gaps through live guest recovery without another read', async () => {
const points = makeHistoryPoints(10);
const payload = {
resourceType: 'vm',
resourceId: 'inst1:node1:100',
range: '24h',
start: 1,
end: 3,
source: 'store',
metrics: { cpu: points, memory: [points[0], points[2]] },
};
chartsApiMocks.getMetricsHistory.mockResolvedValue(payload);
const [guest, setGuest] = createSignal(
makeGuest({ lock: 'backup', diskStatusReason: 'prev-vm-locked' }),
);
render(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
const chart = screen.getAllByTestId('guest-history-group-chart')[0];
expect(chart.querySelectorAll('path')).toHaveLength(1);
expect(chart.querySelectorAll('[data-history-observation="memory"]')).toHaveLength(2);
expect(chart.querySelector('[data-history-gaps]')).toHaveTextContent(
'Missing observations: Memory.',
);
setGuest({
...guest(),
lock: '',
diskStatusReason: '',
memory: {
...guest().memory,
usage: 99,
observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T12:00:00Z' },
},
});
expect(screen.getAllByTestId('guest-history-group-chart')[0]).toBe(chart);
expect(chart.querySelectorAll('[data-history-observation="memory"]')).toHaveLength(2);
expect(chart).not.toHaveTextContent('99.0%');
expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(1);
expect(chartsApiMocks.getMetricsHistory.mock.calls[0][0]).toMatchObject({
resourceType: 'vm',
resourceId: 'inst1:node1:100',
range: '24h',
maxPoints: 240,
});
chartsApiMocks.getMetricsHistory.mockResolvedValue({
...payload,
metrics: { cpu: points, memory: points },
});
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
await waitFor(() => expect(chart.querySelector('[data-history-gaps]')).toBeNull());
expect(chart.querySelectorAll('path')).toHaveLength(2);
expect(chart.querySelector('[data-history-observation="memory"]')).toBeNull();
expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(2);
});
it('keeps the backup precaution visible across drawer tabs without starting a guest check', async () => {
const [guest, setGuest] = createSignal(
makeGuest({
@@ -215,6 +215,11 @@ const GuestDrawerContent: Component<GuestDrawerProps> = (props) => {
hostname={props.guest.name}
canonicalResourceId={props.guest.id}
showManualRunAction
runBlockReason={
guestReadPrecaution()
? 'Discovery is paused during a backup or guest-read deferral. Saved results remain available. Clearing this pause does not prove thaw.'
: null
}
/>
</Suspense>
</div>
@@ -38,6 +38,7 @@ import {
buildGuestDrawerHistoryPath,
getGuestDrawerHistoryRangeBounds,
getGuestDrawerHistoryScale,
getGuestDrawerHistorySegments,
getGuestDrawerHistoryValueLabel,
normalizeGuestDrawerHistoryPoints,
type GuestDrawerHistoryGroupConfig,
@@ -277,8 +278,30 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
if (count === 0) return inspectionValueText();
return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`;
});
const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2));
const singlePointSeries = createMemo(() => series().filter((item) => item.points.length === 1));
const segmentedSeries = createMemo(() =>
series().map((item) => ({
...item,
segments: getGuestDrawerHistorySegments(item.points, observationTimes()),
})),
);
const gapMessage = createMemo(() => {
const labels = segmentedSeries()
.filter((item) => item.segments.length > 1)
.map((item) => item.label);
return labels.length > 0
? `Missing observations: ${labels.join(', ')}. Lines stop where a series has no reading at another stored time in this panel. This does not identify the cause or duration of a monitoring outage.`
: null;
});
const drawableSeries = createMemo(() =>
segmentedSeries().flatMap((item) =>
item.segments.filter((points) => points.length >= 2).map((points) => ({ ...item, points })),
),
);
const singlePointSeries = createMemo(() =>
segmentedSeries().flatMap((item) =>
item.segments.filter((points) => points.length === 1).map((points) => ({ ...item, points })),
),
);
const singleObservation = createMemo(() =>
observationTimes().length === 1 ? { timestamp: observationTimes()[0] } : null,
);
@@ -443,7 +466,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
</For>
<svg
aria-label={`${props.group.label} history`}
aria-describedby={`${inspectionId}-description`}
aria-describedby={`${inspectionId}-description${gapMessage() ? ` ${inspectionId}-gaps` : ''}`}
class="absolute inset-0 h-full w-full cursor-crosshair"
data-testid="guest-history-plot"
onMouseMove={handleHoverMove}
@@ -585,6 +608,13 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<p id={`${inspectionId}-description`} class="sr-only">
{chartDescription()}
</p>
<Show when={gapMessage()}>
{(message) => (
<p id={`${inspectionId}-gaps`} class="mt-2 text-xs text-muted" data-history-gaps>
{message()}
</p>
)}
</Show>
<Show when={!props.loading && singleObservation()}>
{(observation) => (
<p class="mt-2 text-[10px] text-muted">
@@ -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,
@@ -52,6 +52,10 @@ export { GUEST_COLUMNS, VIEW_MODE_COLUMNS } from './guestRowModel';
export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel';
import { getGuestColumnStyle } from './guestRowModel';
// Disk-read reasons that point at something broken the user can fix, as
// opposed to a guest that simply has no agent or is not running.
const DISK_READ_ACTIONABLE_REASONS = new Set(['permission-denied', 'agent-error']);
export function GuestRow(props: GuestRowProps) {
const [rowActionProps] = splitProps(props, ['onClick']);
const {
@@ -234,6 +238,9 @@ export function GuestRow(props: GuestRowProps) {
: 'Unavailable',
message: getDiskStatusTooltip(),
valueLabelContext: retained ? ('last known' as const) : ('current' as const),
// Most reasons are a setup gap (no guest agent, VM stopped) rather than a
// fault, so they stay muted; only a broken read the user can fix is amber.
needsAction: DISK_READ_ACTIONABLE_REASONS.has(reason.replace(/^prev-/, '')),
};
});
const diskValueLabelContext = (): MetricMiniSparklineValueLabelContext =>
@@ -648,7 +655,10 @@ export function GuestRow(props: GuestRowProps) {
{(status) => (
<p
data-workload-disk-read-status
class="mt-0.5 text-center text-[10px] leading-none text-amber-700 dark:text-amber-300"
class={`mt-0.5 text-center text-[10px] leading-none ${
status().needsAction ? 'text-amber-700 dark:text-amber-300' : 'text-muted'
}`}
title={status().message}
>
<span aria-hidden="true">{status().label}</span>
<span class="sr-only">{status().message}</span>
@@ -32,6 +32,7 @@ import { GuestDrawer } from './GuestDrawer';
import { GuestRow } from './GuestRow';
import { NodeDrawer } from './NodeDrawer';
import { buildWorkloadSummaryGroupScope } from './workloadSelectors';
import { getWorkloadGuestMetadataRecord } from './workloadGuestMetadataRecord';
import type { WorkloadsState } from './useWorkloadsState';
import type { WorkloadTableMetric } from './workloadMetricHistoryModel';
@@ -550,15 +551,8 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
const detailControlsId = createMemo(() =>
buildSummaryDisclosureControlsId(guestId()),
);
const metadata = () => {
const byId = props.guestMetadata();
for (const metadataId of metadataIdCandidates()) {
if (metadataId && byId[metadataId]) {
return byId[metadataId];
}
}
return byId[`${guest().instance}:${guest().node}:${guest().vmid}`];
};
const metadata = () =>
getWorkloadGuestMetadataRecord(guest(), props.guestMetadata());
const parentNode = () => node() ?? props.guestParentNodeMap()[guestId()];
const parentNodeOnline = () => {
const pn = parentNode();
@@ -0,0 +1,59 @@
import { Show, Suspense, createSignal, lazy, type Component } from 'solid-js';
import Link2Icon from 'lucide-solid/icons/link-2';
import { TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS } from '@/components/shared/TableCardHeader';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
import type { WorkloadGuest } from '@/types/workloads';
import type { WorkloadGuestMetadataMap } from './workloadGuestMetadataRecord';
import type { WorkloadWebLinkDrafts } from './workloadWebLinksModel';
// The panel and its row model load on first open, so they stay out of the
// WorkloadsSurface chunk and do no work while the editor is closed.
const WorkloadWebLinksDialog = lazy(() => import('./WorkloadWebLinksDialog'));
export interface WorkloadWebLinksActionProps {
guests: () => WorkloadGuest[];
guestMetadata: () => WorkloadGuestMetadataMap;
}
/** Table-header entry point for setting many guest web links at once. */
export const WorkloadWebLinksAction: Component<WorkloadWebLinksActionProps> = (props) => {
const [open, setOpen] = createSignal(false);
// Drafts outlive the panel so closing it by accident keeps typed links.
const [drafts, setDrafts] = createSignal<WorkloadWebLinkDrafts>({});
let triggerRef: HTMLButtonElement | undefined;
const preload = () => void WorkloadWebLinksDialog.preload();
return (
<Show when={!presentationPolicyIsReadOnly()}>
<button
ref={triggerRef}
type="button"
class={`${TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS} min-h-6 gap-1`}
aria-haspopup="dialog"
onPointerEnter={preload}
onFocus={preload}
onClick={() => setOpen(true)}
>
<Link2Icon class="h-3.5 w-3.5" aria-hidden="true" />
Edit links
<Show when={Object.keys(drafts()).length > 0}>
<span class="text-amber-600 dark:text-amber-400">(unsaved)</span>
</Show>
</button>
<Show when={open()}>
<Suspense fallback={null}>
<WorkloadWebLinksDialog
guests={props.guests}
guestMetadata={props.guestMetadata}
drafts={drafts}
setDrafts={setDrafts}
onClose={() => setOpen(false)}
returnFocus={() => triggerRef}
/>
</Suspense>
</Show>
</Show>
);
};
export default WorkloadWebLinksAction;
@@ -0,0 +1,183 @@
import { For, Show, type Component } from 'solid-js';
import XIcon from 'lucide-solid/icons/x';
import { ActionIconButton, Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import { FilterSegmentedControl } from '@/components/shared/FilterToolbar';
import { formControlDense } from '@/components/shared/Form';
import { StatusDot } from '@/components/shared/StatusDot';
import { getSimpleStatusIndicator } from '@/utils/status';
import {
useWorkloadWebLinksState,
type WorkloadWebLinksStateProps,
} from './useWorkloadWebLinksState';
import {
getWorkloadWebLinkPlaceholder,
getWorkloadWebLinkValue,
type WorkloadWebLinkFilter,
} from './workloadWebLinksModel';
export interface WorkloadWebLinksDialogProps extends WorkloadWebLinksStateProps {
returnFocus: () => HTMLElement | undefined;
}
/** Bulk web-link panel, loaded on first open by WorkloadWebLinksAction. */
export const WorkloadWebLinksDialog: Component<WorkloadWebLinksDialogProps> = (props) => {
const state = useWorkloadWebLinksState(props);
return (
<Dialog
isOpen
onClose={state.close}
layout="drawer-right"
panelClass="max-w-full sm:max-w-2xl"
ariaLabelledBy={`${state.formId}-title`}
returnFocus={props.returnFocus}
>
<div class="flex h-full min-h-0 flex-col" data-testid="workload-web-links">
<header class="flex shrink-0 items-start justify-between gap-3 border-b border-border bg-surface px-4 py-3">
<div class="min-w-0">
<h2 id={`${state.formId}-title`} class="text-base font-semibold text-base-content">
Web links
</h2>
<p class="text-xs text-muted">
Add the address you open in your browser for each guest. Pulse shows it as a link
beside the guest.
</p>
</div>
<ActionIconButton
type="button"
onClick={state.close}
label="Close web links"
title="Close"
tone="muted"
size="md"
>
<XIcon class="h-5 w-5" aria-hidden="true" />
</ActionIconButton>
</header>
<div class="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2">
<p class="text-xs text-muted" data-testid="workload-web-links-summary">
{state.linkedCount()} of {state.rows().length} in this view have a link
</p>
<FilterSegmentedControl
aria-label="Show guests"
value={state.filter()}
onChange={(value) => state.setFilter(value as WorkloadWebLinkFilter)}
options={[
{ value: 'all', label: 'All' },
{ value: 'missing', label: 'Without a link' },
]}
/>
</div>
<form
id={state.formId}
noValidate
class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4"
onSubmit={(event) => {
event.preventDefault();
void state.save();
}}
>
<Show
when={state.visibleRows().length > 0}
fallback={
<p class="py-6 text-center text-sm text-muted">
{state.rows().length === 0
? 'No guests in this view.'
: 'Every guest in this view has a link.'}
</p>
}
>
<ul class="divide-y divide-border">
<For each={state.visibleRows()}>
{(row) => {
const inputId = `${state.formId}-${row.metadataId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
const errorId = `${inputId}-error`;
const status = () => getSimpleStatusIndicator(row.status);
const error = () => state.errors()[row.metadataId];
return (
<li class="flex flex-col gap-1.5 py-2.5 sm:flex-row sm:items-start sm:gap-3">
<div class="min-w-0 sm:w-52 sm:shrink-0 sm:pt-1.5">
<label
for={inputId}
class="flex items-center gap-1.5 text-sm font-medium text-base-content"
>
<StatusDot
variant={status().variant}
ariaLabel={status().label}
title={status().label}
size="xs"
/>
<span class="break-all">{row.name}</span>
</label>
<Show when={row.detail}>
<p class="pl-3 text-[11px] text-muted">{row.detail}</p>
</Show>
</div>
<div class="min-w-0 flex-1">
<input
id={inputId}
data-metadata-id={row.metadataId}
type="url"
inputMode="url"
autocomplete="off"
spellcheck={false}
class={formControlDense}
placeholder={getWorkloadWebLinkPlaceholder(row)}
value={getWorkloadWebLinkValue(row, state.drafts())}
onInput={(event) => state.setDraft(row, event.currentTarget.value)}
disabled={state.saving()}
aria-invalid={error() ? 'true' : undefined}
aria-describedby={error() ? errorId : undefined}
/>
<Show when={error()}>
<p id={errorId} class="mt-1 text-xs text-red-600 dark:text-red-400">
{error()}
</p>
</Show>
</div>
</li>
);
}}
</For>
</ul>
</Show>
</form>
<footer class="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-surface px-4 py-3">
<p class="text-xs text-muted" role="status" aria-live="polite">
{state.changes().length === 0
? 'No unsaved changes'
: `${state.changes().length} unsaved ${state.changes().length === 1 ? 'change' : 'changes'}`}
</p>
<div class="flex items-center gap-2">
<Show when={state.changes().length > 0}>
<Button
variant="ghost"
size="sm"
onClick={state.discardChanges}
disabled={state.saving()}
>
Discard
</Button>
</Show>
<Button
type="submit"
form={state.formId}
variant="primary"
size="sm"
disabled={state.changes().length === 0}
isLoading={state.saving()}
>
{state.changes().length > 1 ? `Save ${state.changes().length} links` : 'Save'}
</Button>
</div>
</footer>
</div>
</Dialog>
);
};
export default WorkloadWebLinksDialog;
@@ -16,6 +16,7 @@ import {
hasActiveWorkloadsFilters,
} from './workloadsFilterModel';
import { WorkloadsTable } from './WorkloadsTable';
import { WorkloadWebLinksAction } from './WorkloadWebLinksAction';
import type { WorkloadInventorySourceIssue } from './workloadInventorySourceIssues';
import {
useWorkloadsState,
@@ -246,6 +247,12 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
cancelWorkloadColumnResize={state.cancelWorkloadColumnResize}
clearWorkloadColumnWidth={state.clearWorkloadColumnWidth}
title={props.tableTitle}
actions={
<WorkloadWebLinksAction
guests={state.filteredGuests}
guestMetadata={state.guestMetadata}
/>
}
/>
</Show>
<Show
@@ -73,6 +73,7 @@ type WorkloadsTableProps = Pick<
| 'clearWorkloadColumnWidth'
> & {
title?: JSX.Element;
actions?: JSX.Element;
};
export function WorkloadsTable(props: WorkloadsTableProps) {
@@ -84,7 +85,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
data-summary-clear-surface
data-testid="workloads-table-surface"
>
<TableCardHeader title={props.title} />
<TableCardHeader title={props.title} actions={props.actions} />
<Table
wrapperRef={props.setTableWrapperRef}
class={`platform-table workload-table table-fixed ${props.isMobile() ? `workload-table--mobile ${WORKLOAD_TABLE_MOBILE_MIN_WIDTH_CLASS}` : 'workload-table--desktop min-w-full'}${props.workloadManualColumnSizing() ? ' workload-table--manual-widths' : ''}`}
@@ -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', () => {
@@ -0,0 +1,161 @@
import { createSignal, Suspense } from 'solid-js';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { WorkloadGuest } from '@/types/workloads';
import { GuestDrawer } from '../GuestDrawer';
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import * as discoveryApi from '@/api/discovery';
vi.mock('@/api/discovery', async (importOriginal) => ({
...(await importOriginal<typeof import('@/api/discovery')>()),
getDiscovery: vi.fn(async () => null),
getDiscoveryInfo: vi.fn(async () => ({
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
commands: [],
command_categories: [],
})),
getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })),
triggerDiscovery: vi.fn(async () => null),
}));
vi.mock('@/api/charts', async (importOriginal) => ({
...(await importOriginal<typeof import('@/api/charts')>()),
ChartsAPI: {
getMetricsHistory: vi.fn(async () => ({ metrics: {}, start: 1, end: 2, source: 'store' })),
},
}));
vi.mock('@/stores/license', () => ({
isRangeLocked: () => false,
loadRuntimeCapabilities: vi.fn(),
maxHistoryDays: () => 90,
}));
const guest = (patch: Partial<WorkloadGuest> = {}): WorkloadGuest =>
({
id: 'inst:node:100',
instance: 'inst',
node: 'node',
vmid: 100,
name: 'Backup guest',
type: 'qemu',
status: 'running',
cpu: 0.25,
cpus: 2,
memory: { total: 1024, used: 512, free: 512, usage: 50 },
disk: { total: 1024, used: 512, free: 512, usage: 50 },
networkIn: 1,
networkOut: 2,
diskRead: 3,
diskWrite: 4,
uptime: 100,
template: false,
lastBackup: 0,
tags: [],
lock: '',
lastSeen: '2026-10-04T12:00:00Z',
guestAgentStatus: 'available',
discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' },
...patch,
}) as WorkloadGuest;
beforeEach(() => {
resetAIRuntimeState();
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
resetCreateNonSuspendingQueryCacheForTest();
});
afterEach(() => {
cleanup();
resetAIRuntimeState();
vi.clearAllMocks();
});
describe('guest drawer and real Discovery safety wiring', () => {
it.each([...guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`])])(
'does not offer a live scan for %s, even with an assigned node agent and available metadata',
async (reason) => {
render(() => (
<Suspense>
<GuestDrawer guest={guest({ diskStatusReason: reason })} onClose={vi.fn()} />
</Suspense>
));
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeDisabled());
fireEvent.click(button);
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
expect(screen.getByTestId('guest-read-precaution')).toBeVisible();
},
);
it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])(
'guards the native operation signal %j without fabricating a disk read reason',
async (patch) => {
render(() => (
<Suspense>
<GuestDrawer guest={guest(patch)} onClose={vi.fn()} />
</Suspense>
));
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Run Discovery' })).toBeDisabled(),
);
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
},
);
it('updates the existing same-guest button without remounting the tab or automatically scanning', async () => {
const [value, setValue] = createSignal(guest({ lock: 'backup' }));
render(() => (
<Suspense>
<GuestDrawer guest={value()} onClose={vi.fn()} />
</Suspense>
));
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeDisabled());
setValue(guest());
await waitFor(() => expect(button).toBeEnabled());
expect(screen.getByRole('button', { name: 'Run Discovery' })).toBe(button);
expect(screen.getByRole('tab', { name: 'Discovery' })).toHaveAttribute('aria-selected', 'true');
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
setValue(guest({ diskStatusReason: 'prev-agent-timeout' }));
await waitFor(() => expect(button).toBeDisabled());
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
});
it.each([
{ type: 'qemu' },
{
type: 'lxc',
lock: 'backup',
diskStatusReason: 'prev-agent-timeout',
discoveryTarget: {
resourceType: 'system-container',
agentId: 'node-agent',
resourceId: '100',
},
},
{
type: 'vm',
platformType: 'vmware',
guestAgentStatus: '',
lock: '',
discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' },
},
] as const)(
'preserves ordinary manual scans outside the Proxmox guest safety condition %j',
async (patch) => {
render(() => (
<Suspense>
<GuestDrawer guest={guest(patch)} onClose={vi.fn()} />
</Suspense>
));
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeEnabled());
fireEvent.click(button);
await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1));
},
);
});
@@ -0,0 +1,245 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChartsAPI, type AllMetricsHistoryResponse } from '@/api/charts';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { GuestDrawerHistory } from '../GuestDrawerHistory';
vi.mock('@/stores/license', () => ({
loadRuntimeCapabilities: vi.fn(async () => undefined),
maxHistoryDays: () => 90,
isRangeLocked: () => false,
}));
afterEach(() => {
cleanup();
resetCreateNonSuspendingQueryCacheForTest();
vi.restoreAllMocks();
});
const start = Date.UTC(2026, 9, 4, 12);
const target = { resourceType: 'vm' as const, resourceId: 'fixture:pve:101' };
const point = (minute: number, value: number) => ({
timestamp: start + minute * 60_000,
value,
min: value,
max: value,
});
const response = (
metrics: AllMetricsHistoryResponse['metrics'] = {
cpu: [0, 1, 2, 3, 4].map((minute) => point(minute, 10)),
memory: [point(0, 20), point(1, 25), point(4, 30)],
disk: [point(0, 50), point(4, 50)],
},
): AllMetricsHistoryResponse => ({
...target,
range: '1h',
start,
end: start + 60 * 60_000,
source: 'store',
metrics,
});
const group = (id = 'utilization') =>
screen.getAllByTestId('guest-history-group-chart').find((el) => el.dataset.historyGroup === id)!;
const paths = (colour: string, id = 'utilization') => [
...group(id).querySelectorAll(`path[stroke="${colour}"]`),
];
const marks = (metric: string, id = 'utilization') =>
group(id).querySelectorAll(`[data-history-observation="${metric}"]`);
const note = (id = 'utilization') => group(id).querySelector('[data-history-gaps]');
const mount = () => render(() => <GuestDrawerHistory target={target} range="1h" />);
const refresh = () => fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
describe('drawer History does not join missing stored observations', () => {
it('breaks only the affected metric and keeps isolated recovery observations visible', async () => {
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(paths('#8b5cf6')[0].getAttribute('d')?.match(/L/g)).toHaveLength(4);
expect(paths('#f59e0b')).toHaveLength(1);
expect(paths('#f59e0b')[0].getAttribute('d')?.match(/L/g)).toHaveLength(1);
expect(marks('memory')).toHaveLength(1);
expect(paths('#10b981')).toHaveLength(0);
expect(marks('disk')).toHaveLength(2);
expect(note()).toHaveTextContent('Missing observations: Memory, Disk.');
expect(note()).toHaveTextContent(
'Lines stop where a series has no reading at another stored time in this panel.',
);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('retains isolated measured zero, not an invented zero during a gap', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [point(0, 0), point(1, 1), point(2, 2)],
memory: [point(0, 0), point(2, 0)],
}),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(paths('#f59e0b')).toHaveLength(0);
expect(marks('memory')).toHaveLength(2);
const slider = within(group()).getByRole('slider');
fireEvent.input(slider, { target: { value: '1' } });
expect(slider).toHaveAttribute(
'aria-valuetext',
expect.stringContaining('Memory no observation.'),
);
expect(group()).toHaveTextContent('Memory-');
fireEvent.input(slider, { target: { value: '2' } });
expect(group()).toHaveTextContent('Memory0.0%');
});
it('does not fabricate a trend for interleaved, independently sampled series', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [point(0, 10), point(2, 20)],
memory: [point(1, 30), point(3, 40)],
}),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(group().querySelectorAll('path')).toHaveLength(0);
expect(marks('cpu')).toHaveLength(2);
expect(marks('memory')).toHaveLength(2);
expect(note()).toHaveTextContent('Missing observations: CPU, Memory.');
});
it('preserves multiple continuous segments without dropping either side of the gap', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [0, 1, 2, 3, 4].map((minute) => point(minute, 10)),
memory: [0, 1, 3, 4].map((minute) => point(minute, 20)),
}),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(paths('#f59e0b')).toHaveLength(2);
for (const path of paths('#f59e0b'))
expect(path.getAttribute('d')?.match(/L/g)).toHaveLength(1);
expect(marks('memory')).toHaveLength(0);
expect(within(group()).getByRole('img')).toHaveAccessibleDescription(
expect.stringContaining('Missing observations: Memory.'),
);
});
it('applies the same rule to network and disk I/O, retaining valid zero rates', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
netin: [point(0, 0), point(1, 100), point(2, 200)],
netout: [point(0, 0), point(2, 0)],
diskread: [point(0, 0), point(2, 0)],
diskwrite: [point(0, 0), point(1, 50), point(2, 100)],
}),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Network I/O history' });
expect(paths('#10b981', 'network')).toHaveLength(1);
expect(paths('#fb923c', 'network')).toHaveLength(0);
expect(marks('netout', 'network')).toHaveLength(2);
expect(note('network')).toHaveTextContent('Missing observations: Out.');
expect(paths('#3b82f6', 'disk-io')).toHaveLength(0);
expect(marks('diskread', 'disk-io')).toHaveLength(2);
expect(paths('#f59e0b', 'disk-io')).toHaveLength(1);
expect(note('disk-io')).toHaveTextContent('Missing observations: Read.');
});
it('does not guess missed polls from elapsed time or timestamps in other panels', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [point(0, 10), point(59, 20)],
memory: [point(0, 30), point(59, 40)],
netin: [point(20, 100)],
unconfigured: [point(30, 100)],
}),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(paths('#8b5cf6')).toHaveLength(1);
expect(paths('#f59e0b')).toHaveLength(1);
expect(note()).toBeNull();
});
it('does not create a gap from invalid samples or an entirely absent series', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [point(0, 10), point(1, NaN), point(2, 20)],
memory: [point(0, 30), point(2, 40)],
}),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(paths('#8b5cf6')).toHaveLength(1);
expect(paths('#f59e0b')).toHaveLength(1);
expect(note()).toBeNull();
expect(marks('disk')).toHaveLength(0);
});
it('cannot close a stored gap with live recovery or retained numeric carriers', async () => {
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
const [live, setLive] = createSignal(false);
render(() => (
<GuestDrawerHistory
target={target}
range="1h"
currentMetrics={live() ? { memory: 99, disk: 99 } : {}}
deferredMetrics={live() ? {} : { memory: { lastKnownValue: 25, message: 'Last known.' } }}
/>
));
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
const chart = group();
expect(marks('memory')).toHaveLength(1);
setLive(true);
expect(group()).toBe(chart);
expect(marks('memory')).toHaveLength(1);
expect(paths('#10b981')).toHaveLength(0);
expect(note()).toHaveTextContent('Memory, Disk');
expect(group()).not.toHaveTextContent('99.0%');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('closes a gap only with matching stored observations and preserves dated inspection', async () => {
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response())
.mockResolvedValueOnce(
response({
cpu: [0, 1, 2, 3, 4].map((minute) => point(minute, 10)),
memory: [0, 1, 2, 3, 4].map((minute) => point(minute, 20)),
}),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
const chart = group();
const slider = within(chart).getByRole('slider');
fireEvent.input(slider, { target: { value: '2' } });
expect(slider).toHaveAttribute(
'aria-valuetext',
expect.stringContaining('Memory no observation.'),
);
refresh();
await waitFor(() => expect(note()).toBeNull());
expect(group()).toBe(chart);
expect(paths('#f59e0b')).toHaveLength(1);
expect(paths('#f59e0b')[0].getAttribute('d')?.match(/L/g)).toHaveLength(4);
expect(slider).toHaveValue('2');
expect(slider).toHaveAttribute('aria-valuetext', expect.stringContaining('Memory 20.0%.'));
expect(fetch).toHaveBeenCalledTimes(2);
});
it('retains gaps on a transient refresh failure, but withdraws them on access denial', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response())
.mockRejectedValueOnce(Object.assign(new Error('private transient detail'), { status: 503 }))
.mockRejectedValueOnce(Object.assign(new Error('private denial'), { status: 403 }));
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
refresh();
await screen.findByRole('button', { name: 'Retry history' });
expect(note()).toHaveTextContent('Memory, Disk');
expect(paths('#10b981')).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
await waitFor(() => expect(screen.queryByTestId('guest-history-group-chart')).toBeNull());
expect(document.querySelector('[data-history-gaps]')).toBeNull();
expect(document.body).not.toHaveTextContent(/private transient detail|private denial/);
});
});
@@ -84,7 +84,7 @@ describe('GuestDrawerHistory common-time pointer inspection', () => {
expect(
within(utilization()).getByRole('img', { name: 'Utilization history' }),
).toHaveAccessibleDescription(
`${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`,
`${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation. Missing observations: CPU, Memory. Lines stop where a series has no reading at another stored time in this panel. This does not identify the cause or duration of a monitoring outage.`,
);
fireEvent.pointerLeave(plot);
expect(utilization()).toHaveTextContent('CPU43.0%');
@@ -101,7 +101,8 @@ describe('GuestDrawerHistory common-time pointer inspection', () => {
expect(utilization()).toHaveTextContent('Memory-');
expect(utilization()).toHaveTextContent('Disk0.0%');
expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(1);
expect(utilization().querySelectorAll('path')).toHaveLength(2);
expect(utilization().querySelectorAll('path')).toHaveLength(1);
expect(utilization().querySelectorAll('[data-history-observation]')).toHaveLength(4);
});
it('renders all metrics that really share a timestamp at the same cursor position', async () => {
@@ -165,7 +165,7 @@ describe('GuestDrawerHistory sparse observations and current provenance', () =>
},
);
it('draws a lone series alongside a trend and labels only the live fallback', async () => {
it('preserves interleaved lone observations and labels only the live fallback', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [point(start, 10), point(end, 20)],
@@ -173,12 +173,10 @@ describe('GuestDrawerHistory sparse observations and current provenance', () =>
}),
);
mount({ cpu: 99, memory: 98, disk: 88 });
await waitFor(() => expect(dots()).toHaveLength(1));
expect(group().querySelectorAll('path')).toHaveLength(1);
expect(group().querySelector('[data-history-observation]')).toHaveAttribute(
'data-history-observation',
'memory',
);
await waitFor(() => expect(dots()).toHaveLength(3));
expect(group().querySelectorAll('path')).toHaveLength(0);
expect(group().querySelectorAll('[data-history-observation="memory"]')).toHaveLength(1);
expect(group().querySelectorAll('[data-history-observation="cpu"]')).toHaveLength(2);
expect(group().querySelectorAll('[data-history-current]')).toHaveLength(1);
expect(group().querySelector('[data-history-current="disk"]')).toHaveTextContent(
'Disk88.0%current',
@@ -191,7 +189,7 @@ describe('GuestDrawerHistory sparse observations and current provenance', () =>
expect(group()).toHaveTextContent('Memory0.0%');
expect(group()).toHaveTextContent('Disk-');
expect(group().querySelector('[data-history-current]')).toBeNull();
expect(dots()).toHaveLength(1);
expect(dots()).toHaveLength(3);
});
it('does not connect single observations from different series or borrow their times', async () => {
@@ -0,0 +1,172 @@
import { cleanup, render, screen, within } from '@solidjs/testing-library';
import { createSignal, type ComponentProps } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GuestDrawerOverview } from '../GuestDrawerOverview';
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
import type { WorkloadGuest } from '@/types/workloads';
vi.mock('../GuestPhysicalDisks', () => ({ GuestPhysicalDisks: () => null }));
const path = '/srv/backups/covered-filesystem';
const unavailableDisk = {
cpu: true,
memory: true,
disk: false,
networkIO: true,
diskIO: true,
uptime: true,
} satisfies NonNullable<WorkloadGuest['telemetryAvailability']>;
const disk = {
mountpoint: path,
device: '/dev/vda1',
type: 'ext4',
total: 10 * 1024 ** 3,
used: 9 * 1024 ** 3,
usage: 90,
};
const guest = (overrides: Partial<WorkloadGuest> = {}): WorkloadGuest => ({
id: 'lab:pve-a:101',
vmid: 101,
name: 'backup-guest',
node: 'pve-a',
instance: 'lab',
status: 'running',
type: 'qemu',
cpu: 0.1,
cpus: 2,
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
disk,
disks: [disk],
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
tags: [],
lock: '',
lastBackup: 0,
lastSeen: '2026-10-04T21:27:00Z',
...overrides,
});
const props = (value: WorkloadGuest): ComponentProps<typeof GuestDrawerOverview> => ({
guest: value,
guestOsSummary: 'Linux',
agentHeading: 'Guest agent',
agentLabel: 'QEMU 9.2',
agentTitle: 'Previously observed agent version',
hasAgentInfo: true,
hasFilesystemDetails: true,
hasNetworkInterfaces: false,
hasOsInfo: true,
hasWorkloadActionAgent: false,
showInGuestAgentInstallCue: false,
ipAddresses: [],
networkInterfaces: [],
normalizedTags: [],
backupPresentation: null,
workloadActionAgentTitle: '',
});
const filesystemRow = () => screen.getByText(path).closest('tr')!;
afterEach(cleanup);
describe('filesystem details do not promote retained usage to current health', () => {
it.each(guestDiskDeferrals)(
'keeps prev-%s values labelled, without a current utilization bar',
(reason) => {
render(() => (
<GuestDrawerOverview {...props(guest({ diskStatusReason: `prev-${reason}` }))} />
));
const row = within(filesystemRow());
expect(row.getByText('Last known 90% · 9.00 GB/10.0 GB · EXT4')).toBeVisible();
expect(row.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.getByText(/^Using last known disk stats\./)).toBeVisible();
},
);
it.each([
...guestDiskDeferrals.map(([reason]) => reason),
'permission-denied',
'future-private-reason',
])('declines unqualified numeric usage for %s', (reason) => {
render(() => <GuestDrawerOverview {...props(guest({ diskStatusReason: reason }))} />);
const row = within(filesystemRow());
expect(row.getByText('Usage unavailable · ?/10.0 GB · EXT4')).toBeVisible();
expect(row.queryByRole('progressbar')).not.toBeInTheDocument();
expect(row.queryByText(/90%|9\.00 GB/)).not.toBeInTheDocument();
expect(screen.queryByText('future-private-reason')).not.toBeInTheDocument();
});
it('does not fabricate retained usage from capacity, negative or missing bytes', () => {
const [value, setValue] = createSignal(
guest({ diskStatusReason: 'prev-vm-locked', disks: [{ ...disk, used: undefined }] }),
);
render(() => <GuestDrawerOverview {...props(value())} />);
for (const input of [undefined, -1, NaN]) {
setValue({ ...value(), disks: [{ ...disk, used: input }] });
expect(
within(filesystemRow()).getByText('Usage unavailable · ?/10.0 GB · EXT4'),
).toBeVisible();
expect(within(filesystemRow()).queryByRole('progressbar')).not.toBeInTheDocument();
}
});
it('withdraws a numeric bar on explicit unavailability even without a reason', () => {
render(() => (
<GuestDrawerOverview {...props(guest({ telemetryAvailability: unavailableDisk }))} />
));
expect(within(filesystemRow()).getByText('Usage unavailable · ?/10.0 GB · EXT4')).toBeVisible();
expect(within(filesystemRow()).queryByRole('progressbar')).not.toBeInTheDocument();
});
it('explains explicit unavailability even before any filesystem sample', () => {
render(() => (
<GuestDrawerOverview
{...props(guest({ disks: [], telemetryAvailability: unavailableDisk }))}
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 }),
);
render(() => <GuestDrawerOverview {...props(value())} />);
const initialDrawer = screen.getByTestId('guest-technical-details');
setValue({ ...value(), lock: '', backupInProgress: false });
expect(within(filesystemRow()).getByText(/^Last known 90%/)).toBeVisible();
expect(within(filesystemRow()).queryByRole('progressbar')).not.toBeInTheDocument();
setValue({
...value(),
diskStatusReason: '',
disks: [{ ...disk, used: 3 * 1024 ** 3, usage: 30 }],
});
expect(screen.getByTestId('guest-technical-details')).toBe(initialDrawer);
expect(within(filesystemRow()).getByText('30% · 3.00 GB/10.0 GB · EXT4')).toBeVisible();
expect(
within(filesystemRow()).getByRole('progressbar', { name: `Filesystem ${path} utilization` }),
).toHaveAttribute('aria-valuenow', '30');
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
});
it.each(['qemu', 'lxc'])('preserves fresh %s evidence with no deferral', (type) => {
render(() => <GuestDrawerOverview {...props(guest({ type }))} />);
expect(within(filesystemRow()).getByText('90% · 9.00 GB/10.0 GB · EXT4')).toBeVisible();
expect(within(filesystemRow()).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '90');
expect(
within(filesystemRow()).queryByText(/Last known|Usage unavailable/),
).not.toBeInTheDocument();
});
it('does not attach VM deferral semantics to an independent LXC reading', () => {
render(() => (
<GuestDrawerOverview {...props(guest({ type: 'lxc', diskStatusReason: 'prev-vm-locked' }))} />
));
expect(within(filesystemRow()).getByText('90% · 9.00 GB/10.0 GB · EXT4')).toBeVisible();
expect(within(filesystemRow()).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '90');
});
});
@@ -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: '',
@@ -772,9 +772,28 @@ describe('GuestRow', () => {
expect(notice).toHaveTextContent('Unavailable');
expect(notice).toHaveTextContent(message);
expect(notice).not.toHaveTextContent('Last known');
// The reason is on hover too, not only for screen readers.
expect(notice).toHaveAttribute('title', message);
},
);
it(`keeps a setup gap muted and a broken ${mode} read amber`, () => {
const render = (diskStatusReason: string) =>
renderGuestRow({
guest: makeGuest({
diskStatusReason,
disk: makeDisk({ total: 0, used: 0, usage: -1 }),
disks: [],
}),
visibleColumnIds: ['name', 'disk'],
metricDisplayMode: mode,
}).container.querySelector('[data-workload-disk-read-status]');
expect(render('vm-stopped')?.className).toContain('text-muted');
expect(render('agent-disabled')?.className).toContain('text-muted');
expect(render('permission-denied')?.className).toContain('text-amber-700');
expect(render('prev-agent-error')?.className).toContain('text-amber-700');
});
it(`withdraws the ${mode} notice only on a fresh same-VM observation`, () => {
const [guest, setGuest] = createSignal(makeGuest({ diskStatusReason: 'prev-vm-locked' }));
const { container } = render(() => (
@@ -0,0 +1,202 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import type { WorkloadGuest } from '@/types/workloads';
import { getWorkloadMetadataId } from '@/utils/workloads';
import {
RESOURCE_METADATA_CHANGED_EVENT,
type ResourceMetadataChangedDetail,
} from '@/utils/resourceMetadataEvents';
import { WorkloadWebLinksAction } from '../WorkloadWebLinksAction';
import type { WorkloadGuestMetadataMap } from '../workloadGuestMetadataRecord';
const { updateMetadataMock, successMock, readOnlyMock } = vi.hoisted(() => ({
updateMetadataMock: vi.fn(),
successMock: vi.fn(),
readOnlyMock: vi.fn(() => false),
}));
vi.mock('@/api/guestMetadata', () => ({
GuestMetadataAPI: { updateMetadata: updateMetadataMock },
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: { success: successMock, error: vi.fn(), info: vi.fn() },
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyIsReadOnly: readOnlyMock,
}));
const makeGuest = (overrides: Partial<WorkloadGuest>): WorkloadGuest =>
({
node: 'pve1',
instance: 'cluster',
status: 'running',
type: 'lxc',
...overrides,
}) as WorkloadGuest;
const auth = makeGuest({ id: 'auth', vmid: 107, name: 'auth-service-01' });
const billing = makeGuest({ id: 'billing', vmid: 108, name: 'billing-worker-01' });
const cache = makeGuest({ id: 'cache', vmid: 117, name: 'artifact-cache-01' });
const authId = getWorkloadMetadataId(auth);
const billingId = getWorkloadMetadataId(billing);
const cacheId = getWorkloadMetadataId(cache);
const renderAction = (metadata: WorkloadGuestMetadataMap = {}) => {
const [guests] = createSignal([auth, billing, cache]);
const [guestMetadata, setGuestMetadata] = createSignal(metadata);
const events: ResourceMetadataChangedDetail[] = [];
const onChanged = (event: Event) => {
const detail = (event as CustomEvent<ResourceMetadataChangedDetail>).detail;
events.push(detail);
setGuestMetadata((current) => ({
...current,
[detail.metadataId!]: { id: detail.metadataId!, customUrl: detail.customUrl },
}));
};
window.addEventListener(RESOURCE_METADATA_CHANGED_EVENT, onChanged);
render(() => <WorkloadWebLinksAction guests={guests} guestMetadata={guestMetadata} />);
return {
events,
dispose: () => window.removeEventListener(RESOURCE_METADATA_CHANGED_EVENT, onChanged),
};
};
const openEditor = async () => {
fireEvent.click(screen.getByRole('button', { name: /Edit links/ }));
return within(await screen.findByRole('dialog', { name: 'Web links' }));
};
describe('WorkloadWebLinksAction', () => {
let harness: ReturnType<typeof renderAction> | undefined;
// The panel is lazy-loaded; transform it once so each open resolves promptly.
beforeAll(async () => {
await import('../WorkloadWebLinksDialog');
}, 30_000);
beforeEach(() => {
updateMetadataMock.mockReset();
updateMetadataMock.mockResolvedValue({});
successMock.mockReset();
readOnlyMock.mockReturnValue(false);
});
afterEach(() => {
harness?.dispose();
harness = undefined;
cleanup();
});
it('is absent from read-only sessions', () => {
readOnlyMock.mockReturnValue(true);
harness = renderAction();
expect(screen.queryByRole('button', { name: /Edit links/ })).not.toBeInTheDocument();
});
it('lists every guest in the view with its saved link and a labelled input', async () => {
harness = renderAction({ [authId]: { id: authId, customUrl: 'https://auth.example' } });
const dialog = await openEditor();
expect(dialog.getByTestId('workload-web-links-summary')).toHaveTextContent(
'1 of 3 in this view have a link',
);
expect(dialog.getByLabelText(/auth-service-01/)).toHaveValue('https://auth.example');
expect(dialog.getByLabelText(/artifact-cache-01/)).toHaveValue('');
fireEvent.click(dialog.getByRole('button', { name: 'Without a link' }));
expect(dialog.queryByLabelText(/auth-service-01/)).not.toBeInTheDocument();
expect(dialog.getByLabelText(/billing-worker-01/)).toBeInTheDocument();
});
it('blocks the whole save on an invalid link and explains it on that row', async () => {
harness = renderAction();
const dialog = await openEditor();
fireEvent.input(dialog.getByLabelText(/artifact-cache-01/), {
target: { value: 'https://cache.example' },
});
const billingInput = dialog.getByLabelText(/billing-worker-01/);
fireEvent.input(billingInput, { target: { value: 'billing admin' } });
fireEvent.click(dialog.getByRole('button', { name: 'Save 2 links' }));
await waitFor(() => expect(billingInput).toHaveAttribute('aria-invalid', 'true'));
expect(billingInput).toHaveAccessibleDescription(
'Enter a valid URL (for example: https://198.51.100.100:8080).',
);
expect(updateMetadataMock).not.toHaveBeenCalled();
});
it('saves additions and removals, notifies the table, and closes', async () => {
harness = renderAction({ [authId]: { id: authId, customUrl: 'https://auth.example' } });
const dialog = await openEditor();
fireEvent.input(dialog.getByLabelText(/auth-service-01/), { target: { value: '' } });
fireEvent.input(dialog.getByLabelText(/artifact-cache-01/), {
target: { value: ' https://cache.example ' },
});
expect(dialog.getByRole('status')).toHaveTextContent('2 unsaved changes');
fireEvent.click(dialog.getByRole('button', { name: 'Save 2 links' }));
await waitFor(() => expect(successMock).toHaveBeenCalledWith('Saved 2 links.'));
expect(updateMetadataMock.mock.calls).toEqual([
[cacheId, { customUrl: 'https://cache.example' }],
[authId, { customUrl: '' }],
]);
expect(harness.events).toEqual([
{ metadataKind: 'guest', metadataId: cacheId, customUrl: 'https://cache.example' },
{ metadataKind: 'guest', metadataId: authId, customUrl: '' },
]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Edit links' })).toBeInTheDocument();
});
it('keeps the editor open with the failure on the row that did not save', async () => {
updateMetadataMock.mockImplementation(async (id: string) => {
if (id === billingId) throw new Error('Permission denied');
return {};
});
harness = renderAction();
const dialog = await openEditor();
fireEvent.input(dialog.getByLabelText(/artifact-cache-01/), {
target: { value: 'https://cache.example' },
});
fireEvent.input(dialog.getByLabelText(/billing-worker-01/), {
target: { value: 'https://billing.example' },
});
fireEvent.click(dialog.getByRole('button', { name: 'Save 2 links' }));
await waitFor(() =>
expect(dialog.getByLabelText(/billing-worker-01/)).toHaveAccessibleDescription(
'Permission denied',
),
);
expect(successMock).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: 'Web links' })).toBeInTheDocument();
expect(dialog.getByRole('status')).toHaveTextContent('1 unsaved change');
expect(dialog.getByRole('button', { name: 'Save' })).toBeEnabled();
});
it('keeps unsaved drafts when the editor is closed and flags them on the trigger', async () => {
harness = renderAction();
const dialog = await openEditor();
fireEvent.input(dialog.getByLabelText(/artifact-cache-01/), {
target: { value: 'https://cache.example' },
});
fireEvent.click(dialog.getByRole('button', { name: 'Close web links' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Edit links/ })).toHaveTextContent('(unsaved)');
const reopened = await openEditor();
expect(reopened.getByLabelText(/artifact-cache-01/)).toHaveValue('https://cache.example');
fireEvent.click(reopened.getByRole('button', { name: 'Discard' }));
expect(reopened.getByLabelText(/artifact-cache-01/)).toHaveValue('');
expect(updateMetadataMock).not.toHaveBeenCalled();
});
});
@@ -10,6 +10,8 @@ import workloadsFilterSource from '../WorkloadsFilter.tsx?raw';
import workloadsWorkloadTableSource from '../WorkloadsTable.tsx?raw';
import metricDisplayModeSegmentedControlSource from '../MetricDisplayModeSegmentedControl.tsx?raw';
import workloadPanelSource from '../WorkloadPanel.tsx?raw';
import workloadWebLinksActionSource from '../WorkloadWebLinksAction.tsx?raw';
import workloadWebLinksStateSource from '../useWorkloadWebLinksState.ts?raw';
import workloadTableHeaderSource from '../WorkloadTableHeader.tsx?raw';
import workloadsFilterModelSource from '../workloadsFilterModel.ts?raw';
import workloadsControlsStateSource from '../useWorkloadsControlsState.ts?raw';
@@ -1393,7 +1395,25 @@ describe('Workloads performance contract', () => {
expect(workloadsWorkloadTableSource).toContain('data-summary-clear-surface');
expect(workloadsWorkloadTableSource).toContain('data-testid="workloads-table-surface"');
expect(workloadsWorkloadTableSource).toContain('TableCard');
expect(workloadsWorkloadTableSource).toContain('<TableCardHeader title={props.title} />');
expect(workloadsWorkloadTableSource).toContain(
'<TableCardHeader title={props.title} actions={props.actions} />',
);
expect(workloadsSource).toContain('<WorkloadWebLinksAction');
expect(workloadsSource).toContain('guests={state.filteredGuests}');
expect(workloadPanelSource).toContain(
'getWorkloadGuestMetadataRecord(guest(), props.guestMetadata())',
);
// The bulk link panel and its row model load on first open, outside the
// WorkloadsSurface chunk, and only mount while the editor is open.
expect(workloadWebLinksActionSource).toContain(
"lazy(() => import('./WorkloadWebLinksDialog'))",
);
expect(workloadWebLinksActionSource).toContain('<Show when={open()}>');
// Type-only imports are erased, so the row model stays in the lazy chunk.
expect(workloadWebLinksActionSource).not.toMatch(
/^import (?!type )[^;]*from '\.\/(workloadWebLinksModel|useWorkloadWebLinksState)';/m,
);
expect(workloadWebLinksStateSource).toContain('keepStableWorkloadWebLinkRows(');
expect(workloadsSource).toContain('<TableCardHeader title={props.tableTitle} />');
expect(workloadsSource).toContain('title={props.tableTitle}');
expect(workloadsWorkloadTableSource).toContain(
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { getGuestDrawerHistorySegments } from '../guestDrawerModel';
const point = (timestamp: number, value = 0) => ({ timestamp, value, min: value, max: value });
describe('stored History segments', () => {
it('retains real points and both sides of each missing in-panel time', () => {
const points = Object.freeze([point(1), point(2), point(4), point(6), point(7)]);
const times = Object.freeze([1, 2, 3, 4, 5, 6, 7]);
const segments = getGuestDrawerHistorySegments(points, times);
expect(segments).toEqual([[points[0], points[1]], [points[2]], [points[3], points[4]]]);
expect(segments.flat()).toEqual(points);
expect(segments[1][0]).toBe(points[2]);
});
it('keeps duplicate-time readings in the same segment, without creating a new time', () => {
const points = [point(1, 0), point(1, 10), point(2, 20), point(4, 30)];
expect(getGuestDrawerHistorySegments(points, [1, 2, 3, 4])).toEqual([
points.slice(0, 3),
[points[3]],
]);
});
it('cannot guess gaps from elapsed time when all stored times match', () => {
const points = [point(1), point(1_000_000)];
expect(getGuestDrawerHistorySegments(points, [1, 1_000_000])).toEqual([points]);
});
it('keeps an empty series empty and never accepts a point outside the group times', () => {
expect(getGuestDrawerHistorySegments([], [1, 2, 3])).toEqual([]);
expect(getGuestDrawerHistorySegments([point(1)], [])).toEqual([]);
expect(getGuestDrawerHistorySegments([point(1), point(2), point(3)], [1, 3])).toEqual([
[point(1), point(3)],
]);
});
it('preserves every point at the existing request bound', () => {
const times = Array.from({ length: 240 }, (_, index) => index);
const points = times.filter((time) => time % 2 === 0).map((time) => point(time));
const segments = getGuestDrawerHistorySegments(points, times);
expect(segments).toHaveLength(120);
expect(segments.every((segment) => segment.length === 1)).toBe(true);
expect(segments.flat()).toEqual(points);
});
});
@@ -191,6 +191,32 @@ describe('workloadSelectors', () => {
expect(computeWorkloadStats(guests).degraded).toBe(1);
});
it('counts a running guest with an open alert as needing attention, not a stopped one', () => {
const guests = [
makeGuest(1, { status: 'running', hasOpenAlert: true }),
makeGuest(2, { status: 'running' }),
makeGuest(3, { status: 'stopped', hasOpenAlert: true }),
];
const filterByStatus = (statusMode: 'running' | 'degraded' | 'stopped') =>
filterWorkloads({
guests,
viewMode: 'all',
statusMode,
searchTerm: '',
selectedNode: null,
selectedHostHint: null,
selectedKubernetesContext: null,
}).map((guest) => guest.id);
// A powered-off alert on a stopped guest keeps it under Stopped; the
// Attention chip is for running guests that need a look.
expect(filterByStatus('degraded')).toEqual([guests[0].id]);
expect(filterByStatus('running')).toEqual([guests[1].id]);
expect(filterByStatus('stopped')).toEqual([guests[2].id]);
expect(computeWorkloadStats(guests)).toMatchObject({ running: 1, degraded: 1, stopped: 1 });
});
it('ignores availability probes that are not proven to describe the guest', () => {
const failing = {
enabled: true,
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import type { WorkloadGuest } from '@/types/workloads';
import { getWorkloadMetadataId } from '@/utils/workloads';
import {
buildWorkloadWebLinkRows,
filterWorkloadWebLinkRows,
getWorkloadWebLinkChanges,
getWorkloadWebLinkPlaceholder,
keepStableWorkloadWebLinkRows,
validateWorkloadWebLinkChanges,
} from '../workloadWebLinksModel';
import { getWorkloadGuestMetadataRecord } from '../workloadGuestMetadataRecord';
const makeGuest = (overrides: Partial<WorkloadGuest> = {}): WorkloadGuest =>
({
id: 'inst1-node1-100',
vmid: 100,
name: 'test-vm',
node: 'node1',
instance: 'inst1',
status: 'running',
type: 'qemu',
...overrides,
}) as WorkloadGuest;
describe('workloadWebLinksModel', () => {
it('reads stable metadata first and falls back to the legacy instance:node:vmid key', () => {
const guest = makeGuest();
const stableId = getWorkloadMetadataId(guest);
expect(
getWorkloadGuestMetadataRecord(guest, {
'inst1:node1:100': { id: 'inst1:node1:100', customUrl: 'https://legacy.example' },
})?.customUrl,
).toBe('https://legacy.example');
expect(
getWorkloadGuestMetadataRecord(guest, {
'inst1:node1:100': { id: 'inst1:node1:100', customUrl: 'https://legacy.example' },
[stableId]: { id: stableId, customUrl: 'https://stable.example' },
})?.customUrl,
).toBe('https://stable.example');
});
it('builds name-sorted rows keyed by the save identity with type, ID and node detail', () => {
const alpha = makeGuest({
id: 'a',
vmid: 101,
name: 'alpha-10',
ipAddresses: ['', '10.0.0.5'],
});
const beta = makeGuest({ id: 'b', vmid: 102, name: 'alpha-9', type: 'lxc', node: 'node2' });
const rows = buildWorkloadWebLinkRows([alpha, beta, alpha], {
[getWorkloadMetadataId(alpha)]: {
id: getWorkloadMetadataId(alpha),
customUrl: ' https://alpha.example ',
},
});
expect(rows.map((row) => row.name)).toEqual(['alpha-9', 'alpha-10']);
expect(rows[1]).toMatchObject({
metadataId: getWorkloadMetadataId(alpha),
savedUrl: 'https://alpha.example',
addressHint: '10.0.0.5',
});
expect(rows[1].detail).toContain('101');
expect(rows[1].detail).toContain('node1');
expect(rows[0].detail).toContain('node2');
expect(rows[0].savedUrl).toBe('');
});
it('reports only real changes, treating an emptied field as a removal', () => {
const rows = buildWorkloadWebLinkRows(
[makeGuest({ id: 'a', name: 'a', vmid: 1 }), makeGuest({ id: 'b', name: 'b', vmid: 2 })],
{},
);
const [a, b] = rows;
const withSaved = [{ ...a, savedUrl: 'https://a.example' }, b];
expect(
getWorkloadWebLinkChanges(withSaved, {
[a.metadataId]: ' https://a.example ',
[b.metadataId]: '',
}),
).toEqual([]);
expect(
getWorkloadWebLinkChanges(withSaved, {
[a.metadataId]: '',
[b.metadataId]: ' https://b.example',
}),
).toEqual([
{ metadataId: a.metadataId, name: 'a', url: '' },
{ metadataId: b.metadataId, name: 'b', url: 'https://b.example' },
]);
});
it('validates added links with the shared web-interface rules but never blocks a removal', () => {
expect(
validateWorkloadWebLinkChanges([
{ metadataId: 'ok', name: 'ok', url: 'http://10.0.0.5:8080' },
{ metadataId: 'bad', name: 'bad', url: 'admin page' },
{ metadataId: 'ftp', name: 'ftp', url: 'ftp://files.example' },
{ metadataId: 'gone', name: 'gone', url: '' },
]),
).toEqual({
bad: 'Enter a valid URL (for example: https://198.51.100.100:8080).',
ftp: 'URL must start with http:// or https://.',
});
});
it('filters to guests without a saved link, ignoring unsaved drafts', () => {
const rows = buildWorkloadWebLinkRows(
[makeGuest({ id: 'a', name: 'a', vmid: 1 }), makeGuest({ id: 'b', name: 'b', vmid: 2 })],
{},
);
const withSaved = [{ ...rows[0], savedUrl: 'https://a.example' }, rows[1]];
expect(filterWorkloadWebLinkRows(withSaved, 'missing').map((row) => row.name)).toEqual(['b']);
expect(filterWorkloadWebLinkRows(withSaved, 'all')).toHaveLength(2);
});
it('keeps unchanged row objects across inventory ticks so inputs are not remounted', () => {
const guest = makeGuest({ ipAddresses: ['10.0.0.5'] });
const first = buildWorkloadWebLinkRows([guest], {});
const tick = buildWorkloadWebLinkRows([{ ...guest, cpu: 0.9 } as WorkloadGuest], {});
expect(tick[0]).not.toBe(first[0]);
expect(keepStableWorkloadWebLinkRows(first, tick)).toBe(first);
const renamed = buildWorkloadWebLinkRows([{ ...guest, name: 'renamed' }], {});
const merged = keepStableWorkloadWebLinkRows(first, renamed);
expect(merged).not.toBe(first);
expect(merged[0].name).toBe('renamed');
});
it('suggests the guest address as a placeholder, bracketing IPv6', () => {
const [row] = buildWorkloadWebLinkRows([makeGuest()], {});
expect(getWorkloadWebLinkPlaceholder(row)).toBe('https://');
expect(getWorkloadWebLinkPlaceholder({ ...row, addressHint: '10.0.0.5' })).toBe(
'http://10.0.0.5',
);
expect(getWorkloadWebLinkPlaceholder({ ...row, addressHint: 'fd00::5' })).toBe(
'http://[fd00::5]',
);
});
});
@@ -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}`,
};
};
@@ -355,6 +355,29 @@ export const getGuestDrawerHistoryScale = (
return { minValue: 0, maxValue: Math.max(1, maxValue * 1.15) };
};
// Both inputs are normalized stored observations, ordered by timestamp. A
// series cannot span a time where another series in its panel has a reading
// and this one does not. Do not infer failed polls or cadence from elapsed
// time: the API supplies neither. Keep singleton segments as real evidence.
export const getGuestDrawerHistorySegments = (
points: readonly AggregatedMetricPoint[],
observationTimes: readonly number[],
): AggregatedMetricPoint[][] => {
const timeIndices = new Map(observationTimes.map((timestamp, index) => [timestamp, index]));
const segments: AggregatedMetricPoint[][] = [];
let previousIndex: number | undefined;
for (const point of points) {
const index = timeIndices.get(point.timestamp);
if (index === undefined) continue;
if (previousIndex === undefined || (index !== previousIndex && index !== previousIndex + 1)) {
segments.push([]);
}
segments[segments.length - 1].push(point);
previousIndex = index;
}
return segments;
};
export const buildGuestDrawerHistoryPath = (
points: readonly AggregatedMetricPoint[],
scale: GuestDrawerHistoryScale,
@@ -0,0 +1,135 @@
import { createMemo, createSignal, createUniqueId, type Setter } from 'solid-js';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import { notificationStore } from '@/stores/notifications';
import type { WorkloadGuest } from '@/types/workloads';
import { dispatchResourceMetadataChanged } from '@/utils/resourceMetadataEvents';
import type { WorkloadGuestMetadataMap } from './workloadGuestMetadataRecord';
import {
buildWorkloadWebLinkRows,
filterWorkloadWebLinkRows,
formatWorkloadWebLinkCount,
getWorkloadWebLinkChanges,
keepStableWorkloadWebLinkRows,
validateWorkloadWebLinkChanges,
type WorkloadWebLinkDrafts,
type WorkloadWebLinkFilter,
type WorkloadWebLinkRow,
} from './workloadWebLinksModel';
export interface WorkloadWebLinksStateProps {
guests: () => WorkloadGuest[];
guestMetadata: () => WorkloadGuestMetadataMap;
/** Owned by the table-header trigger so closing the panel keeps typed links. */
drafts: () => WorkloadWebLinkDrafts;
setDrafts: Setter<WorkloadWebLinkDrafts>;
onClose: () => void;
}
/**
* Bulk web-link panel state. The panel only mounts while open, so its live
* inventory reads add no per-tick work to a table whose editor is closed.
*/
export function useWorkloadWebLinksState(props: WorkloadWebLinksStateProps) {
const formId = `workload-web-links-${createUniqueId()}`;
const [filter, setFilter] = createSignal<WorkloadWebLinkFilter>('all');
const [errors, setErrors] = createSignal<Record<string, string>>({});
const [saving, setSaving] = createSignal(false);
const rows = createMemo<WorkloadWebLinkRow[]>(
(previous) =>
keepStableWorkloadWebLinkRows(
previous,
buildWorkloadWebLinkRows(props.guests(), props.guestMetadata()),
),
[],
);
const visibleRows = createMemo(() => filterWorkloadWebLinkRows(rows(), filter()));
const linkedCount = createMemo(() => rows().filter((row) => row.savedUrl).length);
const changes = createMemo(() => getWorkloadWebLinkChanges(rows(), props.drafts()));
const setDraft = (row: WorkloadWebLinkRow, value: string) => {
// Typing a link back to its saved value drops the draft, so the trigger's
// unsaved marker only reflects real edits.
props.setDrafts(({ [row.metadataId]: _previous, ...rest }) =>
value === row.savedUrl ? rest : { ...rest, [row.metadataId]: value },
);
if (errors()[row.metadataId]) {
setErrors(({ [row.metadataId]: _cleared, ...rest }) => rest);
}
};
const discardChanges = () => {
props.setDrafts({});
setErrors({});
};
const focusFirstError = (failed: Record<string, string>) => {
const firstId = rows().find((row) => failed[row.metadataId])?.metadataId;
if (!firstId) return;
queueMicrotask(() =>
Array.from(document.querySelectorAll<HTMLInputElement>(`#${formId} input`))
.find((input) => input.dataset.metadataId === firstId)
?.focus(),
);
};
const save = async () => {
const pending = changes();
if (pending.length === 0 || saving()) return;
const invalid = validateWorkloadWebLinkChanges(pending);
if (Object.keys(invalid).length > 0) {
setErrors(invalid);
setFilter('all');
focusFirstError(invalid);
return;
}
setSaving(true);
const failed: Record<string, string> = {};
let savedCount = 0;
for (const change of pending) {
try {
await GuestMetadataAPI.updateMetadata(change.metadataId, { customUrl: change.url });
dispatchResourceMetadataChanged({
metadataKind: 'guest',
metadataId: change.metadataId,
customUrl: change.url,
});
props.setDrafts(({ [change.metadataId]: _saved, ...rest }) => rest);
savedCount += 1;
} catch (error) {
failed[change.metadataId] =
error instanceof Error && error.message ? error.message : 'Could not save this link.';
}
}
setSaving(false);
setErrors(failed);
if (Object.keys(failed).length === 0) {
notificationStore.success(`Saved ${formatWorkloadWebLinkCount(savedCount)}.`);
props.onClose();
return;
}
focusFirstError(failed);
};
return {
formId,
filter,
setFilter,
drafts: props.drafts,
setDraft,
discardChanges,
errors,
saving,
rows,
visibleRows,
linkedCount,
changes,
save,
close: props.onClose,
};
}
export type WorkloadWebLinksState = ReturnType<typeof useWorkloadWebLinksState>;
@@ -0,0 +1,21 @@
import type { GuestMetadata } from '@/api/guestMetadata';
import type { WorkloadGuest } from '@/types/workloads';
import { getWorkloadMetadataIdCandidates } from '@/utils/workloads';
export type WorkloadGuestMetadataMap = Record<string, GuestMetadata>;
/**
* Saved metadata for a workload row. Stable identities win over the legacy
* instance:node:vmid key that v5 data volumes still carry.
*/
export const getWorkloadGuestMetadataRecord = (
guest: WorkloadGuest,
byId: WorkloadGuestMetadataMap,
): GuestMetadata | undefined => {
for (const metadataId of getWorkloadMetadataIdCandidates(guest)) {
if (metadataId && byId[metadataId]) {
return byId[metadataId];
}
}
return byId[`${guest.instance}:${guest.node}:${guest.vmid}`];
};
@@ -79,6 +79,13 @@ const resolveWorkloadStatusBucket = (guest: WorkloadGuest): WorkloadStatusBucket
return 'degraded';
}
// A running guest with an open warning or critical alert needs attention
// even while its power state and resource status read healthy. Backup age
// is deliberately not counted here: it has its own column and tab.
if (bucket === 'running' && guest.hasOpenAlert) {
return 'degraded';
}
return bucket;
};
@@ -0,0 +1,129 @@
import {
normalizeWebInterfaceUrl,
validateWebInterfaceCustomUrl,
} from '@/components/shared/webInterfaceUrlFieldModel';
import type { WorkloadGuest } from '@/types/workloads';
import { getWorkloadTypeLabel } from '@/utils/workloadTypePresentation';
import { getWorkloadMetadataId, resolveWorkloadType } from '@/utils/workloads';
import { getWorkloadDisplayId } from './guestRowModel';
import {
getWorkloadGuestMetadataRecord,
type WorkloadGuestMetadataMap,
} from './workloadGuestMetadataRecord';
export type WorkloadWebLinkFilter = 'all' | 'missing';
export interface WorkloadWebLinkRow {
metadataId: string;
name: string;
detail: string;
status: string;
addressHint: string;
savedUrl: string;
}
/** Draft URLs keyed by metadata ID; an empty string means remove the link. */
export type WorkloadWebLinkDrafts = Record<string, string>;
export interface WorkloadWebLinkChange {
metadataId: string;
name: string;
url: string;
}
const getWorkloadWebLinkDetail = (guest: WorkloadGuest): string =>
[getWorkloadTypeLabel(resolveWorkloadType(guest)), getWorkloadDisplayId(guest), guest.node]
.map((part) => (part === undefined || part === null ? '' : String(part).trim()))
.filter(Boolean)
.join(' · ');
export const buildWorkloadWebLinkRows = (
guests: readonly WorkloadGuest[],
byId: WorkloadGuestMetadataMap,
): WorkloadWebLinkRow[] => {
const rows: WorkloadWebLinkRow[] = [];
const seen = new Set<string>();
for (const guest of guests) {
const metadataId = getWorkloadMetadataId(guest);
if (!metadataId || seen.has(metadataId)) continue;
seen.add(metadataId);
rows.push({
metadataId,
name: guest.name,
detail: getWorkloadWebLinkDetail(guest),
status: guest.status,
addressHint: guest.ipAddresses?.find((address) => address.trim())?.trim() ?? '',
savedUrl: normalizeWebInterfaceUrl(getWorkloadGuestMetadataRecord(guest, byId)?.customUrl),
});
}
return rows.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
};
const sameWorkloadWebLinkRow = (a: WorkloadWebLinkRow, b: WorkloadWebLinkRow): boolean =>
a.metadataId === b.metadataId &&
a.name === b.name &&
a.detail === b.detail &&
a.status === b.status &&
a.addressHint === b.addressHint &&
a.savedUrl === b.savedUrl;
/**
* Keeps unchanged row objects across live inventory ticks so keyed list
* rendering does not remount an input while someone is typing in it.
*/
export const keepStableWorkloadWebLinkRows = (
previous: readonly WorkloadWebLinkRow[],
next: WorkloadWebLinkRow[],
): WorkloadWebLinkRow[] => {
if (previous.length === 0) return next;
const previousById = new Map(previous.map((row) => [row.metadataId, row]));
const merged = next.map((row) => {
const prior = previousById.get(row.metadataId);
return prior && sameWorkloadWebLinkRow(prior, row) ? prior : row;
});
const unchanged =
merged.length === previous.length && merged.every((row, index) => row === previous[index]);
return unchanged ? (previous as WorkloadWebLinkRow[]) : merged;
};
export const filterWorkloadWebLinkRows = (
rows: readonly WorkloadWebLinkRow[],
filter: WorkloadWebLinkFilter,
): WorkloadWebLinkRow[] => (filter === 'missing' ? rows.filter((row) => !row.savedUrl) : [...rows]);
export const getWorkloadWebLinkValue = (
row: WorkloadWebLinkRow,
drafts: WorkloadWebLinkDrafts,
): string => drafts[row.metadataId] ?? row.savedUrl;
export const getWorkloadWebLinkChanges = (
rows: readonly WorkloadWebLinkRow[],
drafts: WorkloadWebLinkDrafts,
): WorkloadWebLinkChange[] =>
rows.flatMap((row) => {
const draft = drafts[row.metadataId];
if (draft === undefined) return [];
const url = normalizeWebInterfaceUrl(draft);
return url === row.savedUrl ? [] : [{ metadataId: row.metadataId, name: row.name, url }];
});
/** Validation errors keyed by metadata ID. Removing a link is always valid. */
export const validateWorkloadWebLinkChanges = (
changes: readonly WorkloadWebLinkChange[],
): Record<string, string> => {
const errors: Record<string, string> = {};
for (const change of changes) {
const error = change.url ? validateWebInterfaceCustomUrl(change.url) : null;
if (error) errors[change.metadataId] = error;
}
return errors;
};
export const getWorkloadWebLinkPlaceholder = (row: WorkloadWebLinkRow): string => {
if (!row.addressHint) return 'https://';
const host = row.addressHint.includes(':') ? `[${row.addressHint}]` : row.addressHint;
return `http://${host}`;
};
export const formatWorkloadWebLinkCount = (count: number): string =>
`${count} ${count === 1 ? 'link' : 'links'}`;
@@ -257,7 +257,7 @@ describe('KubernetesPageSurface contract', () => {
renderSurface();
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('k8s-node-1 is running an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent('k8s-node-1 runs an older Pulse agent (v5.1.34), so');
expect(notice).toHaveTextContent('Kubernetes nodes, workloads, services, storage');
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
@@ -300,7 +300,7 @@ describe('KubernetesPageSurface contract', () => {
renderSurface();
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('worker-1 is running an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent('worker-1 runs an older Pulse agent (v5.1.34), so');
expect(notice).toHaveTextContent('for this node');
expect(notice).toHaveTextContent('Kubernetes nodes, workloads, services, storage');
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
@@ -28,7 +28,7 @@ describe('PlatformOutdatedAgentNotice', () => {
));
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent(
'tower is running an older Pulse agent (v6.0.0-rc.5). Update it to v6.0.0-rc.6 to see images, networks, and storage for this host.',
'tower runs an older Pulse agent (v6.0.0-rc.5), so images, networks, and storage for this host may be missing.',
);
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
@@ -36,23 +36,7 @@ describe('PlatformOutdatedAgentNotice', () => {
);
});
it('summarises multiple outdated hosts and lists them', () => {
render(() => (
<PlatformOutdatedAgentNotice
hosts={[
{ name: 'tower', version: 'v6.0.0-rc.5' },
{ name: 'delly', version: 'v6.0.0-rc.5' },
]}
targetVersion="v6.0.0-rc.6"
missingLabel="images, networks, and storage"
/>
));
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('2 hosts are running an older Pulse agent.');
expect(notice).toHaveTextContent('Affected: tower, delly.');
});
it('keeps a large affected-host list behind an expandable preview', () => {
it('summarises multiple outdated hosts in one line and keeps names behind a toggle', () => {
render(() => (
<PlatformOutdatedAgentNotice
hosts={[
@@ -62,21 +46,31 @@ describe('PlatformOutdatedAgentNotice', () => {
{ name: 'remote', version: 'v6.0.0-rc.5' },
]}
targetVersion="v6.0.0-rc.6"
missingLabel="images"
missingLabel="images, networks, and storage"
actionHref="/settings/infrastructure"
actionLabel="Open agent upgrade commands"
/>
));
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('Affected: tower, delly, lab, and 1 more.');
expect(notice).not.toHaveTextContent('Affected: tower, delly, lab, remote.');
expect(notice).toHaveTextContent(
'4 hosts run an older Pulse agent, so images, networks, and storage may be missing.',
);
// Maintenance, not an incident: the info tone, with names on demand.
expect(notice.className).toContain('bg-blue-50');
expect(notice).not.toHaveTextContent('Affected:');
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
'/settings/infrastructure',
);
const toggle = screen.getByRole('button', { name: 'Show all 4 hosts' });
const toggle = screen.getByRole('button', { name: 'Which hosts?' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(notice).toHaveTextContent('Affected: tower, delly, lab, remote.');
fireEvent.click(screen.getByRole('button', { name: 'Hide affected names' }));
expect(notice).not.toHaveTextContent('Affected: tower, delly, lab, remote.');
fireEvent.click(screen.getByRole('button', { name: 'Hide names' }));
expect(notice).not.toHaveTextContent('Affected:');
});
it('uses latest-detail copy for hybrid platform pages', () => {
@@ -90,7 +84,7 @@ describe('PlatformOutdatedAgentNotice', () => {
));
expect(screen.getByTestId('platform-outdated-agent-notice')).toHaveTextContent(
'delly is running an older Pulse agent (v5.1.34). Update it to v6.0.0-rc.6 for the latest agent-contributed platform detail on this host.',
'delly runs an older Pulse agent (v5.1.34). Update it to v6.0.0-rc.6 for the latest agent-contributed platform detail.',
);
});
@@ -135,10 +129,10 @@ describe('PlatformOutdatedAgentNotice', () => {
));
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('2 VMs are running an older Pulse agent.');
expect(notice).toHaveTextContent(
'Update them to v6.0.0-rc.6 for the latest in-guest telemetry and command support.',
'2 VMs run an older Pulse agent. Update them to v6.0.0-rc.6 for the latest in-guest telemetry and command support.',
);
fireEvent.click(screen.getByRole('button', { name: 'Which VMs?' }));
expect(notice).toHaveTextContent('Affected: app-01, db-01.');
});
});
@@ -1,5 +1,5 @@
import { Show, createMemo, createSignal } from 'solid-js';
import { AlertTriangle, ArrowRight } from 'lucide-solid';
import { ArrowRight, Info } from 'lucide-solid';
import { InlineNotice } from '@/components/shared/InlineNotice';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
import type { OutdatedAgentHost } from './agentVersion';
@@ -26,18 +26,14 @@ type PlatformOutdatedAgentNoticeProps = {
// is rendered only when there is an actually-outdated resource, so the page
// stays clean in the healthy case. This is the breadcrumb that distinguishes a
// genuinely-empty detail tab from one hidden by a stale agent.
//
// It is maintenance, not an incident, so it is one quiet line in the info tone
// rather than a multi-line warning above every tab: the affected names sit
// behind a toggle and the update action stays one click away.
export function PlatformOutdatedAgentNotice(props: PlatformOutdatedAgentNoticeProps) {
const count = createMemo(() => props.hosts.length);
const names = createMemo(() => props.hosts.map((host) => host.name).join(', '));
const [showAllHosts, setShowAllHosts] = createSignal(false);
const preview = createMemo(() => {
const firstNames = props.hosts
.slice(0, 3)
.map((host) => host.name)
.join(', ');
const remaining = count() - 3;
return remaining > 0 ? `${firstNames}, and ${remaining} more` : firstNames;
});
const actionLabel = createMemo(() => props.actionLabel || 'Open Infrastructure settings');
const subjectSingular = createMemo(() => props.subjectSingular || 'host');
const subjectPlural = createMemo(() => props.subjectPlural || 'hosts');
@@ -49,14 +45,14 @@ export function PlatformOutdatedAgentNotice(props: PlatformOutdatedAgentNoticePr
if (count() === 1) {
const host = props.hosts[0];
if (copyVariant === 'latest-detail') {
return `${host.name} is running an older Pulse agent (${host.version}). Update it${target} for the latest ${props.missingLabel} on this ${subjectSingular()}.`;
return `${host.name} runs an older Pulse agent (${host.version}). Update it${target} for the latest ${props.missingLabel}.`;
}
return `${host.name} is running an older Pulse agent (${host.version}). Update it${target} to see ${props.missingLabel} for this ${subjectSingular()}.`;
return `${host.name} runs an older Pulse agent (${host.version}), so ${props.missingLabel} for this ${subjectSingular()} may be missing.`;
}
if (copyVariant === 'latest-detail') {
return `${count()} ${subjectPlural()} are running an older Pulse agent. Update them${target} for the latest ${props.missingLabel}. Affected: ${preview()}.`;
return `${count()} ${subjectPlural()} run an older Pulse agent. Update them${target} for the latest ${props.missingLabel}.`;
}
return `${count()} ${subjectPlural()} are running an older Pulse agent. Update them${target} to see ${props.missingLabel}. Affected: ${preview()}.`;
return `${count()} ${subjectPlural()} run an older Pulse agent, so ${props.missingLabel} may be missing.`;
});
return (
@@ -64,25 +60,37 @@ export function PlatformOutdatedAgentNotice(props: PlatformOutdatedAgentNoticePr
<InlineNotice
role="status"
data-testid="platform-outdated-agent-notice"
tone="warning"
icon={<AlertTriangle aria-hidden="true" />}
actionHref={props.actionHref}
actionLabel={actionLabel()}
actionIcon={<ArrowRight aria-hidden="true" />}
tone="info"
icon={<Info aria-hidden="true" />}
>
<span>{message()}</span>{' '}
<Show when={count() > 3}>
<span>{message()}</span>
<Show when={count() > 1}>
{' '}
<button
type="button"
class="ml-1 font-medium underline underline-offset-2 hover:no-underline focus-visible:rounded-xs focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2"
class="font-medium underline underline-offset-2 hover:no-underline focus-visible:rounded-xs focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2"
aria-expanded={showAllHosts()}
onClick={() => setShowAllHosts((current) => !current)}
>
{showAllHosts() ? 'Hide affected names' : `Show all ${count()} ${subjectPlural()}`}
{showAllHosts() ? 'Hide names' : `Which ${subjectPlural()}?`}
</button>
<Show when={showAllHosts()}>
<p class="mt-1 wrap-break-word">Affected: {names()}.</p>
</Show>
</Show>
<Show when={props.actionHref}>
{(href) => (
<>
{' '}
<a
href={href()}
class="inline-flex items-center gap-1 whitespace-nowrap font-semibold underline-offset-2 hover:underline"
>
{actionLabel()}
<ArrowRight aria-hidden="true" class="h-3.5 w-3.5" />
</a>
</>
)}
</Show>
<Show when={count() > 1 && showAllHosts()}>
<p class="mt-1 wrap-break-word">Affected: {names()}.</p>
</Show>
</InlineNotice>
</Show>
@@ -24,6 +24,9 @@ export interface CoverageStripProps {
segments: readonly CoverageStripSegment[];
// Compact context shown to the right of the title (e.g. "1.2 TB on disk").
tail?: JSX.Element;
// The rule behind the segments, so a count such as "17 attention" says what
// it is counting.
footnote?: JSX.Element;
}
export const ProxmoxBackupsCoverageStrip: Component<CoverageStripProps> = (props) => {
@@ -80,6 +83,11 @@ export const ProxmoxBackupsCoverageStrip: Component<CoverageStripProps> = (props
)}
</For>
</ul>
<Show when={props.footnote}>
<p class="mt-1.5 text-[11px] leading-4 text-muted" data-coverage-strip-footnote>
{props.footnote}
</p>
</Show>
</div>
);
};
@@ -58,6 +58,7 @@ import {
type RecoverableFilterValue,
type RecoverableSortKey,
} from './proxmoxBackupsTableModel';
import { getBackupPostureRuleText } from './proxmoxBackupsTablePresentation';
import {
COVERAGE_FILTERS,
RECOVERABLE_FILTERS,
@@ -364,20 +365,8 @@ export const ProxmoxBackupsTable: Component<{
];
});
// Source columns auto-hide when no workload anywhere has that data (computed
// over the full set so columns don't flicker as filters change). A PBS-only
// fleet drops the Archive and Snapshot columns.
const coverageHasPbs = createMemo(() =>
recoveryModel().coverageRows.some((row) => Boolean(row.latestPBS) || row.pbsCount > 0),
);
const coverageHasArchive = createMemo(() =>
recoveryModel().coverageRows.some((row) => Boolean(row.latestArchive) || row.archiveCount > 0),
);
const coverageHasSnapshot = createMemo(() =>
recoveryModel().coverageRows.some(
(row) => Boolean(row.latestSnapshot) || row.snapshotCount > 0,
),
);
// The task column auto-hides when no workload has task history (computed
// over the full set so it does not flicker as filters change).
const coverageHasTask = createMemo(() =>
recoveryModel().coverageRows.some((row) => Boolean(row.latestTask)),
);
@@ -420,12 +409,6 @@ export const ProxmoxBackupsTable: Component<{
return cmpString(a.workload.label, b.workload.label, direction);
case 'latest':
return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction);
case 'pbs':
return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction);
case 'archive':
return cmpNumber(a.latestArchive?.createdMs, b.latestArchive?.createdMs, direction);
case 'snapshot':
return cmpNumber(a.latestSnapshot?.createdMs, b.latestSnapshot?.createdMs, direction);
case 'task':
return cmpNumber(a.latestTask?.startedMs, b.latestTask?.startedMs, direction);
}
@@ -735,6 +718,7 @@ export const ProxmoxBackupsTable: Component<{
{/* The health strip renders identically in both route-backed views. */}
<ProxmoxBackupsCoverageStrip
title="Backup health"
footnote={getBackupPostureRuleText(protectionPostures.policy()) ?? undefined}
tail={
<span>
{liveTotalCount()} targets · {recoveryModel().coverageSummary.recoverableArtifacts}{' '}
@@ -925,9 +909,6 @@ export const ProxmoxBackupsTable: Component<{
onSort={handleCoverageSort}
expandedKeys={expandedCoverageRows()}
onToggleExpand={toggleCoverageExpansion}
showPbsColumn={coverageHasPbs()}
showArchiveColumn={coverageHasArchive()}
showSnapshotColumn={coverageHasSnapshot()}
showTaskColumn={coverageHasTask()}
/>
@@ -964,9 +945,6 @@ export const ProxmoxBackupsTable: Component<{
onSort={handleCoverageSort}
expandedKeys={expandedCoverageRows()}
onToggleExpand={toggleCoverageExpansion}
showPbsColumn={coverageHasPbs()}
showArchiveColumn={coverageHasArchive()}
showSnapshotColumn={coverageHasSnapshot()}
showTaskColumn={coverageHasTask()}
/>
</div>
@@ -27,7 +27,6 @@ import {
type WorkloadCoverageRow,
} from './proxmoxBackupRecoveryModel';
import type { CoverageSortKey } from './proxmoxBackupsTableModel';
import { getProxmoxBackupSourcePresentation } from './proxmoxBackupSourcePresentation';
import {
ArtifactSourceBadge,
ArtifactStateBadge,
@@ -42,6 +41,7 @@ import {
getCoverageColumnWidthStyle,
getCoverageLayoutForContainer,
isCoverageEvidenceColumnVisible,
selectCoverageRestoreEvidence,
type CoverageColumnId,
} from './proxmoxBackupsTablePresentation';
import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing';
@@ -155,12 +155,8 @@ export function ProxmoxCoverageTable(props: {
onSort: (key: CoverageSortKey) => void;
expandedKeys: ReadonlySet<string>;
onToggleExpand: (key: string) => void;
// Source columns auto-hide when no workload anywhere has that data (e.g. a
// PBS-only fleet drops the PVE files and Snapshots columns), matching how the
// source-detail tables already drop their conditional columns.
showPbsColumn: boolean;
showArchiveColumn: boolean;
showSnapshotColumn: boolean;
// The task column auto-hides when no workload has task history. Per-source
// ages (PBS snapshot, PVE file, guest snapshot) live in the row expansion.
showTaskColumn: boolean;
layoutWidth?: Accessor<number | null | undefined>;
}) {
@@ -171,9 +167,6 @@ export function ProxmoxCoverageTable(props: {
});
const visibleColumns = createMemo(() =>
getCoverageColumns(layoutMode(), {
pbs: props.showPbsColumn,
archive: props.showArchiveColumn,
snapshot: props.showSnapshotColumn,
task: props.showTaskColumn,
}),
);
@@ -184,9 +177,6 @@ export function ProxmoxCoverageTable(props: {
// ages were short enough for the usual gutters; compact ages use that space.
const ageCellClass = (kind: 'text' | 'numeric-value') =>
`${layoutMode() === 'compact' ? `px-0.5 py-1 ${kind === 'numeric-value' ? 'text-right' : ''}` : getPlatformTableCellClassForKind(kind)} text-base-content`;
const pbsSource = getProxmoxBackupSourcePresentation('pbs');
const archiveSource = getProxmoxBackupSourcePresentation('archive');
const snapshotSource = getProxmoxBackupSourcePresentation('snapshot');
// Polling rebuilds recovery-model objects. Preserve logical row identity so
// Solid does not replace the focused toggle (or the browser's scroll anchor).
const [stableRows, setStableRows] = createStore<WorkloadCoverageRow[]>([]);
@@ -279,47 +269,6 @@ export function ProxmoxCoverageTable(props: {
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
<Show when={columnVisible('pbs')}>
<SortableHead
label={
layoutMode() === 'compact' || layoutMode() === 'expanded'
? 'PBS'
: 'PBS snapshot'
}
sortKey="pbs"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
</Show>
<Show when={columnVisible('archive')}>
<SortableHead
label={layoutMode() === 'compact' ? 'PVE' : 'PVE file'}
sortKey="archive"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
</Show>
<Show when={columnVisible('snapshot')}>
<SortableHead
label={
layoutMode() === 'compact' || layoutMode() === 'expanded'
? 'Guest'
: 'Guest snapshot'
}
sortKey="snapshot"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
</Show>
<Show when={columnVisible('task')}>
<SortableHead
label={layoutMode() === 'compact' ? 'Job' : 'Task'}
@@ -347,10 +296,7 @@ export function ProxmoxCoverageTable(props: {
<For each={tableWindow.visibleItems()}>
{(row) => {
const isExpanded = () => props.expandedKeys.has(row.key);
const evidence = () =>
[...row.artifacts]
.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0))
.slice(0, 8);
const evidence = () => selectCoverageRestoreEvidence(row.artifacts);
const detailRowId = () => `proxmox-coverage-evidence-${row.key}`;
return (
<>
@@ -457,59 +403,6 @@ export function ProxmoxCoverageTable(props: {
)}
</Show>
</TableCell>
<Show when={columnVisible('pbs')}>
<TableCell class={ageCellClass('text')}>
<Show
when={row.latestPBS}
fallback={
<CoverageAgeFallback
unknown={row.ageUnknown?.pbs}
compact={layoutMode() === 'compact'}
emptyTitle={pbsSource.coverageFallbackLabel}
/>
}
>
{(artifact) => (
<ProxmoxBackupAgeText
artifact={artifact()}
compact={layoutMode() === 'compact'}
/>
)}
</Show>
</TableCell>
</Show>
<Show when={columnVisible('archive')}>
<TableCell class={ageCellClass('text')}>
<Show
when={row.latestArchive}
fallback={
<CoverageAgeFallback
unknown={row.ageUnknown?.archive}
compact={layoutMode() === 'compact'}
emptyTitle={archiveSource.coverageFallbackLabel}
/>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
</Show>
</TableCell>
</Show>
<Show when={columnVisible('snapshot')}>
<TableCell class={ageCellClass('text')}>
<Show
when={row.latestSnapshot}
fallback={
<CoverageAgeFallback
unknown={row.ageUnknown?.snapshot}
compact={layoutMode() === 'compact'}
emptyTitle={snapshotSource.coverageFallbackLabel}
/>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
</Show>
</TableCell>
</Show>
<Show when={columnVisible('task')}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
@@ -223,7 +223,17 @@ export const ProxmoxNodesTable: Component<{
? getProxmoxHostTableLayoutModeForContainer(measuredWidth)
: getWorkloadTableLayoutMode(breakpoint.width());
});
const visibleColumns = createMemo(() => getProxmoxHostVisibleColumnsForLayout(layoutMode()));
const singleClusterName = createMemo(() => {
const labels = new Set(props.nodes.map((node) => getResourceClusterLabel(node)));
if (labels.size !== 1) return null;
const [label] = [...labels];
return label && label !== '—' && props.topology?.standalone === 0 ? label : null;
});
const visibleColumns = createMemo(() =>
getProxmoxHostVisibleColumnsForLayout(layoutMode(), {
singleCluster: singleClusterName() !== null,
}),
);
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
const displayMode = () => props.metricDisplayMode?.() ?? 'bars';
const isSparklineMode = () => displayMode() === 'sparklines';
@@ -295,25 +305,41 @@ export const ProxmoxNodesTable: Component<{
actions={
<span class="inline-flex items-center gap-2">
<Show
when={
inventoryCountsVisible() &&
props.topology &&
!props.search?.()?.trim() &&
(props.topology.clusters > 0 || props.topology.standalone > 0)
when={singleClusterName()}
fallback={
<Show
when={
inventoryCountsVisible() &&
props.topology &&
!props.search?.()?.trim() &&
(props.topology.clusters > 0 || props.topology.standalone > 0)
}
>
<span class="text-[10px] font-medium text-muted">
<Show when={props.topology!.clusters > 0}>
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
</Show>
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
{' / '}
</Show>
<Show when={props.topology!.standalone > 0}>
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
</Show>
</span>
</Show>
}
>
<span class="text-[10px] font-medium text-muted">
<Show when={props.topology!.clusters > 0}>
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
</Show>
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
{' / '}
</Show>
<Show when={props.topology!.standalone > 0}>
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
</Show>
</span>
{/* The hidden Cluster column's value lives here, so it stays
visible while searching and when inventory counts are off. */}
{(name) => (
<span
class="text-[10px] font-medium text-muted"
data-testid="proxmox-single-cluster-label"
>
Cluster {name()}
</span>
)}
</Show>
</span>
}
@@ -66,9 +66,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={false}
showSnapshotColumn={true}
showTaskColumn={false}
layoutWidth={() => 1_200}
/>
@@ -81,8 +78,9 @@ describe('ProxmoxCoverageTable column visibility', () => {
expect(headers).toContain('Node');
expect(headers).toContain('Posture▲');
expect(headers).toContain('Last backup');
expect(headers).toContain('PBS snapshot');
expect(headers).toContain('Guest snapshot');
// Per-source ages are expansion detail at every width.
expect(headers).not.toContain('PBS snapshot');
expect(headers).not.toContain('Guest snapshot');
expect(headers).not.toContain('PVE file');
expect(headers).not.toContain('Task');
// Identity data lives in dedicated cells, not stacked under the name.
@@ -93,7 +91,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
expect(document.body.textContent).not.toContain('Node pve1');
});
it('keeps posture and provider evidence visible in compact rows', () => {
it('keeps posture, backup age and job visible in compact rows', () => {
render(() => (
<ProxmoxCoverageTable
rows={[row]}
@@ -106,15 +104,12 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={true}
showSnapshotColumn={true}
showTaskColumn={true}
layoutWidth={() => 330}
/>
));
expect(headerTexts()).toEqual(['Workload', 'Posture▲', 'Age', 'PBS', 'Job']);
expect(headerTexts()).toEqual(['Workload', 'Posture▲', 'Age', 'Job']);
expect(document.body.textContent).toContain('Prot.');
expect(document.body.textContent).not.toContain('VM 100 · pve1');
});
@@ -132,9 +127,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={false}
showSnapshotColumn={false}
showTaskColumn={false}
layoutWidth={() => 1_200}
/>
@@ -158,9 +150,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>(['w1'])}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={false}
showSnapshotColumn={false}
showTaskColumn={false}
layoutWidth={() => 1_200}
/>
@@ -197,9 +186,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={false}
showSnapshotColumn={false}
showTaskColumn={false}
layoutWidth={() => 1_200}
/>
@@ -227,24 +213,15 @@ describe('ProxmoxCoverageTable column visibility', () => {
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showPbsColumn={true}
showArchiveColumn={true}
showSnapshotColumn={true}
showTaskColumn={true}
layoutWidth={() => 1_200}
/>
));
// The header already names the source, so the cell does not repeat it and
// cannot truncate to "No PVE backup f…".
// The cell says None and the full reason sits on hover, so it cannot
// truncate to "No PBS snapshot or PVE b…".
const empty = (title: string) => document.querySelector(`td span[title="${title}"]`);
for (const title of [
'No PBS snapshot or PVE backup file',
'No PBS snapshot',
'No PVE backup file',
'No guest snapshot',
'No recent task',
]) {
for (const title of ['No PBS snapshot or PVE backup file', 'No recent task']) {
expect(empty(title)).toHaveTextContent(/^None$/);
}
});
@@ -194,6 +194,24 @@ describe('ProxmoxNodesTable', () => {
expect(screen.getByText('2 more below')).toBeVisible();
});
it('names a single cluster once instead of repeating it in a column, even while searching', () => {
render(() => (
<ProxmoxNodesTable
nodes={makeNodeResources(3)}
guests={[]}
topology={{ nodes: 3, clusters: 1, standalone: 0 }}
search={() => 'pve-node-2'}
inventoryCountsVisible={() => false}
emptyIcon={<span />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="No nodes"
/>
));
expect(screen.getByTestId('proxmox-single-cluster-label')).toHaveTextContent('Cluster homelab');
expect(screen.queryByRole('columnheader', { name: /cluster/i })).not.toBeInTheDocument();
});
it('hides node and topology totals through the page-owned inventory preference', () => {
render(() => (
<ProxmoxNodesTable
@@ -246,7 +246,7 @@ describe('ProxmoxPageSurface contract', () => {
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview');
expect(screen.getByTestId('nodes-table')).toHaveAttribute('data-rows', '1');
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('delly is running an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent('delly runs an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent(
'latest agent-contributed Proxmox node detail and command support',
);
@@ -4,6 +4,7 @@ import {
getBackupServerColumns,
getBackupServerColumnWidthStyle,
getBackupServerLayoutForContainer,
getBackupPostureRuleText,
getCoverageColumns,
getCoverageColumnWidthStyle,
getCoverageLayoutForContainer,
@@ -11,6 +12,7 @@ import {
getRecoverableColumnWidthStyle,
getRecoverableLayoutForContainer,
isCoverageEvidenceColumnVisible,
selectCoverageRestoreEvidence,
} from '../proxmoxBackupsTablePresentation';
const ids = <T extends string>(columns: readonly { id: T }[]) => columns.map((column) => column.id);
@@ -69,24 +71,23 @@ describe('Proxmox backups responsive table presentation', () => {
});
it('prioritizes workload posture, restore freshness, and task failures in coverage', () => {
const everySource = { pbs: true, archive: true, snapshot: true, task: true };
expect(ids(getCoverageColumns('compact', everySource))).toEqual([
const withTasks = { task: true };
expect(ids(getCoverageColumns('compact', withTasks))).toEqual([
'workload',
'posture',
'latest',
'pbs',
'task',
]);
expect(
getCoverageColumnWidthStyle(
'workload',
'compact',
ids(getCoverageColumns('compact', everySource)),
ids(getCoverageColumns('compact', withTasks)),
),
).toEqual({
width: '40%',
});
expect(ids(getCoverageColumns('operational', everySource))).toEqual([
expect(ids(getCoverageColumns('operational', withTasks))).toEqual([
'workload',
'type',
'node',
@@ -94,31 +95,98 @@ describe('Proxmox backups responsive table presentation', () => {
'latest',
'task',
]);
expect(ids(getCoverageColumns('expanded', everySource))).toEqual([
// Per-source ages (PBS snapshot, PVE file, guest snapshot) are expansion
// detail at every width; the widest layout only adds the target id.
expect(ids(getCoverageColumns('expanded', withTasks))).toEqual(
ids(getCoverageColumns('operational', withTasks)),
);
expect(ids(getCoverageColumns('full', withTasks))).toEqual([
'workload',
'type',
'targetId',
'node',
'posture',
'latest',
'pbs',
'archive',
'snapshot',
'task',
]);
expect(ids(getCoverageColumns('full', everySource))).toHaveLength(10);
});
it('does not reserve empty optional coverage columns', () => {
expect(
ids(
getCoverageColumns('full', {
pbs: true,
archive: false,
snapshot: false,
task: true,
}),
),
).toEqual(['workload', 'type', 'targetId', 'node', 'posture', 'latest', 'pbs', 'task']);
it('does not reserve an empty task column', () => {
expect(ids(getCoverageColumns('full', { task: false }))).toEqual([
'workload',
'type',
'targetId',
'node',
'posture',
'latest',
]);
});
it('states the backup health rule from the server posture policy', () => {
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 7 * 86_400 })).toBe(
'Attention: the newest backup is older than 7 days, a newer backup job failed, expected verification is missing or overdue, or Pulse can see only part of the backup history. Unprotected: Pulse sees the full history and no backup it can count. Guest snapshots alone do not count. Unknown: Pulse cannot read the backup history or has no backup source linked to the guest.',
);
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 86_400 })).toContain(
'older than 1 day,',
);
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 3_600 })).toContain(
'older than 1 hour,',
);
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 36 * 3_600 })).toContain(
'older than 36 hours,',
);
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 0 })).toBeNull();
expect(getBackupPostureRuleText(null)).toBeNull();
});
it('keeps the newest restore point of every source in the coverage expansion', () => {
const snapshots = Array.from({ length: 10 }, (_, index) => ({
id: `snap-${index}`,
sourceKind: 'snapshot',
createdMs: 1_000 + index,
}));
const artifacts = [
{ id: 'pbs-old', sourceKind: 'pbs', createdMs: 10 },
{ id: 'pbs-new', sourceKind: 'pbs', createdMs: 500 },
{ id: 'archive', sourceKind: 'archive', createdMs: 400 },
...snapshots,
];
const evidence = selectCoverageRestoreEvidence(artifacts);
expect(evidence).toHaveLength(8);
expect(evidence.map((artifact) => artifact.id)).toEqual([
'snap-9',
'snap-8',
'snap-7',
'snap-6',
'snap-5',
'snap-4',
'pbs-new',
'archive',
]);
expect(selectCoverageRestoreEvidence(artifacts.slice(0, 3))).toHaveLength(3);
});
it('keeps the newest completed backup when a newer job is running or failed', () => {
const snapshots = Array.from({ length: 10 }, (_, index) => ({
id: `snap-${index}`,
sourceKind: 'snapshot',
createdMs: 1_000 + index,
}));
const artifacts = [
{ id: 'pbs-done', sourceKind: 'pbs', createdMs: 300 },
{ id: 'pbs-failed', sourceKind: 'pbs', createdMs: 600, failed: true },
{ id: 'pbs-running', sourceKind: 'pbs', createdMs: 700, running: true },
...snapshots,
];
const ids = selectCoverageRestoreEvidence(artifacts).map((artifact) => artifact.id);
expect(ids).toHaveLength(8);
expect(ids).toContain('pbs-running');
expect(ids).toContain('pbs-done');
expect(ids).not.toContain('pbs-failed');
});
it('keeps the recovery-point answer visible before verbose metadata', () => {
@@ -19,6 +19,16 @@ describe('proxmoxHostTableModel', () => {
expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' });
});
it('drops the cluster column when every node shares one cluster', () => {
const wide = getProxmoxHostVisibleColumnsForLayout('wide').map((column) => column.id);
const single = getProxmoxHostVisibleColumnsForLayout('wide', { singleCluster: true }).map(
(column) => column.id,
);
expect(wide).toContain('cluster');
expect(single).not.toContain('cluster');
expect(single).toEqual(wide.filter((id) => id !== 'cluster'));
});
it('matches the guest table columns and keeps node names whole at phone width', () => {
const columns = getProxmoxHostVisibleColumnsForLayout('phone');
const ids = columns.map((column) => column.id);
@@ -153,15 +153,11 @@ export function cmpBool(a: boolean, b: boolean, direction: 'asc' | 'desc'): numb
// sysadmin-default question on a backup page. String columns default to
// `asc` (A→Z). Boolean columns default to `desc` so "true" sorts first.
export type CoverageSortKey =
'posture' | 'workload' | 'latest' | 'pbs' | 'archive' | 'snapshot' | 'task';
export type CoverageSortKey = 'posture' | 'workload' | 'latest' | 'task';
export const COVERAGE_SORT_DEFAULT_DIRECTION: Record<CoverageSortKey, 'asc' | 'desc'> = {
posture: 'asc',
workload: 'asc',
latest: 'desc',
pbs: 'desc',
archive: 'desc',
snapshot: 'desc',
task: 'desc',
};
@@ -23,16 +23,7 @@ export type BackupServerColumnId =
| 'dedup';
export type CoverageColumnId =
| 'workload'
| 'type'
| 'targetId'
| 'node'
| 'posture'
| 'latest'
| 'pbs'
| 'archive'
| 'snapshot'
| 'task';
'workload' | 'type' | 'targetId' | 'node' | 'posture' | 'latest' | 'task';
export type RecoverableColumnId =
| 'workload'
@@ -182,66 +173,48 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
{ id: 'node', label: 'Node', kind: 'text' },
{ id: 'posture', label: 'Posture', kind: 'text' },
{ id: 'latest', label: 'Last backup', kind: 'numeric-value' },
{ id: 'pbs', label: 'PBS snapshot', kind: 'text' },
{ id: 'archive', label: 'PVE file', kind: 'text' },
{ id: 'snapshot', label: 'Guest snapshot', kind: 'text' },
{ id: 'task', label: 'Task', kind: 'text' },
];
const COVERAGE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly CoverageColumnId[]> = {
// On narrow surfaces answer: which workload, what posture, how recent is the
// newest independent backup, and did the latest task succeed? Provider-by-provider
// evidence is progressive detail in the expansion row; target identity folds
// beneath the name so the scan stays legible at 320px.
compact: ['workload', 'posture', 'latest', 'pbs', 'task'],
// Every layout answers: which workload, what posture, how recent is the
// newest independent backup, and did the latest task succeed? The
// provider-by-provider ages (PBS snapshot, PVE file, guest snapshot) are
// progressive detail in the expansion row at every width: side by side they
// made a stale backup look fresh beside a recent guest snapshot, which does
// not count as a backup. Target identity folds beneath the name on phones.
compact: ['workload', 'posture', 'latest', 'task'],
basic: ['workload', 'node', 'posture', 'latest', 'task'],
operational: ['workload', 'type', 'node', 'posture', 'latest', 'task'],
expanded: ['workload', 'type', 'node', 'posture', 'latest', 'pbs', 'archive', 'snapshot', 'task'],
full: COVERAGE_COLUMNS.map((column) => column.id),
expanded: ['workload', 'type', 'node', 'posture', 'latest', 'task'],
full: ['workload', 'type', 'targetId', 'node', 'posture', 'latest', 'task'],
};
const COVERAGE_WEIGHTS: Record<
ProxmoxBackupsTableLayoutMode,
Partial<Record<CoverageColumnId, number>>
> = {
// Posture keeps its whole compact word ("Unknown"), the two age cells hold
// the suffix-free compact age, and the job dot takes what a "Job" header
// needs.
compact: { workload: 40, posture: 20, latest: 15, pbs: 15, task: 10 },
// Posture keeps its whole compact word ("Unknown"), the age cell holds the
// suffix-free compact age, and the job dot takes what a "Job" header needs.
compact: { workload: 46, posture: 22, latest: 18, task: 14 },
// Above the phone projection the identity columns (workload, node) take the
// slack that the short type badge, target id, and age cells cannot use, so
// names stay whole instead of truncating beside empty space.
basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 },
operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
expanded: {
workload: 19,
type: 6.5,
node: 13.5,
posture: 13.5,
latest: 9.5,
pbs: 9.5,
archive: 10,
snapshot: 9.5,
task: 9,
},
expanded: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
full: {
workload: 17,
type: 5.5,
targetId: 8,
node: 11.5,
posture: 11,
latest: 8.5,
pbs: 10.5,
archive: 8.5,
snapshot: 12,
task: 7.5,
workload: 24,
type: 7,
targetId: 9,
node: 16,
posture: 16,
latest: 13,
task: 15,
},
};
export interface CoverageSourceVisibility {
pbs: boolean;
archive: boolean;
snapshot: boolean;
task: boolean;
}
@@ -252,9 +225,6 @@ export const getCoverageColumns = (
const layoutColumns = new Set(COVERAGE_VISIBLE[layout]);
return COVERAGE_COLUMNS.filter((column) => {
if (!layoutColumns.has(column.id)) return false;
if (column.id === 'pbs') return sourceVisibility.pbs;
if (column.id === 'archive') return sourceVisibility.archive;
if (column.id === 'snapshot') return sourceVisibility.snapshot;
if (column.id === 'task') return sourceVisibility.task;
return true;
});
@@ -385,3 +355,67 @@ export const isCoverageEvidenceColumnVisible = (
return layout === 'operational' || layout === 'expanded' || layout === 'full';
return layout === 'expanded' || layout === 'full';
};
// What the backup health counts mean, stated from the server's own posture
// policy so "17 attention" is never a number without a rule. The causes mirror
// internal/recovery/posture.go. Null until the policy has loaded; the strip
// then shows no rule rather than a guessed one.
export const getBackupPostureRuleText = (
policy: { freshnessWindowSeconds: number } | null | undefined,
): string | null => {
const seconds = policy?.freshnessWindowSeconds ?? 0;
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return `Attention: the newest backup is older than ${formatPostureWindow(seconds)}, a newer backup job failed, expected verification is missing or overdue, or Pulse can see only part of the backup history. Unprotected: Pulse sees the full history and no backup it can count. Guest snapshots alone do not count. Unknown: Pulse cannot read the backup history or has no backup source linked to the guest.`;
};
const formatPostureWindow = (seconds: number): string => {
if (seconds >= 86_400 && seconds % 86_400 === 0) {
const days = seconds / 86_400;
return days === 1 ? '1 day' : `${days} days`;
}
const hours = Math.max(1, Math.round(seconds / 3_600));
return hours === 1 ? '1 hour' : `${hours} hours`;
};
const COVERAGE_RESTORE_EVIDENCE_LIMIT = 8;
// Newest restore points for a coverage row's expansion. The newest point and
// the newest completed point of every source always make the cut, so neither a
// burst of recent guest snapshots nor a running or failed job can hide the
// latest usable PBS or PVE backup now that the table carries no per-source
// age columns.
export const selectCoverageRestoreEvidence = <
T extends {
id: string;
sourceKind: string;
createdMs?: number;
running?: boolean;
failed?: boolean;
},
>(
artifacts: readonly T[],
limit = COVERAGE_RESTORE_EVIDENCE_LIMIT,
): T[] => {
const newestFirst = [...artifacts].sort(
(left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0),
);
const picked = new Set<string>();
const newestSeen = new Set<string>();
const completedSeen = new Set<string>();
for (const artifact of newestFirst) {
if (!newestSeen.has(artifact.sourceKind)) {
newestSeen.add(artifact.sourceKind);
picked.add(artifact.id);
}
if (!artifact.running && !artifact.failed && !completedSeen.has(artifact.sourceKind)) {
completedSeen.add(artifact.sourceKind);
picked.add(artifact.id);
}
}
const cap = Math.max(limit, picked.size);
for (const artifact of newestFirst) {
if (picked.size >= cap) break;
picked.add(artifact.id);
}
return newestFirst.filter((artifact) => picked.has(artifact.id));
};
@@ -141,10 +141,15 @@ export const PROXMOX_HOST_TABLE_COLUMNS: ProxmoxHostTableColumn[] = [
export const getProxmoxHostVisibleColumnsForLayout = (
layoutMode: WorkloadTableLayoutMode,
options: { singleCluster?: boolean } = {},
): ProxmoxHostTableColumn[] => {
const layoutRank = HOST_TABLE_LAYOUT_ORDER[layoutMode];
return PROXMOX_HOST_TABLE_COLUMNS.filter(
(column) => HOST_TABLE_LAYOUT_ORDER[HOST_COLUMN_MIN_LAYOUT[column.id]] <= layoutRank,
(column) =>
HOST_TABLE_LAYOUT_ORDER[HOST_COLUMN_MIN_LAYOUT[column.id]] <= layoutRank &&
// When every node shares one cluster the column repeats one value on
// every row; the table header names that cluster instead.
!(options.singleCluster && column.id === 'cluster'),
);
};
@@ -252,7 +252,7 @@ describe('StandalonePageSurface', () => {
render(() => <StandalonePageSurface />);
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('tower is running an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent('tower runs an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent(
'latest agent command support and agent-managed platform detail',
);
@@ -179,7 +179,7 @@ describe('TrueNASPageSurface contract', () => {
);
expect(screen.getByTestId('systems-table')).toHaveAttribute('data-rows', '1');
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('truenas-scale is running an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent('truenas-scale runs an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent(
'latest agent-contributed TrueNAS system detail and command support',
);
@@ -178,8 +178,8 @@ describe('VmwarePageSurface contract', () => {
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview');
expect(screen.getByTestId('hosts-table')).toHaveAttribute('data-rows', '1');
const notice = screen.getByTestId('platform-outdated-agent-notice');
expect(notice).toHaveTextContent('app-01 is running an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent('latest in-guest telemetry and command support on this VM');
expect(notice).toHaveTextContent('app-01 runs an older Pulse agent (v5.1.34).');
expect(notice).toHaveTextContent('latest in-guest telemetry and command support.');
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-app-01',
@@ -1288,6 +1288,46 @@ describe('useWorkloads', () => {
dispose();
});
it('flags a workload whose canonical health names an open alert, not backup age', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
data: [
{
...sampleResource,
id: 'alerting',
name: 'alerting',
health: { verdict: 'attention', reasons: [{ code: 'warning_alert', detail: 'memory' }] },
},
{
...sampleResource,
id: 'stale-backup',
name: 'stale-backup',
vmid: 102,
health: { verdict: 'attention', reasons: [{ code: 'backup_stale' }] },
},
{ ...sampleResource, id: 'quiet', name: 'quiet', vmid: 103 },
],
meta: { totalPages: 1 },
});
let dispose = () => {};
let result: ReturnType<UseWorkloadsModule['useWorkloads']> | undefined;
createRoot((d) => {
dispose = d;
const [enabled] = createSignal(true);
result = useWorkloads(enabled);
});
await flushAsync();
await waitForWorkloadCount(() => result!.workloads().length, 3);
const byName = new Map(result!.workloads().map((workload) => [workload.name, workload]));
expect(byName.get('alerting')?.hasOpenAlert).toBe(true);
expect(byName.get('stale-backup')?.hasOpenAlert).toBe(false);
expect(byName.get('quiet')?.hasOpenAlert).toBe(false);
dispose();
});
it('uses the canonical Kubernetes cluster name for pod context labels', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
data: [
@@ -73,6 +73,7 @@ type APIResource = {
type?: string;
name?: string;
status?: string;
health?: { verdict?: string; reasons?: Array<{ code?: string; detail?: string }> };
uptime?: number;
lastSeen?: string;
sources?: string[];
@@ -558,6 +559,9 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
(platformType === 'vmware-vsphere' ? resource.vmware?.powerState : null),
),
resourceStatus: (resource.status || '').trim().toLowerCase() || undefined,
hasOpenAlert: (resource.health?.reasons ?? []).some(
(reason) => reason.code === 'warning_alert' || reason.code === 'critical_alert',
),
type:
workloadType === 'vm'
? 'vm'
@@ -719,6 +723,7 @@ const mapCanonicalResourceToWorkload = (resource: Resource): WorkloadGuest | nul
type: resource.type,
name: resource.name,
status: resource.status,
health: resource.health,
uptime: resource.uptime,
lastSeen: resource.lastSeen ? new Date(resource.lastSeen).toISOString() : undefined,
sources: resource.sources,
+2
View File
@@ -30,6 +30,8 @@ export type WorkloadGuest = (VM | Container) & {
metricsTarget?: ResourceMetricsTarget;
/** Canonical unified-resource health, kept separate from runtime power state in `status`. */
resourceStatus?: string;
/** The unified-resource health names an open warning or critical alert. */
hasOpenAlert?: boolean;
displayId?: string;
image?: string;
// Provider/runtime-native identifier for app-container actions such as Docker image updates.
+20 -12
View File
@@ -19,18 +19,20 @@ func (m *Manager) evaluateFilterCondition(guest any, condition FilterCondition)
// guestMetrics holds common metrics for filter evaluation
type guestMetrics struct {
CPU float64 // CPU usage as percentage (0-100)
MemUsage float64 // Memory usage percentage
DiskUsage float64 // Disk usage percentage
DiskRead int64 // Bytes/s
DiskWrite int64 // Bytes/s
NetworkIn int64 // Bytes/s
NetworkOut int64 // Bytes/s
IORateValidity models.IORateValidity
Name string
Node string
ID string
Status string
CPU float64 // CPU usage as percentage (0-100)
MemUsage float64 // Memory usage percentage
DiskUsage float64 // Disk usage percentage
MemoryUnavailable bool
DiskUnavailable bool
DiskRead int64 // Bytes/s
DiskWrite int64 // Bytes/s
NetworkIn int64 // Bytes/s
NetworkOut int64 // Bytes/s
IORateValidity models.IORateValidity
Name string
Node string
ID string
Status string
}
// extractGuestMetrics extracts common metrics from a VM or Container
@@ -57,8 +59,14 @@ func evaluateGuestCondition(metrics guestMetrics, condition FilterCondition) boo
case "cpu":
value = metrics.CPU
case "memory":
if metrics.MemoryUnavailable {
return false
}
value = metrics.MemUsage
case "disk":
if metrics.DiskUnavailable {
return false
}
value = metrics.DiskUsage
case "diskread":
if !validity.DiskRead {
+25 -6
View File
@@ -259,8 +259,11 @@ func (m *Manager) CheckGuest(guest any, instanceName string) {
memoryMetric = &UnifiedResourceMetric{Percent: memUsage}
}
diskReadMetric, diskWriteMetric, networkInMetric, networkOutMetric := guestIORateMetrics(snapshot)
diskMetric := &UnifiedResourceMetric{Percent: diskUsage}
if len(disks) > 0 {
var diskMetric *UnifiedResourceMetric
if !snapshot.DiskUnavailable {
diskMetric = &UnifiedResourceMetric{Percent: diskUsage}
}
if !snapshot.DiskUnavailable && len(disks) > 0 {
seenAggregateDiskKeys := make(map[string]struct{})
for idx, disk := range disks {
if disk.Total <= 0 || disk.Usage < 0 {
@@ -295,6 +298,12 @@ func (m *Manager) CheckGuest(guest any, instanceName string) {
NetworkOut: networkOutMetric,
}, thresholds, evalOpts)
// Retained/failed filesystem evidence is useful to display, but neither
// its values nor an empty result can establish a breach, recovery or disk
// removal. CPU/I/O and independent current memory were evaluated above.
// Still apply explicit per-filesystem disablement to retained identities;
// this is operator policy, not recovery inferred from an old value.
if len(disks) > 0 {
seenDiskKeys := make(map[string]struct{})
seenDiskResources := make(map[string]struct{})
@@ -331,6 +340,12 @@ func (m *Manager) CheckGuest(guest any, instanceName string) {
effectiveDiskThreshold = ensureHysteresisThreshold(diskOverride.Disk)
}
}
if snapshot.DiskUnavailable {
if effectiveDiskThreshold == nil || effectiveDiskThreshold.Trigger <= 0 {
m.clearAlert(canonicalMetricStateID(perDiskResourceID, "disk"))
}
continue
}
log.Debug().
Str("guest", name).
@@ -376,11 +391,15 @@ func (m *Manager) CheckGuest(guest any, instanceName string) {
MonitorOnly: monitorOnly,
})
}
if cleared := m.cleanupGuestDiskAlerts(guestID, seenDiskResources); cleared > 0 {
m.saveActiveAlertsAsync("guest-disk-set-changed")
if !snapshot.DiskUnavailable {
if cleared := m.cleanupGuestDiskAlerts(guestID, seenDiskResources); cleared > 0 {
m.saveActiveAlertsAsync("guest-disk-set-changed")
}
}
} else if !snapshot.DiskUnavailable {
if cleared := m.cleanupGuestDiskAlerts(guestID, nil); cleared > 0 {
m.saveActiveAlertsAsync("guest-disk-alerts-cleared")
}
} else if cleared := m.cleanupGuestDiskAlerts(guestID, nil); cleared > 0 {
m.saveActiveAlertsAsync("guest-disk-alerts-cleared")
}
}
+220
View File
@@ -0,0 +1,220 @@
package alerts
import (
"bytes"
"encoding/json"
"sort"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts/eventlog"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func guestObservationManager(t *testing.T) *Manager {
t.Helper()
m := newEventLogManager(t)
cfg := characterizationBaseConfig()
cfg.GuestDefaults.DiskWrite = &HysteresisThreshold{Trigger: 5, Clear: 4}
m.UpdateConfig(cfg)
disableTestTimeThresholds(m)
return m
}
func guestObservationVM() models.VM {
return models.VM{
ID: "site:node:105", VMID: 105, Name: "database", Node: "node", Instance: "site", Type: "qemu", Status: "running",
Memory: models.Memory{Total: 1000, Used: 960, Free: 40, Usage: 96,
Observation: models.MemoryObservation{State: "current", Source: "guest-agent-meminfo", ObservedAt: time.Now().Add(-time.Second)}},
Disk: models.Disk{Total: 1000, Used: 980, Free: 20, Usage: 98},
Disks: []models.Disk{{Total: 1000, Used: 980, Free: 20, Usage: 98, Mountpoint: "/", Device: "/dev/vda"},
{Total: 1000, Used: 980, Free: 20, Usage: 98, Mountpoint: "/data", Device: "/dev/vdb"}},
IORateValidity: models.IORateValidity{Explicit: true},
}
}
func guestObservationBytes(t *testing.T, m *Manager) []byte {
t.Helper()
active := m.GetActiveAlerts()
sort.Slice(active, func(i, j int) bool { return active[i].ID < active[j].ID })
data, err := json.Marshal(active)
if err != nil {
t.Fatal(err)
}
return data
}
func TestGuestAlertRetainedObservationsCannotFire(t *testing.T) {
for _, state := range []string{"last-known", "unavailable", "unknown-version"} {
for _, kind := range []string{"vm", "vm-pointer", "lxc", "lxc-pointer"} {
t.Run(kind+"/"+state, func(t *testing.T) {
m := guestObservationManager(t)
vm := guestObservationVM()
vm.Memory.Observation.State = state
vm.DiskStatusReason, vm.GuestAgentStatus = "prev-vm-locked", "deferred"
var guest any = vm
switch kind {
case "vm-pointer":
guest = &vm
case "lxc", "lxc-pointer":
ct := models.Container{ID: vm.ID, VMID: vm.VMID, Name: vm.Name, Node: vm.Node, Instance: vm.Instance, Status: vm.Status, Memory: vm.Memory,
Disk: models.Disk{Usage: 10}, IORateValidity: vm.IORateValidity}
guest = ct
if kind == "lxc-pointer" {
guest = &ct
}
}
for poll := 0; poll < 4; poll++ {
m.CheckGuest(guest, "site")
}
if got := m.GetActiveAlerts(); len(got) != 0 {
t.Fatalf("retained observations opened alerts: %+v", got)
}
if len(queryAlertEvents(t, m, eventlog.Filter{})) != 0 {
t.Fatal("retained observations emitted lifecycle/delivery evidence")
}
})
}
}
}
func TestGuestAlertUnavailableDiskInventoryIsNotRecovery(t *testing.T) {
for _, reason := range []string{"prev-vm-locked", "prev-lock-unverified", "agent-timeout", "agent-not-running", "permission-denied", "agent-error", "no-filesystems"} {
t.Run(reason, func(t *testing.T) {
m := guestObservationManager(t)
vm := guestObservationVM()
m.CheckGuest(vm, "site")
active := m.GetActiveAlerts()
if len(active) != 3 {
t.Fatalf("current memory and two filesystems must fire: %+v", active)
}
for _, a := range active {
if err := m.AcknowledgeAlert(a.ID, "operator"); err != nil {
t.Fatal(err)
}
}
before := guestObservationBytes(t, m)
events := len(queryAlertEvents(t, m, eventlog.Filter{}))
vm.Memory.Usage, vm.Memory.Used, vm.Memory.Free = 10, 100, 900
vm.Memory.Observation.State = "last-known"
vm.Disk.Usage, vm.DiskStatusReason = 10, reason
for i := range vm.Disks {
vm.Disks[i].Usage = 10
}
m.CheckGuest(vm, "site")
vm.Disks = nil // Failed/expired retained inventory is not removal.
vm.Disk.Usage = -1
for poll := 0; poll < 4; poll++ {
m.CheckGuest(vm, "site")
}
if after := guestObservationBytes(t, m); !bytes.Equal(before, after) {
t.Fatalf("unknown observation changed incident values/identity/ack/age: before=%s after=%s", before, after)
}
if len(queryAlertEvents(t, m, eventlog.Filter{})) != events {
t.Fatal("unknown observation emitted lifecycle/delivery evidence")
}
vm.Memory.Observation.State = "current"
vm.Disk.Usage, vm.DiskStatusReason = 10, ""
vm.Disks = []models.Disk{{Total: 1000, Used: 100, Free: 900, Usage: 10, Mountpoint: "/", Device: "/dev/vda"}}
m.CheckGuest(vm, "site") // Fresh recovery plus authoritative removal of /data.
m.CheckGuest(vm, "site")
if len(m.GetActiveAlerts()) != 0 || len(queryAlertEvents(t, m, eventlog.Filter{Types: []string{eventlog.TypeResolved}})) != 3 {
t.Fatal("fresh recovery/removal must resolve each original incident exactly once")
}
})
}
}
func TestGuestAlertRetainedAggregateAndPendingCannotAdvance(t *testing.T) {
m := guestObservationManager(t)
vm := guestObservationVM()
vm.Disks = nil
vm.Memory.Usage = 90
m.mu.Lock()
m.config.MetricTimeThresholds = map[string]map[string]int{"guest": {"memory": 60, "disk": 60}}
m.mu.Unlock()
m.CheckGuest(vm, "site")
if len(m.GetActiveAlerts()) != 0 {
t.Fatal("fresh warning observations should begin their delay, not fire")
}
m.mu.Lock()
m.core.ShiftPending(-2 * time.Minute)
m.mu.Unlock()
vm.Memory.Observation.State, vm.DiskStatusReason = "last-known", "prev-agent-cooldown"
for poll := 0; poll < 4; poll++ {
m.CheckGuest(vm, "site")
}
if len(m.GetActiveAlerts()) != 0 {
t.Fatal("retained memory/disk promoted a delayed observation to an incident")
}
vm.Memory.Observation.State, vm.DiskStatusReason = "current", ""
m.CheckGuest(vm, "site")
if len(m.GetActiveAlerts()) != 2 {
t.Fatal("fresh evidence must resume the existing delay policy")
}
}
func TestGuestAlertFreshIndependentMetricsAndDisablement(t *testing.T) {
for _, state := range []string{"", "current"} {
t.Run("legacy-or-current/"+state, func(t *testing.T) {
m := guestObservationManager(t)
vm := guestObservationVM()
vm.Memory.Observation.State = state
// Guest-agent state alone cannot suppress independent current data.
vm.GuestAgentStatus, vm.Lock = "deferred", "backup"
vm.Memory.Observation.Source = "agent"
m.CheckGuest(vm, "site")
if len(m.GetActiveAlerts()) != 3 {
t.Fatal("legacy/current independent memory and filesystems were suppressed")
}
vm.Memory.Observation.State, vm.DiskStatusReason = "last-known", "prev-vm-locked"
cfg := m.GetConfig()
cfg.GuestDefaults.Memory = &HysteresisThreshold{Trigger: -1}
cfg.GuestDefaults.Disk = &HysteresisThreshold{Trigger: -1}
m.UpdateConfig(cfg)
m.CheckGuest(vm, "site")
if len(m.GetActiveAlerts()) != 0 {
t.Fatal("explicit rule disablement must remain independent of telemetry")
}
})
}
m := guestObservationManager(t)
vm := guestObservationVM()
vm.Memory.Observation.State, vm.DiskStatusReason = "last-known", "prev-vm-locked"
vm.CPU, vm.DiskWrite = .9, 10*1024*1024
vm.IORateValidity.DiskWrite = true
m.CheckGuest(vm, "site")
active := m.GetActiveAlerts()
if len(active) != 2 {
t.Fatalf("current CPU/I/O should still fire, retained memory/disk should not: %+v", active)
}
for _, a := range active {
if a.Type != "cpu" && a.Type != "diskWrite" {
t.Fatalf("unexpected retained metric alert: %+v", a)
}
}
}
func TestGuestFiltersDoNotUseRetainedObservations(t *testing.T) {
m := guestObservationManager(t)
vm := guestObservationVM()
for _, field := range []string{"memory", "disk"} {
for _, op := range []string{">", "<", "=="} {
condition := FilterCondition{Type: "metric", Field: field, Operator: op, Value: 96.0}
vm.Memory.Observation.State, vm.DiskStatusReason = "last-known", "prev-vm-locked"
if m.evaluateVMCondition(vm, condition) || m.evaluateFilterCondition(&vm, condition) {
t.Fatalf("retained %s matched %s", field, op)
}
}
}
vm.Memory.Observation.State, vm.DiskStatusReason = "current", ""
for _, field := range []string{"memory", "disk"} {
if !m.evaluateVMCondition(vm, FilterCondition{Type: "metric", Field: field, Operator: ">", Value: 90.0}) {
t.Fatalf("fresh %s could not match", field)
}
}
vm.Memory = models.UnavailableMemory(1000)
if m.evaluateVMCondition(vm, FilterCondition{Type: "metric", Field: "memory", Operator: "<", Value: 90.0}) {
t.Fatal("unavailable memory was treated as a measured zero in a filter")
}
}
+28 -16
View File
@@ -28,9 +28,10 @@ type guestSnapshot struct {
CPUPercent float64
MemUsage float64
// MemoryUnavailable prevents a missing cache-aware sample from clearing a
// real alert or starting a false one.
// Unavailable observations include retained display values. They cannot
// supply a new breach, recovery, filter match or filesystem removal.
MemoryUnavailable bool
DiskUnavailable bool
DiskUsage float64
DiskRead int64
DiskWrite int64
@@ -81,18 +82,20 @@ func (g guestSnapshot) resourceType() string {
func (g guestSnapshot) metrics() guestMetrics {
return guestMetrics{
CPU: g.CPUPercent,
MemUsage: g.MemUsage,
DiskUsage: g.DiskUsage,
DiskRead: g.DiskRead,
DiskWrite: g.DiskWrite,
NetworkIn: g.NetworkIn,
NetworkOut: g.NetworkOut,
IORateValidity: g.IORateValidity,
Name: g.Name,
Node: g.Node,
ID: g.ID,
Status: g.Status,
CPU: g.CPUPercent,
MemUsage: g.MemUsage,
DiskUsage: g.DiskUsage,
MemoryUnavailable: g.MemoryUnavailable,
DiskUnavailable: g.DiskUnavailable,
DiskRead: g.DiskRead,
DiskWrite: g.DiskWrite,
NetworkIn: g.NetworkIn,
NetworkOut: g.NetworkOut,
IORateValidity: g.IORateValidity,
Name: g.Name,
Node: g.Node,
ID: g.ID,
Status: g.Status,
}
}
@@ -108,7 +111,8 @@ func guestSnapshotFromVM(vm models.VM) guestSnapshot {
Lock: vm.Lock,
CPUPercent: unifiedresources.ProxmoxGuestCPUPercent(vm.CPU),
MemUsage: vm.Memory.Usage,
MemoryUnavailable: vm.Memory.UsageUnavailable,
MemoryUnavailable: guestMemoryUnavailableForAlerts(vm.Memory),
DiskUnavailable: vm.DiskStatusReason != "" || vm.Disk.Usage < 0,
DiskUsage: vm.Disk.Usage,
DiskRead: vm.DiskRead,
DiskWrite: vm.DiskWrite,
@@ -133,7 +137,8 @@ func guestSnapshotFromContainer(container models.Container) guestSnapshot {
Lock: container.Lock,
CPUPercent: unifiedresources.ProxmoxGuestCPUPercent(container.CPU),
MemUsage: container.Memory.Usage,
MemoryUnavailable: container.Memory.UsageUnavailable,
MemoryUnavailable: guestMemoryUnavailableForAlerts(container.Memory),
DiskUnavailable: container.Disk.Usage < 0,
DiskUsage: container.Disk.Usage,
DiskRead: container.DiskRead,
DiskWrite: container.DiskWrite,
@@ -146,6 +151,13 @@ func guestSnapshotFromContainer(container models.Container) guestSnapshot {
}.normalizeCollections()
}
func guestMemoryUnavailableForAlerts(memory models.Memory) bool {
// The producer owns freshness and source selection. Legacy values without
// an observation retain their existing behavior; do not infer freshness
// from disk/QGA state, since PVE or a linked Pulse agent can be independent.
return memory.UsageUnavailable || (memory.Observation.State != "" && memory.Observation.State != "current")
}
func guestKindFromType(guestType string) guestKind {
switch strings.ToLower(strings.TrimSpace(guestType)) {
case "qemu", "vm":
+66
View File
@@ -19,6 +19,72 @@ func TestGuestSnapshotResourceTypeUsesCanonicalSystemContainer(t *testing.T) {
}
}
func TestGuestSnapshotObservationAvailability(t *testing.T) {
for _, test := range []struct {
name string
memory models.Memory
disk models.Disk
reason string
wantMemory bool
wantDisk bool
}{
{name: "legacy measured zero", memory: models.Memory{}, disk: models.Disk{}},
{name: "current independent read", memory: models.Memory{Usage: 96, Observation: models.MemoryObservation{State: "current", Source: "agent"}}, disk: models.Disk{Usage: 98}},
{name: "retained memory only", memory: models.Memory{Usage: 96, Observation: models.MemoryObservation{State: "last-known"}}, disk: models.Disk{Usage: 98}, wantMemory: true},
{name: "unavailable numeric placeholder", memory: models.Memory{Usage: 0, Observation: models.MemoryObservation{State: "unavailable"}}, wantMemory: true},
{name: "unknown observation version", memory: models.Memory{Usage: 96, Observation: models.MemoryObservation{State: "future-version"}}, wantMemory: true},
{name: "explicit unavailable overrides current", memory: models.Memory{Usage: 96, UsageUnavailable: true, Observation: models.MemoryObservation{State: "current"}}, wantMemory: true},
{name: "retained filesystem only", memory: models.Memory{Usage: 96}, disk: models.Disk{Usage: 98}, reason: "prev-vm-locked", wantDisk: true},
{name: "failed empty filesystem inventory", memory: models.Memory{Usage: 96}, disk: models.Disk{Usage: -1}, reason: "agent-error", wantDisk: true},
{name: "legacy unavailable aggregate", memory: models.Memory{Usage: 96}, disk: models.Disk{Usage: -1}, wantDisk: true},
} {
t.Run(test.name, func(t *testing.T) {
// A deferred QGA session does not by itself make independent
// selected memory or Pulse-agent filesystems unavailable.
vm := models.VM{Memory: test.memory, Disk: test.disk, DiskStatusReason: test.reason, GuestAgentStatus: "deferred"}
for _, guest := range []any{vm, &vm} {
snapshot, ok := extractGuestSnapshot(guest)
if !ok || snapshot.MemoryUnavailable != test.wantMemory || snapshot.DiskUnavailable != test.wantDisk {
t.Fatalf("observation flags lost: %+v", snapshot)
}
metrics := snapshot.metrics()
if metrics.MemoryUnavailable != test.wantMemory || metrics.DiskUnavailable != test.wantDisk || metrics.MemUsage != test.memory.Usage || metrics.DiskUsage != test.disk.Usage {
t.Fatal("filter projection lost availability or rewrote retained values")
}
}
ct := models.Container{Memory: test.memory, Disk: test.disk}
for _, guest := range []any{ct, &ct} {
snapshot, _ := extractGuestSnapshot(guest)
if snapshot.MemoryUnavailable != test.wantMemory || snapshot.DiskUnavailable != (test.disk.Usage < 0) {
t.Fatal("LXC observation availability changed with an unrelated VM disk failure")
}
}
})
}
}
func TestGuestAlertRetainedDiskStillHonoursExplicitOverride(t *testing.T) {
for _, override := range []ThresholdConfig{{Disabled: true}, {Disk: &HysteresisThreshold{Trigger: -1}}} {
m := guestObservationManager(t)
vm := guestObservationVM()
vm.Memory.Usage = 10
m.CheckGuest(vm, "site")
if len(m.GetActiveAlerts()) != 2 {
t.Fatal("expected two current filesystem incidents")
}
_, keySource, key := guestDiskIdentity(vm.Disks[0], 0)
cfg := m.GetConfig()
cfg.Overrides[guestDiskOverrideKey(vm, vm.ID, keySource)] = override
m.UpdateConfig(cfg)
vm.DiskStatusReason = "prev-vm-locked"
m.CheckGuest(vm, "site")
active := m.GetActiveAlerts()
if len(active) != 1 || active[0].ResourceID == vm.ID+"-disk-"+key {
t.Fatalf("explicit filesystem disablement was lost or crossed disk identity: %+v", active)
}
}
}
func TestGuestSnapshotUsesCanonicalProxmoxCPUPercent(t *testing.T) {
for _, tc := range []struct {
name string
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"os/exec"
"runtime"
"strings"
"sync/atomic"
"testing"
@@ -39,13 +40,27 @@ func (e guestSafetyDiscoveryExecutor) IsAgentConnected(id string) bool {
// Real server/session/registration/agent/Discovery paths, with a fake non-QGA
// config reader and fake provider process. This is NOT native PVE acceptance.
func testGuestExecRealServerAgentDiscoveryAdmission(t *testing.T) {
platforms := []string{runtime.GOOS, "linux", "darwin", "windows"}
seen := make(map[string]bool)
for _, platform := range platforms {
if seen[platform] {
continue
}
seen[platform] = true
t.Run(platform, func(t *testing.T) {
testGuestExecRealServerAgentDiscoveryPlatform(t, platform)
})
}
}
func testGuestExecRealServerAgentDiscoveryPlatform(t *testing.T, platform string) {
s := agentexec.NewServer(func(token, agent, host string) bool {
return token == "fixture" && agent == "node-agent" && host == "node"
})
ts := httptest.NewServer(http.HandlerFunc(s.HandleWebSocket))
defer ts.Close()
logger := zerolog.Nop()
c := NewCommandClient(Config{PulseURL: ts.URL, APIToken: "fixture", StateDir: t.TempDir(), Logger: &logger}, "node-agent", "node", "linux", "6")
c := NewCommandClient(Config{PulseURL: ts.URL, APIToken: "fixture", StateDir: t.TempDir(), Logger: &logger}, "node-agent", "node", platform, "6")
var locked, lockDuringHandoff, failedGuest atomic.Bool
var qgaCalls, configReads atomic.Int32
locked.Store(true)
@@ -109,6 +124,27 @@ func testGuestExecRealServerAgentDiscoveryAdmission(t *testing.T) {
}
}
locked.Store(false)
if runtime.GOOS != "linux" || platform != "linux" {
// PVE admission belongs to a Linux host agent. A platform label must
// not make a non-Linux executable advertise that capability, and a
// Linux executable labelled as another platform must still be refused.
// Exercise the real registration and server gate, not a skipped test
// or an artificial capability override just to reach the happy path.
for _, rt := range []servicediscovery.ResourceType{servicediscovery.ResourceTypeVM, servicediscovery.ResourceTypeDockerVM} {
id := "105"
if rt == servicediscovery.ResourceTypeDockerVM {
id = "105:app"
}
got, err := scanner.Scan(context.Background(), servicediscovery.DiscoveryRequest{ResourceType: rt, ResourceID: id, TargetID: "node-agent"})
if err == nil || err.Error() != agentexec.GuestExecDeferred(agentexec.GuestExecGuardUnavailable).Error() || got == nil || len(got.CommandOutputs) != 0 || qgaCalls.Load() != 0 || configReads.Load() != 0 {
t.Fatalf("unsupported platform reached guest admission: %+v %v calls=%d reads=%d", got, err, qgaCalls.Load(), configReads.Load())
}
}
if !s.IsAgentConnected("node-agent") {
t.Fatal("platform deferral dropped session liveness")
}
return
}
got, err := scanner.ScanVM(context.Background(), "node-agent", "node", "105")
want := int32(len(servicediscovery.GetCommandsForResource(servicediscovery.ResourceTypeVM)))
if err != nil || got == nil || int32(len(got.CommandOutputs)) != want || qgaCalls.Load() != want {
@@ -0,0 +1,140 @@
package monitoring
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
// Exercise actual HTTP client, builders, saved snapshots, unified views, History
// and the production alert call. No native QGA or backup is performed.
func TestGuestBackupDeferralAlertObservationLifecycle(t *testing.T) {
const mib = uint64(1024 * 1024)
var phase, guestCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
if phase.Load() == 1 {
fmt.Fprint(w, `{"data":{"lock":"backup"}}`)
} else {
fmt.Fprint(w, `{"data":{}}`)
}
case strings.HasSuffix(r.URL.Path, "/status/current"):
fmt.Fprintf(w, `{"data":{"status":"running","agent":1,"maxmem":%d,"mem":%d}}`, 1000*mib, 1000*mib)
case strings.Contains(r.URL.Path, "/agent/"):
guestCalls.Add(1)
switch {
case strings.HasSuffix(r.URL.Path, "file-read"):
available := 40 * 1024
if phase.Load() == 2 {
available = 800 * 1024
}
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"content": fmt.Sprintf("MemTotal: 1024000 kB\nMemFree: 10240 kB\nMemAvailable: %d kB\n", available)}})
case strings.HasSuffix(r.URL.Path, "get-fsinfo"):
used := 980 * mib
if phase.Load() == 2 {
used = 200 * mib
}
fmt.Fprintf(w, `{"data":{"result":[{"mountpoint":"/","type":"ext4","total-bytes":%d,"used-bytes":%d}]}}`, 1000*mib, used)
case strings.HasSuffix(r.URL.Path, "network-get-interfaces"):
fmt.Fprint(w, `{"data":{"result":[]}}`)
case strings.HasSuffix(r.URL.Path, "get-osinfo"):
fmt.Fprint(w, `{"data":{"name":"Linux","version":"fixture"}}`)
default:
fmt.Fprint(w, `{"data":{"result":{"version":"1.0"}}}`)
}
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := proxmox.NewClient(proxmox.ClientConfig{Host: server.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
am := alerts.NewManagerWithDataDir(t.TempDir(), alerts.WithoutPersistedAlertRestore())
defer am.Stop()
cfg := am.GetConfig()
cfg.Enabled = true
cfg.GuestDefaults.Memory = &alerts.HysteresisThreshold{Trigger: 85, Clear: 80}
cfg.GuestDefaults.Disk = &alerts.HysteresisThreshold{Trigger: 90, Clear: 85}
cfg.GuestDefaults.CPU = &alerts.HysteresisThreshold{Trigger: 80, Clear: 75}
cfg.MetricTimeThresholds = map[string]map[string]int{"guest": {"memory": 0, "disk": 0, "cpu": 0}}
am.UpdateConfig(cfg)
registry := unifiedresources.NewRegistry(nil)
m := &Monitor{config: &config.Config{}, alertManager: am, rateTracker: NewRateTracker(), metricsHistory: NewMetricsHistory(32, time.Hour), guestMetadataLimiter: make(map[string]time.Time)}
res := proxmox.ClusterResource{Type: "qemu", Node: "node", Name: "guest", VMID: 105, Status: "running", MaxMem: 1000 * mib, Mem: 1000 * mib, MaxDisk: 1000 * mib}
identity := makeGuestID("alert-observation", "node", 105)
var previous *models.VM
build := func() models.VM {
t.Helper()
vm, raw, source, notes, at, ok := m.buildVMFromClusterResource(context.Background(), "alert-observation", res, client, identity, nil, previous)
if !ok {
t.Fatal("VM disappeared")
}
m.recordGuestSnapshot("alert-observation", "qemu", "node", 105, GuestMemorySnapshot{Status: vm.Status, RetrievedAt: at, MemorySource: source, Memory: vm.Memory, Raw: raw, Notes: notes})
m.recordGuestMetrics([]models.VM{vm}, nil, time.Now().Add(-time.Second))
registry.IngestSnapshot(models.StateSnapshot{VMs: []models.VM{vm}})
next := previousVMFromView(registry.VMs()[0])
previous = &next
return vm
}
initial := build()
// The observation was made before alert policy became enabled for this VM.
// A retained high value must not create an incident upon the next poll.
phase.Store(1)
before := guestCalls.Load()
locked := build()
if locked.Memory.Observation.State != "last-known" || !strings.HasPrefix(locked.DiskStatusReason, "prev-") {
t.Fatalf("fixture did not produce real last-known evidence: %+v", locked)
}
m.checkGuestAlertsForVM("alert-observation", locked)
if len(am.GetActiveAlerts()) != 0 {
t.Fatal("production alert call treated retained backup-lock evidence as current")
}
// The original current evidence can establish an alert. Subsequent lock
// polls must neither refresh its observation nor resolve it on expiry.
m.checkGuestAlertsForVM("alert-observation", initial)
if len(am.GetActiveAlerts()) != 2 {
t.Fatal("current memory/filesystem evidence did not establish two alerts")
}
for poll := 0; poll < 4; poll++ {
m.checkGuestAlertsForVM("alert-observation", build())
}
if guestCalls.Load() != before || len(am.GetActiveAlerts()) != 2 {
t.Fatal("backup deferral queried QGA or lost the original alerts")
}
for _, a := range am.GetActiveAlerts() {
if a.Value != 96 && a.Value != 98 {
t.Fatal("retained poll changed the last trusted alert value")
}
}
if got := len(m.metricsHistory.GetGuestMetrics(identity, "memory", time.Hour)); got != 1 {
t.Fatalf("retained memory refreshed History: %d points", got)
}
if got := len(m.metricsHistory.GetGuestMetrics(identity, "disk", time.Hour)); got != 1 {
t.Fatalf("retained filesystem refreshed History: %d points", got)
}
phase.Store(2)
entry := m.vmAgentMemCache[guestMemoryCacheKey("alert-observation", "node", 105)]
entry.fetchedAt = time.Now().Add(-2 * vmAgentMemCacheTTL)
m.vmAgentMemCache[guestMemoryCacheKey("alert-observation", "node", 105)] = entry
resumed := build()
m.checkGuestAlertsForVM("alert-observation", resumed)
if guestCalls.Load() <= before || resumed.Memory.Observation.State != "current" || resumed.DiskStatusReason != "" || len(am.GetActiveAlerts()) != 0 {
t.Fatal("fresh unlocked memory/filesystem evidence did not recover the original incidents")
}
}
@@ -0,0 +1,294 @@
package monitoring
import (
"context"
"fmt"
"math"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
func guestHistoryObservationMonitor(t *testing.T) *Monitor {
t.Helper()
wasMock := mock.IsMockEnabled()
mustSetMockEnabled(t, false)
t.Cleanup(func() { mustSetMockEnabled(t, wasMock) })
cfg := metrics.DefaultConfig(t.TempDir())
cfg.WriteBufferSize = 512
cfg.FlushInterval = time.Hour
store, err := metrics.NewStore(cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.WaitForMaintenance(5 * time.Second); err != nil {
t.Fatal(err)
}
return &Monitor{metricsHistory: NewMetricsHistory(128, 24*time.Hour), metricsStore: store}
}
func guestHistoryStoredPoints(t *testing.T, m *Monitor, kind, id, metric string) []metrics.MetricPoint {
t.Helper()
m.metricsStore.Flush()
now := time.Now()
points, err := m.metricsStore.Query(kind, id, metric, now.Add(-time.Minute), now.Add(time.Minute), 0)
if err != nil {
t.Fatal(err)
}
return points
}
func TestGuestHistoryMemoryObservationAdmission(t *testing.T) {
for _, tc := range []struct {
name string
memory models.Memory
want bool
}{
{name: "legacy", memory: models.Memory{Total: 100, Used: 40, Free: 60, Usage: 40}, want: true},
{name: "current", memory: models.Memory{Total: 100, Used: 40, Free: 60, Usage: 40, Observation: models.MemoryObservation{State: "current"}}, want: true},
{name: "current measured zero", memory: models.Memory{Total: 100, Free: 100, Observation: models.MemoryObservation{State: "current"}}, want: true},
{name: "last-known", memory: models.Memory{Total: 100, Used: 40, Free: 60, Usage: 40, Observation: models.MemoryObservation{State: "last-known"}}},
{name: "unavailable observation with retained numbers", memory: models.Memory{Total: 100, Used: 40, Free: 60, Usage: 40, Observation: models.MemoryObservation{State: "unavailable"}}},
{name: "unrecognised observation", memory: models.Memory{Total: 100, Used: 40, Free: 60, Usage: 40, Observation: models.MemoryObservation{State: "future-state"}}},
{name: "missing usage", memory: models.UnavailableMemory(100)},
{name: "NaN", memory: models.Memory{Total: 100, Used: 40, Free: 60, Usage: math.NaN()}},
{name: "contradictory bytes", memory: models.Memory{Total: 100, Used: 140, Free: 60, Usage: 40}},
} {
t.Run(tc.name, func(t *testing.T) {
gotUsage, gotUsed := historyMemoryUsage(tc.memory), historyMemoryUsed(tc.memory)
if tc.want {
if gotUsage != tc.memory.Usage || gotUsed != float64(tc.memory.Used) {
t.Errorf("current/legacy observation lost percentage or byte history: %v / %v", gotUsage, gotUsed)
}
} else if gotUsage != -1 || gotUsed != -1 {
t.Errorf("non-current observation became percentage/byte History: %v / %v", gotUsage, gotUsed)
}
})
}
}
func TestGuestHistoryObservationContinuity(t *testing.T) {
m := guestHistoryObservationMonitor(t)
now := time.Now()
vm := models.VM{ID: "history:node:105", Instance: "history", Node: "node", VMID: 105, Type: "qemu", Status: "running", CPU: .1, LastSeen: now,
Memory: models.Memory{Total: 100, Used: 40, Free: 60, Usage: 40, Observation: models.MemoryObservation{State: "current", Source: "available-field", ObservedAt: now}},
Disk: models.Disk{Total: 100, Used: 50, Free: 50, Usage: 50}, DiskWrite: 10,
IORateValidity: models.IORateValidity{Explicit: true, DiskWrite: true}}
ct := models.Container{ID: "history:node:106", Status: "running", LastSeen: now, CPU: .2, Memory: vm.Memory, Disk: vm.Disk}
record := func() {
t.Helper()
vm.LastSeen, ct.LastSeen = time.Now(), time.Now()
m.recordGuestMetrics([]models.VM{vm}, []models.Container{ct}, time.Now().Add(-time.Second))
}
record()
type series struct{ kind, id, metric string }
seriesToPreserve := []series{{"vm", vm.ID, "memory"}, {"vm", vm.ID, "memoryused"}, {"vm", vm.ID, "disk"}, {"container", ct.ID, "memory"}, {"container", ct.ID, "memoryused"}}
originalMemory := make(map[series][]MetricPoint)
originalStored := make(map[series][]metrics.MetricPoint)
for _, s := range seriesToPreserve {
originalMemory[s] = m.metricsHistory.GetGuestMetrics(s.id, s.metric, time.Hour)
originalStored[s] = guestHistoryStoredPoints(t, m, s.kind, s.id, s.metric)
if len(originalMemory[s]) != 1 || len(originalStored[s]) != 1 {
t.Fatalf("fresh %s %s failed to establish both histories", s.kind, s.metric)
}
}
// Persisted points use second precision. Cross a second before the failed
// polls so a falsely renewed point cannot hide behind the original key.
time.Sleep(1100 * time.Millisecond)
for i, state := range []string{"last-known", "unavailable", "future-state"} {
vm.Memory.Observation.State, ct.Memory.Observation.State = state, state
vm.GuestAgentStatus = "available" // Not just backup/cooldown deferral.
vm.DiskStatusReason = []string{"no-status", "agent-error", "prev-agent-timeout"}[i]
record()
}
for _, s := range seriesToPreserve {
if got := m.metricsHistory.GetGuestMetrics(s.id, s.metric, time.Hour); !reflect.DeepEqual(got, originalMemory[s]) {
t.Errorf("%s %s renewed or changed its last trusted in-memory observations", s.kind, s.metric)
}
if got := guestHistoryStoredPoints(t, m, s.kind, s.id, s.metric); !reflect.DeepEqual(got, originalStored[s]) {
t.Errorf("%s %s renewed or changed its last trusted persisted observations", s.kind, s.metric)
}
}
for _, metric := range []string{"cpu", "diskwrite"} {
if got := m.metricsHistory.GetGuestMetrics(vm.ID, metric, time.Hour); len(got) != 4 {
t.Errorf("independently current %s was suppressed: %d points", metric, len(got))
}
}
if got := m.metricsHistory.GetGuestMetrics(ct.ID, "disk", time.Hour); len(got) != 4 {
t.Error("independently current container filesystem was suppressed by memory state")
}
// Current measured zero is recovery evidence, not missing usage. The
// original points are kept; this repair never deletes existing History.
time.Sleep(1100 * time.Millisecond)
vm.Memory = models.Memory{Total: 100, Free: 100, Observation: models.MemoryObservation{State: "current", Source: "available-field", ObservedAt: time.Now()}}
ct.Memory = vm.Memory
vm.Disk = models.Disk{Total: 100, Free: 100}
vm.DiskStatusReason = ""
record()
for _, s := range seriesToPreserve {
points := m.metricsHistory.GetGuestMetrics(s.id, s.metric, time.Hour)
if len(points) != 2 || points[1].Value != 0 || !reflect.DeepEqual(points[:1], originalMemory[s]) {
t.Errorf("%s %s did not resume with current zero while preserving old evidence", s.kind, s.metric)
}
stored := guestHistoryStoredPoints(t, m, s.kind, s.id, s.metric)
if len(stored) != 2 || stored[1].Value != 0 || !reflect.DeepEqual(stored[:1], originalStored[s]) {
t.Errorf("%s %s persistent recovery lost current zero or original evidence", s.kind, s.metric)
}
for _, duration := range []time.Duration{time.Minute, 4 * time.Hour} {
chart := m.GetGuestMetricsForChart(s.id, s.kind, s.id, duration)[s.metric]
if len(chart) != 2 || chart[0].Value != originalMemory[s][0].Value || chart[1].Value != 0 {
t.Errorf("%s %s chart (%v) presented retained polls as new readings: %+v", s.kind, s.metric, duration, chart)
}
}
}
}
func TestGuestHistoryPreservesIndependentCurrentMemory(t *testing.T) {
for _, source := range []string{"available-field", "agent", "guest-agent-meminfo"} {
for _, snapshotSource := range []string{"", "previous-snapshot", "guest-agent-meminfo"} {
t.Run(source+"/diagnostic-"+snapshotSource, func(t *testing.T) {
m := guestHistoryObservationMonitor(t)
now := time.Now()
vm := models.VM{ID: "independent:node:105", Instance: "independent", Node: "node", VMID: 105, Status: "running", LastSeen: now, GuestAgentStatus: "deferred",
Memory: models.Memory{Total: 100, Used: 25, Free: 75, Usage: 25, Observation: models.MemoryObservation{State: "current", Source: source, ObservedAt: now.Add(-time.Second)}}}
if snapshotSource != "" {
m.recordGuestSnapshot(vm.Instance, "qemu", vm.Node, vm.VMID, GuestMemorySnapshot{Status: "running", RetrievedAt: now, MemorySource: snapshotSource})
}
m.recordGuestMetrics([]models.VM{vm}, nil, now.Add(-time.Second))
for _, metric := range []string{"memory", "memoryused"} {
if got := m.metricsHistory.GetGuestMetrics(vm.ID, metric, time.Hour); len(got) != 1 || got[0].Value != 25 {
t.Errorf("current selected %s/%s lost History to unrelated deferred disk or diagnostic source", source, metric)
}
if got := guestHistoryStoredPoints(t, m, "vm", vm.ID, metric); len(got) != 1 || got[0].Value != 25 {
t.Errorf("current selected %s/%s lost persistent History", source, metric)
}
}
})
}
}
// Preserve the historical guard for an unannotated deferred guest. New
// producer observations, not a change to numeric/display availability,
// are what let independently current memory bypass that legacy heuristic.
m := guestHistoryObservationMonitor(t)
vm := models.VM{ID: "legacy-deferred", Status: "running", LastSeen: time.Now(), GuestAgentStatus: "deferred", Memory: models.Memory{Total: 100, Used: 25, Free: 75, Usage: 25}}
m.recordGuestMetrics([]models.VM{vm}, nil, time.Now().Add(-time.Second))
for _, metric := range []string{"memory", "memoryused"} {
if got := m.metricsHistory.GetGuestMetrics(vm.ID, metric, time.Hour); len(got) != 0 {
t.Errorf("unannotated deferral lost its historical guard: %s", metric)
}
}
}
func TestGuestHistoryPollFailureAndRecovery(t *testing.T) {
const mib = uint64(1024 * 1024)
var phase atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
fmt.Fprint(w, `{"data":{}}`)
case strings.HasSuffix(r.URL.Path, "/status/current"):
if strings.Contains(r.URL.Path, "/106/") {
http.Error(w, "permission denied", http.StatusForbidden)
return
}
meminfo := ""
if phase.Load() != 1 {
available := 800 * mib
if phase.Load() == 2 {
available = 600 * mib
}
meminfo = fmt.Sprintf(`,"meminfo":{"total":%d,"available":%d}`, 1000*mib, available)
}
fmt.Fprintf(w, `{"data":{"status":"running","agent":1,"maxmem":%d,"mem":%d%s}}`, 1000*mib, 1000*mib, meminfo)
case strings.HasSuffix(r.URL.Path, "file-read"):
http.Error(w, "permission denied", http.StatusForbidden)
case strings.HasSuffix(r.URL.Path, "get-fsinfo"):
if phase.Load() == 1 {
http.Error(w, "permission denied", http.StatusForbidden)
return
}
fmt.Fprintf(w, `{"data":{"result":[{"mountpoint":"/","type":"ext4","total-bytes":%d,"used-bytes":%d}]}}`, 1000*mib, 300*mib)
case strings.HasSuffix(r.URL.Path, "network-get-interfaces"):
fmt.Fprint(w, `{"data":{"result":[]}}`)
case strings.HasSuffix(r.URL.Path, "get-osinfo"):
fmt.Fprint(w, `{"data":{"name":"Linux","version":"fixture"}}`)
case strings.HasSuffix(r.URL.Path, "info"):
fmt.Fprint(w, `{"data":{"result":{"version":"1.0"}}}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := proxmox.NewClient(proxmox.ClientConfig{Host: server.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
m := guestHistoryObservationMonitor(t)
m.rateTracker = NewRateTracker()
m.guestMetadataLimiter = make(map[string]time.Time)
registry := unifiedresources.NewRegistry(nil)
res := proxmox.ClusterResource{Type: "qemu", Node: "node", Name: "guest", VMID: 105, Status: "running", MaxMem: 1000 * mib, Mem: 1000 * mib, MaxDisk: 1000 * mib, Disk: 400 * mib, CPU: .1}
id := makeGuestID("poll-history", res.Node, res.VMID)
var previous *models.VM
build := func() models.VM {
t.Helper()
vm, raw, source, notes, at, ok := m.buildVMFromClusterResource(context.Background(), "poll-history", res, client, id, nil, previous)
if !ok {
t.Fatal("guest disappeared")
}
m.recordGuestSnapshot("poll-history", "qemu", res.Node, res.VMID, GuestMemorySnapshot{Status: vm.Status, RetrievedAt: at, MemorySource: source, Memory: vm.Memory, Raw: raw, Notes: notes})
m.recordGuestMetrics([]models.VM{vm}, nil, time.Now().Add(-time.Second))
registry.IngestSnapshot(models.StateSnapshot{VMs: []models.VM{vm}})
next := previousVMFromView(registry.VMs()[0])
previous = &next
return vm
}
initial := build()
if initial.Memory.Observation.State != "current" || initial.Memory.Usage != 20 || initial.DiskStatusReason != "" {
t.Fatalf("initial poll did not supply current native observations: %+v", initial)
}
phase.Store(1)
failed := build()
if failed.Memory.Observation.State != "last-known" || failed.Memory.Usage != 20 || failed.GuestAgentStatus == "deferred" || !strings.HasPrefix(failed.DiskStatusReason, "prev-") {
t.Fatalf("fixture failed to reproduce ordinary failed-read retention: %+v", failed)
}
for _, metric := range []string{"memory", "memoryused", "disk"} {
if got := m.metricsHistory.GetGuestMetrics(id, metric, time.Hour); len(got) != 1 {
t.Errorf("real client/poll ordinary failure added %s History: %d points", metric, len(got))
}
}
// A new guest with no status has only a cluster disk allocation. Its
// positive number is not filesystem evidence, even without prev- prefix.
other := res
other.VMID = 106
newVM, _, _, _, _, ok := m.buildVMFromClusterResource(context.Background(), "poll-history", other, client, makeGuestID("poll-history", "node", 106), nil, nil)
if !ok || newVM.DiskStatusReason != "no-status" || newVM.Disk.Usage <= 0 {
t.Fatalf("fixture did not retain the unverified positive allocation: %+v", newVM)
}
m.recordGuestMetrics([]models.VM{newVM}, nil, time.Now().Add(-time.Second))
if got := m.metricsHistory.GetGuestMetrics(newVM.ID, "disk", time.Hour); len(got) != 0 {
t.Error("unverified cluster disk allocation became a filesystem History sample")
}
phase.Store(2)
resumed := build()
if resumed.Memory.Observation.State != "current" || resumed.Memory.Usage != 40 || resumed.DiskStatusReason != "" {
t.Fatalf("recovery did not supply current independent PVE and filesystem observations: %+v", resumed)
}
for _, metric := range []string{"memory", "memoryused", "disk"} {
if got := m.metricsHistory.GetGuestMetrics(id, metric, time.Hour); len(got) != 2 {
t.Errorf("real current recovery did not resume %s History: %d points", metric, len(got))
}
}
}
+20 -5
View File
@@ -43,9 +43,9 @@ type guestMetadataCacheEntry struct {
osName string
osVersion string
agentVersion string
fetchedAt time.Time
osInfoFailureCount int // Track consecutive OS info failures
osInfoSkip bool // Skip OS info calls after repeated failures (refs #692)
fetchedAt time.Time // Last accepted useful metadata, not the last attempt.
osInfoFailureCount int // Track consecutive OS info failures
osInfoSkip bool // Skip OS info calls after repeated failures (refs #692)
}
func guestMetadataCacheHasUsefulData(entry guestMetadataCacheEntry) bool {
@@ -267,6 +267,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI
osName := cached.osName
osVersion := cached.osVersion
agentVersion := cached.agentVersion
metadataObserved := false
if reserved {
if !m.acquireGuestMetadataSlot(ctx) {
@@ -295,6 +296,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI
processedIPs, processedIfaces := processGuestNetworkInterfaces(ifaces)
if len(processedIPs) > 0 || len(processedIfaces) > 0 {
ipAddresses, networkIfaces = processedIPs, processedIfaces
metadataObserved = true
} else if len(cached.ipAddresses) == 0 && len(cached.networkInterfaces) == 0 {
ipAddresses = nil
networkIfaces = nil
@@ -359,6 +361,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI
extractedOSName, extractedOSVersion := extractGuestOSInfo(agentInfo)
if extractedOSName != "" || extractedOSVersion != "" {
osName, osVersion = extractedOSName, extractedOSVersion
metadataObserved = true
}
osInfoFailureCount = 0 // Reset on success
osInfoSkip = false
@@ -392,17 +395,25 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI
Msg("Guest agent version unavailable")
} else if version, ok := versionRaw.(string); ok && version != "" {
agentVersion = version
metadataObserved = true
} else if cached.agentVersion == "" {
agentVersion = ""
}
// Errors and empty/unusable replies may retain display identity, but cannot
// make that identity new evidence that the guest agent is still responsive.
// Retry timing is tracked separately by the limiter, not by renewing its age.
fetchedAt := cached.fetchedAt
if metadataObserved {
fetchedAt = time.Now()
}
entry := guestMetadataCacheEntry{
ipAddresses: cloneStringSlice(ipAddresses),
networkInterfaces: cloneGuestNetworkInterfaces(networkIfaces),
osName: osName,
osVersion: osVersion,
agentVersion: agentVersion,
fetchedAt: time.Now(),
fetchedAt: fetchedAt,
osInfoFailureCount: osInfoFailureCount,
osInfoSkip: osInfoSkip,
}
@@ -414,7 +425,11 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI
m.guestMetadataCache[key] = entry
m.guestMetadataMu.Unlock()
if reserved {
m.scheduleGuestMetadataFetchForEntry(key, time.Now(), entry)
if metadataObserved {
m.scheduleGuestMetadataFetchForEntry(key, time.Now(), entry)
} else {
m.deferGuestMetadataRetry(key, time.Now())
}
}
return ipAddresses, networkIfaces, osName, osVersion, agentVersion
@@ -0,0 +1,248 @@
package monitoring
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
type metadataObservationClient struct {
mockPVEClient
err error
network []proxmox.VMNetworkInterface
os map[string]interface{}
version string
calls int
}
func (c *metadataObservationClient) GetVMNetworkInterfaces(context.Context, string, int) ([]proxmox.VMNetworkInterface, error) {
c.calls++
return c.network, c.err
}
func (c *metadataObservationClient) GetVMAgentInfo(context.Context, string, int) (map[string]interface{}, error) {
c.calls++
return c.os, c.err
}
func (c *metadataObservationClient) GetVMAgentVersion(context.Context, string, int) (string, error) {
c.calls++
return c.version, c.err
}
func metadataObservationFixture(at time.Time) guestMetadataCacheEntry {
return guestMetadataCacheEntry{
ipAddresses: []string{"192.0.2.10"},
networkInterfaces: []models.GuestNetworkInterface{{Name: "eth0", MAC: "02:00:00:00:00:01", Addresses: []string{"192.0.2.10"}}},
osName: "Linux",
osVersion: "fixture",
agentVersion: "1.0",
fetchedAt: at,
}
}
func TestGuestMetadataFailedRefreshPreservesObservation(t *testing.T) {
for _, tc := range []struct {
name string
client metadataObservationClient
}{
{"ordinary-error", metadataObservationClient{err: errors.New("metadata unavailable")}},
{"empty-success", metadataObservationClient{}},
{"filtered-network", metadataObservationClient{network: []proxmox.VMNetworkInterface{{Name: "lo", IPAddresses: []proxmox.VMIPAddress{{Address: "127.0.0.1"}}}}}},
{"unusable-os", metadataObservationClient{os: map[string]interface{}{"result": map[string]interface{}{"unrelated": "fixture"}}}},
} {
t.Run(tc.name, func(t *testing.T) {
key := guestMetadataCacheKey("metadata", "node", 105)
original := metadataObservationFixture(time.Now().Add(-2 * guestMetadataCacheTTL))
m := &Monitor{guestMetadataCache: map[string]guestMetadataCacheEntry{key: original}, guestMetadataLimiter: make(map[string]time.Time), guestMetadataRetryBackoff: 45 * time.Second}
status := &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}}
client := tc.client
for attempt := 0; attempt < 2; attempt++ {
m.guestMetadataLimiter[key] = time.Now().Add(-time.Second)
start := time.Now()
ips, ifaces, name, version, agent := m.fetchGuestAgentMetadata(context.Background(), &client, "metadata", "node", "guest", 105, status, false)
end := time.Now()
if !reflect.DeepEqual(ips, original.ipAddresses) || !reflect.DeepEqual(ifaces, original.networkInterfaces) || name != original.osName || version != original.osVersion || agent != original.agentVersion {
t.Error("unsuccessful refresh lost last-known identity")
}
entry := m.guestMetadataCache[key]
if !entry.fetchedAt.Equal(original.fetchedAt) || m.hasRecentGuestMetadataEvidence("metadata", "node", 105, end) {
t.Error("unsuccessful refresh renewed old guest-agent evidence")
}
if next := m.guestMetadataLimiter[key]; next.Before(start.Add(45*time.Second)) || next.After(end.Add(45*time.Second)) {
t.Error("unsuccessful refresh did not retain the configured retry backoff")
}
// Caller-owned slices cannot change the retained cache.
ips[0] = "192.0.2.99"
ifaces[0].Addresses[0] = "192.0.2.99"
if !reflect.DeepEqual(m.guestMetadataCache[key].networkInterfaces, original.networkInterfaces) || !reflect.DeepEqual(m.guestMetadataCache[key].ipAddresses, original.ipAddresses) {
t.Error("retained identity aliases returned values")
}
calls := client.calls
m.fetchGuestAgentMetadata(context.Background(), &client, "metadata", "node", "guest", 105, status, false)
if client.calls != calls {
t.Error("backoff queued another metadata command")
}
}
if client.calls != 6 {
t.Errorf("metadata calls = %d, want exactly two three-command attempts", client.calls)
}
})
}
}
func TestGuestMetadataUsefulRefreshRenewsOnlyObservedEvidence(t *testing.T) {
for _, tc := range []struct {
name string
client metadataObservationClient
}{
{"network", metadataObservationClient{network: []proxmox.VMNetworkInterface{{Name: "eth1", IPAddresses: []proxmox.VMIPAddress{{Address: "192.0.2.11"}}}}}},
{"os", metadataObservationClient{os: map[string]interface{}{"name": "NewOS", "version": "2"}}},
{"agent-version", metadataObservationClient{version: "2.0"}},
{"unchanged-but-observed", metadataObservationClient{version: "1.0"}},
} {
t.Run(tc.name, func(t *testing.T) {
key := guestMetadataCacheKey("metadata", "node", 105)
original := metadataObservationFixture(time.Now().Add(-2 * guestMetadataCacheTTL))
m := &Monitor{guestMetadataCache: map[string]guestMetadataCacheEntry{key: original}, guestMetadataLimiter: make(map[string]time.Time)}
client := tc.client
start := time.Now()
m.fetchGuestAgentMetadata(context.Background(), &client, "metadata", "node", "guest", 105, &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}}, false)
end := time.Now()
entry := m.guestMetadataCache[key]
if entry.fetchedAt.Before(start) || entry.fetchedAt.After(end) || !m.hasRecentGuestMetadataEvidence("metadata", "node", 105, end) {
t.Error("accepted useful metadata did not establish current evidence")
}
if tc.client.version != "" && entry.agentVersion != tc.client.version {
t.Error("current agent version was lost")
}
if tc.client.os != nil && (entry.osName != "NewOS" || entry.osVersion != "2") {
t.Error("current OS identity was lost")
}
if len(tc.client.network) > 0 && (len(entry.networkInterfaces) != 1 || entry.networkInterfaces[0].Name != "eth1") {
t.Error("current network metadata was lost")
}
if len(tc.client.network) == 0 && !reflect.DeepEqual(entry.networkInterfaces, original.networkInterfaces) {
t.Error("an independent useful field cleared retained network identity")
}
})
}
}
// The real HTTP/client/metadata/builder path must not turn a complete failed or
// empty metadata refresh into recent guest-agent evidence. This is not native
// QGA execution, backup overlap or workload recovery.
func TestGuestMetadataObservationHTTPPollLifecycle(t *testing.T) {
for _, mode := range []string{"refused", "empty", "unusable"} {
t.Run(mode, func(t *testing.T) {
const mib = uint64(1024 * 1024)
var phase, calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
p := phase.Load()
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
fmt.Fprint(w, `{"data":{}}`)
case strings.HasSuffix(r.URL.Path, "/status/current"):
agent := `1`
if p == 2 {
agent = `{"enabled":1,"available":0}`
}
fmt.Fprintf(w, `{"data":{"status":"running","agent":%s,"maxmem":%d,"mem":%d,"meminfo":{"available":%d},"cpu":0.2}}`, agent, 8*mib, 3*mib, 5*mib)
case strings.Contains(r.URL.Path, "/agent/"):
calls.Add(1)
if p == 1 {
if mode == "refused" {
http.Error(w, "permission denied", http.StatusForbidden)
return
}
switch {
case strings.HasSuffix(r.URL.Path, "network-get-interfaces"):
fmt.Fprint(w, `{"data":{"result":[]}}`)
case strings.HasSuffix(r.URL.Path, "get-osinfo") && mode == "unusable":
fmt.Fprint(w, `{"data":{"unrelated":"fixture"}}`)
case strings.HasSuffix(r.URL.Path, "get-osinfo"):
fmt.Fprint(w, `{"data":{}}`)
default:
fmt.Fprint(w, `{"data":{"result":{}}}`)
}
return
}
ip, version := "192.0.2.10", "1.0"
if p == 3 {
ip, version = "192.0.2.11", "2.0"
}
switch {
case strings.HasSuffix(r.URL.Path, "network-get-interfaces"):
fmt.Fprintf(w, `{"data":{"result":[{"name":"eth0","ip-addresses":[{"ip-address":%q,"ip-address-type":"ipv4","prefix":24}]}]}}`, ip)
case strings.HasSuffix(r.URL.Path, "get-osinfo"):
fmt.Fprint(w, `{"data":{"name":"Linux","version":"fixture"}}`)
case strings.HasSuffix(r.URL.Path, "info"):
fmt.Fprintf(w, `{"data":{"result":{"version":%q}}}`, version)
case strings.HasSuffix(r.URL.Path, "get-fsinfo"):
fmt.Fprintf(w, `{"data":{"result":[{"mountpoint":"/","type":"ext4","total-bytes":%d,"used-bytes":%d}]}}`, 1000*mib, 300*mib)
default:
http.NotFound(w, r)
}
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := proxmox.NewClient(proxmox.ClientConfig{Host: server.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
m := &Monitor{config: &config.Config{}, rateTracker: NewRateTracker(), metricsHistory: NewMetricsHistory(16, time.Hour), guestMetadataLimiter: make(map[string]time.Time)}
res := proxmox.ClusterResource{Type: "qemu", Node: "node", Name: "guest", VMID: 105, Status: "running", MaxMem: 8 * mib, Mem: 3 * mib, MaxDisk: 1000 * mib, CPU: 0.2}
build := func() models.VM {
vm, _, _, _, _, ok := m.buildVMFromClusterResource(context.Background(), "metadata", res, client, "metadata:node:105", nil, nil)
if !ok {
t.Fatal("guest disappeared")
}
return vm
}
first := build()
if first.GuestAgentStatus != "available" || first.AgentVersion != "1.0" || calls.Load() != 4 {
t.Fatalf("healthy fixture absent: status=%s version=%s calls=%d", first.GuestAgentStatus, first.AgentVersion, calls.Load())
}
key := guestMetadataCacheKey("metadata", "node", 105)
original := m.guestMetadataCache[key]
original.fetchedAt = time.Now().Add(-2 * guestMetadataCacheTTL)
m.guestMetadataCache[key] = original
m.guestMetadataLimiter[key] = time.Now().Add(-time.Second)
phase.Store(1)
ips, ifaces, name, version, agent := m.fetchGuestAgentMetadata(context.Background(), client, "metadata", "node", "guest", 105, &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}}, false)
if !reflect.DeepEqual(ips, first.IPAddresses) || !reflect.DeepEqual(ifaces, first.NetworkInterfaces) || name != first.OSName || version != first.OSVersion || agent != first.AgentVersion {
t.Error("failed HTTP refresh lost last-known metadata")
}
if !m.guestMetadataCache[key].fetchedAt.Equal(original.fetchedAt) || m.hasRecentGuestMetadataEvidence("metadata", "node", 105, time.Now()) {
t.Error("failed HTTP refresh became new evidence")
}
phase.Store(2)
unavailable := build()
if unavailable.GuestAgentStatus != "not-running" || unavailable.GuestAgentExpected {
t.Errorf("retained identity changed guest-agent runtime classification: %s/%t", unavailable.GuestAgentStatus, unavailable.GuestAgentExpected)
}
if unavailable.CPU != first.CPU || unavailable.Memory.Used != first.Memory.Used || unavailable.ID != first.ID || calls.Load() != 7 {
t.Error("independent status/identity was lost or unavailable agent was queried")
}
phase.Store(3)
recovered := build()
if recovered.GuestAgentStatus != "available" || recovered.AgentVersion != "2.0" || len(recovered.IPAddresses) != 1 || recovered.IPAddresses[0] != "192.0.2.11" || !m.hasRecentGuestMetadataEvidence("metadata", "node", 105, time.Now()) || calls.Load() != 11 {
t.Error("healthy poll did not recover current metadata and evidence")
}
})
}
}
+2 -2
View File
@@ -1035,8 +1035,8 @@ func TestFetchGuestAgentMetadataPreservesCachedValuesOnEmptyResponses(t *testing
if len(updated.networkInterfaces) != 1 || updated.networkInterfaces[0].Name != "eth0" {
t.Fatalf("expected cache interfaces to remain populated, got %#v", updated.networkInterfaces)
}
if updated.fetchedAt.Before(cachedFetchedAt) {
t.Fatalf("expected cache timestamp to be refreshed, old=%v new=%v", cachedFetchedAt, updated.fetchedAt)
if !updated.fetchedAt.Equal(cachedFetchedAt) {
t.Fatalf("empty responses renewed retained metadata, old=%v new=%v", cachedFetchedAt, updated.fetchedAt)
}
}
@@ -136,10 +136,15 @@ func (m *Monitor) recordGuestMetrics(allVMs []models.VM, allContainers []models.
if vm.Status == "running" {
diskRead, diskWrite, networkIn, networkOut := guestHistoryRates(vm.DiskRead, vm.DiskWrite, vm.NetworkIn, vm.NetworkOut, vm.IORateValidity)
memoryUsage, memoryUsed, diskUsage := historyMemoryUsage(vm.Memory), historyMemoryUsed(vm.Memory), vm.Disk.Usage
if strings.HasPrefix(vm.DiskStatusReason, "prev-") {
// A failed filesystem read can leave a positive cluster allocation,
// not just a retained prev- reading. Neither is current usage.
if vm.DiskStatusReason != "" {
diskUsage = -1
}
if vm.GuestAgentStatus == "deferred" {
// Only legacy unannotated memory needs the diagnostic heuristic.
// An explicit observation belongs to the selected source: deferred
// filesystem/QGA work cannot disqualify independent current memory.
if vm.GuestAgentStatus == "deferred" && vm.Memory.Observation.State == "" {
snapshot := m.previousGuestSnapshot(vm.Instance, "qemu", vm.Node, vm.VMID)
if snapshot == nil || snapshot.MemorySource == "previous-snapshot" || strings.HasPrefix(CanonicalMemorySource(snapshot.MemorySource), "guest-agent-meminfo") {
memoryUsage, memoryUsed = -1, -1
@@ -176,15 +181,18 @@ func guestHistoryRates(diskRead, diskWrite, networkIn, networkOut int64, validit
return diskRead, diskWrite, networkIn, networkOut
}
// Retained numbers remain useful for display, but a newly refreshed guest row
// cannot turn them into a new percentage or byte observation. The producer owns
// freshness; ordinary current TTL-cache hits and legacy readings remain valid.
func historyMemoryUsage(memory models.Memory) float64 {
if !memory.HasKnownUsage() {
if !memory.HasKnownUsage() || (memory.Observation.State != "" && memory.Observation.State != "current") {
return -1
}
return memory.Usage
}
func historyMemoryUsed(memory models.Memory) float64 {
if !memory.HasKnownUsage() {
if !memory.HasKnownUsage() || (memory.Observation.State != "" && memory.Observation.State != "current") {
return -1
}
return float64(memory.Used)
@@ -0,0 +1,366 @@
package installtests
import (
"context"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func trueNASAPIBlocks(doc string) ([]string, error) {
_, section, found := strings.Cut(doc, "### Testing and adding a connection (API)\n")
if !found {
return nil, fmt.Errorf("connection API guidance is missing")
}
section, _, _ = strings.Cut(section, "\n## Troubleshooting")
matches := regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(section, -1)
if len(matches) != 3 {
return nil, fmt.Errorf("want separate preparation/test/save blocks, got %d", len(matches))
}
blocks := make([]string, 3)
for i, match := range matches {
blocks[i] = match[1]
}
return blocks, nil
}
func trueNASAPISafety(blocks []string) error {
for _, required := range []string{"set -eu", "umask 077", `[ -L "$HOME/.config" ]`,
`[ -L "$config_dir" ]`, `[ -L "$connection_file" ]`, `[ ! -f "$connection_file" ]`,
`chmod 700 "$config_dir"`, `chmod 600 "$connection_file"`, `vi "$connection_file"`} {
if !strings.Contains(blocks[0], required) {
return fmt.Errorf("unsafe file preparation: missing %s", required)
}
}
for i, block := range blocks[1:] {
for _, required := range []string{"set -eu", "umask 077", `result_file=$(mktemp "$HOME/.config/pulse/truenas-`,
"status=$(curl --disable --fail-with-body --silent --show-error", "--connect-timeout 5 --max-time 20",
"--proto '=http' --noproxy 127.0.0.1", `--header "@$HOME/.config/pulse/api-header"`,
`--data-binary "@$HOME/.config/pulse/truenas-connection.json"`,
`--output "$result_file" --write-out '%{http_code}'`,
fmt.Sprintf(`[ "$status" = %d ]`, 200+i)} {
if !strings.Contains(block, required) {
return fmt.Errorf("unsafe request %d: missing %s", i+1, required)
}
}
for _, forbidden := range []string{"--insecure", "--location", "--trace", "--verbose", "--config", "--retry", "X-API-Token:", "Authorization:"} {
if strings.Contains(block, forbidden) {
return fmt.Errorf("unsafe request: %s", forbidden)
}
}
if strings.Count(block, "curl ") != 1 || strings.Contains(block, " | ") {
return fmt.Errorf("each copied request must retain its own result and make only one POST")
}
}
return nil
}
func trueNASAPIGuidance(t *testing.T) []string {
t.Helper()
doc, err := os.ReadFile(repoFile("docs", "TRUENAS.md"))
if err != nil {
t.Fatal(err)
}
blocks, err := trueNASAPIBlocks(string(doc))
if err != nil {
t.Fatal(err)
}
if err := trueNASAPISafety(blocks); err != nil {
t.Fatal(err)
}
mirror, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", "TRUENAS.md"))
if err != nil || string(mirror) != string(doc) {
t.Fatal("shipped TrueNAS guide must be identical to the verified recipe")
}
return blocks
}
func runTrueNASRecipe(t *testing.T, recipe, home, bin string, extraEnv ...string) (string, int) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "-c", recipe)
cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+bin+":"+os.Getenv("PATH"),
"http_proxy=", "HTTP_PROXY=", "https_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "all_proxy=", "NO_PROXY=", "no_proxy=")
cmd.Env = append(cmd.Env, extraEnv...)
output, err := cmd.CombinedOutput()
if ctx.Err() != nil {
t.Fatal("copied recipe exceeded its bounded test deadline")
}
if err == nil {
return string(output), 0
}
failure, ok := err.(*exec.ExitError)
if !ok {
t.Fatal(err)
}
return string(output), failure.ExitCode()
}
func TestTrueNASAPIGuidancePreparationIsPrivateAndFailClosed(t *testing.T) {
for _, mode := range []string{"new", "existing", "config symlink", "directory symlink", "file symlink", "non-regular", "mkdir failure", "chmod failure"} {
t.Run(mode, func(t *testing.T) {
home := t.TempDir()
bin := filepath.Join(home, "bin")
if err := os.Mkdir(bin, 0700); err != nil {
t.Fatal(err)
}
edited := filepath.Join(home, "editor-started")
if err := os.WriteFile(filepath.Join(bin, "vi"), []byte("#!/bin/sh\nprintf 'started' > \"$EDITOR_STARTED\"\n"), 0700); err != nil {
t.Fatal(err)
}
dir := filepath.Join(home, ".config", "pulse")
file := filepath.Join(dir, "truenas-connection.json")
if mode == "existing" || mode == "file symlink" || mode == "non-regular" {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
}
target := filepath.Join(home, "untouched")
if strings.Contains(mode, "symlink") {
link := file
if mode == "file symlink" {
if err := os.WriteFile(target, []byte("private-existing-key"), 0644); err != nil {
t.Fatal(err)
}
} else {
if err := os.Mkdir(target, 0755); err != nil {
t.Fatal(err)
}
link = filepath.Join(home, ".config")
if mode == "directory symlink" {
if err := os.Mkdir(link, 0755); err != nil {
t.Fatal(err)
}
link = dir
}
}
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
}
if mode == "existing" {
if err := os.WriteFile(file, []byte("private-existing-key"), 0644); err != nil {
t.Fatal(err)
}
}
if mode == "non-regular" {
if err := os.Mkdir(file, 0700); err != nil {
t.Fatal(err)
}
}
if strings.HasSuffix(mode, " failure") {
if err := os.WriteFile(filepath.Join(bin, strings.TrimSuffix(mode, " failure")), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
}
output, exit := runTrueNASRecipe(t, trueNASAPIGuidance(t)[0], home, bin, "EDITOR_STARTED="+edited)
_, editorErr := os.Stat(edited)
if mode != "new" && mode != "existing" {
if exit == 0 || !os.IsNotExist(editorErr) {
t.Fatal("unsafe or failed preparation reached the editor")
}
if strings.Contains(mode, "symlink") {
info, err := os.Stat(target)
if err != nil || info.Mode().Perm() != map[bool]os.FileMode{true: 0644, false: 0755}[mode == "file symlink"] {
t.Fatal("refused path modified its target permissions")
}
}
} else {
if exit != 0 || editorErr != nil {
t.Fatalf("safe preparation failed: exit %d, %s", exit, output)
}
for path, permissions := range map[string]os.FileMode{dir: 0700, file: 0600} {
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != permissions {
t.Fatal("credential path has incorrect permissions")
}
}
content, err := os.ReadFile(file)
if err != nil || (mode == "existing" && string(content) != "private-existing-key") {
t.Fatal("preparation did not preserve the existing credential file")
}
}
if strings.Contains(output, "private-existing-key") {
t.Fatal("preparation exposed private contents")
}
})
}
}
func TestTrueNASAPIGuidanceRejectsUnsafeRecipesAndExactParent(t *testing.T) {
blocks := trueNASAPIGuidance(t)
for name, edit := range map[string][2]string{
"config": {"curl --disable ", "curl "}, "body": {`--output "$result_file" `, ""},
"deadline": {"--max-time 20 ", ""}, "retry": {"curl --disable ", "curl --disable --retry 2 "},
"redirect": {"curl --disable ", "curl --disable --location "},
"inline token": {`"@$HOME/.config/pulse/api-header"`, `'X-API-Token: synthetic-secret'`},
"fail-open": {"set -eu", ""}, "wrong save status": {`[ "$status" = 201 ]`, `[ "$status" = 200 ]`},
} {
t.Run(name, func(t *testing.T) {
changed := make([]string, len(blocks))
for i, block := range blocks {
changed[i] = strings.ReplaceAll(block, edit[0], edit[1])
}
if trueNASAPISafety(changed) == nil {
t.Fatal("unsafe mutation was accepted")
}
})
}
if inputs := os.Getenv("PULSE_PROOF_INPUTS"); inputs != "" {
parent, err := os.ReadFile(filepath.Join(inputs, "truenas-parent.md"))
if os.IsNotExist(err) {
// Other source proofs can supply unrelated inputs. The mutation
// controls above still apply without this optional parent receipt.
return
}
if err != nil {
t.Fatal(err)
}
old, err := trueNASAPIBlocks(string(parent))
if err != nil {
t.Fatal(err)
}
if err := trueNASAPISafety(old); err == nil {
t.Fatal("unchanged exact parent guide passed new safety controls")
} else {
t.Logf("exact parent rejected: %s", err)
}
}
}
// Synthetic peers exercise the actual copied commands and deliberately echo
// both fixture secrets. This is credential/transport safety, not native
// TrueNAS collection, Pulse authorisation or a customer's setup acceptance.
func TestTrueNASAPIGuidanceCopiedRequestsKeepSecretsPrivate(t *testing.T) {
for _, scenario := range []string{"success", "HTTP error", "redirect", "wrong success status", "trusted HTTPS", "untrusted HTTPS"} {
for index, recipe := range trueNASAPIGuidance(t)[1:] {
t.Run(fmt.Sprintf("%s/%d", scenario, index+1), func(t *testing.T) {
realCurl, err := exec.LookPath("curl")
if err != nil {
t.Fatal("curl is required to verify copied TrueNAS guidance")
}
home := t.TempDir()
bin := filepath.Join(home, "bin")
dir := filepath.Join(home, ".config", "pulse")
for _, path := range []string{bin, dir} {
if err := os.MkdirAll(path, 0700); err != nil {
t.Fatal(err)
}
}
const token = "synthetic-pulse-secret"
const key = "synthetic-truenas-secret"
const body = `{"host":"https://synthetic.invalid","apiKey":"` + key + `"}`
for file, content := range map[string]string{"api-header": "X-API-Token: " + token, "truenas-connection.json": body} {
if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0600); err != nil {
t.Fatal(err)
}
}
capture := filepath.Join(home, "argv")
trace := filepath.Join(home, "must-not-trace")
wrapper := "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$TN_ARGV\"\nexec \"$TN_CURL\" \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "curl"), []byte(wrapper), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, ".curlrc"), []byte("insecure\nlocation\ntrace = "+trace+"\n"), 0600); err != nil {
t.Fatal(err)
}
requests := make(chan string, 4)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
requests <- strings.Join([]string{r.Method, r.URL.Path, r.Header.Get("X-API-Token"), r.Header.Get("Content-Type"), string(payload)}, "\n")
code := 200 + index
switch scenario {
case "HTTP error":
code = 400
case "redirect":
w.Header().Set("Location", "/must-not-follow")
code = 307
case "wrong success status":
code = 201 - index
}
w.WriteHeader(code)
fmt.Fprint(w, token+" "+key+" private-response")
}))
server.Config.ErrorLog = log.New(io.Discard, "", 0)
if strings.Contains(scenario, "HTTPS") {
server.StartTLS()
recipe = strings.Replace(recipe, "--proto '=http'", "--proto '=https'", 1)
if scenario == "trusted HTTPS" {
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
if err := os.WriteFile(filepath.Join(home, "ca.pem"), ca, 0600); err != nil {
t.Fatal(err)
}
recipe = strings.Replace(recipe, "curl --disable ", `curl --disable --cacert "$HOME/ca.pem" `, 1)
}
} else {
server.Start()
}
t.Cleanup(server.Close)
recipe = strings.Replace(recipe, "http://127.0.0.1:7655", server.URL, 1)
output, exit := runTrueNASRecipe(t, recipe, home, bin, "TN_ARGV="+capture, "TN_CURL="+realCurl)
wantExit := 0
if scenario == "HTTP error" {
wantExit = 22
} else if scenario == "untrusted HTTPS" {
wantExit = 60
} else if scenario == "redirect" || scenario == "wrong success status" {
wantExit = 1
}
if exit != wantExit {
t.Fatalf("copied request exit %d, want %d: %s", exit, wantExit, output)
}
argv, err := os.ReadFile(capture)
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{token, key, "private-response"} {
if strings.Contains(string(argv), secret) || strings.Contains(output, secret) {
t.Fatal("copied request exposed a fixture credential or response")
}
}
if _, err := os.Stat(trace); !os.IsNotExist(err) {
t.Fatal("curl configuration enabled tracing")
}
responses, err := filepath.Glob(filepath.Join(dir, "truenas-*.??????"))
if err != nil || len(responses) != 1 {
t.Fatal("request must retain one new private response file")
}
info, err := os.Stat(responses[0])
if err != nil || info.Mode().Perm() != 0600 {
t.Fatal("response file is not private")
}
if scenario == "untrusted HTTPS" {
if len(requests) != 0 {
t.Fatal("untrusted TLS peer received credentials")
}
} else {
if len(requests) != 1 {
t.Fatal("request was missing, redirected or retried")
}
path := "/api/truenas/connections"
if index == 0 {
path += "/test"
}
if got := <-requests; got != strings.Join([]string{"POST", path, token, "application/json", body}, "\n") {
t.Fatal("copied POST differs from the documented endpoint, header or JSON file")
}
response, err := os.ReadFile(responses[0])
if err != nil || string(response) != token+" "+key+" private-response" {
t.Fatal("complete sensitive response was not retained locally")
}
}
})
}
}
}
+13 -6
View File
@@ -27,10 +27,12 @@ PAYLOAD = {"name": "test-nas", "host": "https://nas.example.invalid",
def commands():
blocks = re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL)
preparation = next(block for block in blocks if "umask 077" in block)
requests = [block for block in blocks if block.startswith("curl ")]
return preparation, requests
section = DOC.read_text().split("### Testing and adding a connection (API)\n", 1)[1]
section = section.split("\n## Troubleshooting", 1)[0]
blocks = re.findall(r"```bash\n(.*?)```", section, re.DOTALL)
if len(blocks) != 3:
raise AssertionError("expected separate preparation, test and save recipes")
return blocks[0], blocks[1:]
@contextmanager
@@ -41,7 +43,7 @@ def server(status=200):
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
requests.append((self.path, dict(self.headers), json.loads(body)))
self.send_response(status)
self.send_response(status if status != 200 or self.path.endswith("/test") else 201)
self.end_headers()
self.wfile.write(b'{"fixture":true}\n')
@@ -192,6 +194,11 @@ class TrueNASDocsTest(unittest.TestCase):
for secret in (TOKEN, NAS_KEY):
self.assertNotIn(secret, " ".join(argv))
self.assertNotIn(secret.encode(), result.stdout + result.stderr)
output = Path(argv[argv.index("--output") + 1])
self.assertEqual(output.parent, private)
self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600)
self.assertEqual(output.read_bytes(), b'{"fixture":true}\n')
self.assertNotIn(b'{"fixture":true}', result.stdout + result.stderr)
return result
def test_test_and_save_send_header_and_json_from_private_files(self):
@@ -215,7 +222,7 @@ class TrueNASDocsTest(unittest.TestCase):
with server(status) as (port, _):
result = self.request(Path(temporary), command, port)
self.assertEqual(result.returncode, 22, result.stderr.decode())
self.assertIn(b'{"fixture":true}', result.stdout)
self.assertNotIn(b'{"fixture":true}', result.stdout + result.stderr)
if __name__ == "__main__":