mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Merge remote-tracking branch 'origin/main' into feat/vsphere-overview-health
This commit is contained in:
+94
-28
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
+161
@@ -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/);
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -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 () => {
|
||||
|
||||
+6
-8
@@ -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 () => {
|
||||
|
||||
+172
@@ -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');
|
||||
});
|
||||
});
|
||||
+3
@@ -85,6 +85,9 @@ describe('GuestDrawerOverview filesystem labels', () => {
|
||||
).toHaveAttribute('colspan', '2');
|
||||
expect(screen.queryByText('prev-vm-locked')).not.toBeInTheDocument();
|
||||
for (const path of paths) expect(screen.getByText(path)).toBeInTheDocument();
|
||||
expect(screen.getByText('Last known 50% · 5.00 GB/10.0 GB · MP0')).toBeVisible();
|
||||
expect(screen.getByText('Usage unavailable · ?/10.0 GB · MP1')).toBeVisible();
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
setGuest({
|
||||
...guest(),
|
||||
diskStatusReason: '',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+21
-1
@@ -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'}`;
|
||||
+2
-2
@@ -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',
|
||||
);
|
||||
|
||||
+89
-21
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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":
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user