mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Keep deferred guest readings, alerts and History truthful (#2472)
## What changes and why Keep guest monitoring truthful while reads are deferred: retained memory/filesystem values must not become new alert decisions, new History observations, current filesystem health, or continuous chart lines across missing readings. Failed or empty metadata refreshes retain the last useful observation time rather than manufacturing freshness. Independent current readings and measured-zero recovery keep their existing behaviour. Manual guest Discovery pauses during the existing backup/read precaution, preserves saved evidence, and keeps delayed outcomes attached to their original guest and run. The published drawer-header link to a saved web interface remains available separately from live Discovery. The existing TrueNAS guide and shipped copy keep credentials and responses private and bound individual requests. The guest work follows the prolonged freeze reported in #2439 and reproduced downstream observation/lifecycle gaps. It does not identify the freeze's cause or prove native recovery. The guidance repair follows inspection of unsafe existing recipes. No new collector, schema, retention policy, threshold or product surface is introduced by this batch. ## Included source and reconciliation Current published base `efe6fc21ae10a6bf173cc17e6203aaf6c97dc130` → proposed head `1914f5fb7b278b5fc4cc10a292e909a10714a2f8` (39 introduced commits, including additive integrations). - TrueNAS guidance: `cadfd1f4e1d9f909a45977f94aadd4f1324e82ef` → `d589407a743a12fe492e93785d9e24c12bbb4fb5`. - Discovery pause/outcome ownership: `2cb3f6a9043e0bab9c54c5b5ef57b3e1cb994781` → `bbaee0a3f43dc7906011be5c78d684f9a1327bae`, including additive recovery and lifecycle controls. - Alert admission: `b0f8b96c8a159ac242b85f2d09f8dfffd5c29c7a` → `0fd7768e9d1379f8aa0126b33d4047b4c52c0d87`. - History writer admission: `a15920eaebea00ab8c353ddd3861b9cd7ff7d379`. - Filesystem presentation: `2525d76c04a9834c2130e97c1a529e164a9c7796` → `5b75f21159fda3c01fbe6688aa9cfde68779fba8`. - Metadata freshness: `f1e221a0fb9b0804f200c13490f8526424cb7e1c`. - Missing-observation chart presentation: `0ad41e4c9a309ce459dd717d16d343b46efcac8a` → `5e7e49650fcdf5131b7c5006fe487336ab660e15`. - Platform-aware Discovery fixture: `a2608c9fadf76c242527a636ee08968fa16ddde8`; TrueNAS recipe reconciliation `1f0d12af85ec7c2ad8df391b8e6958e80fa98179`; additive upstream/header composition and content-bound browser receipts through `b01dad904c3436e70bc01588ea1546fffaa05dc6`; batch-start upstream merge `f1c7e413bac2e3306d2169bcd069c60325e56d5f`, then exact bounded refreshed-upstream merges `07112b0e3ab2be174a5ac7a11f87ac0837edceb0` and `1914f5fb7b278b5fc4cc10a292e909a10714a2f8`. The previous proposal head `6a20d106242598bd6b466e9ae888ea931d284b30` is completed-failed, not still validating. Native run `37239183481` incorrectly required a Linux/PVE capability on Windows/macOS; Build and Test `37239183545` extracted zero wrapped TrueNAS recipes. Reviewed additive fixtures correct both without weakening runtime safety. The old head cannot land independently, so this update includes those corrections and the coherent observation batch. Every accepted commit identity and all ancestry are retained. Published cluster labels, saved guest links, unique demo workload names the newer storage priority/alert explanations, and the published Alerts overview are preserved. Bounded refresh receipt `2916978f4e4b49d084752e973e19a5c0f7dd19f0bf4ac60cc31875876318528d` completed at the exact observed upstream without changing source; the subsequent full-SHA merge composes it additively. A second successful bounded refresh `59f1939979347921d99e262398d5347077bfce6faf6f42535050840a3a19820b` incorporates the subsequently published Alerts overview in the same way. Neither is a replay of a failed or uncertain attempt. ## Validation and remaining gates Retained evidence includes connected alert/API/producer/recovery controls (`source-p58_phvc`), Discovery/component/build/browser checks (`source-gy5x8buj`), installer/docs controls (`source-7s0iorys`, `source-uo6rxs61`), metadata connected/race/vet/build proof (`source-ji23nb_c`), and ten connected suites plus focused safety races/vet and native-target compilation (`source-seboen4h`). Browser content receipts cover the final seven changed frontend runtime files, including the composed saved-link/safety header and chart gaps. The History presentation proof `source-ualisb1k` completed 22,571 frontend tests across 1,234 files, types/lint/format/build and bundle/integrity checks; its overall exit 1 from trailing nonexistent optional test paths remains recorded, not converted into a whole-command pass. Four browser groups/32 checks and eight inspected captures separately passed. Earlier reproductions, fixture failures and reporting-limit failures remain evidence. Publication-gate checks: - `source-qt6m6mf_`: seven connected TrueNAS recipe controls, full mock suite, scoped real agent/guest safety, alert observation and metadata/History tests pass. Backend/docs source is byte-identical in the final composition; this evidence was not repeated after frontend-only upstream advances. - `source-xy1lnmgt`: types pass and 281 of 283 scoped frontend controls pass; two first-case History tests hit the unchanged 5,000ms timeout. Exit 1 is retained and the following build did not run. No functional assertion failure or cause was established. - One predeclared single-worker/file-serial comparison, `source-17bch24o`, passes all 23 controls in those two files with unchanged source, assertions and timeouts. This establishes isolated functional behaviour, not a diagnosis or clearance of parallel CI. - `source-dvlfnm5t`: refreshed storage composition passes types, 1,177 tests/56 files and the real production/embed build. The event-parser line-bound flag is recorded; the complete 26,474 raw bytes independently match the receipt. - Final exact-head `source-sjjtzjqm`: types, scoped published Alerts overview/translation/presentation controls and the real production/embed build pass. Complete raw output is independently hash-matched and the guest stopped. - All executed receipts, including the failed scoped run, retain complete output and completed guest stop. Provenance, clean source, diff/ancestry checks, all 24 introduced non-merge commits' parent-bound canonical guards using only their original recorded trailers, and the final seven-file browser-content receipt guard pass. Required exact-head administrative source review and GitHub checks must complete before landing; this update is not a blind failed-job retry or a CI waiver. Native overlap, independent thaw, fresh writes on every covered filesystem, workload liveness, truthful alert delivery and safe restoration of only prior-active services/timers remain separate. UI clearance, saved values, an OK backup or these source tests are not recovery, a containing release or installed/reporter acceptance. Related to #2439 [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
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
|
||||
|
||||
@@ -249,6 +249,24 @@ Fresh linked Pulse-agent disk evidence with no reason remains unmarked, and
|
||||
non-VM rows do not inherit QGA guidance. The cue adds no action or tab stop;
|
||||
touch on it continues through the existing row disclosure to the full reason.
|
||||
|
||||
The opened guest Overview applies the same admission to every filesystem, not
|
||||
only the aggregate row or its status sentence. A valid `prev-` value is labelled
|
||||
**Last known** beside its mount identity and used/capacity bytes; retained values
|
||||
do not receive current utilization bars or threshold colours. Other VM read
|
||||
reasons and explicit disk telemetry unavailability show **Usage unavailable**,
|
||||
retaining identity and known capacity without promoting old used bytes. Missing
|
||||
or invalid retained usage is unavailable, not an empty healthy filesystem. The
|
||||
read explanation precedes the filesystem breakdown and remains visible before
|
||||
any sample. Clearing a lock alone cannot clear a still-supplied reason. A fresh
|
||||
same-guest read restores the normal accessible utilization bar; independent LXC
|
||||
and linked-agent readings with no VM reason retain their existing semantics.
|
||||
|
||||
Verification: `GuestDrawerOverview.filesystem-provenance.test.tsx`, the existing
|
||||
filesystem regressions and builder controls in `DiskList.test.tsx`, plus
|
||||
`browser-tests/guest-filesystem-provenance.cjs` using the full production guest
|
||||
drawer on desktop/light/dark and touch WebKit. These are synthetic presentation
|
||||
and input proofs, not native backup/thaw, covered-filesystem writes or resumption.
|
||||
|
||||
Verification: mounted `GuestRow.test.tsx` and `MetricMiniSparkline.test.tsx`
|
||||
regressions and `browser-tests/guest-disk-provenance.cjs` exercise retained and
|
||||
pre-sample states, display/lens changes, current-value semantics and same-row
|
||||
@@ -8308,3 +8326,34 @@ chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS,
|
||||
403/503 controls and pending recovery. Unit controls separately cover current
|
||||
request ownership and disposal. No mutation, route, navigation entry, queue
|
||||
policy, permission or recipient-delivery contract is added by these states.
|
||||
|
||||
### Drawer History does not interpolate across missing observations
|
||||
|
||||
The shared `GuestDrawerHistory` renderer connects a series only across
|
||||
consecutive stored times in that configured panel. If another series has a
|
||||
stored observation at an intervening time and this series does not, its line
|
||||
stops. Each isolated observation, including a measured zero, remains visible
|
||||
as a point; separate continuous segments retain all their original values,
|
||||
timestamps and common-window coordinates. Independently sampled series must
|
||||
not imply simultaneous observations or a continuous trend through absent data.
|
||||
|
||||
A visible, SVG-described explanation names the affected configured series and
|
||||
states the limit: missing readings do not identify an outage's cause or duration.
|
||||
There is no elapsed-time/cadence heuristic, synthetic zero, invented observation,
|
||||
new API field or collector change. Times outside the configured panel cannot
|
||||
split its series. Entirely absent series retain the existing missing/fallback
|
||||
policy. Live or retained snapshot carriers cannot repair a stored gap; only a
|
||||
matching stored response can. Source/range/access isolation, transient retained
|
||||
reads and exact-time keyboard/pointer/touch inspection remain in force.
|
||||
|
||||
`GuestDrawerHistory.gaps.test.tsx` and `guestHistorySegments.test.ts` cover
|
||||
split lines, singleton recovery, zero rates, interleaved sampling, multiple
|
||||
segments, configured-group isolation, invalid points, elapsed-time controls,
|
||||
live updates, stored refresh and denied/transient reads. `GuestDrawer.test.tsx`
|
||||
checks the full drawer's live-state recovery and unchanged request budget.
|
||||
Updated pointer/sparse
|
||||
regressions preserve their original missing-value and fallback obligations.
|
||||
`browser-tests/history-observation-gaps.cjs` exercises the production full guest
|
||||
drawer with synthetic APIs in Chromium/WebKit desktop and 390/320-pixel light
|
||||
and dark views. This is presentation acceptance, not native backup/QGA safety,
|
||||
provider outage attribution, installed recovery or release availability.
|
||||
|
||||
@@ -1690,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
|
||||
|
||||
@@ -3671,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.
|
||||
|
||||
@@ -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')!,
|
||||
);
|
||||
@@ -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,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,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."
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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