Make monitoring, notification results and recovery guidance safer and more truthful (#2429)

## Outcome and why

Make existing monitoring evidence, notification results and recovery
guidance safer and more truthful.

- **Guest-agent reads respect backup activity.** Coordinate guest reads,
defer them around backup locks and incomplete responses, and show
freshness/deferral rather than presenting retained readings as current.
The VM diagnostic helper stays off the guest-agent channel. The serious
frozen-VM report in #2439 motivates this source mitigation; it is not a
reproduced freeze cause or native thaw proof. Advice remains to stop
Pulse before the backup and restart only after independently verified
thaw, with the monitoring/alert outage explained.
- **Current, permission-scoped monitoring details.** Keep drawers on the
current resource snapshot; retain stored disk History independently of
current collection; withdraw History and fallback readings on access
denial; scope fetched History to the active organisation. Improve honest
PBS/NAS, Docker, Kubernetes and guest disk rows, units, wrapping and
unknown/pending states. These address the existing #1723/#2077 panel
concerns without claiming every appliance symptom resolved.
- **Fewer false metadata changes, honest notification results.** Sort
deduplicated label-derived tags while retaining real metadata edits and
clears, addressing #2441's narrow cause and one contributor to #2199.
Keep delayed firing/recovery, pending groups and repeat cooldowns tied
to their alert occurrence. Preserve SMTP final-DATA acceptance despite
cleanup failure and reject interrupted ntfy responses rather than
reporting delivery success. Failed notification-evidence reads remain
visible, not empty success.
- **Safer existing help and recovery.** Explain the actual configuration
archive and its omissions, key/stopped-state requirements, retargeting
and partial-import risks. Keep backup versus snapshot evidence and
rollback consent separate. Use private credential-header files, bounded
failure-aware diagnostics, bootstrap installation authentication and
non-destructive login recovery. Updated instructions cover scheduler,
webhook freshness, deployment, platform navigation and bounded write/CPU
evidence (#1966/#2199); they do not establish runtime performance
relief.
- **Evidence-preserving demo update/recovery.** Route owned signed
runtime transactions through captured baselines, sustained observations
and guarded forward/reverse restoration, including restoration when
receipt writing fails. Add a separate native acceptance workflow without
claiming it has run or that an installed transaction has passed.
Release-note title guards are aligned with the existing signed-runtime
recovery model. No release is selected or published here.

## Reviewed source and upstream reconciliation

The complete current unpublished range is
`8e14b4a63e954c52cf30f0c533b213020abc9397..e597bb2a4beba8679782a453e0653bf53d3f743d`:
**138 commits, 86 non-merge source/proof commits**. Every previously
reviewed identity and ancestor is retained, including starting local tip
`3c0fdcc74255d3ea6192475206326ee176ddaa7c`.

Earlier publication updated PR2429 from
`7a5635b2c0b76e13f510cc7a27b7ca0cd55091ba`; its failures and additive
remedies are retained history. Failed logs identify the VM-diagnostics
fixture lacking authoritative configuration (Build run `37143637085`),
the Deferred badge substring also matching the unreadable-evidence
warning (E2E run `37143637132`), macOS temporary-path aliases (Native
run `37143637054`), and clear-text billing-state rewriting (CodeQL check
`111263223348`). Reviewed additive repairs `091f9877`, `d9a2c786`,
`cd20663a`, and `7d739698` address those paths. Re-running that old head
would not incorporate these repairs.

Later reviewed work is included now because the old batch cannot land
independently:

- Web candidate `0f480d23aff50bd33236d125aafbfd67b1a65fd9` preserves
`c54aa9e3` backup-response validation: unreadable/malformed responses
are not empty success, and one source's failure does not hide another
source's readable inventory. It composes measured disk labels with
paused-read provenance and same-VM resumption.
- Recovery help `2201fd59` distinguishes completed-backup evidence from
safe restore; proxy help `c381995d` keeps navigation aligned with the
forwarded-header trust boundary; independent bounded guest-write witness
`ee069f21` records writes/liveness without issuing guest-agent commands.
None proves native thaw or installed recovery.
- Billing-safe demo profile repair `7d739698` requires an already
admitted demo capability and leaves billing/key material untouched;
canonical encrypted/legacy/HMAC controls remain with the application.
Diagnostic and delivery fixtures assert the real admission/evidence
boundaries without weakening them.
- Core candidate `509c12f1ae7c27fd004890c4db9e6d12312c874c` isolates
strict zero-allocation checks from unrelated background fixture workers.
Current upstream independently adds a related isolation helper at
`8e14b4a6`. Additive merge `49e37ddd` reconciles the upstream entry
point with the reviewed bounded child launcher, measurement witness,
escaping result sink and background-allocation control. No production
normalisation or allocation threshold changes.

Startup-fetched upstream `8e14b4a63e954c52cf30f0c533b213020abc9397` was
merged by full identity. No reviewed commits were rebased, amended or
recreated. No stopped upstream-refresh attempt was replayed or
retrospectively cleared. Final frontend runtime and fixture bytes match
the owning Web candidate; additional upstream receipts are preserved
unchanged.

### Current proposal decision

PR2429 at `49e37dddfcf3ed03f5c47cdcca10a367969da9b5` has completed
validation, not a running batch to preserve. All functional backend/API
shards, frontend, eight E2E shards/verdict, native and CodeQL checks
succeeded. Required governance run `37161775883` failed because its
source assertion still demanded the billing-integrity removal and
entitlement rewrite that reviewed security repair `7d739698`
deliberately removed. Additive correction `e597bb2a` instead requires
read-only admission of an existing demo capability and rejects those
former unsafe writes. Production paths and thresholds are unchanged.
This batch cannot land independently until that required defect is
repaired, so the newer reviewed Recovery link work is included rather
than repeatedly advancing a checking head.

Product Intelligence candidate
`2a9e4183a6ff670d2f08dab3f67f4b95b27f6c1c`, source `48dc32d1`, is
retained by integration `3c0fdcc7`. Both Recovery mirrors now navigate
to the actual rendered safe-evidence heading. Its renderer regression
and Chromium/phone WebKit keyboard-focus controls pass at the owning
exact source; the initial missing dependency and fixture-only full-shell
API500 remain separate adverse setup results. Retained `source-3ytpvq_m`
passes 58 docs controls, types, focused lint/copy, production
build/budgets and mirrors. No released or native recovery claim follows.

Advisory Benchmarks in completed Build/Test run `37161775840` reports
`HandleMetricsHistory_MemoryFallback/single-metric-4` **+15.59%**,
117.0us to 135.3us, p=0.035, n=10. That result is not a pass, diagnosed
noise or release approval. No measurement or workflow was replayed;
Delivery receives the exact head/run/evidence for its existing
performance follow-through. The test-only correction does not remedy
this performance observation. New exact-head CI must run normally.

## Validation and remaining limits

Current lightweight final-source fixture proof via
`pulse-maintainer-proof` at `e597bb2a` passes all **60 release-promotion
policy controls and 58 demo transaction fixture controls** (complete
output; exit 0). The disposable source-proof request remained queued for
capacity at the last observation and has no terminal receipt; it is not
counted as a pass. No new production bytes require repeating already
sufficient heavy functional evidence. A first dirty-source proof
admission was refused before execution, and the initial correction
commit was refused for noncontiguous trailers; the clean committed
correction fixes that metadata, with no accepted commit rewritten.
Current provenance, whitespace, the three added source/proof commits'
own-parent/history guards, 34-path final browser coverage,
contract/registry audits and shipped-doc/public-doc checks pass. Earlier
83-commit governance and unchanged functional evidence are retained, not
claimed as new execution.

Retained reviewed evidence is reused where final bytes are unchanged:

- Earlier Web `source-iad0hwfi` / `source-e56ectzj`: 21,916 frontend
passes / three skips, types/lint/format, real production embed/build and
inspected desktop/phone fixture families. Migration `source-uawz1_yy`
and `source-eb0vj05m` retain types, focused tests, production
build/budgets and inspected combined help/dialog rendering.
- Core `source-s1fokff6` / `source-ect7ogdd`: consumer/tag/API/race
controls and real embed/application build. Earlier cleanup and
performance/native limitations remain adverse evidence, not cleared
acceptance.
- Composed fixes `source-00eqpxhc`: 140 frontend tests,
types/build/budgets; `source-zfh88hkq`: diagnostic/billing controls and
58 demo methods. These are retained reviewed proofs at their recorded
source, not a new execution here.
- Final owning Web: unchanged 580-case `source-fi8xj80e`; 467 combined
tests and types completed in `source-9ua5hue8`, which then failed test
formatting; the separate committed correction `source-jq0lly7u` passes.
Chromium/WebKit rendering, eight inspected settled screenshots and
own-parent receipts cover the combined reader/disk content. Trusted
phone row/name taps did not activate the drawer; mouse-click rendering
is not touch acceptance. That unresolved input observation remains with
Web.
- Core allocation candidate: `source-g3lk4tyh` passes connected
controls, full required API shard under races, vet and real application
build; `source-720fc09_` supplies actual frontend assets;
`source-ya9bup8h` passes release-tagged embed/allocation consumers.
Skipped native/load/SLO controls remain explicit.

Previously recorded provenance, whitespace, **all 83**
own-parent/history governance checks, final-content browser coverage for
**34 frontend source files**, 62 shipped-doc mirrors, 211 public
Markdown documents, workflow trust and 16-subsystem contract/registry
audits pass.

Retained exact-source `source-bh_gki5e` at `49e37ddd` passes
normaliser/allocation/background-worker and VM-diagnostic admission
checks, followed by normaliser/allocation race checks: 311 passing case
events, no skips; complete 44,002-byte output is hash-matched and the
guest stop completed. This uses the repository's explicit synthetic
test-assets setup, not a new real production embed or full API-suite
acceptance. The first all-API attempt `source-d2rcrw3i` failed at
compile setup because the clean offline VM lacked generated frontend
assets; no test ran. That failed attempt and an earlier dirty-source
admission failure remain recorded separately. Unchanged
frontend/real-build evidence above is reused, not replaced by the
synthetic test index.

Independent review and required exact-head GitHub checks still govern
landing; old failed checks are not a pass. Source publication
establishes neither a release nor native backup thaw, fleet performance,
installed update/recovery, appliance/panel acceptance or reporter
confirmation. The frozen release candidate is unchanged; containing
all-main release and native follow-through remain separate work. No
issue is intentionally closed by this partial maintenance batch.

<details>
<summary>Complete unpublished non-merge source/proof inventory</summary>

```text
e597bb2a4b Keep release governance aligned with billing-safe demo profiles
2a9e4183a6 Keep the safe-reporting browser check on the Docs fixture
48dc32d1a3 Fix Recovery safe-reporting navigation
0f480d23af Verify composed disk labels without losing paused-read context
c54aa9e3e7 Reject unreadable backup responses before publishing inventory
509c12f1ae Isolate route allocation checks from API fixture workers
d203eeb17a Bind backup read tests to cancellation and independent settlement
d9292ef38c Keep backup inventory failures source-local
2201fd59b4 Make recovery help distinguish backup evidence from safe restore
5355a41c08 Record final browser proof for composed disk and backup presentation
7d73969882 Keep demo runtime profile changes out of signed billing state
091f987798 Exercise authoritative guest-lock admission in VM diagnostics fixtures
d9a2c78612 Assert held delivery rows separately from unreadable evidence
c381995d24 Keep proxy-auth help navigation independent of emoji fragments
cd20663a7e Keep demo recovery fixtures portable across temporary path aliases
ee069f2144 Add independent bounded guest-write evidence for backup thaw
bbf839a261 Keep completed guest backup evidence visible during activity
d36dba5dff Verify trusted-peer proxy help in the shipped Docs viewer
cbbed83add Make reverse-proxy help match the forwarded-header trust boundary
971d4e9f77 Keep auth context tests independent of tenant runtime writers
76484e4e8c Verify combined migration warnings and guides on Tailwind 4
b8eaed71c8 Record final-content browser proof for the composed frontend
1e9c2bdae6 Bind migration browser checks to the composed source graph
e5986b2edc Remove trailing whitespace from migration browser harness
11c7f32dba Format the combined Swarm wrapping regression assertion
26c64634f0 Verify configuration scope without outbound-network assumptions
9a05a5d477 Keep connected metadata controls independent of tag ordering
4e57511d1e Stabilise label-derived tags without hiding metadata changes
73d2c64e0f Make configuration migration warnings match the actual backup
6725576c7e Allow full-width guest freshness explanations in drawer ownership guard
a355525d57 Keep fresh drawer test bytes consistent with its percentage
b22d3b4490 Explain deferred guest reads without unsafe backup advice
3c57c2d207 Defer guest commands after incomplete HTTP error responses
6ec491bdf3 Keep VM disk diagnostics off the guest-agent channel
98e2a5ef69 Keep release guards aligned with signed runtime recovery
a090a67756 Keep guest-agent admission single-attempt for password sessions
577e40edf3 Keep guest-agent continuity guard aligned with backup deferral
41457c8a82 Make copied webhook verification freshness-aware
35914abab7 Validate release notes by their exact first title
2df28d5877 Defer and coordinate Proxmox guest-agent reads around backup locks
ec5c96a290 Keep scheduler diagnostics private and failure-aware
9df89142d3 Keep notification activity truthful when evidence reads fail
3a98883de1 Preserve notification verdicts through SMTP cleanup and ntfy diagnostics
43b81bbb2b Exercise curl header removal and explicit empty roles separately
2789a15fe5 Keep authentication diagnostics private and permission-aware
9c3d79ba6a Scope Swarm browser check to the current-state evidence
2839333474 Make operator-row browser fixture self-contained
480be08288 Preserve REST operator evidence and readable platform rows
462cdfb170 Recover local logins without deleting authentication state
200ce831a8 Keep installation quick starts on bootstrap authentication
aefbb2b6c4 Verify private-auth help through production rendering
f273e86189 Keep Docker setup credentials private across help languages
e899ecb0cf Keep backup evidence and rollback consent truthful on narrow screens
45835634ea Qualify rollback help by updater and backup scope
6e5acd9c02 Keep denied resource evidence out of drawer fallbacks
d16e665e6c Route deployment updates through evidence-preserving procedures
2dda2e6653 Scope fetched History to the active organisation
227657b1b0 Retain the attempted terminal phase on writer failure
5cf724b4e7 Keep recovery independent of its phase writer interface
f827f7150f Restore owned demo runtime despite receipt I/O failure
ae427e6afe Respect the Docs heading inset in recovery visual checks
c71bdbbda7 Capture the recovery procedure after theme and scroll settle
4fbb69a7dd Make server update and rollback help evidence-preserving
a23029a80c Keep demo installation proof aligned with signed runtime operation
ac995ef565 Keep pending notification groups occurrence-qualified
a2fc29b70b Limit guarded demo updates to owned signed runtime files
6f5bf96370 Withdraw History readings when access is denied
63b24757d6 Accept readable wrapped help tables as well as local scrolling
0027f7c9d9 Verify help table cells with the production style configuration
661d7c7b2e Activate paired firing work in the receipt-ordering fixture
1a1b59072b Point monitoring help at the current platform navigation
0dcd592ef2 Keep notification cooldowns ordered by alert occurrence
ee451f8c8c Restore canonical ordering for demo registry entries
7ec246381f Check retained demo profile inside its owning transaction
360721cc91 Bind demo cohost observation to the real Relay health route
c9a0f0bf6a Keep stored disk History independent of current collection
d0d621a7c2 Exercise detector admission in partial-delivery recovery proof
4df3e8974e Add disposable native acceptance for owned demo recovery
1c9440546a Use destination receipts for delayed partial-delivery recovery
2c57a54ec4 Preserve sending-row crash recovery in the occurrence fixture
8f42497b8f Keep delayed alert recovery scoped to its resolved occurrence
b3937a4803 Use the actual lookup receipt shape in demo ownership proof
751fb67523 Bind demo controls without broadening unrelated release proof
f56e28e6af Own demo update and recovery through sustained host observation
286c8a1f51 Make write and CPU troubleshooting bounded and evidence-preserving
0d8d5beb5f Keep open resource details on current snapshots
```
</details>


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot]
2026-10-04 00:24:56 +00:00
committed by GitHub
277 changed files with 24409 additions and 2622 deletions
+700
View File
@@ -0,0 +1,700 @@
#!/usr/bin/env python3
"""Demo-only host transaction. Called by the reviewed CI dispatcher, not a worker tool."""
from __future__ import annotations
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import signal
import socket
import stat
import subprocess
import sys
import tarfile
import time
from urllib.parse import urlsplit
WINDOW = 300
INTERVAL = 5
READY_TIMEOUT = 60
SERVICES = ("pulse", "pulse-relay", "caddy")
ROOT = Path("/var/lib/pulse-deploy/demo")
LOCK = Path("/var/lib/pulse-deploy/relay/deploy.lock")
PATHS = {"binary": Path("/opt/pulse/bin/pulse"),
"version": Path("/opt/pulse/VERSION"),
"unit": Path("/etc/systemd/system/pulse.service"),
"dropins": Path("/etc/systemd/system/pulse.service.d"),
"data": Path("/etc/pulse")}
RELAY_HEALTH = "https://relay.pulserelay.pro/healthz"
COUNT_KEYS = ("nodes", "vms_per_node", "lxcs_per_node", "docker_hosts",
"docker_containers", "generic_hosts", "k8s_clusters", "k8s_nodes",
"k8s_pods", "k8s_deployments")
DURATION_KEYS = ("seed_duration", "sample_interval", "update_interval")
# Only generated demo operational memory is reset on the already-unhealthy
# recovery route. Its complete original estate remains in the private snapshot.
DEMO_HISTORY = ("ai_incidents.json", "ai_incidents.json.tmp",
"alerts/alert-history.json", "alerts/alert-history.backup.json",
"alerts/alert-history.json.imported", "alerts/alert-history.backup.json.imported",
"alerts/events.db", "alerts/events.db-shm", "alerts/events.db-wal")
CRASH = re.compile(r"\bpanic\b|\bfatal error\b|segmentation fault|core dumped|watchdog timeout", re.I)
TERMINAL = {"committed", "healthy_noop", "rolled_back", "refused"}
TERMINAL_EXIT = {"committed": 0, "healthy_noop": 0, "rolled_back": 1, "refused": 1}
MAX_BINARY = 128 * 1024 * 1024
class Failure(Exception):
"""Only fixed stage names, never command output or private response bodies."""
def atomic_json(path, value):
tmp = path.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as stream:
os.chmod(tmp, 0o600)
json.dump(value, stream, sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(tmp, path)
fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def validate(request):
if set(request) != {"mode", "hostname", "local_url", "public_url", "version", "profile", "control_sha", "run_id", "run_attempt", "binary_sha256", "version_sha256"}:
raise Failure("request-shape")
if request["mode"] not in {"update", "recover"}:
raise Failure("request-mode")
for field, pattern in (("version", r"v[0-9]+\.[0-9]+\.[0-9]+"),
("hostname", r"[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}"),
("control_sha", r"[0-9a-f]{40}"),
("run_id", r"[1-9][0-9]*"), ("run_attempt", r"[1-9][0-9]*")):
if not isinstance(request[field], str) or not re.fullmatch(pattern, request[field]):
raise Failure("request-identity")
for field, public in (("local_url", False), ("public_url", True)):
try:
url = urlsplit(request[field])
valid = (url.scheme == ("https" if public else "http")
and url.hostname and not url.username and not url.password
and not url.query and not url.fragment
and (url.path == "/api/health" if public else url.path in {"", "/"})
and (public or url.hostname in {"localhost", "127.0.0.1", "::1"})
and 0 < (url.port or (443 if public else 80)) < 65536)
except (TypeError, ValueError):
valid = False
if not valid:
raise Failure("request-url")
profile = request["profile"]
if not isinstance(profile, dict) or set(profile) != set(COUNT_KEYS + DURATION_KEYS):
raise Failure("request-profile")
for name, value in profile.items():
pattern = r"[1-9][0-9]{0,3}" if name in COUNT_KEYS else r"[1-9][0-9]{0,3}[smhd]"
if not isinstance(value, str) or not re.fullmatch(pattern, value):
raise Failure("request-profile")
for name in ("binary_sha256", "version_sha256"):
digest = request[name]
if (not isinstance(digest, str)
or (request["mode"] == "update" and not re.fullmatch(r"[0-9a-f]{64}", digest))
or (request["mode"] == "recover" and digest != "")):
raise Failure("request-runtime")
def runtime_members(archive, version):
"""Select two regular files from an already signature-verified archive.
Never extract archive paths or execute its installer. This demo-only route
does not update bundled agents, scripts, helpers, units or timer assets.
"""
files = {}
count = 0
with tarfile.open(archive, "r:gz") as stream:
for member in stream:
count += 1
if count > 4096:
raise Failure("archive-member-bound")
name = member.name.removeprefix("./")
if name not in {"pulse", "bin/pulse", "VERSION"}:
continue
label = "version" if name == "VERSION" else "binary"
if (label in files or not member.isfile() or member.issparse()
or not 0 < member.size <= (128 if label == "version" else MAX_BINARY)):
raise Failure("archive-runtime-shape")
with stream.extractfile(member) as source:
files[label] = source.read(member.size + 1)
if len(files[label]) != member.size:
raise Failure("archive-runtime-size")
if set(files) != {"binary", "version"}:
raise Failure("archive-runtime-missing")
if files["version"].strip() not in {version.encode(), version.removeprefix("v").encode()}:
raise Failure("archive-runtime-version")
# The demo estate is the existing Linux/amd64 server, not a new platform.
data = files["binary"]
if len(data) < 64 or data[:6] != b"\x7fELF\x02\x01" or data[18:20] != b"\x3e\x00":
raise Failure("archive-runtime-architecture")
return files
def copy_path(src, dst):
"""Copy without following symlinks, retaining ownership as well as modes."""
info = src.lstat()
if stat.S_ISLNK(info.st_mode):
dst.symlink_to(os.readlink(src))
elif stat.S_ISDIR(info.st_mode):
dst.mkdir(mode=0o700)
for child in src.iterdir():
copy_path(child, dst / child.name)
shutil.copystat(src, dst, follow_symlinks=False)
elif stat.S_ISREG(info.st_mode):
shutil.copy2(src, dst, follow_symlinks=False)
else:
raise Failure("snapshot-special-file")
os.chown(dst, info.st_uid, info.st_gid, follow_symlinks=False)
if not stat.S_ISLNK(info.st_mode):
fd = os.open(dst, os.O_RDONLY | (os.O_DIRECTORY if stat.S_ISDIR(info.st_mode) else 0))
try:
os.fsync(fd)
finally:
os.close(fd)
def remove_path(path):
if path.is_symlink() or path.is_file():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
def estate_hash(paths):
rows = []
for label, root in sorted(paths.items()):
exists = root.exists() or root.is_symlink()
if not exists:
rows.append((label, "absent"))
continue
for path in [root] + (sorted(root.rglob("*")) if root.is_dir() and not root.is_symlink() else []):
info = path.lstat()
kind = stat.S_IFMT(info.st_mode)
content = os.readlink(path) if stat.S_ISLNK(info.st_mode) else ""
if stat.S_ISREG(info.st_mode):
digest = hashlib.sha256()
with open(path, "rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
content = digest.hexdigest()
rows.append((label, str(path.relative_to(root)), kind, stat.S_IMODE(info.st_mode), info.st_uid, info.st_gid, content))
return hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest()
class Host:
now = staticmethod(time.monotonic)
sleep = staticmethod(time.sleep)
def command(self, argv, *, timeout=20, env=None):
try:
result = subprocess.run(argv, capture_output=True, timeout=timeout, env=env, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
raise Failure("command-unavailable") from exc
if result.returncode or len(result.stdout) > 1024 * 1024:
raise Failure("command-failed")
return result.stdout.decode("utf-8", errors="strict")
def prior_closed(self, attempt, status):
"""Do not unblock on a stale JSON terminal from a failed writer."""
if not re.fullmatch(r"[0-9a-f]{64}", attempt.name):
return False
try:
text = self.command(["systemctl", "show", "pulse-demo-" + attempt.name[:32],
"--property=ActiveState,SubState,MainPID,ExecMainCode,ExecMainStatus", "--no-pager"])
fields = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
expected = TERMINAL_EXIT[status]
return (fields["ActiveState"], fields["SubState"], fields["MainPID"], fields["ExecMainCode"], fields["ExecMainStatus"]) == (
"active" if expected == 0 else "failed", "exited" if expected == 0 else "failed", "0", "1", str(expected))
except (Failure, KeyError, ValueError):
return False
def identity(self, hostname):
if socket.gethostname() != hostname or os.geteuid() != 0:
raise Failure("host-identity")
unit = self.command(["systemctl", "show", "pulse", "--property=FragmentPath,User,Group,ExecStart,DropInPaths", "--no-pager"])
fields = dict(line.split("=", 1) for line in unit.splitlines() if "=" in line)
if (fields.get("FragmentPath") != str(PATHS["unit"])
or fields.get("User") != "pulse" or fields.get("Group") != "pulse"
or str(PATHS["binary"]) not in fields.get("ExecStart", "")):
raise Failure("service-identity")
for name in ("binary", "unit", "data"):
if PATHS[name].is_symlink() or not PATHS[name].exists():
raise Failure("estate-identity")
if PATHS["dropins"].is_symlink():
raise Failure("dropin-identity")
if PATHS["version"].is_symlink() or any(p.is_symlink() for p in PATHS["binary"].parents):
raise Failure("distribution-identity")
for path in fields.get("DropInPaths", "").split():
if Path(path).parent != PATHS["dropins"] or Path(path).is_symlink():
raise Failure("unsupported-dropin")
if self.command(["systemctl", "is-enabled", "pulse"]).strip() != "enabled":
raise Failure("service-disabled")
def state(self):
states = {}
for service in SERVICES:
text = self.command(["systemctl", "show", service, "--property=ActiveState,SubState,MainPID,NRestarts", "--no-pager"])
fields = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
if (set(fields) != {"ActiveState", "SubState", "MainPID", "NRestarts"}
or not fields["MainPID"].isdigit() or not fields["NRestarts"].isdigit()):
raise Failure("service-observation")
states[service] = fields
return states
def status(self, url):
# No redirects, response bodies, credential arguments or ambient curl config.
try:
result = self.command(["curl", "--disable", "--silent", "--connect-timeout", "3", "--max-time", "8", "--output", "/dev/null", "--write-out", "%{http_code}", "--", url], timeout=10)
except Failure:
return "000"
return result if re.fullmatch(r"[1-5][0-9]{2}", result) else "000"
def version(self, url):
try:
body = self.command(["curl", "--disable", "--silent", "--fail", "--connect-timeout", "3", "--max-time", "8", "--", url.rstrip("/") + "/api/version"], timeout=10)
if len(body) > 8192:
raise Failure("version-observation")
value = json.loads(body)["version"]
if not isinstance(value, str) or not re.fullmatch(r"v?[0-9]+\.[0-9]+\.[0-9]+", value):
raise Failure("version-observation")
return value.lstrip("v")
except (ValueError, KeyError, TypeError) as exc:
raise Failure("version-observation") from exc
def cursors(self):
cursors = {}
for service in SERVICES:
text = self.command(["journalctl", "-u", service, "-n", "1", "--output=json", "--show-cursor", "--no-pager"])
matches = re.findall(r"^-- cursor: ([^\s]+)$", text, re.M)
if len(matches) != 1:
raise Failure("journal-cursor")
cursors[service] = matches[0]
return cursors
def check_journal(self, cursors):
for service, cursor in cursors.items():
text = self.command(["journalctl", "-u", service, "--after-cursor", cursor, "--output=json", "--quiet", "--no-pager"])
for line in text.splitlines():
try:
entry = json.loads(line)
if not isinstance(entry.get("MESSAGE"), str) or not isinstance(entry.get("__CURSOR"), str):
raise Failure("journal-observation")
except (ValueError, TypeError, AttributeError) as exc:
raise Failure("journal-observation") from exc
if CRASH.search(entry["MESSAGE"]):
raise Failure("new-service-crash")
cursors[service] = entry["__CURSOR"]
def stop(self):
self.command(["systemctl", "stop", "pulse"], timeout=90)
def start(self):
self.command(["systemctl", "daemon-reload"])
self.command(["systemctl", "start", "pulse"], timeout=90)
def install(self, attempt, request):
# Trust is checked against the pinned release key in CI, before these
# exact bytes enter the authenticated SSH envelope. Check the retained
# byte identities again before any swap; no arbitrary shell is run.
staged = {label: attempt / label for label in ("binary", "version")}
for label, path in staged.items():
if path.is_symlink() or not path.is_file():
raise Failure("runtime-payload-shape")
if not 0 < path.stat().st_size <= (128 if label == "version" else MAX_BINARY):
raise Failure("runtime-payload-bound")
if hashlib.sha256(path.read_bytes()).hexdigest() != request[label + "_sha256"]:
raise Failure("runtime-payload-identity")
if staged["version"].read_bytes().strip() not in {
request["version"].encode(), request["version"].removeprefix("v").encode()}:
raise Failure("runtime-payload-version")
for label, source in staged.items():
destination = PATHS[label]
# Sibling staging keeps replacement atomic; owned recovery removes
# the sibling if a failure interrupts either of the two swaps.
temporary = destination.with_name(destination.name + ".demo-new")
if temporary.exists() or temporary.is_symlink():
raise Failure("runtime-staging-exists")
try:
with open(source, "rb") as src, open(temporary, "xb") as dst:
shutil.copyfileobj(src, dst)
owner = PATHS["binary"].stat()
os.fchown(dst.fileno(), owner.st_uid, owner.st_gid)
os.fchmod(dst.fileno(), 0o755 if label == "binary" else 0o644)
dst.flush()
os.fsync(dst.fileno())
os.replace(temporary, destination)
fd = os.open(destination.parent, os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
finally:
if temporary.is_file() and not temporary.is_symlink():
temporary.unlink()
def profile_values(self, request):
values = {"DEMO_MODE": "true", "PULSE_MOCK_MODE": "true", "PULSE_MOCK_RANDOM_METRICS": "true",
"PULSE_MOCK_STOPPED_PERCENT": "6", "PULSE_MOCK_SEED_METRICS_STORE": "false"}
for key, value in request["profile"].items():
key = {"seed_duration": "trends_seed_duration", "sample_interval": "trends_sample_interval"}.get(key, key)
values["PULSE_MOCK_" + key.upper()] = value
return values
def profile_admission(self):
"""Read-only prerequisite; this transaction never grants entitlements.
The running application owns billing HMAC verification, encrypted
secrets and legacy migration. Do not decrypt, clear integrity, re-sign
or rewrite billing/key material to enable a demo profile.
"""
env_file = PATHS["data"] / ".env"
billing_file = PATHS["data"] / "billing.json"
if env_file.is_symlink() or billing_file.is_symlink():
raise Failure("configuration-symlink")
if not env_file.is_file():
raise Failure("configuration-shape")
def unique_fields(pairs):
result = {}
for key, value in pairs:
if key in result:
raise Failure("billing-shape")
result[key] = value
return result
def invalid_constant(_value):
raise Failure("billing-shape")
try:
fd = os.open(billing_file, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
raise Failure("billing-shape")
raw = stream.read(1024 * 1024 + 1)
if len(raw) > 1024 * 1024:
raise Failure("billing-shape")
billing = json.loads(raw, object_pairs_hook=unique_fields,
parse_constant=invalid_constant)
except (OSError, ValueError, UnicodeError, RecursionError) as exc:
raise Failure("billing-observation") from exc
if (not isinstance(billing, dict)
or not isinstance(billing.get("capabilities"), list)
or not all(isinstance(capability, str) for capability in billing["capabilities"])):
raise Failure("billing-shape")
if "demo_fixtures" not in billing["capabilities"]:
raise Failure("billing-demo-capability-required")
def profile_matches(self, request):
self.profile_admission()
text = (PATHS["data"] / ".env").read_text().splitlines()
values = self.profile_values(request)
return all(text.count(key + "=" + value) == 1 for key, value in values.items())
def profile(self, request, unhealthy):
env_file = PATHS["data"] / ".env"
# Recheck after quiescent capture too; a failed prerequisite must not
# partially change the profile or bypass the application's licence gate.
self.profile_admission()
values = self.profile_values(request)
text = env_file.read_text()
for key, value in values.items():
pattern = re.compile(r"^[ \t]*" + re.escape(key) + r"=.*$", re.M)
text = pattern.sub(key + "=" + value, text) if pattern.search(text) else text.rstrip("\n") + "\n" + key + "=" + value + "\n"
env_file.write_text(text)
data_owner = PATHS["data"].stat()
os.chown(env_file, data_owner.st_uid, data_owner.st_gid)
os.chmod(env_file, 0o600)
if unhealthy:
for relative in DEMO_HISTORY:
path = PATHS["data"] / relative
# Never follow a writable parent symlink to a path outside the estate.
if any(p.is_symlink() for p in path.parents if p != PATHS["data"].parent):
raise Failure("history-symlink")
if path.is_symlink():
raise Failure("history-symlink")
if path.is_file():
path.unlink()
def active(state):
return state["ActiveState"] == "active" and state["SubState"] == "running" and int(state["MainPID"]) > 0
class Transaction:
def __init__(self, host, request, attempt):
self.host, self.request, self.attempt = host, request, attempt
self.receipt = {"schema_version": 2, "status": "intent", "mutated": False,
"control_sha": request["control_sha"], "run_id": request["run_id"],
"run_attempt": request["run_attempt"], "mode": request["mode"],
"expected_version": request["version"],
"footprint": "server-binary-version-and-demo-data",
"binary_sha256": request["binary_sha256"], "version_sha256": request["version_sha256"],
"forward": {}, "recovery": {}, "recovery_required": False,
"observation_failures": [], "child_result_required": True}
def save(self, status):
self.receipt["status"] = status
try:
atomic_json(self.attempt / "receipt.json", self.receipt)
except OSError as error:
# Retain only the fixed phase, never an OS message/private path.
if status not in self.receipt["observation_failures"]:
self.receipt["observation_failures"].append(status)
raise Failure("receipt-observation") from error
def recovery_save(self, status):
"""Receipt storage must not gate the already-owned restoration."""
self.receipt["status"] = status
try:
self.save(status)
except (OSError, Failure) as error:
if isinstance(error, Failure) and str(error) != "receipt-observation":
raise
if status not in self.receipt["observation_failures"]:
self.receipt["observation_failures"].append(status)
def finish(self, status):
"""Best-effort failed evidence is not a verified terminal outcome."""
self.receipt["observed_outcome"] = status
def unverified():
self.receipt["status"] = "observation_failed"
self.receipt["recovery_required"] = True
if self.receipt.get("rollback") == "verified":
self.receipt["rollback"] = "unverified"
if self.receipt["observation_failures"]:
unverified()
terminal = self.receipt["status"] if self.receipt["observation_failures"] else status
try:
self.save(terminal)
except (OSError, Failure) as error:
if isinstance(error, Failure) and str(error) != "receipt-observation":
raise
if terminal not in self.receipt["observation_failures"]:
self.receipt["observation_failures"].append(terminal)
unverified()
# One bounded attempt to retain the failure, not to obtain a pass.
self.recovery_save("observation_failed")
def urls(self):
return (self.request["local_url"].rstrip("/") + "/api/health", self.request["public_url"], RELAY_HEALTH)
def healthy(self, states=None):
return all(active(s) for s in (states or self.host.state()).values()) and all(self.host.status(url) == "200" for url in self.urls())
def ready(self, cursors):
deadline = self.host.now() + READY_TIMEOUT
while True:
self.host.check_journal(cursors)
if self.healthy():
return
if self.host.now() >= deadline:
raise Failure("listener-readiness")
self.host.sleep(INTERVAL)
def watch(self, phase, expected_version, cursors):
baseline = self.host.state()
started = self.host.now()
evidence = self.receipt[phase]
evidence.update({"required_seconds": WINDOW, "samples": 0, "elapsed_seconds": 0,
"initial_services": baseline})
while True:
self.host.check_journal(cursors)
state = self.host.state()
evidence["last_services"] = state
if state != baseline or not self.healthy(state):
raise Failure("sustained-health")
evidence["observed_version"] = self.host.version(self.request["local_url"])
if evidence["observed_version"] != expected_version:
raise Failure("runtime-version")
runtime = estate_hash({name: PATHS[name] for name in ("binary", "version")})
expected = self.receipt.get("runtime_snapshot_sha256" if phase == "recovery" else "installed_runtime_sha256")
if expected and runtime != expected:
raise Failure("distribution-state-changed")
evidence["runtime_sha256"] = runtime
evidence["samples"] += 1
elapsed = self.host.now() - started
evidence["elapsed_seconds"] = elapsed
if elapsed >= WINDOW:
return
self.host.sleep(min(INTERVAL, WINDOW - elapsed))
def capture(self):
snapshot = self.attempt / "snapshot"
size = sum(path.lstat().st_size for root in PATHS.values() if root.exists()
for path in ([root] + (list(root.rglob("*")) if root.is_dir() else []))
if path.is_file() and not path.is_symlink())
# Keep room for the independent estate and the two atomic runtime swaps.
# Never run the installer's backup rotation or delete data for headroom.
if any(shutil.disk_usage(parent).free < 2 * size + 64 * 1024 * 1024
for parent in (self.attempt, PATHS["data"].parent)):
raise Failure("snapshot-headroom")
snapshot.mkdir(mode=0o700)
manifest = {}
for label, path in PATHS.items():
manifest[label] = path.exists() or path.is_symlink()
if manifest[label]:
copy_path(path, snapshot / label)
atomic_json(snapshot / "paths.json", manifest)
digest = estate_hash(PATHS)
self.receipt["snapshot_sha256"] = digest
self.receipt["runtime_snapshot_sha256"] = estate_hash({name: PATHS[name] for name in ("binary", "version")})
self.receipt["snapshot_retained"] = True
return digest
def restore(self):
snapshot = self.attempt / "snapshot"
for label, exists in json.loads((snapshot / "paths.json").read_text()).items():
remove_path(PATHS[label])
if exists:
copy_path(snapshot / label, PATHS[label])
if estate_hash(PATHS) != self.receipt["snapshot_sha256"]:
raise Failure("restored-estate-identity")
def run(self):
old_handlers = {}
healthy_baseline = False
stopped = False
original_version = ""
states = {}
def cancel(_signum, _frame):
raise Failure("cancelled-forward")
try:
validate(self.request)
self.host.identity(self.request["hostname"])
for entry in ROOT.glob("attempts/*"):
if entry == self.attempt:
continue
receipt = entry / "receipt.json"
prior = json.loads(receipt.read_text()) if receipt.exists() else {}
if (prior.get("status") not in TERMINAL
or (prior.get("child_result_required") and not self.host.prior_closed(entry, prior["status"]))):
raise Failure("prior-recovery-required")
# Refuse absent/malformed or unprovisioned billing before stopping
# a service, installing a binary or changing any estate file.
self.host.profile_admission()
states = self.host.state()
if not all(active(states[name]) for name in ("pulse-relay", "caddy")) or self.host.status(RELAY_HEALTH) != "200":
raise Failure("cohost-unhealthy")
healthy_baseline = self.healthy(states)
if healthy_baseline:
original_version = self.host.version(self.request["local_url"])
if self.request["mode"] == "recover" and original_version != self.request["version"].lstrip("v"):
raise Failure("recovery-source-mismatch")
elif self.request["mode"] == "update":
raise Failure("update-baseline-unhealthy")
self.receipt["healthy_baseline"] = healthy_baseline
self.receipt["baseline_services"] = states
self.receipt["baseline_version"] = original_version
old_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)}
for sig in old_handlers:
signal.signal(sig, cancel)
# The no-op is observed too; an immediate healthy sample is not success.
if self.request["mode"] == "recover" and healthy_baseline and self.host.profile_matches(self.request):
self.watch("forward", original_version, self.host.cursors())
self.save("healthy_noop")
return 0
self.save("capturing")
stopped = True
self.host.stop()
self.capture()
self.save("applying")
self.receipt["mutated"] = True
cursors = self.host.cursors()
if self.request["mode"] == "update":
self.host.install(self.attempt, self.request)
self.receipt["installed_runtime_sha256"] = estate_hash({
name: PATHS[name] for name in ("binary", "version")})
self.host.profile(self.request, not healthy_baseline)
self.host.start()
self.ready(cursors)
self.watch("forward", self.request["version"].lstrip("v"), cursors)
# No unrelated Relay/Caddy restart is owned by this transaction.
final = self.host.state()
for service in ("pulse-relay", "caddy"):
if final[service] != states[service]:
raise Failure("cohost-identity-changed")
self.save("committed")
return 0
except Exception as exc:
self.receipt["failure"] = str(exc) if isinstance(exc, Failure) else "transaction-observation"
# Owned restoration, observation and the terminal receipt survive cancellation.
for sig in old_handlers:
signal.signal(sig, signal.SIG_IGN)
if stopped:
try:
self.recovery_save("recovering")
self.host.stop()
if "snapshot_sha256" in self.receipt:
self.restore()
if not healthy_baseline:
self.receipt["recovery_required"] = True
self.receipt["rollback"] = "unavailable-unhealthy-baseline"
outcome = "recovery_required"
else:
cursors = self.host.cursors()
self.host.start()
self.ready(cursors)
self.watch("recovery", original_version, cursors)
final = self.host.state()
if any(final[name] != states[name] for name in ("pulse-relay", "caddy")):
raise Failure("cohost-identity-changed")
self.receipt["rollback"] = "verified"
outcome = "rolled_back"
except Exception as recovery:
self.receipt["rollback"] = "failed"
self.receipt["recovery_failure"] = str(recovery) if isinstance(recovery, Failure) else "recovery-observation"
self.receipt["recovery_required"] = True
outcome = "rollback_failed"
# A terminal write failure cannot undo or interrupt recovery,
# nor relabel a restored estate as a failed file restoration.
self.finish(outcome)
else:
self.finish("refused")
# Distinguish evidence loss from an ordinary failed-but-observed
# operation, even if the last visible JSON still says rolled_back.
return 2 if self.receipt["observation_failures"] else 1
finally:
for sig, handler in old_handlers.items():
signal.signal(sig, handler)
def main():
# Attempt paths are generated by the fixed CI bootstrap, not CLI overrides.
if len(sys.argv) != 2:
return 2
attempt = Path(sys.argv[1])
if attempt.parent != ROOT / "attempts" or not re.fullmatch(r"[0-9a-f]{64}", attempt.name) or attempt.is_symlink():
return 2
request = json.loads((attempt / "request.json").read_text())
transaction = Transaction(Host(), request, attempt)
fd = None
try:
try:
LOCK.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
fd = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, ValueError):
transaction.receipt["failure"] = "host-lock-unavailable"
transaction.finish("refused")
return 2 if transaction.receipt["observation_failures"] else 1
return transaction.run()
finally:
if fd is not None:
os.close(fd)
if __name__ == "__main__":
sys.exit(main())
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""The two existing demo CI paths share one retained, independently owned child."""
from __future__ import annotations
import base64
import hashlib
import importlib.util
import json
import datetime
import re
import os
from pathlib import Path
import shlex
import subprocess
import sys
BOOTSTRAP = r'''
import base64, fcntl, hashlib, json, os, pathlib, subprocess, sys, time
root = pathlib.Path('/var/lib/pulse-deploy/demo')
payload = json.loads(sys.stdin.read(192 * 1024 * 1024 + 1))
assert os.geteuid() == 0 and set(payload) == {'request', 'source', 'binary', 'version_file'}
source = base64.b64decode(payload['source'], validate=True)
binary = base64.b64decode(payload['binary'], validate=True)
version_file = base64.b64decode(payload['version_file'], validate=True)
assert len(source) < 128 * 1024 and len(binary) <= 128 * 1024 * 1024 and len(version_file) <= 128
for name, data in [('binary', binary), ('version', version_file)]:
assert (hashlib.sha256(data).hexdigest() == payload['request'][name + '_sha256']) if data else (payload['request'][name + '_sha256'] == '')
identity = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
root.mkdir(mode=0o700, parents=True, exist_ok=True)
assert not root.is_symlink() and root.stat().st_uid == 0
os.chmod(root, 0o700)
(root / 'attempts').mkdir(mode=0o700, exist_ok=True)
assert not (root / 'attempts').is_symlink()
fd = os.open(root / 'submit.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX)
attempt = root / 'attempts' / identity
if not attempt.exists():
attempt.mkdir(mode=0o700)
for name, data in [('transaction.py', source), ('binary', binary), ('version', version_file),
('request.json', json.dumps(payload['request']).encode()),
('intent.json', json.dumps({'schema_version': 1, 'identity': identity, 'state': 'intent'}).encode())]:
with open(attempt / name, 'xb') as stream:
os.chmod(attempt / name, 0o600)
stream.write(data); stream.flush(); os.fsync(stream.fileno())
# Intent is durable before starting the unit. An uncertain launch is never replayed.
for directory in (attempt, root / 'attempts', root, root.parent):
dfd = os.open(directory, os.O_DIRECTORY); os.fsync(dfd); os.close(dfd)
try:
child = subprocess.run(['systemd-run', '--quiet',
'--unit=pulse-demo-' + identity[:32],
'--property=RemainAfterExit=yes',
'--property=RuntimeMaxSec=45min',
'--property=TimeoutStopSec=20min', '--property=KillMode=mixed',
'/usr/bin/python3', str(attempt / 'transaction.py'), str(attempt)],
capture_output=True, timeout=30)
launch = {'schema_version': 1, 'state': 'submitted' if child.returncode == 0 else 'uncertain',
'unit': 'pulse-demo-' + identity[:32]}
except (OSError, subprocess.TimeoutExpired):
launch = {'schema_version': 1, 'state': 'uncertain'}
with open(attempt / 'launch.json', 'x') as stream:
os.chmod(attempt / 'launch.json', 0o600)
json.dump(launch, stream); stream.flush(); os.fsync(stream.fileno())
dfd = os.open(attempt, os.O_DIRECTORY); os.fsync(dfd); os.close(dfd)
os.close(fd)
# Closing or losing this observer never stops the independently owned unit.
deadline = time.monotonic() + 4000
terminal = {'committed', 'healthy_noop', 'rolled_back', 'rollback_failed', 'recovery_required', 'refused', 'observation_failed'}
while time.monotonic() < deadline:
try:
receipt = json.loads((attempt / 'receipt.json').read_text())
except (OSError, ValueError):
receipt = None
if receipt and receipt.get('status') in terminal:
# A replace can become visible before directory fsync fails. Never
# accept that provisional favourable receipt while its writer is still
# running, or after the writer exits failed. Retain the unit's result;
# reading it neither launches nor stops a child.
try:
child = subprocess.run(['systemctl', 'show', 'pulse-demo-' + identity[:32],
'--property=ActiveState,SubState,MainPID,ExecMainCode,ExecMainStatus',
'--no-pager'], capture_output=True, timeout=20)
if child.returncode:
break
fields = dict(line.split('=', 1) for line in child.stdout.decode().splitlines() if '=' in line)
pid = int(fields['MainPID'])
code = int(fields['ExecMainCode'])
status = int(fields['ExecMainStatus'])
pending = (pid > 0 and fields['ActiveState'] in {'activating', 'active', 'deactivating'})
complete = (pid == 0 and code in {1, 2, 3}
and (fields['ActiveState'], fields['SubState']) in {('active', 'exited'), ('failed', 'failed')})
if complete:
succeeded = (fields['ActiveState'], fields['SubState'], code, status) == ('active', 'exited', 1, 0)
receipt['request_identity'] = identity
receipt['child_result'] = {'exit_code': status if code == 1 else None,
'signal': status if code in {2, 3} else None}
favourable = receipt['status'] in {'committed', 'healthy_noop'}
expected = 0 if favourable else (2 if receipt['status'] == 'observation_failed' else 1)
if code != 1 or status != expected or favourable != succeeded:
receipt.update(status='uncertain', failure='terminal-child-mismatch', recovery_required=True)
print(json.dumps(receipt))
sys.exit(0 if favourable and succeeded else 1)
if not pending:
break
except (OSError, ValueError, KeyError, subprocess.TimeoutExpired):
break
launch = attempt / 'launch.json'
if launch.exists() and json.loads(launch.read_text()).get('state') == 'uncertain':
break
time.sleep(5)
print(json.dumps({'schema_version': 2, 'status': 'uncertain', 'request_identity': identity,
'failure': 'terminal-receipt-unavailable', 'recovery_required': True}))
sys.exit(1)
'''
def payload(mode, environ=os.environ):
profile = {key: environ['MOCK_' + key.upper()] for key in
('nodes', 'vms_per_node', 'lxcs_per_node', 'docker_hosts', 'docker_containers',
'generic_hosts', 'k8s_clusters', 'k8s_nodes', 'k8s_pods', 'k8s_deployments',
'seed_duration', 'sample_interval', 'update_interval')}
binary = Path('/tmp/pulse-demo-binary').read_bytes() if mode == 'update' else b''
version_file = Path('/tmp/pulse-demo-VERSION').read_bytes() if mode == 'update' else b''
request = {'mode': mode, 'hostname': environ['DEMO_EXPECTED_HOSTNAME'],
'local_url': environ['DEMO_LOCAL_BASE_URL'], 'public_url': environ['DEMO_PUBLIC_HEALTH_URL'],
'version': environ['EXPECTED_VERSION'], 'profile': profile,
'control_sha': environ['GITHUB_SHA'], 'run_id': environ['GITHUB_RUN_ID'],
'run_attempt': environ['GITHUB_RUN_ATTEMPT'],
'binary_sha256': hashlib.sha256(binary).hexdigest() if binary else '',
'version_sha256': hashlib.sha256(version_file).hexdigest() if version_file else ''}
return {'request': request, 'source': base64.b64encode(Path(__file__).with_name('demo-runtime-transaction.py').read_bytes()).decode(),
'binary': base64.b64encode(binary).decode(), 'version_file': base64.b64encode(version_file).decode()}
NATIVE_PATHS = ('.github/scripts/demo-runtime-transaction.py',
'.github/scripts/dispatch-demo-runtime.py',
'.github/scripts/tests/demo_runtime_native.py',
'.github/workflows/demo-runtime-native.yml')
def require_native(environ=os.environ):
"""Fail closed before SSH; a metadata/component pass is not native proof."""
sha, repository = environ['GITHUB_SHA'], environ['GITHUB_REPOSITORY']
if not re.fullmatch(r'[0-9a-f]{40}', sha) or repository != 'rcourtman/Pulse':
raise ValueError('native-source-identity')
since = (datetime.datetime.now(datetime.timezone.utc).date() - datetime.timedelta(days=6)).isoformat()
base = f'repos/{repository}/actions/workflows/demo-runtime-native.yml/runs?branch=main&event=push&per_page=5'
def read(query):
result = subprocess.run(['gh', 'api', query], capture_output=True, timeout=30, check=False)
if result.returncode:
raise ValueError('native-evidence-unavailable')
rows = json.loads(result.stdout)['workflow_runs']
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
raise ValueError('native-evidence-malformed')
for row in rows:
if (type(row.get('id')) is not int or not re.fullmatch(r'[0-9a-f]{40}', str(row.get('head_sha')))
or type(row.get('run_attempt')) is not int or not isinstance(row.get('updated_at'), str)
or not isinstance(row.get('created_at'), str)):
raise ValueError('native-evidence-malformed')
return rows
rows = read(base + '&created=' + since + '..*')
if len(rows) < 5:
rows += read(base)
merged = {}
for row in rows:
old = merged.get(row['id'])
if old is None or (row['run_attempt'], row['updated_at']) > (old['run_attempt'], old['updated_at']):
merged[row['id']] = row
for row in sorted(merged.values(), key=lambda r: (r['created_at'], r['id']), reverse=True):
head = row['head_sha']
if (row.get('event') != 'push' or row.get('head_branch') != 'main'
or row.get('path') != '.github/workflows/demo-runtime-native.yml'):
raise ValueError('native-run-identity')
ancestor = subprocess.run(['git', 'merge-base', '--is-ancestor', head, sha], capture_output=True)
if ancestor.returncode != 0:
continue
matching = subprocess.run(['git', 'diff', '--quiet', head, sha, '--', *NATIVE_PATHS], capture_output=True)
if matching.returncode == 1:
continue
if matching.returncode != 0:
raise ValueError('native-source-unavailable')
# Never select an older green sample over a newer matching failure or
# pending run. The native driver exits success only with full cleanup.
if row.get('status') != 'completed' or row.get('conclusion') != 'success':
raise ValueError('native-acceptance-not-passed')
return {'run_id': row['id'], 'run_attempt': row['run_attempt'], 'source_sha': head}
raise ValueError('native-acceptance-missing')
def prepare_runtime():
version = os.environ['EXPECTED_VERSION']
if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', version):
raise ValueError('release-version')
spec = importlib.util.spec_from_file_location('demo_runtime', Path(__file__).with_name('demo-runtime-transaction.py'))
engine = importlib.util.module_from_spec(spec); spec.loader.exec_module(engine)
# Called only after mandatory pinned-key SSH signature verification in CI.
files = engine.runtime_members('/tmp/pulse-demo-release.tgz', version)
for label, name in (('binary', '/tmp/pulse-demo-binary'), ('version', '/tmp/pulse-demo-VERSION')):
with open(name, 'xb') as stream:
os.chmod(name, 0o600)
stream.write(files[label])
def main():
if len(sys.argv) != 2 or sys.argv[1] not in {'prepare', 'update', 'recover'}:
return 2
if sys.argv[1] == 'prepare':
prepare_runtime()
return 0
# Check exact reviewed native source before opening the production SSH path.
try:
require_native()
except (ValueError, KeyError, OSError, subprocess.TimeoutExpired):
print(json.dumps({'schema_version': 2, 'status': 'refused', 'mutated': False,
'failure': 'native-acceptance-unavailable'}))
return 1
# Same project CI SSH identity and pinned host verification as before. This
# is not an installed worker dispatcher or a new project credential.
command = ['ssh', '-i', str(Path.home() / '.ssh/id_ed25519'), '-o', 'IdentitiesOnly=yes',
'-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' + str(Path.home() / '.ssh/known_hosts'),
os.environ['DEMO_SERVER_USER'] + '@' + os.environ['DEMO_SERVER_HOST'],
'sudo python3 -c ' + shlex.quote(BOOTSTRAP)]
try:
result = subprocess.run(command, input=json.dumps(payload(sys.argv[1])).encode(), timeout=4100, check=False)
except (OSError, subprocess.TimeoutExpired):
print(json.dumps({'schema_version': 2, 'status': 'uncertain', 'failure': 'observer-unavailable', 'recovery_required': True}))
return 1
return result.returncode
if __name__ == '__main__':
sys.exit(main())
+2 -393
View File
@@ -1,395 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 16 ]; then
echo "usage: recover-demo-runtime.sh EXPECTED_HOSTNAME LOCAL_BASE_URL EXPECTED_VERSION MOCK_NODES MOCK_VMS_PER_NODE MOCK_LXCS_PER_NODE MOCK_DOCKER_HOSTS MOCK_DOCKER_CONTAINERS MOCK_GENERIC_HOSTS MOCK_K8S_CLUSTERS MOCK_K8S_NODES MOCK_K8S_PODS MOCK_K8S_DEPLOYMENTS MOCK_SEED_DURATION MOCK_SAMPLE_INTERVAL MOCK_UPDATE_INTERVAL" >&2
exit 2
fi
EXPECTED_HOSTNAME="$1"
LOCAL_BASE_URL="${2%/}"
EXPECTED_VERSION="${3#v}"
MOCK_NODES="$4"
MOCK_VMS_PER_NODE="$5"
MOCK_LXCS_PER_NODE="$6"
MOCK_DOCKER_HOSTS="$7"
MOCK_DOCKER_CONTAINERS="$8"
MOCK_GENERIC_HOSTS="$9"
MOCK_K8S_CLUSTERS="${10}"
MOCK_K8S_NODES="${11}"
MOCK_K8S_PODS="${12}"
MOCK_K8S_DEPLOYMENTS="${13}"
MOCK_SEED_DURATION="${14}"
MOCK_SAMPLE_INTERVAL="${15}"
MOCK_UPDATE_INTERVAL="${16}"
SERVICE_NAME="pulse"
RELAY_SERVICE_NAME="pulse-relay"
EXPECTED_BINARY="/opt/pulse/bin/pulse"
EXPECTED_UNIT="/etc/systemd/system/pulse.service"
MUTATED=false
BACKUP_DIR=""
HISTORY_BACKUP_ARCHIVE=""
DEMO_HISTORY_PATHS=(
ai_incidents.json
ai_incidents.json.tmp
alerts/alert-history.json
alerts/alert-history.backup.json
alerts/alert-history.json.imported
alerts/alert-history.backup.json.imported
alerts/events.db
alerts/events.db-shm
alerts/events.db-wal
)
for fixture_count in \
"$MOCK_NODES" \
"$MOCK_VMS_PER_NODE" \
"$MOCK_LXCS_PER_NODE" \
"$MOCK_DOCKER_HOSTS" \
"$MOCK_DOCKER_CONTAINERS" \
"$MOCK_GENERIC_HOSTS" \
"$MOCK_K8S_CLUSTERS" \
"$MOCK_K8S_NODES" \
"$MOCK_K8S_PODS" \
"$MOCK_K8S_DEPLOYMENTS"; do
[[ "$fixture_count" =~ ^[1-9][0-9]*$ ]]
done
[[ "$MOCK_SEED_DURATION" =~ ^[1-9][0-9]*[smhd]$ ]]
[[ "$MOCK_SAMPLE_INTERVAL" =~ ^[1-9][0-9]*[smh]$ ]]
[[ "$MOCK_UPDATE_INTERVAL" =~ ^[1-9][0-9]*[smh]$ ]]
log() {
printf '[demo-recovery] %s\n' "$*" >&2
}
unit_hash() {
sudo sha256sum "$EXPECTED_UNIT" | awk '{print $1}'
}
binary_hash() {
sudo sha256sum "$EXPECTED_BINARY" | awk '{print $1}'
}
dropin_manifest_hash() {
local paths path
paths="$(systemctl show "$SERVICE_NAME" --property=DropInPaths --value)"
if [ -z "$paths" ]; then
printf '%s' 'none' | sha256sum | awk '{print $1}'
return
fi
for path in $paths; do
sudo test -f "$path"
printf '%s\t' "$path"
sudo sha256sum "$path" | awk '{print $1}'
done | sort | sha256sum | awk '{print $1}'
}
runtime_config_hash() {
local path
for path in /etc/pulse/.env /etc/pulse/billing.json; do
sudo test -f "$path"
printf '%s\t' "$path"
sudo sha256sum "$path" | awk '{print $1}'
done | sha256sum | awk '{print $1}'
}
set_env_value() {
local key="$1"
local value="$2"
local env_file="/etc/pulse/.env"
if sudo grep -Eq "^[[:space:]]*${key}=" "$env_file"; then
sudo sed -i "s|^[[:space:]]*${key}=.*|${key}=${value}|" "$env_file"
else
printf '\n%s=%s\n' "$key" "$value" | sudo tee -a "$env_file" >/dev/null
fi
}
runtime_profile_matches() {
sudo grep -Fxq "PULSE_MOCK_NODES=${MOCK_NODES}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_VMS_PER_NODE=${MOCK_VMS_PER_NODE}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_LXCS_PER_NODE=${MOCK_LXCS_PER_NODE}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_DOCKER_HOSTS=${MOCK_DOCKER_HOSTS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_DOCKER_CONTAINERS=${MOCK_DOCKER_CONTAINERS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_GENERIC_HOSTS=${MOCK_GENERIC_HOSTS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_K8S_CLUSTERS=${MOCK_K8S_CLUSTERS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_K8S_NODES=${MOCK_K8S_NODES}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_K8S_PODS=${MOCK_K8S_PODS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_K8S_DEPLOYMENTS=${MOCK_K8S_DEPLOYMENTS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_SEED_METRICS_STORE=false" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_TRENDS_SEED_DURATION=${MOCK_SEED_DURATION}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_TRENDS_SAMPLE_INTERVAL=${MOCK_SAMPLE_INTERVAL}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_UPDATE_INTERVAL=${MOCK_UPDATE_INTERVAL}" /etc/pulse/.env
}
cleanup_config_backup() {
[ -n "$BACKUP_DIR" ] || return 0
sudo unlink "$BACKUP_DIR/runtime.env" 2>/dev/null || true
sudo unlink "$BACKUP_DIR/billing.json" 2>/dev/null || true
if [ -n "$HISTORY_BACKUP_ARCHIVE" ]; then
sudo unlink "$HISTORY_BACKUP_ARCHIVE" 2>/dev/null || true
HISTORY_BACKUP_ARCHIVE=""
fi
rmdir "$BACKUP_DIR" 2>/dev/null || true
BACKUP_DIR=""
}
clear_demo_operational_history() {
local rel_path
local -a existing=()
for rel_path in "${DEMO_HISTORY_PATHS[@]}"; do
if sudo test -f "/etc/pulse/${rel_path}"; then
existing+=("$rel_path")
fi
done
((${#existing[@]} > 0)) || return 0
HISTORY_BACKUP_ARCHIVE="$BACKUP_DIR/demo-operational-history.tar"
sudo tar --create --preserve-permissions \
--file "$HISTORY_BACKUP_ARCHIVE" --directory /etc/pulse -- "${existing[@]}"
for rel_path in "${existing[@]}"; do
sudo unlink "/etc/pulse/${rel_path}"
done
}
restore_demo_operational_history() {
local rel_path
[ -n "$HISTORY_BACKUP_ARCHIVE" ] || return 0
sudo test -f "$HISTORY_BACKUP_ARCHIVE" || return 0
for rel_path in "${DEMO_HISTORY_PATHS[@]}"; do
sudo unlink "/etc/pulse/${rel_path}" 2>/dev/null || true
done
sudo tar --extract --preserve-permissions \
--file "$HISTORY_BACKUP_ARCHIVE" --directory /etc/pulse
}
restore_runtime_config() {
[ -n "$BACKUP_DIR" ] || return 0
sudo cp --archive "$BACKUP_DIR/runtime.env" /etc/pulse/.env
sudo cp --archive "$BACKUP_DIR/billing.json" /etc/pulse/billing.json
}
local_version() {
curl --connect-timeout 3 --max-time 8 -fsS \
"${LOCAL_BASE_URL}/api/version" | jq -er '.version'
}
local_healthy() {
curl --connect-timeout 3 --max-time 8 -fsS \
"${LOCAL_BASE_URL}/api/health" >/dev/null && local_version >/dev/null
}
emit_evidence() {
local status="$1"
local before_pid="$2"
local after_pid="$3"
local before_relay_pid="$4"
local after_relay_pid="$5"
local before_binary_sha="$6"
local after_binary_sha="$7"
local before_unit_sha="$8"
local after_unit_sha="$9"
local before_dropins_sha="${10}"
local after_dropins_sha="${11}"
local version="${12}"
local before_config_sha="${13}"
local after_config_sha="${14}"
jq -n \
--arg status "$status" \
--argjson mutated "$MUTATED" \
--arg hostname "$(hostname)" \
--arg service "$SERVICE_NAME" \
--arg before_pid "$before_pid" \
--arg after_pid "$after_pid" \
--arg relay_service "$RELAY_SERVICE_NAME" \
--arg before_relay_pid "$before_relay_pid" \
--arg after_relay_pid "$after_relay_pid" \
--arg binary "$EXPECTED_BINARY" \
--arg before_binary_sha256 "$before_binary_sha" \
--arg after_binary_sha256 "$after_binary_sha" \
--arg unit "$EXPECTED_UNIT" \
--arg before_unit_sha256 "$before_unit_sha" \
--arg after_unit_sha256 "$after_unit_sha" \
--arg before_dropins_sha256 "$before_dropins_sha" \
--arg after_dropins_sha256 "$after_dropins_sha" \
--arg before_runtime_config_sha256 "$before_config_sha" \
--arg after_runtime_config_sha256 "$after_config_sha" \
--arg version "$version" \
--arg expected_version "$EXPECTED_VERSION" \
--arg mock_nodes "$MOCK_NODES" \
--arg mock_vms_per_node "$MOCK_VMS_PER_NODE" \
--arg mock_lxcs_per_node "$MOCK_LXCS_PER_NODE" \
--arg mock_docker_hosts "$MOCK_DOCKER_HOSTS" \
--arg mock_docker_containers "$MOCK_DOCKER_CONTAINERS" \
--arg mock_generic_hosts "$MOCK_GENERIC_HOSTS" \
--arg mock_k8s_clusters "$MOCK_K8S_CLUSTERS" \
--arg mock_k8s_nodes "$MOCK_K8S_NODES" \
--arg mock_k8s_pods "$MOCK_K8S_PODS" \
--arg mock_k8s_deployments "$MOCK_K8S_DEPLOYMENTS" \
--arg mock_seed_duration "$MOCK_SEED_DURATION" \
--arg mock_sample_interval "$MOCK_SAMPLE_INTERVAL" \
--arg mock_update_interval "$MOCK_UPDATE_INTERVAL" \
'{schema_version: 1, status: $status, mutated: $mutated,
hostname: $hostname, service: $service,
before_pid: $before_pid, after_pid: $after_pid,
relay_service: $relay_service,
before_relay_pid: $before_relay_pid,
after_relay_pid: $after_relay_pid,
binary: $binary,
before_binary_sha256: $before_binary_sha256,
after_binary_sha256: $after_binary_sha256,
unit: $unit,
before_unit_sha256: $before_unit_sha256,
after_unit_sha256: $after_unit_sha256,
before_dropins_sha256: $before_dropins_sha256,
after_dropins_sha256: $after_dropins_sha256,
before_runtime_config_sha256: $before_runtime_config_sha256,
after_runtime_config_sha256: $after_runtime_config_sha256,
version: $version, expected_version: $expected_version,
runtime_profile: {mock_nodes: $mock_nodes,
mock_vms_per_node: $mock_vms_per_node,
mock_lxcs_per_node: $mock_lxcs_per_node,
mock_docker_hosts: $mock_docker_hosts,
mock_docker_containers: $mock_docker_containers,
mock_generic_hosts: $mock_generic_hosts,
mock_k8s_clusters: $mock_k8s_clusters,
mock_k8s_nodes: $mock_k8s_nodes,
mock_k8s_pods: $mock_k8s_pods,
mock_k8s_deployments: $mock_k8s_deployments,
mock_seed_duration: $mock_seed_duration,
mock_sample_interval: $mock_sample_interval,
mock_update_interval: $mock_update_interval}}'
}
[ "$(hostname)" = "$EXPECTED_HOSTNAME" ] || {
log "target hostname does not match the governed demo environment"
exit 1
}
[ "$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value)" = "$EXPECTED_UNIT" ]
[ "$(systemctl show "$SERVICE_NAME" --property=User --value)" = "pulse" ]
[ "$(systemctl show "$SERVICE_NAME" --property=Group --value)" = "pulse" ]
[ "$(systemctl is-enabled "$SERVICE_NAME")" = "enabled" ]
[ "$(systemctl is-active "$RELAY_SERVICE_NAME")" = "active" ]
systemctl show "$SERVICE_NAME" --property=ExecStart --value | grep -Fq "$EXPECTED_BINARY"
sudo test -x "$EXPECTED_BINARY"
sudo test -f "$EXPECTED_UNIT"
BEFORE_PID="$(systemctl show "$SERVICE_NAME" --property=MainPID --value)"
BEFORE_RELAY_PID="$(systemctl show "$RELAY_SERVICE_NAME" --property=MainPID --value)"
BEFORE_BINARY_SHA="$(binary_hash)"
BEFORE_UNIT_SHA="$(unit_hash)"
BEFORE_DROPINS_SHA="$(dropin_manifest_hash)"
BEFORE_CONFIG_SHA="$(runtime_config_hash)"
if local_healthy && runtime_profile_matches; then
VERSION="$(local_version)"
[ "${VERSION#v}" = "$EXPECTED_VERSION" ]
emit_evidence healthy_noop "$BEFORE_PID" "$BEFORE_PID" \
"$BEFORE_RELAY_PID" "$BEFORE_RELAY_PID" \
"$BEFORE_BINARY_SHA" "$BEFORE_BINARY_SHA" \
"$BEFORE_UNIT_SHA" "$BEFORE_UNIT_SHA" \
"$BEFORE_DROPINS_SHA" "$BEFORE_DROPINS_SHA" "$VERSION" \
"$BEFORE_CONFIG_SHA" "$BEFORE_CONFIG_SHA"
exit 0
fi
rollback_on_error() {
local rc=$?
local failed_pid
trap - ERR
if [ "$MUTATED" = true ]; then
failed_pid="$(systemctl show "$SERVICE_NAME" --property=MainPID --value || true)"
if [[ "$failed_pid" =~ ^[1-9][0-9]*$ ]] && sudo kill -0 "$failed_pid" 2>/dev/null; then
# The HTTP listener never opened, so pprof is unavailable. SIGQUIT gives
# one bounded Go goroutine dump in the service journal before the already
# failed process is stopped; it does not widen recovery to any other unit.
log "capturing failed Pulse startup goroutines before compensation"
sudo kill -QUIT "$failed_pid" || true
sleep 2
fi
log "recovery validation failed; compensating by stopping only pulse.service"
sudo systemctl stop "$SERVICE_NAME" || true
restore_runtime_config || true
restore_demo_operational_history || true
fi
emit_evidence compensated_unavailable "$BEFORE_PID" \
"$(systemctl show "$SERVICE_NAME" --property=MainPID --value || true)" \
"$BEFORE_RELAY_PID" \
"$(systemctl show "$RELAY_SERVICE_NAME" --property=MainPID --value || true)" \
"$BEFORE_BINARY_SHA" "$(binary_hash || true)" \
"$BEFORE_UNIT_SHA" "$(unit_hash || true)" \
"$BEFORE_DROPINS_SHA" "$(dropin_manifest_hash || true)" "" \
"$BEFORE_CONFIG_SHA" "$(runtime_config_hash || true)"
cleanup_config_backup
exit "$rc"
}
BACKUP_DIR="$(mktemp -d)"
sudo cp --archive /etc/pulse/.env "$BACKUP_DIR/runtime.env"
sudo cp --archive /etc/pulse/billing.json "$BACKUP_DIR/billing.json"
trap rollback_on_error ERR
log "applying the target-compatible demo runtime profile and restarting only pulse.service"
MUTATED=true
set_env_value PULSE_MOCK_NODES "$MOCK_NODES"
set_env_value PULSE_MOCK_VMS_PER_NODE "$MOCK_VMS_PER_NODE"
set_env_value PULSE_MOCK_LXCS_PER_NODE "$MOCK_LXCS_PER_NODE"
set_env_value PULSE_MOCK_DOCKER_HOSTS "$MOCK_DOCKER_HOSTS"
set_env_value PULSE_MOCK_DOCKER_CONTAINERS "$MOCK_DOCKER_CONTAINERS"
set_env_value PULSE_MOCK_GENERIC_HOSTS "$MOCK_GENERIC_HOSTS"
set_env_value PULSE_MOCK_K8S_CLUSTERS "$MOCK_K8S_CLUSTERS"
set_env_value PULSE_MOCK_K8S_NODES "$MOCK_K8S_NODES"
set_env_value PULSE_MOCK_K8S_PODS "$MOCK_K8S_PODS"
set_env_value PULSE_MOCK_K8S_DEPLOYMENTS "$MOCK_K8S_DEPLOYMENTS"
# Persistent mock-store backfill is reserved for the isolated local-dev data
# directory. The governed demo already has persistent runtime data, so a stale
# opt-in here can turn startup into an unbounded SQLite backfill.
set_env_value PULSE_MOCK_SEED_METRICS_STORE false
set_env_value PULSE_MOCK_TRENDS_SEED_DURATION "$MOCK_SEED_DURATION"
set_env_value PULSE_MOCK_TRENDS_SAMPLE_INTERVAL "$MOCK_SAMPLE_INTERVAL"
set_env_value PULSE_MOCK_UPDATE_INTERVAL "$MOCK_UPDATE_INTERVAL"
sudo chown pulse:pulse /etc/pulse/.env
sudo chmod 600 /etc/pulse/.env
# Alert lifecycle and AI incident history on this host are generated demo
# operational memory. A failed 50-node bootstrap can leave thousands of
# lifecycle events whose v6.4 replay queues one serialized incident save per
# event before HTTP opens. Back up and clear only those fixed demo-history
# files, including the retired JSON sources that repopulate a cleared event
# log; compensation restores them if the bounded profile still fails.
clear_demo_operational_history
sudo systemctl restart "$SERVICE_NAME"
for _ in $(seq 1 12); do
if [ "$(systemctl is-active "$SERVICE_NAME" || true)" = "active" ] && local_healthy; then
break
fi
sleep 5
done
[ "$(systemctl is-active "$SERVICE_NAME")" = "active" ]
[ "$(systemctl show "$SERVICE_NAME" --property=SubState --value)" = "running" ]
local_healthy
runtime_profile_matches
AFTER_PID="$(systemctl show "$SERVICE_NAME" --property=MainPID --value)"
AFTER_RELAY_PID="$(systemctl show "$RELAY_SERVICE_NAME" --property=MainPID --value)"
AFTER_BINARY_SHA="$(binary_hash)"
AFTER_UNIT_SHA="$(unit_hash)"
AFTER_DROPINS_SHA="$(dropin_manifest_hash)"
AFTER_CONFIG_SHA="$(runtime_config_hash)"
VERSION="$(local_version)"
[ "$AFTER_PID" -gt 0 ]
[ "$AFTER_PID" != "$BEFORE_PID" ]
[ "$AFTER_RELAY_PID" = "$BEFORE_RELAY_PID" ]
[ "$AFTER_BINARY_SHA" = "$BEFORE_BINARY_SHA" ]
[ "$AFTER_UNIT_SHA" = "$BEFORE_UNIT_SHA" ]
[ "$AFTER_DROPINS_SHA" = "$BEFORE_DROPINS_SHA" ]
[ "${VERSION#v}" = "$EXPECTED_VERSION" ]
trap - ERR
cleanup_config_backup
emit_evidence recovered "$BEFORE_PID" "$AFTER_PID" \
"$BEFORE_RELAY_PID" "$AFTER_RELAY_PID" \
"$BEFORE_BINARY_SHA" "$AFTER_BINARY_SHA" \
"$BEFORE_UNIT_SHA" "$AFTER_UNIT_SHA" \
"$BEFORE_DROPINS_SHA" "$AFTER_DROPINS_SHA" "$VERSION" \
"$BEFORE_CONFIG_SHA" "$AFTER_CONFIG_SHA"
# CI observer only; remote systemd owns mutation, restoration and both watches.
exec python3 .github/scripts/dispatch-demo-runtime.py recover
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Secret-free, fresh public CI only. Never run this fixture on a service host."""
import base64
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import pwd
import shutil
import socket
import subprocess
import time
SCRIPTS = Path(__file__).resolve().parents[1]
RESULT = Path.cwd() / "demo-native-result.json"
ROOT = Path("/var/lib/pulse-deploy/demo")
OWNED = [Path("/etc/pulse"), Path("/opt/pulse"), Path("/var/lib/pulse-deploy"),
Path("/etc/systemd/system/pulse.service"), Path("/etc/systemd/system/pulse-relay.service"),
Path("/usr/local/share/ca-certificates/pulse-demo-native.crt")]
def command(argv, timeout=120):
result = subprocess.run(argv, capture_output=True, timeout=timeout)
if result.returncode:
raise RuntimeError("fixture-command-failed")
return result.stdout
def binary(version, fail_after=0, port=17655):
return ('''#!/usr/bin/python3
import http.server,json,os,threading,time
VERSION = %r
if %r == 17655:
from pathlib import Path
Path('/etc/pulse/persistent-marker').write_text('candidate data ' + VERSION)
def fail():
time.sleep(%r)
print('panic: synthetic disposable service fault',flush=True)
os._exit(1)
if %r: threading.Thread(target=fail,daemon=True).start()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
expected = {'/healthz'} if %r == 17656 else {'/api/health','/api/version'}
if self.path not in expected:
self.send_response(404); self.end_headers(); return
body=json.dumps({'version':VERSION} if self.path=='/api/version' else {'status':'healthy'}).encode()
self.send_response(200); self.end_headers(); self.wfile.write(body)
def log_message(self,*args): pass
http.server.HTTPServer(('127.0.0.1',%r),Handler).serve_forever()
''' % (version, port, fail_after, fail_after, port, port)).encode()
def unit(executable, user="pulse"):
return f"[Unit]\nDescription=Disposable demo native fixture\n[Service]\nUser={user}\nGroup={user}\nExecStart={executable}\nRestart=no\n[Install]\nWantedBy=multi-user.target\n"
def wait(predicate, limit):
deadline = time.monotonic() + limit
while time.monotonic() < deadline:
if predicate():
return
time.sleep(1)
raise RuntimeError("fixture-observation-timeout")
def main():
result = {"schema_version": 1, "control_sha": os.environ.get("GITHUB_SHA"),
"run_id": os.environ.get("GITHUB_RUN_ID"), "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
"cases": [], "cleanup_complete": False, "signed_published_installer_acceptance": False}
processes = []
admitted = False
original_caddy = None
caddyfile = Path("/etc/caddy/Caddyfile")
try:
result["stage"] = "disposable-runner-admission"
# Environment fields are context, not permission: combine them with a
# fresh empty estate, effective root, disposable hosted VM and exact job.
if (os.geteuid() != 0 or os.environ.get("GITHUB_ACTIONS") != "true"
or os.environ.get("RUNNER_ENVIRONMENT") != "github-hosted"
or socket.gethostname() in {"pulse-relay", "pulse-license", "pulse-dev"}
or any(path.exists() or path.is_symlink() for path in OWNED)):
raise RuntimeError("not-an-empty-disposable-public-ci-runner")
admitted = True
result["stage"] = "prepare-native-fixtures"
original_caddy = caddyfile.read_bytes()
command(["systemctl", "show", "--property=Version"])
if not subprocess.run(["id", "pulse"], capture_output=True).returncode == 0:
command(["useradd", "--system", "--user-group", "--no-create-home", "pulse"])
owner = pwd.getpwnam("pulse")
Path("/opt/pulse/bin").mkdir(parents=True)
Path("/etc/pulse").mkdir(mode=0o755)
os.chown("/etc/pulse", owner.pw_uid, owner.pw_gid)
Path("/etc/pulse/.env").write_text("DEMO_MODE=true\n")
Path("/etc/pulse/billing.json").write_text('{"capabilities":["demo_fixtures"],"limits":{},"meters_enabled":[],"plan_version":"community","subscription_state":"active"}')
Path("/etc/pulse/persistent-marker").write_text("original data")
for path in Path("/etc/pulse").iterdir():
os.chown(path, owner.pw_uid, owner.pw_gid)
Path("/opt/pulse/bin/pulse").write_bytes(binary("1.0.0")); Path("/opt/pulse/bin/pulse").chmod(0o755)
Path("/opt/pulse/VERSION").write_text("1.0.0\n")
Path("/opt/pulse/scripts").mkdir()
untouched = {"agent": Path("/opt/pulse/bin/pulse-agent-fixture"),
"script": Path("/opt/pulse/scripts/install-fixture.sh")}
for path in untouched.values():
path.write_bytes(b"unchanged bundled distribution asset")
untouched_before = {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in untouched.items()}
Path("/opt/pulse/bin/relay").write_bytes(binary("1.0.0", port=17656)); Path("/opt/pulse/bin/relay").chmod(0o755)
for name, executable in (("pulse", "/opt/pulse/bin/pulse"), ("pulse-relay", "/opt/pulse/bin/relay")):
Path(f"/etc/systemd/system/{name}.service").write_text(unit(executable))
command(["systemctl", "daemon-reload"])
command(["systemctl", "enable", "--now", "pulse", "pulse-relay"])
cert = Path("/etc/caddy/demo-native.crt"); key = Path("/etc/caddy/demo-native.key")
command(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
"-subj", "/CN=localhost", "-addext", "subjectAltName=IP:127.0.0.1",
"-addext", "basicConstraints=critical,CA:TRUE",
"-keyout", str(key), "-out", str(cert)])
key.chmod(0o640); shutil.chown(key, group="caddy")
shutil.copyfile(cert, OWNED[-1]); command(["update-ca-certificates"])
caddyfile.write_text("{\n admin off\n auto_https off\n}\n" +
f"https://127.0.0.1:18443 {{\n tls {cert} {key}\n reverse_proxy 127.0.0.1:17655\n}}\n" +
f"https://127.0.0.1:18444 {{\n tls {cert} {key}\n reverse_proxy 127.0.0.1:17656\n}}\n")
command(["systemctl", "restart", "caddy"])
spec = importlib.util.spec_from_file_location("dispatcher", SCRIPTS / "dispatch-demo-runtime.py")
dispatcher = importlib.util.module_from_spec(spec); spec.loader.exec_module(dispatcher)
original = (SCRIPTS / "demo-runtime-transaction.py").read_bytes()
# Only the cohost endpoint is rebound. Transaction, paths, locking,
# systemd, HTTP/TLS, estate, clocks and watches remain real and unchanged.
endpoint = b'https://relay.pulserelay.pro/healthz'
assert original.count(endpoint) == 1
source = original.replace(endpoint, b'https://127.0.0.1:18444/healthz')
result["original_engine_sha256"] = hashlib.sha256(original).hexdigest()
result["fixture_engine_sha256"] = hashlib.sha256(source).hexdigest()
result["bootstrap_sha256"] = hashlib.sha256(dispatcher.BOOTSTRAP.encode()).hexdigest()
result["driver_sha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
result["tools"] = {"systemd": command(["systemctl", "--version"]).decode().splitlines()[0],
"caddy": command(["caddy", "version"]).decode().strip()}
profile = {name: "2" for name in ("nodes", "vms_per_node", "lxcs_per_node", "docker_hosts", "docker_containers", "generic_hosts", "k8s_clusters", "k8s_nodes", "k8s_pods", "k8s_deployments")}
profile.update(seed_duration="2h", sample_interval="5m", update_interval="15s")
def submit(version, fault=0):
candidate = binary(version, fault)
metadata = (version + "\n").encode()
request = {"mode": "update", "hostname": socket.gethostname(), "local_url": "http://127.0.0.1:17655",
"public_url": "https://127.0.0.1:18443/api/health", "version": "v" + version,
"profile": profile, "control_sha": os.environ["GITHUB_SHA"],
"run_id": os.environ["GITHUB_RUN_ID"], "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"],
"binary_sha256": hashlib.sha256(candidate).hexdigest(),
"version_sha256": hashlib.sha256(metadata).hexdigest()}
payload = {"request": request, "source": base64.b64encode(source).decode(),
"binary": base64.b64encode(candidate).decode(), "version_file": base64.b64encode(metadata).decode()}
identity = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
proc = subprocess.Popen(["python3", "-c", dispatcher.BOOTSTRAP], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
processes.append(proc); proc.stdin.write(json.dumps(payload).encode()); proc.stdin.close()
return proc, ROOT / "attempts" / identity, payload
# Readiness is real, including authenticated TLS through actual Caddy.
result["stage"] = "native-listener-readiness"
wait(lambda: subprocess.run(["curl", "--disable", "--silent", "--fail", "https://127.0.0.1:18443/api/health"], capture_output=True).returncode == 0, 60)
proc, attempt, payload = submit("1.0.1")
result["stage"] = "changed-executable-forward"
proc.wait(timeout=900)
receipt = json.loads((attempt / "receipt.json").read_text()); result["cases"].append(receipt)
assert proc.returncode == 0 and receipt["status"] == "committed" and receipt["forward"]["elapsed_seconds"] >= 300
assert Path("/etc/pulse/persistent-marker").read_text() == "candidate data 1.0.1"
assert Path("/opt/pulse/VERSION").read_text() == "1.0.1\n"
assert {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in untouched.items()} == untouched_before
# A later candidate fails at 55s; lose the observer while the owned
# systemd child continues, then TERM during actual full recovery.
proc, attempt, payload = submit("1.0.2", 55)
result["stage"] = "delayed-fault-observer-loss"
wait(lambda: (attempt / "receipt.json").exists() and json.loads((attempt / "receipt.json").read_text())["status"] == "applying", 60)
proc.terminate(); proc.wait(timeout=30)
command(["systemctl", "is-active", "pulse-demo-" + attempt.name[:32]])
wait(lambda: json.loads((attempt / "receipt.json").read_text())["status"] == "recovering", 180)
result["stage"] = "term-during-owned-recovery"
command(["systemctl", "kill", "--kill-whom=main", "--signal=TERM", "pulse-demo-" + attempt.name[:32]])
replay = subprocess.run(["python3", "-c", dispatcher.BOOTSTRAP], input=json.dumps(payload).encode(), capture_output=True, timeout=900)
receipt = json.loads((attempt / "receipt.json").read_text()); result["cases"].append(receipt)
assert replay.returncode == 1 and receipt["status"] == "rolled_back" and receipt["recovery"]["elapsed_seconds"] >= 300
assert receipt["failure"] in {"new-service-crash", "sustained-health"}
assert Path("/etc/pulse/persistent-marker").read_text() == "candidate data 1.0.1"
assert Path("/opt/pulse/VERSION").read_text() == "1.0.1\n"
assert {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in untouched.items()} == untouched_before
version = json.loads(command(["curl", "--disable", "--silent", "--fail", "http://127.0.0.1:17655/api/version"]))["version"]
assert version == "1.0.1"
result["stage"] = "complete"
result["passed"] = True
except Exception as error:
result["passed"] = False
result["failure_type"] = type(error).__name__
result["failure_code"] = str(error) if isinstance(error, RuntimeError) else "fixture-observation-or-assertion"
finally:
if admitted:
try:
for proc in processes:
if proc.poll() is None:
proc.terminate(); proc.wait(timeout=30)
# Stop only retained children belonging to this fresh fixture.
if (ROOT / "attempts").exists():
for attempt in (ROOT / "attempts").iterdir():
subprocess.run(["systemctl", "stop", "pulse-demo-" + attempt.name[:32]], capture_output=True, timeout=1300)
command(["systemctl", "disable", "--now", "pulse", "pulse-relay"])
command(["systemctl", "stop", "caddy"])
if original_caddy is not None:
caddyfile.write_bytes(original_caddy)
for path in OWNED:
if path.is_dir(): shutil.rmtree(path)
elif path.exists(): path.unlink()
for path in (Path("/etc/caddy/demo-native.crt"), Path("/etc/caddy/demo-native.key")):
if path.exists(): path.unlink()
command(["systemctl", "daemon-reload"]); command(["update-ca-certificates", "--fresh"])
result["cleanup_complete"] = not any(path.exists() for path in OWNED)
except Exception as cleanup:
result["cleanup_failure_type"] = type(cleanup).__name__
result["passed"] = False
RESULT.write_text(json.dumps(result, indent=2) + "\n")
RESULT.chmod(0o644)
return 0 if result.get("passed") and result["cleanup_complete"] else 1
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
name: Demo transaction native acceptance
on:
pull_request:
paths:
- '.github/scripts/demo-runtime-transaction.py'
- '.github/scripts/dispatch-demo-runtime.py'
- '.github/scripts/tests/demo_runtime_native.py'
- '.github/workflows/demo-runtime-native.yml'
push:
branches: [main]
paths:
- '.github/scripts/demo-runtime-transaction.py'
- '.github/scripts/dispatch-demo-runtime.py'
- '.github/scripts/tests/demo_runtime_native.py'
- '.github/workflows/demo-runtime-native.yml'
permissions:
contents: read
concurrency:
group: demo-native-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
native:
runs-on: ubuntu-24.04
timeout-minutes: 35
steps:
- name: Check out exact source without retained credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Prepare Caddy only in the disposable public CI runner
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends caddy openssl
- name: Exercise real systemd Caddy and retained child recovery
run: sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_SHA,GITHUB_RUN_ID,GITHUB_RUN_ATTEMPT python3 .github/scripts/tests/demo_runtime_native.py
- name: Retain sanitised terminal receipts and cleanup
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-native-${{ github.run_id }}
path: demo-native-result.json
if-no-files-found: error
retention-days: 14
+1
View File
@@ -14,6 +14,7 @@ jobs:
name: Verify Current Committed Stable Demo (No Mutation)
permissions:
contents: read
actions: read
uses: ./.github/workflows/update-demo-server.yml
secrets: inherit
with:
+8 -28
View File
@@ -5,6 +5,7 @@ on:
permissions:
contents: read
actions: read
concurrency:
# This is the same physical target lock used by update-demo-server.yml.
@@ -15,7 +16,7 @@ concurrency:
jobs:
recover:
runs-on: ubuntu-24.04
timeout-minutes: 45
timeout-minutes: 90
environment: demo-stable
env:
DEMO_EXPECTED_HOSTNAME: ${{ vars.DEMO_EXPECTED_HOSTNAME }}
@@ -99,6 +100,7 @@ jobs:
- name: Recover current installed runtime
id: recovery
env:
GH_TOKEN: ${{ github.token }}
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
EXPECTED_VERSION: ${{ steps.stable.outputs.tag }}
@@ -117,16 +119,8 @@ jobs:
MOCK_UPDATE_INTERVAL: ${{ steps.runtime_profile.outputs.mock_update_interval }}
run: |
set -euo pipefail
ssh -i ~/.ssh/id_ed25519 \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/known_hosts \
"$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"bash -s -- $(printf '%q ' "$DEMO_EXPECTED_HOSTNAME" "$DEMO_LOCAL_BASE_URL" "$EXPECTED_VERSION" "$MOCK_NODES" "$MOCK_VMS_PER_NODE" "$MOCK_LXCS_PER_NODE" "$MOCK_DOCKER_HOSTS" "$MOCK_DOCKER_CONTAINERS" "$MOCK_GENERIC_HOSTS" "$MOCK_K8S_CLUSTERS" "$MOCK_K8S_NODES" "$MOCK_K8S_PODS" "$MOCK_K8S_DEPLOYMENTS" "$MOCK_SEED_DURATION" "$MOCK_SAMPLE_INTERVAL" "$MOCK_UPDATE_INTERVAL")" \
< .github/scripts/recover-demo-runtime.sh \
> demo-runtime-recovery.json
jq -e '.schema_version == 1 and (.status == "recovered" or .status == "healthy_noop")' \
demo-runtime-recovery.json
bash .github/scripts/recover-demo-runtime.sh > demo-runtime-recovery.json
jq -e '.schema_version == 2 and (.status == "committed" or .status == "healthy_noop") and .forward.elapsed_seconds >= 300' demo-runtime-recovery.json
echo "mutated=$(jq -r '.mutated' demo-runtime-recovery.json)" >> "$GITHUB_OUTPUT"
- name: Capture bounded Pulse failure diagnostics
@@ -199,22 +193,9 @@ jobs:
export PULSE_DEMO_AUTH_PASS="${DEMO_AUTH_PASS:-demo}"
./scripts/run_demo_public_browser_smoke.sh
- name: Compensate failed mutated recovery
if: failure() && steps.recovery.outputs.mutated == 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
BEFORE="$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"systemctl show pulse --property=ActiveState,SubState,MainPID --value | paste -sd, -")"
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"sudo systemctl stop pulse"
AFTER="$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"systemctl show pulse --property=ActiveState,SubState,MainPID --value | paste -sd, -")"
jq -n --arg before "$BEFORE" --arg after "$AFTER" \
'{schema_version: 1, status: "compensated_unavailable", before: $before, after: $after}' \
> demo-runtime-rollback.json
# The retained systemd transaction already owns compensation. A later
# browser/network failure is evidence to reconcile, not authority to stop
# an unrelated or already-committed Pulse process in a second SSH session.
- name: Retain bounded recovery evidence
if: always()
@@ -223,7 +204,6 @@ jobs:
name: stable-demo-runtime-recovery-${{ github.run_id }}
path: |
demo-runtime-recovery.json
demo-runtime-rollback.json
demo-runtime-diagnostics.txt
if-no-files-found: warn
retention-days: 14
@@ -340,6 +340,7 @@ jobs:
if: ${{ needs.acquire_customer_promotion_lease.result == 'success' && needs.acquire_customer_promotion_lease.outputs.superseded != 'true' && inputs.prerelease != true && startsWith(inputs.version, '6.') }}
permissions:
contents: read
actions: read
uses: ./.github/workflows/update-demo-server.yml
secrets: inherit
with:
+1
View File
@@ -639,6 +639,7 @@ jobs:
needs: dry-run
permissions:
contents: read
actions: read
uses: ./.github/workflows/update-demo-server.yml
secrets: inherit
with:
+42 -304
View File
@@ -40,6 +40,7 @@ on:
permissions:
contents: read
actions: read
# GitHub environments gate secret access and deployment admission, but do not
# serialize jobs. Share one resource lock with the recovery workflow so two
@@ -189,7 +190,7 @@ jobs:
needs: resolve
if: needs.resolve.outputs.skip != 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
timeout-minutes: 90
environment: ${{ needs.resolve.outputs.environment_name }}
env:
DEMO_EXPECTED_HOSTNAME: ${{ vars.DEMO_EXPECTED_HOSTNAME }}
@@ -343,23 +344,27 @@ jobs:
echo "::error::Timeout waiting for release assets"
exit 1
- name: Materialize tagged installer
- name: Verify signed release and select only demo server runtime
if: inputs.verify_only != true
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
EXPECTED_VERSION: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
TAG="${WORKFLOW_OUTPUT_1}"
TRUSTED_SSH_PUBLIC_KEY="$(
go run ./scripts/release_update_key.go public-key-ssh \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
--comment pulse-installer
)"
git show "refs/tags/${TAG}:install.sh" > /tmp/pulse-install.sh
sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" /tmp/pulse-install.sh
chmod +x /tmp/pulse-install.sh
asset="pulse-${EXPECTED_VERSION}-linux-amd64.tar.gz"
curl --disable --fail --location --silent --show-error --max-time 300 \
-o /tmp/pulse-demo-release.tgz "https://github.com/rcourtman/Pulse/releases/download/${EXPECTED_VERSION}/${asset}"
curl --disable --fail --location --silent --show-error --max-time 30 \
-o /tmp/pulse-demo-release.sshsig "https://github.com/rcourtman/Pulse/releases/download/${EXPECTED_VERSION}/${asset}.sshsig"
printf 'pulse-installer %s\n' "$TRUSTED_SSH_PUBLIC_KEY" > /tmp/pulse-demo-signers
ssh-keygen -Y verify -f /tmp/pulse-demo-signers -I pulse-installer \
-n pulse-install -s /tmp/pulse-demo-release.sshsig < /tmp/pulse-demo-release.tgz
python3 .github/scripts/dispatch-demo-runtime.py prepare
- name: Tailscale
id: tailscale
@@ -450,309 +455,42 @@ jobs:
echo "::error::Verification-only check found a demo version mismatch; refusing to update the host."
exit 1
- name: Prepare demo host storage
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
WORKFLOW_OUTPUT_1: ${{ steps.config.outputs.service_name }}
run: |
set -euo pipefail
SERVICE_NAME="${WORKFLOW_OUTPUT_1}"
REMOTE_SCRIPT=$(cat <<'EOF'
set -euo pipefail
SERVICE_NAME="$1"
CONFIG_DIR="/etc/${SERVICE_NAME:-pulse}"
BACKUP_PARENT="$(dirname "$CONFIG_DIR")"
BACKUP_BASENAME="$(basename "$CONFIG_DIR")"
KEEP_BACKUPS=2
BACKUP_MARGIN_KB=$((64 * 1024))
echo "Demo host filesystem before cleanup:"
df -h "$BACKUP_PARENT" /tmp || true
mapfile -t old_backups < <(
sudo find "$BACKUP_PARENT" -maxdepth 1 -type d -name "${BACKUP_BASENAME}.backup.*" -printf '%T@ %p\n' 2>/dev/null \
| sort -nr \
| awk -v keep="$KEEP_BACKUPS" 'NR > keep {sub(/^[^ ]+ /, ""); print}'
)
if ((${#old_backups[@]} > 0)); then
printf 'Removing %d old demo backup directorie(s).\n' "${#old_backups[@]}"
printf '%s\0' "${old_backups[@]}" | sudo xargs -0 rm -rf --
fi
if [[ -d "$CONFIG_DIR" ]]; then
config_kb="$(sudo du -sk "$CONFIG_DIR" | awk 'NR == 1 {print $1}')"
free_kb="$(df -Pk "$BACKUP_PARENT" | awk 'NR == 2 {print $4}')"
required_kb=$((config_kb + BACKUP_MARGIN_KB))
if ((free_kb < required_kb)); then
mapfile -t remaining_backups < <(
sudo find "$BACKUP_PARENT" -maxdepth 1 -type d -name "${BACKUP_BASENAME}.backup.*" -printf '%T@ %p\n' 2>/dev/null \
| sort -n \
| awk '{sub(/^[^ ]+ /, ""); print}'
)
for backup_dir in "${remaining_backups[@]}"; do
printf 'Removing demo backup to restore install headroom: %s\n' "$backup_dir"
sudo rm -rf -- "$backup_dir"
free_kb="$(df -Pk "$BACKUP_PARENT" | awk 'NR == 2 {print $4}')"
if ((free_kb >= required_kb)); then
break
fi
done
fi
pruned_volatile=false
if ((free_kb < required_kb)); then
echo "Pruning demo volatile runtime stores to restore install headroom."
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl stop "$SERVICE_NAME" || true
fi
mapfile -d '' volatile_paths < <(
sudo find "$CONFIG_DIR" -xdev -type f \( \
-name "metrics.db" -o \
-name "metrics.db-shm" -o \
-name "metrics.db-wal" -o \
-name "notification_queue.db" -o \
-name "notification_queue.db-shm" -o \
-name "notification_queue.db-wal" -o \
-name "deploy.db" -o \
-name "deploy.db-shm" -o \
-name "deploy.db-wal" \
\) -print0 2>/dev/null || true
)
for volatile_path in "${volatile_paths[@]}"; do
printf 'Removing demo volatile store: %s\n' "$(basename "$volatile_path")"
sudo rm -f -- "$volatile_path"
pruned_volatile=true
done
config_kb="$(sudo du -sk "$CONFIG_DIR" | awk 'NR == 1 {print $1}')"
free_kb="$(df -Pk "$BACKUP_PARENT" | awk 'NR == 2 {print $4}')"
required_kb=$((config_kb + BACKUP_MARGIN_KB))
fi
if ((free_kb < required_kb)); then
echo "::error::Demo host does not have enough free space to back up $CONFIG_DIR before install."
echo "Config: ${config_kb}KB, available: ${free_kb}KB, required: ${required_kb}KB"
sudo find "$BACKUP_PARENT" -maxdepth 1 -type d -name "${BACKUP_BASENAME}.backup.*" -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | sort || true
if [[ "$pruned_volatile" == "true" ]] && command -v systemctl >/dev/null 2>&1; then
sudo systemctl start "$SERVICE_NAME" || true
fi
exit 1
fi
fi
echo "Demo host filesystem after cleanup:"
df -h "$BACKUP_PARENT" /tmp || true
EOF
)
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" "bash -s -- $(printf '%q ' "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
- name: Upload tagged installer
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
scp -i ~/.ssh/id_ed25519 /tmp/pulse-install.sh "$DEMO_SERVER_USER@$DEMO_SERVER_HOST:/tmp/pulse-install.sh"
- name: Update demo server
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
WORKFLOW_OUTPUT_2: ${{ steps.config.outputs.service_name }}
run: |
set -euo pipefail
TAG="${WORKFLOW_OUTPUT_1}"
SERVICE_NAME="${WORKFLOW_OUTPUT_2}"
REMOTE_SCRIPT=$(cat <<'EOF'
set -euo pipefail
TAG="$1"
SERVICE_NAME="$2"
INSTALLER_ENV=()
if [ -n "$SERVICE_NAME" ]; then
INSTALLER_ENV+=("PULSE_SERVICE_NAME=$SERVICE_NAME")
fi
sudo env "${INSTALLER_ENV[@]}" bash /tmp/pulse-install.sh --version "$TAG"
rm -f /tmp/pulse-install.sh
EOF
)
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"bash -s -- $(printf '%q ' "$TAG" "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
- name: Restore demo runtime configuration
- name: Apply guarded demo transaction
if: inputs.verify_only != true
env:
GH_TOKEN: ${{ github.token }}
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
WORKFLOW_OUTPUT_1: ${{ steps.config.outputs.service_name }}
WORKFLOW_OUTPUT_2: ${{ needs.resolve.outputs.mock_nodes }}
WORKFLOW_OUTPUT_3: ${{ needs.resolve.outputs.mock_vms_per_node }}
WORKFLOW_OUTPUT_4: ${{ needs.resolve.outputs.mock_lxcs_per_node }}
WORKFLOW_OUTPUT_5: ${{ needs.resolve.outputs.mock_docker_hosts }}
WORKFLOW_OUTPUT_6: ${{ needs.resolve.outputs.mock_docker_containers }}
WORKFLOW_OUTPUT_7: ${{ needs.resolve.outputs.mock_generic_hosts }}
WORKFLOW_OUTPUT_8: ${{ needs.resolve.outputs.mock_k8s_clusters }}
WORKFLOW_OUTPUT_9: ${{ needs.resolve.outputs.mock_k8s_nodes }}
WORKFLOW_OUTPUT_10: ${{ needs.resolve.outputs.mock_k8s_pods }}
WORKFLOW_OUTPUT_11: ${{ needs.resolve.outputs.mock_k8s_deployments }}
WORKFLOW_OUTPUT_12: ${{ needs.resolve.outputs.mock_seed_duration }}
WORKFLOW_OUTPUT_13: ${{ needs.resolve.outputs.mock_sample_interval }}
WORKFLOW_OUTPUT_14: ${{ needs.resolve.outputs.mock_update_interval }}
EXPECTED_VERSION: ${{ needs.resolve.outputs.tag }}
CURRENT_MATCHES: ${{ steps.current.outputs.skip_current }}
MOCK_NODES: ${{ needs.resolve.outputs.mock_nodes }}
MOCK_VMS_PER_NODE: ${{ needs.resolve.outputs.mock_vms_per_node }}
MOCK_LXCS_PER_NODE: ${{ needs.resolve.outputs.mock_lxcs_per_node }}
MOCK_DOCKER_HOSTS: ${{ needs.resolve.outputs.mock_docker_hosts }}
MOCK_DOCKER_CONTAINERS: ${{ needs.resolve.outputs.mock_docker_containers }}
MOCK_GENERIC_HOSTS: ${{ needs.resolve.outputs.mock_generic_hosts }}
MOCK_K8S_CLUSTERS: ${{ needs.resolve.outputs.mock_k8s_clusters }}
MOCK_K8S_NODES: ${{ needs.resolve.outputs.mock_k8s_nodes }}
MOCK_K8S_PODS: ${{ needs.resolve.outputs.mock_k8s_pods }}
MOCK_K8S_DEPLOYMENTS: ${{ needs.resolve.outputs.mock_k8s_deployments }}
MOCK_SEED_DURATION: ${{ needs.resolve.outputs.mock_seed_duration }}
MOCK_SAMPLE_INTERVAL: ${{ needs.resolve.outputs.mock_sample_interval }}
MOCK_UPDATE_INTERVAL: ${{ needs.resolve.outputs.mock_update_interval }}
run: |
set -euo pipefail
SERVICE_NAME="${WORKFLOW_OUTPUT_1}"
MOCK_NODES="${WORKFLOW_OUTPUT_2}"
MOCK_VMS_PER_NODE="${WORKFLOW_OUTPUT_3}"
MOCK_LXCS_PER_NODE="${WORKFLOW_OUTPUT_4}"
MOCK_DOCKER_HOSTS="${WORKFLOW_OUTPUT_5}"
MOCK_DOCKER_CONTAINERS="${WORKFLOW_OUTPUT_6}"
MOCK_GENERIC_HOSTS="${WORKFLOW_OUTPUT_7}"
MOCK_K8S_CLUSTERS="${WORKFLOW_OUTPUT_8}"
MOCK_K8S_NODES="${WORKFLOW_OUTPUT_9}"
MOCK_K8S_PODS="${WORKFLOW_OUTPUT_10}"
MOCK_K8S_DEPLOYMENTS="${WORKFLOW_OUTPUT_11}"
MOCK_SEED_DURATION="${WORKFLOW_OUTPUT_12}"
MOCK_SAMPLE_INTERVAL="${WORKFLOW_OUTPUT_13}"
MOCK_UPDATE_INTERVAL="${WORKFLOW_OUTPUT_14}"
REMOTE_SCRIPT=$(cat <<'EOF'
set -euo pipefail
SERVICE_NAME="${1:-pulse}"
DEMO_LOCAL_BASE_URL="$2"
MOCK_NODES="$3"
MOCK_VMS_PER_NODE="$4"
MOCK_LXCS_PER_NODE="$5"
MOCK_DOCKER_HOSTS="$6"
MOCK_DOCKER_CONTAINERS="$7"
MOCK_GENERIC_HOSTS="$8"
MOCK_K8S_CLUSTERS="$9"
MOCK_K8S_NODES="${10}"
MOCK_K8S_PODS="${11}"
MOCK_K8S_DEPLOYMENTS="${12}"
MOCK_SEED_DURATION="${13}"
MOCK_SAMPLE_INTERVAL="${14}"
MOCK_UPDATE_INTERVAL="${15}"
CONFIG_DIR="/etc/${SERVICE_NAME:-pulse}"
SERVICE_USER="$(systemctl show "$SERVICE_NAME" --property=User --value 2>/dev/null || true)"
SERVICE_GROUP="$(systemctl show "$SERVICE_NAME" --property=Group --value 2>/dev/null || true)"
SERVICE_USER="${SERVICE_USER:-pulse}"
SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
MODE=update
if [ "$CURRENT_MATCHES" = "true" ]; then MODE=recover; fi
python3 .github/scripts/dispatch-demo-runtime.py "$MODE" > demo-runtime-transaction.json
jq -e '.schema_version == 2 and (.status == "committed" or .status == "healthy_noop") and .forward.elapsed_seconds >= 300' demo-runtime-transaction.json
resolve_config_dir() {
local fallback="$CONFIG_DIR"
local unit_env=""
local fragment=""
unit_env="$(systemctl show "$SERVICE_NAME" --property=Environment --value 2>/dev/null || true)"
for entry in $unit_env; do
case "$entry" in
PULSE_DATA_DIR=*)
printf '%s\n' "${entry#PULSE_DATA_DIR=}"
return 0
;;
esac
done
fragment="$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value 2>/dev/null || true)"
if [ -n "$fragment" ] && sudo test -r "$fragment"; then
local unit_data_dir
unit_data_dir="$(sudo sed -n -E 's/^Environment="?PULSE_DATA_DIR=([^"]*)"?$/\1/p' "$fragment" | tail -1)"
if [ -n "$unit_data_dir" ]; then
printf '%s\n' "$unit_data_dir"
return 0
fi
fi
printf '%s\n' "$fallback"
}
CONFIG_DIR="$(resolve_config_dir)"
ENV_FILE="$CONFIG_DIR/.env"
set_env_value() {
local key="$1"
local value="$2"
sudo mkdir -p "$CONFIG_DIR"
if sudo test -f "$ENV_FILE"; then
if sudo grep -Eq "^[[:space:]]*${key}=" "$ENV_FILE"; then
sudo sed -i "s|^[[:space:]]*${key}=.*|${key}=${value}|" "$ENV_FILE"
else
printf '\n%s=%s\n' "$key" "$value" | sudo tee -a "$ENV_FILE" >/dev/null
fi
else
printf '# Pulse demo runtime configuration\n%s=%s\n' "$key" "$value" | sudo tee "$ENV_FILE" >/dev/null
fi
}
ensure_demo_fixture_entitlement() {
local billing_file="$CONFIG_DIR/billing.json"
local tmp_file
tmp_file="$(mktemp)"
sudo mkdir -p "$CONFIG_DIR"
if sudo test -s "$billing_file" && sudo jq '
.capabilities = (((.capabilities // []) + ["demo_fixtures"]) | unique)
| .limits = (.limits // {})
| .meters_enabled = (.meters_enabled // [])
| .plan_version = (if (.plan_version // "") == "" then "community" else .plan_version end)
| .subscription_state = (if (.subscription_state // "") == "" then "active" else .subscription_state end)
| del(.integrity)
' "$billing_file" > "$tmp_file"; then
:
else
cat > "$tmp_file" <<'JSON'
{"capabilities":["demo_fixtures"],"limits":{},"meters_enabled":[],"plan_version":"community","subscription_state":"active"}
JSON
fi
sudo tee "$billing_file" < "$tmp_file" >/dev/null
rm -f "$tmp_file"
sudo chown "$SERVICE_USER:$SERVICE_GROUP" "$billing_file" || sudo chown "$SERVICE_USER" "$billing_file" || true
sudo chmod 600 "$billing_file"
echo "Demo fixture entitlement ensured in governed demo billing state."
}
set_env_value DEMO_MODE true
set_env_value PULSE_MOCK_MODE true
set_env_value PULSE_MOCK_NODES "$MOCK_NODES"
set_env_value PULSE_MOCK_VMS_PER_NODE "$MOCK_VMS_PER_NODE"
set_env_value PULSE_MOCK_LXCS_PER_NODE "$MOCK_LXCS_PER_NODE"
set_env_value PULSE_MOCK_DOCKER_HOSTS "$MOCK_DOCKER_HOSTS"
set_env_value PULSE_MOCK_DOCKER_CONTAINERS "$MOCK_DOCKER_CONTAINERS"
set_env_value PULSE_MOCK_GENERIC_HOSTS "$MOCK_GENERIC_HOSTS"
set_env_value PULSE_MOCK_K8S_CLUSTERS "$MOCK_K8S_CLUSTERS"
set_env_value PULSE_MOCK_K8S_NODES "$MOCK_K8S_NODES"
set_env_value PULSE_MOCK_K8S_PODS "$MOCK_K8S_PODS"
set_env_value PULSE_MOCK_K8S_DEPLOYMENTS "$MOCK_K8S_DEPLOYMENTS"
set_env_value PULSE_MOCK_RANDOM_METRICS true
set_env_value PULSE_MOCK_STOPPED_PERCENT 6
# Never carry the isolated local-dev metrics-store opt-in onto the
# persistent public demo data directory.
set_env_value PULSE_MOCK_SEED_METRICS_STORE false
set_env_value PULSE_MOCK_TRENDS_SEED_DURATION "$MOCK_SEED_DURATION"
set_env_value PULSE_MOCK_TRENDS_SAMPLE_INTERVAL "$MOCK_SAMPLE_INTERVAL"
set_env_value PULSE_MOCK_UPDATE_INTERVAL "$MOCK_UPDATE_INTERVAL"
ensure_demo_fixture_entitlement
sudo chown "$SERVICE_USER:$SERVICE_GROUP" "$ENV_FILE" || sudo chown "$SERVICE_USER" "$ENV_FILE" || true
sudo chmod 600 "$ENV_FILE"
sudo systemctl daemon-reload
sudo systemctl restart "$SERVICE_NAME"
for attempt in $(seq 1 30); do
if curl -fsS "${DEMO_LOCAL_BASE_URL}/api/health" >/dev/null; then
echo "Demo service restarted with governed demo runtime configuration."
exit 0
fi
sleep 2
done
echo "::error::Demo service did not become healthy after restoring demo runtime configuration."
exit 1
EOF
)
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"bash -s -- $(printf '%q ' "$SERVICE_NAME" "$DEMO_LOCAL_BASE_URL" "$MOCK_NODES" "$MOCK_VMS_PER_NODE" "$MOCK_LXCS_PER_NODE" "$MOCK_DOCKER_HOSTS" "$MOCK_DOCKER_CONTAINERS" "$MOCK_GENERIC_HOSTS" "$MOCK_K8S_CLUSTERS" "$MOCK_K8S_NODES" "$MOCK_K8S_PODS" "$MOCK_K8S_DEPLOYMENTS" "$MOCK_SEED_DURATION" "$MOCK_SAMPLE_INTERVAL" "$MOCK_UPDATE_INTERVAL")" <<<"$REMOTE_SCRIPT"
- name: Retain guarded demo transaction evidence
if: always() && inputs.verify_only != true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: stable-demo-transaction-${{ github.run_id }}
path: demo-runtime-transaction.json
if-no-files-found: warn
retention-days: 14
- name: Verify demo runtime
env:
+42 -15
View File
@@ -37,19 +37,40 @@ Patrol mode sets how far Pulse can go when Patrol finds something that needs att
**UI:** Patrol → Patrol mode
**API:**
```bash
# Get current Patrol mode settings
curl -s -u admin:admin http://localhost:7655/api/ai/patrol/autonomy
**API:** Use an administrator API token with `settings:write` in the
[private header file](API.md#api-token-recommended). This endpoint requires
that scope even for GET; a `monitoring:read` token is not sufficient. For a
one-off read, you can instead open the path in your signed-in administrator
browser without extracting its cookie. There is no shared example password
to configure or substitute into these commands.
# Update Patrol mode.
# The API keeps the autonomy_level field name for compatibility.
curl -X PUT http://localhost:7655/api/ai/patrol/autonomy \
-u admin:admin \
-H "Content-Type: application/json" \
-d '{"autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600}'
Read the current settings first (curl 7.76 or later, on the Pulse host):
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/ai/patrol/autonomy
```
The next command **changes Patrol mode**, not just connection health. Use it
only when you intend to enable investigation and queue fixes for approval on
a plan with that capability. The API retains `autonomy_level` for compatibility:
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request PUT --header 'Content-Type: application/json' --data-binary @- \
http://127.0.0.1:7655/api/ai/patrol/autonomy <<'JSON'
{"autonomy_level":"approval","investigation_budget":15,"investigation_timeout_sec":600}
JSON
```
For remote use, substitute your Pulse **HTTPS** URL and keep certificate
verification enabled. Do not add `--insecure`, verbose/trace output or redirect
following; keep `--disable` first to ignore local curl defaults. A nonzero exit,
401 or 403 is a failed check, not evidence that a change was saved. Stop and
resolve authentication, permissions or the reported error before proceeding;
after an uncertain write, read the saved settings before retrying. Keep
tokens, cookies and unredacted responses out of reports.
### License Requirements
- `monitor`: Available on all plans. Community and Relay can run Patrol with BYOK.
@@ -77,12 +98,18 @@ Control levels govern what the interactive Pulse Assistant can do during chat se
**UI:** Settings → Pulse Intelligence → Assistant → Chat command mode
**API:**
**API:** This intentionally changes chat command access. Use the same private
administrator header file with `settings:write`; the token must also have
permission to change settings. Prefer the UI for one-off changes, and verify
the saved **Chat command mode** there afterwards. Do not use a write as an
authentication test.
```bash
curl -X PUT http://localhost:7655/api/settings/ai/update \
-u admin:admin \
-H "Content-Type: application/json" \
-d '{"control_level": "controlled"}'
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request PUT --header 'Content-Type: application/json' --data-binary @- \
http://127.0.0.1:7655/api/settings/ai/update <<'JSON'
{"control_level":"controlled"}
JSON
```
### Approval Flow (Controlled Mode)
+18 -6
View File
@@ -15,14 +15,26 @@ needed for your task. The read-only example below requires `monitoring:read`.
Pass the token in the `X-API-Token` header, using a private header file so the
secret does not appear in shell history or process arguments.
On the machine running curl, prepare the file and open it in an editor:
On a trusted machine, as the account running curl, prepare the file and open
it in an editor. This preserves an existing file, restricts its directory and
stops before editing if preparation fails or a credential path is a symlink:
```bash
umask 077
mkdir -p "$HOME/.config/pulse"
touch "$HOME/.config/pulse/api-header"
chmod 600 "$HOME/.config/pulse/api-header"
vi "$HOME/.config/pulse/api-header"
(
set -eu
umask 077
auth_dir="$HOME/.config/pulse"
auth_file="$auth_dir/api-header"
if [ -L "$HOME/.config" ] || [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then
printf 'Refusing a symlinked credential path.\n' >&2
exit 1
fi
mkdir -p "$auth_dir"
chmod 700 "$auth_dir"
touch "$auth_file"
chmod 600 "$auth_file"
vi "$auth_file"
)
```
In the editor, save just this line, replacing `<token>` with the API token:
+113 -28
View File
@@ -83,19 +83,16 @@ Auto-update preferences are stored in `system.json` and edited via the UI.
### Docker
```bash
# Pull latest image
docker pull rcourtman/pulse:vX.Y.Z
Use [Docker server updates](DOCKER.md#-updates). Change the configured image to
the exact release first, then pull and recreate only the Pulse service with the
same mounted data. Pulling another tag alone does not change the image in your
Compose file. Do not run `docker compose down` for a Pulse-only update or remove
its data volume.
# Restart container
docker compose down && docker compose up -d
```
This command uses the public Community image. Private Pro runtime installs must
use the private image and credentials supplied by the private download/update
path; replacing a Pro image with `rcourtman/pulse` changes the runtime edition.
If you use the legacy `docker-compose` binary, replace `docker compose` with `docker-compose`.
Private Pro installs must keep the private image supplied by the
[download page](https://pulserelay.pro/download.html); replacing it with
`rcourtman/pulse` changes the runtime edition. Server updates and monitored
container updates are separate operations.
### ProxmoxVE LXC (Manual)
@@ -124,30 +121,118 @@ sudo systemctl restart pulse
## Rollback
If an update causes issues:
An update error or a stuck progress modal does not prove that the old version
is still running. Check the running version and service health before retrying
or restoring anything; preserve the update error, logs and history. Do not run
another update just to reproduce a failure.
Before any rollback, take a consistent private backup of the current state and
check the proposed backup's scope. Keep the failed installation and update
snapshots until recovery is verified. Returning to an older binary is not a
full-state recovery. Restoring a matching data snapshot can discard settings,
alert changes and history written since that snapshot.
### Automatic Rollback
Pulse creates a backup before updating. If the update fails:
1. The previous version is automatically restored
2. Service restarts with the old version
3. Error details are logged
The in-app updater creates an installation snapshot before applying files. If
applying files fails, it attempts to restore that snapshot and logs any restore
error. This is not a guarantee that every update failure restores all state or
restarts successfully. Confirm the running version, connections and service
health afterwards; a progress message alone is not recovery evidence.
### Roll back from Update History
If your installed version offers it, open **Settings → System → Updates →
Update History**. **Roll back** is offered for a successful in-app update with
a retained backup. The confirmation names the version before that update and
Pulse restarts to use the restored binary. Check that version and the actual
backup contents before confirming.
**Update History is not a full-state recovery tool.** Its restore scope depends
on the installed updater. Older updaters may replace `data/`, `config/` and
`.env` under the installation directory, but omit active data stored elsewhere.
Do not assume later settings and alert changes will be reverted, even if the
confirmation says so. If active data shares those installation paths, do not
restore it while Pulse is running; use the stopped-service procedure in
[Manual Rollback](#manual-rollback).
Older versions may not have this control. An absent button or backup does not
justify deleting data or creating an empty configuration. Docker server installs
use their previous image and matching data backup, not this in-app rollback.
### What an update snapshot contains
In-app snapshots normally live at `backup-<timestamp>/` under the runtime data
directory (usually `/etc/pulse`), with `/tmp/pulse-backup-<timestamp>` as a
low-space fallback. Use the `backup_path` recorded for the actual update in
`update-history.jsonl`, rather than guessing a timestamp. The updater retains
the most recent three snapshots; a `/tmp` snapshot may also be lost on reboot.
Snapshot scope is version-specific. The published **v6.4.5** and
**v6.4.6-rc.1** in-app updaters use the limited installation snapshot described
below. Do not assume a backup made by another updater version has the same
contents or restore behaviour.
The limited snapshot copies the running server binary, available `VERSION` and
`.env` files, and available `data/` and `config/` directories under
`PULSE_INSTALL_DIR` (default `/opt/pulse`). It is not necessarily a complete
backup of the active `PULSE_DATA_DIR`, external metrics/audit stores, service
units, bundled agents or update helpers. Copy errors can leave a partial
snapshot: a recorded path is not proof that every file was saved.
A backup's existence, or the presence of a database file, does not establish
completeness, consistency or that Update History will restore it. A snapshot
that includes active data still needs a verified, stopped-service recovery
procedure; its presence is not permission to rewind live runtime stores.
Verify the backup against your effective service paths and update logs. Preserve
the active data directory, matching `.encryption.key`, audit history and its
signing key together, including custom paths and organisation directories. Use
a stopped, consistent filesystem/volume backup; copying a live `.db` file alone
can omit SQLite sidecar files. Keep these backups private, not in an issue or
diagnostic attachment. See [configuration paths](CONFIGURATION.md) and
[audit storage and safe recovery](AUDIT_LOGGING.md#storage).
### Manual Rollback
Update backups created by in-app updates are stored as `backup-<timestamp>/` folders inside the Pulse data directory (`/etc/pulse` or `/data`). If that directory does not have enough free space, Pulse falls back to `/tmp/pulse-backup-<timestamp>`. Pulse keeps the most recent three in-app rollback snapshots and prunes older ones from retention.
There is no rollback UI. To revert, stop Pulse, restore the backup contents to `/opt/pulse`, then restart.
Example (systemd/LXC):
If Pulse cannot start, work inside the Pulse host or LXC, not on the Proxmox host
itself. Start with read-only service discovery:
```bash
sudo systemctl stop pulse
sudo cp -a /etc/pulse/backup-<timestamp>/pulse /opt/pulse/pulse
sudo cp -a /etc/pulse/backup-<timestamp>/VERSION /opt/pulse/VERSION
sudo rm -rf /opt/pulse/data /opt/pulse/config
sudo cp -a /etc/pulse/backup-<timestamp>/data /opt/pulse/data
sudo cp -a /etc/pulse/backup-<timestamp>/config /opt/pulse/config
sudo cp -a /etc/pulse/backup-<timestamp>/.env /opt/pulse/.env
sudo systemctl start pulse
systemctl is-active pulse
systemctl show pulse --property=FragmentPath --property=DropInPaths
```
An inactive service makes `is-active` return non-zero; that is useful evidence,
not a reason to reset it. Inspect the named unit and drop-ins locally for the
effective executable, `PULSE_INSTALL_DIR` and `PULSE_DATA_DIR`. Do not post full
service environments or `.env` contents; they may contain credentials.
1. Stop Pulse before taking the failed-state backup or replacing files. Preserve
the actual executable, distribution files, unit/drop-ins and every active
data path, not just a guessed `/opt/pulse/data` directory.
2. Match the retained backup to the intended pre-update version. Check its
files and the update log for missing/copy-failed entries. If the backup is
missing, partial or has the wrong scope, stop here; do not delete live
directories to make a restore fit.
3. Prefer restoring a verified pre-update filesystem/volume snapshot with your
host backup tooling. Check it in an isolated recovery instance first, with
no access to monitored systems or notification destinations. Do not start a
second connected copy with the same agent identities.
4. For a file-level repair, stage verified replacements and keep reversible
copies of the failed state. Restore only the confirmed paths, ownership and
permissions for this deployment. Do not merge unrelated snapshots, delete
data/configuration directories, or regenerate encryption or signing keys.
5. Start Pulse and verify the running version and edition, service health,
saved connections, agent identities and retained history. If any check fails,
preserve the error and backups rather than retrying the update blindly.
For Docker, change the configured image back to the recorded previous tag or
digest and recreate only Pulse with its existing volume. An older image may not
understand data migrated by the newer version; use a verified matching data
backup when required. Never remove the volume or initialise a fresh one as a
rollback shortcut. An image change alone is not a data rollback.
## Update History
History entries are stored in `update-history.jsonl` under the Pulse data directory (`/etc/pulse` or `/data`), and exposed via `GET /api/updates/history` (admin auth required).
+80 -17
View File
@@ -4,7 +4,7 @@ Pulse uses a split-configuration model to ensure security and flexibility.
| File | Purpose | Security Level |
| ------ | --------- | ---------------- |
| `.env` | Authentication & Secrets | 🔒 **Critical** (Read-only by owner) |
| `.env` | Authentication & Secrets | 🔒 **Critical** (Owner access only) |
| `.encryption.key` | Encryption key for `.enc` files | 🔒 **Critical** |
| `audit/.audit-signing.key` | Encrypted signing key for the default audit store; preserve with its history and matching encryption key | 🔒 **Sensitive** |
| `system.json` | General Settings | 📝 Standard |
@@ -66,30 +66,93 @@ Multi-tenant layout:
## 🔐 Authentication (`.env`)
This file controls access to Pulse. It is **never** exposed to the UI.
For a new install, leave `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` unset and
complete the bootstrap-token setup in your browser. See
[First login](INSTALL.md#step-1-get-the-token). Do not deploy a shared example password.
```bash
# /etc/pulse/.env
The Pulse-generated `.env` controls local authentication and is not exposed
to the UI. Preserve its owner-only permissions. A bcrypt hash is sensitive too:
someone who obtains it can attempt offline password cracking.
# Admin Credentials (bcrypt hashed; plain text auto-hashes on startup)
```dotenv
# Pulse-generated file, not a command to paste into a shell
PULSE_AUTH_USER='admin'
PULSE_AUTH_PASS='$2a$12$...'
PULSE_AUTH_PASS='<complete-bcrypt-hash>'
```
<details>
<summary><strong>Advanced: Automated Setup (Skip UI)</strong></summary>
Deployment-supplied environment values take precedence over this file. Changing
the generated file or using Pulse's password-change UI does not replace a
password supplied by your deployment manager; update that managed source too.
You can pre-configure Pulse by setting environment variables. Plain text credentials are automatically hashed on startup.
### Private Docker authentication file
If automation must skip browser setup, keep the credentials in a private file,
not a `docker run -e PULSE_AUTH_PASS=...` command, shell history or a shared
Compose file. On the Docker host, prepare the file below. These commands do not
erase an existing file; edit it locally, without printing its contents:
```bash
# Docker Example
docker run -d \
-e PULSE_DEPLOYMENT_METHOD=docker_run \
-e PULSE_AUTH_USER=admin \
-e PULSE_AUTH_PASS=secret123 \
rcourtman/pulse:latest
(
set -e
umask 077
auth_dir="$HOME/.config/pulse"
auth_file="$auth_dir/docker-auth.env"
if [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then
printf 'Refusing a symlinked credential path.\n' >&2
exit 1
fi
mkdir -p "$auth_dir"
chmod 700 "$auth_dir"
touch "$auth_file"
chmod 600 "$auth_file"
vi "$auth_file"
)
```
</details>
In that editor, enter these two settings and replace the placeholder with a
**complete bcrypt hash** generated by a trusted local tool with an interactive
password prompt. Do not put the password in that tool's command arguments.
Unlike Pulse's own quoted `.env`, Docker's `--env-file` input uses unquoted
values; keep the hash's `$` characters literal, with no `$$` substitution:
```dotenv
PULSE_AUTH_USER=admin
PULSE_AUTH_PASS=<complete-bcrypt-hash>
```
For a **new Community container**, replace `vX.Y.Z` with the exact published
tag you intend to install, then run:
```bash
(
set -e
if [ ! -s "$HOME/.config/pulse/docker-auth.env" ]; then
printf 'Prepare the private authentication file first.\n' >&2
exit 1
fi
docker run -d \
--name pulse \
-p 7655:7655 \
-v pulse_data:/data \
--env-file "$HOME/.config/pulse/docker-auth.env" \
-e PULSE_DEPLOYMENT_METHOD=docker_run \
--restart unless-stopped \
rcourtman/pulse:vX.Y.Z
)
```
This keeps credential values out of the command arguments, **not out of Docker's
container environment**: administrators with Docker access can still read them.
Pulse hashes a plaintext `PULSE_AUTH_PASS` for authentication at startup, but
does not scrub its original value from Docker or your deployment file. Keep the
file private, outside the repository, and do not share `docker inspect` or
resolved Compose configuration. Do not source the file into your shell.
For an existing or paid Pro deployment, retain its image, mounts and managed
configuration; this new-container example is not an upgrade procedure. Compose
interpolation and Docker `--env-file` are different formats. Prefer bootstrap
setup for Compose unless your deployment manager already has a private
credential mechanism. See the [Docker guide](DOCKER.md) for setup and updates.
<details>
<summary><strong>Advanced: OIDC / SSO</strong></summary>
@@ -244,7 +307,7 @@ Environment variables take precedence over `system.json`.
| `DISCOVERY_HTTP_TIMEOUT_MS` | HTTP probe timeout (ms) | `2000` |
| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form | `false` |
| `DEMO_MODE` | Enable read-only demo mode | `false` |
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated IPs/CIDRs trusted to supply `X-Forwarded-For`/`X-Real-IP` | *(unset)* |
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated immediate proxy IPs/CIDRs trusted for forwarded client IP, scheme, host and port. Use the peer seen by Pulse; wildcard ranges are rejected. See [Reverse Proxy](REVERSE_PROXY.md#before-configuring-the-proxy). | *(unset)* |
| `PULSE_TRUSTED_NETWORKS` | Comma-separated CIDRs treated as trusted local networks (does not bypass auth) | *(unset)* |
| `ALLOW_UNPROTECTED_EXPORT` | Allow unauthenticated config export on public networks when no auth is configured (use with caution) | `false` |
+45 -16
View File
@@ -4,7 +4,7 @@ Pulse supports multiple deployment models. This page clarifies what differs betw
## Summary
| Model | Recommended for | Data/config path | Updates |
| Model | Recommended for | Default data/config path | Updates |
| --- | --- | --- | --- |
| Proxmox VE LXC (installer) | Proxmox-first deployments | `/etc/pulse` | In-app updates supported |
| systemd (bare metal / VM) | Traditional Linux hosts | `/etc/pulse` | In-app updates supported |
@@ -59,11 +59,16 @@ Pulse uses a split config model:
- **Relay config**: `relay.enc` (encrypted, Relay and above)
- **RBAC roles**: `rbac_roles.json` (Pro/legacy Pro+/Cloud)
Path mapping:
Default path mapping:
- systemd/LXC: `/etc/pulse/*`
- Docker/Helm: `/data/*`
These are defaults, not a backup inventory. Check the active deployment's
`PULSE_DATA_DIR`, mounts and any external stores before copying or restoring
files. Metrics can use a separate `PULSE_METRICS_DB_PATH`; see
[Metrics storage](METRICS_HISTORY.md#storage-location).
The audit paths above are the default store, not a licence-dependent guarantee
that storage initialised successfully. Runtime-specific storage can differ;
see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage) before restoring
@@ -84,30 +89,54 @@ Provider-hosted MSP layout:
## Updates by Model
Before changing the server binary or image, record its running version and
edition, keep the saved deployment configuration, and take a consistent private
backup of the actual data and external stores. Preserve encrypted files with
their matching `.encryption.key` and audit signing key. Use a stopped, consistent
filesystem/volume backup or supported backup tooling; copying a live `.db` file
alone can omit SQLite sidecar files. Do not post configuration files, environment
dumps or Helm secret values in an issue.
An update snapshot is not necessarily a complete data backup, and reverting a
binary or image does not undo data migrations. Check
[rollback and backup scope](AUTO_UPDATE.md#rollback) before updating or reverting;
keep the previous binary/image and matching data backup until recovery is checked.
### systemd and Proxmox LXC
Use the UI:
Use **Settings → System → Updates**. Follow
[server update guidance](AUTO_UPDATE.md) for the deployment's update controls and
Update History. Pulse attempts snapshot and rollback operations; a recorded
backup path alone does not establish that all active data was copied or that
recovery succeeded.
- **Settings → System → Updates**
These deployments can apply updates by downloading a release and swapping binaries/config safely with backups and history.
For LXC, Pulse's service and data are inside the container, not on the Proxmox
host. Keep the failed state and check the running version and service health
before retrying a failed update.
### Docker
Pull a new image and restart:
Follow [Docker server updates](DOCKER.md#-updates) from the original Compose
project or saved container deployment. Persist the exact target in its `image:`
line, or in `PULSE_IMAGE` when that line uses the variable; pulling a different
tag alone does not change the configured image. Paid Pro installs must keep
their private image, not replace it with the Community image.
Pull and recreate only the Pulse service, stopping if the pull fails. Preserve
its data volume, ports and settings, and check the running image and server
version afterwards. This updates the Pulse server, not its agents or the
containers it monitors; there is no need to restart unrelated Compose services.
```bash
docker pull rcourtman/pulse:latest
docker compose up -d
```
### Kubernetes (Helm)
Upgrade the chart:
Use the existing Helm release and namespace, the chosen chart version and your
saved deployment values. Review changes to persistence, Secret references and
image edition before rollout; keep the current PVC, keys and settings. Do not
let a default Community image replace paid Pro.
```bash
helm repo update
helm upgrade pulse pulse/pulse -n pulse
```
See [Kubernetes deployment settings](KUBERNETES.md) and use your cluster's
rollout and recovery procedure. A Helm rollback changes release resources; it
does not guarantee restoration of Pulse data changed by a newer binary.
### Provider-hosted MSP
+53 -25
View File
@@ -45,9 +45,6 @@ services:
environment:
- TZ=Europe/London
- PULSE_DEPLOYMENT_METHOD=docker_compose
# Optional: Pre-configure auth (skips setup wizard)
# - PULSE_AUTH_USER=admin
# - PULSE_AUTH_PASS=secret123
volumes:
pulse_data:
@@ -55,6 +52,13 @@ volumes:
Run with: `docker compose up -d`
Leave authentication overrides unset for a new install and complete
[bootstrap-token setup](INSTALL.md#step-1-get-the-token) in your browser. Do not add a
shared example password to the Compose file. If automation must skip setup,
use a private deployment-managed credential source; the
[private Docker authentication file](CONFIGURATION.md#private-docker-authentication-file)
example is for `docker run`, not Compose interpolation.
The `PULSE_IMAGE` variable lets the same compose file run either the public
community image or, for eligible paid customers, the private Pulse Pro image
shown on <https://pulserelay.pro/download.html>.
@@ -79,7 +83,10 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid
> **Tip**: Set `LOG_LEVEL=warn` to reduce log volume while still capturing important events.
> **Note**: API tokens are managed in the UI and stored in `api_tokens.json`.
> **Note**: Plain text values in `PULSE_AUTH_PASS` are auto-hashed on startup.
> **Note**: Plain text values in `PULSE_AUTH_PASS` are hashed for authentication
> at startup, but remain in the container environment and deployment file.
> Docker administrators can read those values. Prefer bootstrap setup; never
> share full `docker inspect` or resolved Compose output.
For SSD-sensitive installs, keep `/data` persistent and put only metrics
history on tmpfs:
@@ -118,21 +125,46 @@ services:
## 🔄 Updates
To update Pulse to a specific release tag:
These steps update the Pulse server, not the containers it monitors. Record the
current image and take a consistent private backup of the mounted Pulse data
before changing it. Keep the previous image and data backup for recovery; do
not delete volumes.
```bash
docker pull rcourtman/pulse:vX.Y.Z
docker stop pulse
docker rm pulse
# Re-run your docker run command
docker inspect pulse --format '{{.Config.Image}} {{.Image}}'
```
If using Compose:
Run commands from the original Compose project directory. First set the exact
target in your Compose `image:` line, or persist `PULSE_IMAGE` in the project's
`.env` if the file uses `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}`.
Setting `PULSE_IMAGE` has no effect on a hardcoded image line. Community uses
`rcourtman/pulse:vX.Y.Z`; paid Pro installs must keep the private image and
existing registry login from <https://pulserelay.pro/download.html>. Do not
replace Pro with the Community image or post registry credentials.
For the `pulse` service in the examples above:
```bash
docker compose pull
docker compose up -d
(
set -e
docker compose pull pulse
docker compose up -d --no-deps pulse
)
```
If pulling fails, stop rather than recreating with an unverified or old image.
Pulling a different tag alone does not update your configured image. There is
no need to bring the whole Compose project down. Use your actual service and
container names if they differ; legacy `docker-compose` users can substitute
that command name.
For `docker run` or an app UI, change the image in the existing saved deployment
and recreate it with the same data mount, ports and settings; do not start a
second Pulse against that data. Check the running image again, Pulse's displayed
server version and service health after recreation. See
[rollback and backup scope](AUTO_UPDATE.md#rollback) before reverting a version:
an image change alone does not undo data migrations.
---
## 🔄 Docker / Podman Updates
@@ -188,12 +220,10 @@ To keep update detection anonymous-only (no credential store reads, no credentia
Paid Pulse Pro Docker installs use the private Pulse Pro registry rather than
the public `rcourtman/pulse` image. Open <https://pulserelay.pro/download.html>,
paste your activation key, run the Docker login command shown there, then run
the shown `PULSE_IMAGE=license.pulserelay.pro/pulse-pro:<version> docker compose pull`
and `docker compose up -d` commands from the host that already runs Pulse. If
your compose file has a hardcoded `image: rcourtman/pulse:...` line, change it
to `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}` or directly to the private
image shown on the download page before running those commands.
enter your activation key in the page and use its registry login instructions
on the host that already runs Pulse. Persist the private image for both pull
and recreation as described in [server updates](#-updates); an image override
used for only the pull does not select it for the later recreation.
### Disabling Update Features
@@ -240,13 +270,11 @@ Pulse can start, stop, and restart Docker / Podman containers directly from the
## 🛠️ Troubleshooting
- **Forgot Password?**
```bash
docker exec pulse rm /data/.env
docker restart pulse
# Access UI again. Pulse will require a bootstrap token for setup.
# Get it with:
docker exec pulse /app/pulse bootstrap-token
```
Follow the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password).
Update the active credential source; do not delete `.env`, remove the data
volume or repeat setup. A deployment-supplied password overrides the generated
file, and changing Docker's managed environment needs a recreate/redeploy,
not just a restart. SSO accounts and temporary lockouts have separate paths.
- **Logs**
```bash
+7 -24
View File
@@ -118,30 +118,13 @@ discovery are disabled by default. See
trust boundary and rollout checklist.
### I forgot my password. How do I reset it?
**Docker**:
```bash
docker exec pulse rm /data/.env
docker restart pulse
# Access UI again. Pulse will require a bootstrap token for setup.
# Get it with:
docker exec pulse /app/pulse bootstrap-token
```
**Systemd**:
Delete `/etc/pulse/.env` and restart the service. Pulse will require a bootstrap token for setup:
```bash
sudo pulse bootstrap-token
```
**Proxmox LXC** (installed from the Proxmox shell):
Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`:
```bash
pct exec <ctid> -- rm /etc/pulse/.env
pct exec <ctid> -- systemctl restart pulse
pct exec <ctid> -- /usr/local/bin/pulse bootstrap-token
```
If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one.
Use the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password).
For the local administrator, update only the password in its active credential
source, preserving the username, tokens, configuration and data; deleting `.env`
is not a universal reset. The guide covers Docker, systemd and Proxmox LXC,
including deployment overrides, private backups and verification. SSO login,
temporary lockouts and a fresh install's missed bootstrap token need different
steps; do not reset a working installation to retrieve that token.
### How do I enable HTTPS?
Set `HTTPS_ENABLED=true` and provide `TLS_CERT_FILE` and `TLS_KEY_FILE` environment variables. See [Configuration](CONFIGURATION.md#-https--tls).
+33 -5
View File
@@ -107,6 +107,9 @@ docker run -d \
rcourtman/pulse:vX.Y.Z
```
For a new container, continue with [bootstrap-token setup](#step-1-get-the-token)
in your browser; do not add an example password to the command.
### Docker Compose
Create a `docker-compose.yml` file:
@@ -122,8 +125,6 @@ services:
- pulse_data:/data
environment:
- PULSE_DEPLOYMENT_METHOD=docker_compose
- PULSE_AUTH_USER=admin
- PULSE_AUTH_PASS=secret123
volumes:
pulse_data:
@@ -134,7 +135,22 @@ to the private Pulse Pro image shown on
<https://pulserelay.pro/download.html> without rebuilding the file around a
second deployment path.
> **Note**: Plain text passwords set via `PULSE_AUTH_PASS` are auto-hashed on startup. For production, prefer Quick Security Setup or a pre-hashed bcrypt value.
Leave authentication overrides unset for a new install and complete
[bootstrap-token setup](#step-1-get-the-token) in your browser. Do not deploy a
shared example password. If automation must skip setup, use a private
deployment-managed credential source. The
[authentication guide](CONFIGURATION.md#private-docker-authentication-file)
explains the visibility and override limits; its `docker run --env-file`
example is not a Compose interpolation recipe.
For an existing installation, preserve its image, data mounts and managed
configuration; do not reset authentication to repeat first-time setup. If you
used a shared example password, replace it in your deployment's credential
source. A deployment-supplied password takes precedence over changes made in
Pulse's password-change UI. Hashing it inside Pulse does not remove the
original value from Docker's environment or your deployment file. Never share
full `docker inspect` or resolved Compose output.
> **Note**: Docker monitoring requires the unified agent on the Docker host with socket access; the Pulse server container does not need `/var/run/docker.sock`. See [UNIFIED_AGENT.md](UNIFIED_AGENT.md).
---
@@ -302,9 +318,21 @@ update. Use **Settings → Infrastructure → Install on a host** for a first in
or a v5-to-v6 in-place upgrade.
### Rollback
If an update causes issues on systemd installations, backups are created automatically during the update process.
An update error does not establish which version is running. Check the running
version and service health before retrying or rolling back, and preserve the
failed installation and update logs.
**Manual rollback**: In-app updates store backups under `/etc/pulse/backup-<timestamp>/`. The systemd auto-update timer uses a temporary `/tmp/pulse-backup-<timestamp>` during the update and auto-restores on failure.
In-app update snapshots and updater-script backups have different contents and
lifetimes. Neither is guaranteed to include all active data; an older binary
also may not understand data migrated by a newer version. Update History is
not a full-state recovery tool, and a recorded backup path is not proof of a
complete backup.
Check the [version-specific snapshot scope](AUTO_UPDATE.md#what-an-update-snapshot-contains)
and use the [stopped-service recovery procedure](AUTO_UPDATE.md#manual-rollback)
when data needs restoring. Keep matching data and keys together, verify the
backup privately and preserve reversible copies of the failed state. Do not
replace live runtime data or delete it to make a rollback fit.
---
+104 -70
View File
@@ -1,88 +1,122 @@
# 🚚 Migrating Pulse
# Migrating Pulse
This guide covers migrating Pulse to a new host using the built-in encrypted export/import workflow.
**Create Backup** in **Settings → System → Recovery** creates an encrypted
configuration export, not a full backup of the installation. Check its scope
before moving Pulse or retiring the old server. Do not use it alone when you
need to preserve history, agent enrolment state or settings outside that scope.
## 🚀 Quick Migration Guide
## Choose the backup you need
### ❌ DON'T: Copy Files
Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption keys and credentials can break.
### Configuration transfer
### ✅ DO: Use Export/Import
Use export/import to move the included settings between installations. Pulse
decrypts the export with its passphrase and saves the imported credentials with
the destination's encryption key. Do not copy individual encrypted files into
a fresh installation with a different key.
#### 1. Export (Old Server)
1. Go to **Settings → System → Recovery**.
2. Click **Create Backup**.
3. Enter a strong passphrase and download the encrypted backup.
The current export format includes:
#### 2. Import (New Server)
1. Install a fresh Pulse instance.
2. Go to **Settings → System → Recovery**.
3. Click **Restore Configuration** and upload your file.
4. Enter the passphrase.
## 📦 What Gets Migrated
| Included ✅ | Not Included ❌ |
| Included | Not included |
| :--- | :--- |
| Nodes & credentials | Historical metrics history (`metrics.db`) |
| Alerts & overrides | Browser sessions and local cookies |
| Notifications (email, webhooks, Apprise) | Local login username/password (`.env`) |
| System settings (`system.json`) | Update history/backup folders |
| API token records | — |
| OIDC config | — |
| SSO / SAML config | — |
| TrueNAS connections (`truenas.enc`) | — |
| Guest metadata/notes | — |
| — | Relay config (`relay.enc`) — re-enable in Settings |
| — | Host metadata (notes/tags/AI command overrides) |
| — | Docker metadata cache |
| — | Agent profiles and assignments |
| — | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) |
| — | RBAC roles (`rbac_roles.json`) — re-create after import |
| — | Relay/Pro/legacy Pro+/Cloud license (`license.enc`) |
| — | Server sessions (`sessions.json`) |
| — | Update history (`update-history.jsonl`) |
| Proxmox VE, PBS and PMG connections and credentials | TrueNAS, vSphere and Machine Availability connections |
| Alert configuration, overrides and intent policies | Active alerts, incidents, notification queue and delivery history |
| Email, webhook, Apprise and external watchdog configuration | Metrics history, audit history and report schedules |
| System settings | Local login credentials, environment overrides and sessions |
| API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state |
| SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles |
| Guest metadata and notes | Host/Docker metadata, agent profiles and assignments |
| — | AI settings, findings, usage and chat history |
| — | Licence files, existing Relay configuration, binaries, service definitions and update backups |
## 🔄 Common Scenarios
Contents depend on the version that created the export. Older formats can omit
settings included above. Imported API-token records retain their scopes and
expiry; they do not reveal the original tokens or transfer every agent's state.
SSO configuration **is included**, even though local login credentials are not.
### Moving to New Hardware
Export from old → Install new → Import.
### Full-state recovery
### Docker ↔ Systemd ↔ Kubernetes
The export file works across all installation methods. You can migrate from Docker to Kubernetes or vice versa seamlessly.
If you need the same installation's history and excluded state, retain a
consistent filesystem/volume backup as well. Stop Pulse before taking that
backup; monitoring and alert delivery pause while it is stopped. Preserve every
effective data path, the matching `.encryption.key`, audit data and signing key,
environment files, external certificates, binaries and service/container
configuration together. Custom paths and organisation directories matter.
Copying a live database file alone can omit its sidecar files.
### Disaster Recovery
1. Install Pulse using Docker or your preferred method (see [INSTALL.md](INSTALL.md)).
2. Import your latest backup.
3. Restored in < 5 minutes.
Keep that backup private and test recovery on an isolated destination before
retiring the source. Do not mix encrypted files and keys from different
installations or restore over a running Pulse. See [configuration
paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#storage).
The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains)
is a third, limited backup; it is not a substitute for either procedure above.
## 📋 Post-Migration Checklist
## Transfer the included configuration
Because local login credentials are stored in `.env` (not part of exports), you must:
1. On the old server, sign in and open **Settings → System → Recovery →
Create Backup**. Use a strong, unique passphrase of at least 12 characters
and store it separately from the downloaded file. With password login,
choose **Use a custom passphrase** if you do not want to use that password.
Keep the old instance and your separate full-state backup until validation
is complete.
2. Install Pulse at the destination using the [installation guide](INSTALL.md).
Configure destination-local administrator access before importing. Keep the
destination isolated from agents and notification destinations during the
trial so two servers do not send duplicate alerts or remote actions.
3. If the destination already has useful settings, export them first. Open
**Settings → System → Recovery → Restore Configuration**, select the file
and enter its passphrase. Import replaces the included configuration and
API-token records; it does not merge them with the destination's records.
4. Check the result before changing DNS or agent addresses. A success message
establishes neither full recovery nor fresh monitoring. If import reports a
reload/apply failure, settings may already have been written: preserve both
installations and inspect the error before repeating it.
1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance.
2. **Confirm API access**:
* If you created API tokens in the UI, those token records are included in the export and should continue working.
3. **Update Agents**:
* **Unified Agent**: Update the `--token` flag in your service definition.
* **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment.
* **New server address?** Agents dial the address they were installed
with (the `--url` flag, or `PULSE_URL` for containerized agents), so
agents disappear from the new instance until that value is updated in
each agent's service definition. Fronting Pulse with a DNS name
instead of an IP address makes future moves invisible to agents.
* *Tip: Use **Settings → Infrastructure → Install on a host** to generate updated install commands.*
4. **Relay/Pro/legacy Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports).
Keep the archive, passphrase, configuration files and credentials out of issue
threads. Use the authenticated settings form rather than a command containing
a password, API token or session cookie.
## 🔒 Security
## Cut over and verify
* **Encryption**: Exports are encrypted with passphrase-based encryption (PBKDF2 + AES-GCM).
* **Storage**: Safe to store in cloud backups or password managers.
* **Passphrase**: Use a strong, unique passphrase (min 12 chars).
- **Login and permissions:** verify destination-local administrator access and
any imported SSO provider, group mappings and callback address. Re-create
custom roles and supply external CA/certificate files if needed. Environment
overrides can take precedence over imported settings.
- **Connections and history:** check every expected platform, guest and backup
against a fresh observation, not a cached row. Re-create excluded TrueNAS,
vSphere and Machine Availability connections. Missing historical data after
configuration-only transfer is not proof that collection has failed.
- **Agents:** preserve each agent's existing identity and private credential.
With a changed server address, use the [agent retargeting
guide](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) on the monitored host.
Do not create another token just for a server move or paste it into `--token`.
Confirm fresh reports and any enrolment/authorisation result; imported token
records alone do not prove that every agent is admitted. Re-create excluded
profiles and assignments before enabling remote actions.
- **Alerts and notifications:** avoid running both instances against the same
destinations. After cutover, check thresholds, schedules and one controlled
notification test with its actual delivery result. Importing destination
settings does not migrate pending notifications or incident history.
- **Licence and remaining settings:** re-activate Pro if required and verify
any excluded settings you rely on. Existing paired phones need their retained
configuration; configuration export alone is not a Mobile/Relay migration.
Relay is no longer sold; do not enable it as a new migration route.
## 🔧 Troubleshooting
Retire the old installation only after these checks succeed. Keep the private,
consistent pre-move backup for recovery. If validation fails, stop the trial
destination before returning traffic to the intact source; check agent URLs
and avoid two active writers. There is no guaranteed five-minute recovery time.
* **"Invalid passphrase"**: Ensure exact match (case-sensitive).
* **Missing Nodes**: Verify export date.
* **Connection Errors**: Update node IPs in Settings if they changed.
* **Logging**: Adjust `LOG_LEVEL`/`LOG_FORMAT` via environment variables if needed.
## Troubleshooting
- **Wrong passphrase or rejected file:** use the original export file and its
exact passphrase. Do not delete destination data or regenerate encryption
keys to make an import succeed.
- **A setting is missing:** compare it with the export scope above and the
exporting version. Use the old installation or full-state backup rather
than assuming import contained it.
- **Agents are absent:** check their primary URL, TLS trust and fresh admission
evidence using the linked guide. Do not uninstall, re-enrol or delete saved
identities just because the dashboard is empty.
- **Need help:** retain the source/destination versions, deployment types,
operation time and exact redacted error. Follow [safe
reporting](TROUBLESHOOTING.md#getting-help); do not attach the recovery backup.
+3 -2
View File
@@ -152,7 +152,7 @@ It does **not** allow:
If you have multiple PBS servers, add each one separately in Settings. Pulse will:
- Monitor each server independently
- Show backups from all servers in the unified Recovery view
- Show backups from all servers under **Proxmox → Backups** (`/proxmox/backups`)
- Deduplicate if the same backup appears via both PVE passthrough and direct PBS
---
@@ -301,7 +301,8 @@ or change retention to hide a display problem.
## Data Source Indicator
In the Recovery view, PBS backups show a data source indicator:
Under **Proxmox → Backups** (`/proxmox/backups`), PBS backups show a data source
indicator. There is no current top-level Recovery page:
- **"PBS"** badge alone = Direct PBS connection (full data)
- **"PBS via PVE"** = Passthrough via PVE storage (limited data)
+70 -17
View File
@@ -5,8 +5,9 @@ Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudfl
## 🚀 Quick Start
1. **Generate Secret**: Create a strong random string.
2. **Configure Pulse**:
```bash
2. **Configure Pulse**: Set these in your deployment's private configuration,
not a shell command, shared Compose file or repository:
```dotenv
PROXY_AUTH_SECRET=your-random-secret
PROXY_AUTH_USER_HEADER=X-Authentik-Username
```
@@ -40,6 +41,12 @@ Neither of these can be enforced from inside Pulse: a forged header and a genuin
## 📦 Examples
The secret values below are placeholders, not defaults. Replace them with the
same strong unique secret in Pulse and the proxy, using private configuration
files or your deployment's secret mechanism. Keep the rendered configuration
and container environment private too; do not share `docker inspect` or
resolved Compose output.
### Authentik (with Traefik)
**docker-compose.yml**:
```yaml
@@ -80,13 +87,60 @@ location / {
| **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. |
### Verify Headers
Use `curl` to simulate a proxy request:
```bash
curl -H "X-Proxy-Secret: your-secret" \
-H "X-Authentik-Username: admin" \
http://localhost:7655/api/state
First use a normal signed-in browser through your proxy. Check that the intended
user has the expected access; do not extract its cookies or use **Copy as cURL**.
The shared proxy secret can assert any identity, so a direct test belongs only
on a trusted backend/proxy host or inside its private network. Do not expose the
backend port or change firewall rules to make this test possible.
If an authorised administrator needs to isolate Pulse's role check, prepare a
temporary private header file in an editor. In that editor, enter the following,
substituting your configured user/role header names, real shared secret and a
test user's **non-admin** role (not the admin role). Do not add an API token or
session cookie: those would test a different credential path.
```text
X-Proxy-Secret: <your-real-shared-secret>
X-Authentik-Username: testuser
X-Proxy-Roles: none
```
Run this read-only probe (curl 7.76 or later). It sends no settings changes and
prints only the HTTP status, not settings, cookies or credential-bearing logs:
```bash
(
set -eu
umask 077
probe_dir=$(mktemp -d)
header_file="$probe_dir/proxy-headers"
trap 'test ! -e "$header_file" || rm -- "$header_file"; rmdir -- "$probe_dir"' EXIT
touch "$header_file"
vi "$header_file"
curl --disable --silent --show-error --output /dev/null \
--write-out 'HTTP %{http_code}\n' --header "@$header_file" \
http://127.0.0.1:7655/api/system/settings
)
```
The loopback address applies only on the Pulse host with a loopback listener.
Otherwise use its existing private backend address over HTTPS with certificate
verification enabled. Do not send forged test identities through a public proxy
that should replace them. Keep `--disable` first to ignore local curl defaults;
do not enable verbose/trace output, `--insecure` or redirect following.
With role gating configured on Pulse 6.2.2 or later, expect **403** for a valid
non-admin identity. Repeat the same read-only probe with the role line omitted,
then blank: both should still be **403**. **200** means admin access is allowed;
check whether role gating is configured. **401** means authentication failed,
not that the non-admin role was checked. A redirect, connection/TLS error or
`HTTP 000` is not a successful denial test. Curl deliberately does not fail on
403 here because it is the expected result; assess the printed status separately
from transport success. Stop on an unexpected result rather than granting more
access. This tests Pulse's direct header handling, **not** your proxy's header
replacement or end-to-end access boundary. Share only the status and redacted
configuration names, never the header file or secret.
## Pulse 5.x (end-of-life)
The 5.x line is end-of-life and does not receive fixes. **Upgrade to 6.2.2 or later**, which is where proxy-auth role gating behaves as documented above.
@@ -96,15 +150,14 @@ If you cannot upgrade immediately, 5.x needs two configuration changes before ro
1. **Set `PROXY_AUTH_ADMIN_ROLE` explicitly.** 5.x never applies the documented `admin` default. While it is empty, 5.x skips role checking altogether and treats every proxy-authenticated user as an administrator, whatever their role header says.
2. **Send a non-empty role header on every authenticated request.** 5.x only evaluates roles when that header carries a value; an absent or empty header leaves the user an administrator. A user with no groups in your identity provider commonly produces exactly that, so have the proxy send a placeholder such as `none` rather than nothing. Any non-empty value that does not contain your admin role correctly resolves to non-admin.
Verify both with a request carrying a non-admin role — it should be refused:
```bash
curl -si -H "X-Proxy-Secret: your-secret" \
-H "X-Authentik-Username: testuser" \
-H "X-Proxy-Roles: none" \
http://localhost:7655/api/system/settings | head -1
```
Expected: `HTTP/1.1 403 Forbidden`. Repeat it with the role header omitted entirely — that must also be refused. If either returns `200`, admin access is still open.
Use the private-file probe above with a non-empty non-admin role: it should
return **403**. A direct 5.x request with the role header omitted can still
return **200** despite the explicit admin role; that is the old behaviour, not
proof that the workaround failed. The mitigation depends on the real proxy
always supplying a non-empty role, including for users with no groups. Verify
that end-to-end with such a user; do not expose or bypass the proxy to rely on
this workaround. A successful direct test alone does not establish that every
proxied request carries the role. Upgrade rather than treating the workaround
as equivalent to the current fail-closed behaviour.
6.2.2 and later fix both behaviors: role gating activates on the presence of the role header alone, and an absent or blank header resolves to non-admin.
+166 -151
View File
@@ -1,173 +1,188 @@
# Recovery
Pulse v6 includes a **provider-neutral recovery view** that aggregates backup, snapshot, and replication artifacts across all connected platforms into a single interface.
Pulse collects backup, snapshot and replication evidence from connected
providers. Use it to find recorded artifacts, identify gaps in protection and
check the source of a backup before using the provider's own recovery tools.
Pulse does not restore workloads from these views.
## Overview
## Where to look
Recovery is event-first and answers two questions:
There is no top-level Recovery page. Open the existing platform view:
1. **"What happened?"** → The **Recovery events** table shows individual recovery points (artifacts) with timestamps, outcomes, and sizes.
2. **"What can I actually recover?"** → **Protection coverage** shows the canonical posture for each resource: protected, attention, unprotected, or unknown.
- **Proxmox → Backups** (`/proxmox/backups`): **By date** lists recorded
artifacts; **Coverage** groups them by workload. Expand a coverage row to
inspect its posture reason and individual artifacts. Backup servers have
their own rows on the same page.
- **TrueNAS → Protection** (`/truenas/protection`): snapshots and replication
activity reported by the TrueNAS connection.
- **Kubernetes**: recovery records depend on the agent reporting VolumeSnapshot
or Velero inventory. An installed agent or a connected cluster does not
establish that either is available.
## Supported Providers
Provider collection is separate from presentation. Not every platform displays
the same columns or filters, and a missing reading is not proof that no backup
exists. [Check missing evidence](#missing-or-inconsistent-evidence) before
changing the provider or Pulse's stored data.
| Provider | Recovery Point Types |
|---|---|
| **Proxmox Backup Server (PBS)** | Full and incremental backups, sync jobs, verify tasks |
| **Proxmox VE (PVE)** | Local dump-style backups (`vzdump`) |
| **TrueNAS** | ZFS snapshots, replication tasks |
| **Kubernetes** | VolumeSnapshots, Velero backups (when available) |
## What the evidence means
## Concepts
Keep these observations separate:
### Subject (What Was Protected)
A subject is the thing being protected:
- A Proxmox VM or container
- A TrueNAS dataset (e.g., `tank/apps/postgres`)
- A Kubernetes PVC (e.g., `monitoring/prometheus-pvc`)
Subjects link to unified resources via `subjectResourceId` when possible.
### Recovery Point (An Artifact / Event)
A recovery point is a single concrete artifact:
- A PBS backup snapshot
- A local `vzdump` backup file
- A ZFS snapshot
- A replication run result
### Rollup (A Subject Summary)
A rollup groups recovery points for a subject to show:
- **Protection status** — is this subject actively protected?
- **Latest point** — when was the most recent successful backup/snapshot?
- **Health** — are there recent failures or warnings?
### Protection Posture (A Trust Decision)
A protection posture combines subject-linked recovery points with the latest
provider collection evidence. It deliberately keeps four operator-facing
states:
- **Protected** — a qualifying current recovery point is linked to the resource
and complete provider evidence does not invalidate the claim.
- **Attention** — evidence exists, but it is stale, failing, incomplete, or
unverified when verification is expected.
- **Unprotected** — complete evidence confirms that no qualifying protection
exists.
- **Unknown** — identity, permissions, provider history, or collection
completeness cannot support a stronger claim.
A backup or snapshot artifact may still be shown while posture is unknown.
Artifacts answer what Pulse found; posture answers what Pulse can safely claim.
Snapshot presence alone is never presented as independent recovery.
## Navigating Recovery
### Recovery Events
Shows individual recovery points. Key columns:
| Column | Description |
|---|---|
| Time | When the point was created (started/completed) |
| Subject | What was backed up |
| Method | Kind + mode of the backup |
| Outcome | success / warning / failed / running |
| Size | Size of the artifact (when available) |
| Verified | Whether the backup has been verified (tri-state) |
### Protection Coverage
The Proxmox **Backups → Coverage** view shows one row per workload. The default
table stays compact; expanding a row reveals the plain-language posture reason,
provider evidence quality, and individual restore artifacts.
| Column | Description |
|---|---|
| Item | The protected resource (VM name, dataset path, etc.) |
| Item Type | Canonical resource category |
| Posture | Protected, attention, unprotected, or unknown |
| Restore | Most recent successful recovery point timestamp |
| Provider columns | Latest PBS, PVE, or guest-snapshot artifact where available |
### Filtering
Both workspaces support:
- **Platform filter** — show only points from a specific platform
- **Outcome filter** — show only failed, successful, or running points
- **Time range** — filter to a specific time window
- **Search** — full-text search across items and details
## API Reference
| Method | Endpoint | Description |
| Observation | What it establishes | What it does not establish |
|---|---|---|
| `GET` | `/api/recovery/points` | List individual recovery points |
| `GET` | `/api/recovery/rollups` | List subject rollups (protection coverage) |
| `GET` | `/api/recovery/postures` | List canonical per-resource protection postures |
| `GET` | `/api/recovery/series` | Time-series data for recovery charts |
| `GET` | `/api/recovery/facets` | Available filter facets (providers, kinds, outcomes) |
| Successful task or `success` outcome | A provider-reported result or collected artifact | Guest thaw, application health or a tested restore |
| Running task or partial artifact | Work or an incomplete artifact is observed | A completed recovery point, even if a size is shown |
| Verified backup | The provider's verification result for that artifact | A successful application restore or guest thaw |
| Recent snapshot | A point on the source system | An independent copy that survives loss of that system |
| Protected posture | Current, linked evidence meets Pulse's protection policy | A recovery guarantee or permission to discard other backups |
### Query Parameters
**A backup task's OK status is not confirmation that the guest has thawed.**
For freeze-enabled Proxmox backups, stop Pulse before the backup and restart it
only after independently confirming guest thaw. Pulse monitoring and alert
delivery are unavailable while it is stopped. Do not send additional
guest-agent commands during freeze/thaw to diagnose the problem, or disable
filesystem freezing merely to make a task look successful. Follow the
[backup safety precaution](VM_DISK_MONITORING.md#backup-safety); it is not a
claim that Pulse has repaired or reproduced a native thaw failure.
All recovery endpoints support:
Before relying on an artifact, confirm its provider, repository, workload
identity and time in the provider's own tools. For recovery assurance, test a
restore to an isolated destination using that provider's procedure and check
the restored application's data and operation. Do not overwrite the live
workload as a diagnostic test, delete older backups, or change retention to
hide a Pulse display problem.
| Parameter | Description |
### Artifacts and subjects
A **recovery point** is a collected backup, snapshot or replication result.
Its **subject** is the protected workload, dataset or PVC; its **repository**
is the storage location. Pulse links subjects to monitored resources where it
can, and retains provider-local references when it cannot.
Independent Proxmox installations can reuse a VMID. Compare the connection,
node, guest type/ID, datastore, namespace and artifact time; matching names or
VMIDs alone are not sufficient to identify a duplicate or the right restore.
Direct PBS observations and PBS-through-PVE observations can differ in detail.
See [PBS data sources](PBS.md#data-source-indicator).
### Outcomes and verification
Recorded outcomes are `success`, `warning`, `failed`, `running` or `unknown`.
An unknown outcome is not success; an error fetching records is not an empty
history. Where available, the API returns `startedAt`, `completedAt`,
`sizeBytes` and provider-specific `details`. Missing times and sizes are not
zero-valued measurements. A source backup timestamp is not necessarily the
completion time of a later copy or sync task.
The `verified` field is optional: `true` is a positive provider verification
observation; `false` supplies no positive verification, so inspect the
provider's detailed state; an omitted value supplies no verification result.
Do not interpret an omitted field as a failed verification. Rollups may also
report `verifyIntent` (`verified`, `stale` or `unknown`) and `lastVerifiedAt`;
these summarise observed verification recency, not an end-to-end restore test.
### Protection posture
Pulse combines subject-linked artifacts with current provider collection
evidence:
- **Protected**: a qualifying current recovery point is linked to the resource
and complete provider evidence does not invalidate the claim.
- **Attention**: evidence is stale, failing, incomplete or unverified when
verification is expected.
- **Unprotected**: complete evidence confirms that no qualifying protection
exists.
- **Unknown**: identity, permissions or collection completeness cannot support
a stronger claim.
A retained artifact can remain visible while posture is unknown. A running
backup is not a new completed recovery point; inspect any earlier completed
point separately rather than treating current activity as protection.
## Missing or inconsistent evidence
1. Open the relevant platform view, clear its search and filters, and check the
selected connection and organisation. Record the route that is affected.
2. Inspect connection health, the latest observation time and any collection
error. Wait for that provider's configured polling interval, not a fixed
30-second assumption. A green connection badge does not prove that every
history or backup read succeeded.
3. Check the expected artifact in the provider's own inventory using an
existing authorised session. Verify its identity, location and permissions;
do not grant write or restore permissions merely to fill a monitoring row.
4. For an **Unknown** posture, expand the coverage row and use the specific
limiting evidence. Resolve that collection or identity gap; do not turn
retained artifacts into a protection claim by clearing Pulse history.
If the guest stopped responding during backup, preserve the task's warning or
thaw error and the observations already available. Do not repeat the backup,
reset the guest, or run active guest-agent probes just to reproduce it. Use
the provider's recovery procedure and a separate trusted guest console to
assess guest liveness and thaw, not Pulse's backup badge.
When reporting missing records, share the failing view, running Pulse version,
affected provider, time range, expected reading and relevant redacted error.
Use consistent placeholders for private hostnames, datasets and IDs. Do not
share full backup inventories, request headers, tokens, cookies or recovery
keys. See [safe issue reporting](TROUBLESHOOTING.md#-getting-help).
## API reference
These are read-only monitoring endpoints. Use an existing authenticated browser
session or a `monitoring:read` token supplied through a private header file,
never a token in a URL or diagnostic command argument. See [API access](API.md).
| Method | Endpoint | Result |
|---|---|---|
| `GET` | `/api/recovery/points` | Paged recovery points |
| `GET` | `/api/recovery/rollups` | Paged subject summaries |
| `GET` | `/api/recovery/series` | Completed-point counts grouped by day |
| `GET` | `/api/recovery/facets` | Filter values from matching points |
| `GET` | `/api/recovery/postures` | Canonical resource postures and limiting evidence |
### Point and rollup filters
Points, rollups, series and facets accept these filters:
| Parameter | Values or meaning |
|---|---|
| `provider` | Filter by provider (`pve`, `pbs`, `truenas`, `k8s`) |
| `kind` | Filter by kind (`backup`, `snapshot`, `replication`) |
| `outcome` | Filter by outcome (`success`, `failed`, `warning`, `running`) |
| `since` | ISO 8601 timestamp — only points after this time |
| `until` | ISO 8601 timestamp — only points before this time |
| `subject` | Filter by subject reference |
| `limit` | Max results (default: 500) |
| `platform` | `proxmox-pve`, `proxmox-pbs`, `truenas` or `kubernetes`; `provider` is an alias |
| `kind` | `backup`, `snapshot` or `other`; TrueNAS replication results use `backup` |
| `mode` | `local`, `remote` or `snapshot` |
| `outcome` | `success`, `warning`, `failed`, `running` or `unknown` |
| `from` | Lower time bound in RFC3339, for example `2026-10-01T00:00:00Z` |
| `to` | Upper time bound in RFC3339 |
| `subjectResourceId` | Exact linked resource identity; `itemResourceId` is an alias |
| `rollupId` | Exact subject summary identity |
| `q` | Text search; `query` is an alias |
`/api/recovery/postures` has a deliberately bounded table contract. Supply one
or more repeated `resourceId` parameters for a resource or batch lookup (at
most 200), or omit them for a paged list. It also accepts `state`, `page`, and
`limit`; `state=attention` returns the actionable attention list. The response
includes the posture policy and provider evidence states so clients do not
re-derive trust from raw artifacts.
For example, this relative browser request selects PBS backup records within
the stated time bounds without putting a credential in the URL:
## Troubleshooting
```text
/api/recovery/points?platform=proxmox-pbs&kind=backup&from=2026-10-01T00:00:00Z&to=2026-10-02T00:00:00Z&page=1&limit=100
```
### No recovery data showing
Use `from`/`to`, not `since`/`until`, and `subjectResourceId`, not `subject`.
Unrecognised query names do not apply those filters. Invalid `from` or `to`
values return an error rather than an empty result.
1. Verify at least one data source provides backup/snapshot data:
- **PBS**: Ensure a PBS connection exists in Settings → Infrastructure.
- **TrueNAS**: Ensure a TrueNAS connection exists in Settings → TrueNAS.
- **PVE**: Local backups from PVE are included automatically.
2. Wait one polling cycle (~30 seconds) for data to appear.
3. Check the source filter — make sure you're not filtering to an empty source.
Points and rollups accept `page` (starting at 1) and `limit` (default **100**,
maximum **500**). Their response has a `data` array and `meta` pagination;
inspect every required page rather than assuming the first is the inventory.
Series and facets return aggregate data, not that paged-list contract.
### PBS backups showing but not TrueNAS snapshots (or vice versa)
### Posture lookup
Check the **Source** filter on the Recovery page. Each provider surfaces its recovery points independently. Clear all filters to see everything.
`/api/recovery/postures` uses a separate filter contract. Repeat `resourceId`
for a batch of at most **200** resource IDs, or omit it for a paged list.
`state=attention` selects attention postures; `page` and `limit` control list
pagination. Its response includes policy and provider evidence states. Do not
apply point filters to this endpoint or re-derive posture from artifact count.
### Recovery points showing as "failed"
## See also
Click the row to expand the details drawer, which shows the provider-specific error message. Common causes:
- **PBS**: Datastore unreachable, verification failed, prune job errors
- **TrueNAS**: Replication target unreachable, dataset locked, insufficient space
- **PVE**: Backup storage full, vzdump process error
### Current backups show an unknown posture
Expand the workload row and inspect the limiting evidence. Pulse uses unknown
when the current poll cannot prove provider-history completeness, permission
scope, or subject identity. Fix the reported collection or access gap and wait
for the next provider poll; Pulse does not promote retained backup artifacts to
protected while that uncertainty remains.
## See Also
- [PBS Integration](PBS.md) — Proxmox Backup Server monitoring
- [TrueNAS Integration](TRUENAS.md) — TrueNAS snapshot and replication monitoring
- [Unified Resource Model](UNIFIED_RESOURCES.md) — how recovery integrates with the unified model
- [PBS integration](PBS.md) — access, data sources and host History
- [TrueNAS integration](TRUENAS.md) — snapshots and replication monitoring
- [VM disk monitoring](VM_DISK_MONITORING.md) — guest-agent and backup safety
- [Migrating Pulse](MIGRATION.md) — configuration transfer versus a full-state backup of Pulse itself
+94 -29
View File
@@ -2,49 +2,118 @@
Pulse uses WebSockets for real-time updates. Your proxy **MUST** support WebSockets.
## Before configuring the proxy
Use HTTPS at the public proxy and keep Pulse's backend private. The examples
below are configuration fragments, not complete TLS or authentication setups.
Keep Pulse authentication enabled; TLS termination does not authenticate users.
For identity supplied by an IdP/proxy, follow the separate
[proxy authentication guide](PROXY_AUTH.md), including its Header Trust Boundary.
**Forwarded HTTPS headers are trusted only from a configured immediate peer.**
Set `PULSE_TRUSTED_PROXY_CIDRS` in Pulse's deployment configuration to the proxy
address **as seen by Pulse**, not the browser's address or the public hostname.
For a proxy on the same host connecting to `127.0.0.1:7655`, use:
```dotenv
PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32
```
Use `::1/128` instead if that connection actually uses IPv6 loopback. A Docker
proxy normally reaches Pulse from its private container/network address, not
loopback; trust only that proxy's fixed address or a narrowly isolated proxy
network. Do not trust a whole LAN or all Docker networks to make a warning go
away. Wildcard ranges `0.0.0.0/0` and `::/0` are rejected at startup.
At the public edge, replace client-supplied forwarded scheme, host and client-IP
headers with values established by the proxy; do not append an untrusted value.
With multiple proxy hops, configure the upstream trust boundary on each hop
and trust only the final peer in Pulse. `PULSE_TRUSTED_NETWORKS` is not a
substitute for proxy trust, and proxy trust is not an authentication bypass.
See [configuration overrides](CONFIGURATION.md#common-overrides-environment-variables).
## ⚡ Quick Configs
### Nginx
Inside your HTTPS `server` block, for a single public-edge proxy:
```nginx
location / {
proxy_pass http://localhost:7655;
proxy_pass http://127.0.0.1:7655;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Scheme "";
proxy_set_header Forwarded "";
# Critical for WebSockets
proxy_read_timeout 86400; # 24h
}
```
### Caddy
Caddy handles the WebSocket upgrade and sets forwarded client-IP, host and
scheme headers automatically. The removals below discard other client-supplied
forwarding metadata. Pulse still needs the matching trusted-peer setting above:
```caddy
pulse.example.com {
reverse_proxy localhost:7655
reverse_proxy 127.0.0.1:7655 {
header_up -Forwarded
header_up -X-Forwarded-Scheme
header_up -X-Forwarded-Port
}
}
```
### Traefik (Docker Compose)
These labels assume an existing HTTPS entrypoint named `websecure` and a
configured certificate. Keep Pulse on the private proxy network; do not publish
its backend port publicly. Apply trusted-peer configuration to Pulse separately.
Keep Traefik's `forwardedHeaders.insecure` disabled; upstream forwarded headers
need their own narrow trusted-IP configuration, not blanket client trust.
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.pulse.rule=Host(`pulse.example.com`)"
- "traefik.http.routers.pulse.entrypoints=websecure"
- "traefik.http.routers.pulse.tls=true"
- "traefik.http.routers.pulse.service=pulse"
- "traefik.http.services.pulse.loadbalancer.server.port=7655"
```
### Apache
Inside a TLS-enabled virtual host, with the required proxy, rewrite and headers
modules enabled:
```apache
ProxyPreserveHost On
ProxyAddHeaders On
RequestHeader unset X-Forwarded-For
RequestHeader unset X-Forwarded-Host
RequestHeader unset X-Forwarded-Port
RequestHeader unset X-Real-IP
RequestHeader set X-Forwarded-Proto "https"
RequestHeader unset X-Forwarded-Scheme
RequestHeader unset Forwarded
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://localhost:7655/$1" [P,L]
RewriteRule ^/?(.*) "ws://127.0.0.1:7655/$1" [P,L]
ProxyPass / http://localhost:7655/
ProxyPassReverse / http://localhost:7655/
ProxyPass / http://127.0.0.1:7655/
ProxyPassReverse / http://127.0.0.1:7655/
```
---
@@ -55,33 +124,29 @@ ProxyPassReverse / http://localhost:7655/
If your reverse proxy terminates SSL but Pulse shows "HTTPS: HTTP only" in Settings → Security:
**Cause**: Pulse detects HTTPS in two ways:
1. Direct TLS connection (`req.TLS != nil`)
2. The `X-Forwarded-Proto: https` header
Pulse recognises direct TLS, or `X-Forwarded-Proto: https` from an immediate
peer in `PULSE_TRUSTED_PROXY_CIDRS`. An untrusted peer's header is ignored even
when the browser used HTTPS. Adding the header alone is not sufficient.
If your proxy terminates SSL but doesn't forward this header, Pulse sees plain HTTP.
Check the proxy's actual backend connection address and the effective Pulse
setting first, then check that the TLS proxy sends exactly `https`. For Nginx,
keep `proxy_set_header X-Forwarded-Proto $scheme` inside the HTTPS server's
location; do not label a plain-HTTP request as HTTPS. Caddy normally needs no
header override. Any Caddy `header_up` override belongs **inside** its
`reverse_proxy` block, not directly in the site block.
**Fix**: Add the `X-Forwarded-Proto` header in your proxy config:
```nginx
# Nginx
proxy_set_header X-Forwarded-Proto $scheme;
```
```caddy
# Caddy (automatic, but explicit override if needed)
header_up X-Forwarded-Proto {scheme}
```
```apache
# Apache
RequestHeader set X-Forwarded-Proto "https"
```
After applying configuration through your normal deployment path, sign in
through the real HTTPS URL and recheck Security Posture and live dashboard
updates. If using SSO, check its redirect/callback on that same URL. A successful
`/api/health` request alone does not verify authentication, WebSockets or proxy
trust. Do not disable authentication, certificate verification or origin checks,
or expose the backend, to test this. Share only redacted configuration and
HTTP/error status, not cookies, proxy secrets or a full network export.
### Other Issues
- **"Connection Lost"**: WebSocket upgrade failed. Check `Upgrade` and `Connection` headers.
- **502 Bad Gateway**: Pulse is not running on port 7655.
- **502 Bad Gateway**: Check Pulse's actual listener and the proxy's upstream address, port and network reachability; this does not by itself prove Pulse is stopped. Container `localhost` refers to that container, not another service.
- **CORS Errors**: Do not add CORS headers in the proxy; Pulse handles them. Set **Settings → System → Network → Allowed Origins** or use `ALLOWED_ORIGINS` if needed.
- **OIDC redirects fail**: Ensure `X-Forwarded-Proto` is set (see above).
- **Wrong client IPs**: Set `PULSE_TRUSTED_PROXY_CIDRS` to your proxy IP/CIDR so `X-Forwarded-For` is trusted.
- **Wrong client IPs**: Check the immediate peer and header replacement above. Do not broaden proxy trust or change authentication to repair attribution.
+219 -47
View File
@@ -7,8 +7,9 @@
Pulse does not provide an email password-reset flow. Choose the path that
matches how this self-hosted instance authenticates:
- **Local Pulse username and password**: recovery requires shell access to the
Pulse host or container. Follow the deployment-specific steps below.
- **Local Pulse administrator username and password**: recovery requires
authorised access to the host or deployment's credential source. Follow the
steps below; keep the existing username.
- **OIDC, SAML, or proxy authentication**: contact the identity-provider or
Pulse administrator. If the administrator deliberately kept local login as a
fallback, they can open the Pulse URL with `?show_local=true`; this only
@@ -17,45 +18,81 @@ matches how this self-hosted instance authenticates:
administrator can use the lockout reset in Pulse; password recovery is not
required.
The local recovery steps remove only the Pulse-generated authentication file.
After restart, Pulse still requires the host-only bootstrap token before it
will accept replacement credentials. If `PULSE_AUTH_USER` or `PULSE_AUTH_PASS`
is supplied by Docker Compose, Kubernetes, systemd, or another deployment
manager, update that deployment configuration instead; its environment values
override the generated file.
#### Recover the existing local administrator login
**Docker**:
Do not delete `.env` or repeat first-time setup to recover a password. The file
can also hold deployment settings, and setup replaces the primary API token.
Replace only the password in its active credential source instead, preserving
the username, API tokens, encryption keys, monitoring configuration and data.
This is login recovery, not a complete response to a compromised account;
existing sessions and API tokens need separate review if credentials leaked.
```bash
docker exec pulse rm /data/.env
docker restart pulse
# Access UI again. Pulse will require a bootstrap token for setup.
# Get it with:
docker exec pulse /app/pulse bootstrap-token
```
1. **Find the active credential source locally.** Deployment-supplied
`PULSE_AUTH_USER` and `PULSE_AUTH_PASS` take precedence over Pulse's `.env`.
Do not post full service units, container inspections or resolved Compose
configuration; they can contain secrets.
- **Docker**: the generated file is in the mounted data directory, normally
`/data/.env` inside the container. If Compose, `--env-file`, Kubernetes or
another manager supplies the password, edit that managed source instead.
- **Systemd**: check the active service's unit, environment files and
drop-ins privately. Root-run setup can also save credentials in
`/etc/systemd/system/pulse.service.d/override.conf` (or the corresponding
`pulse-backend.service.d` path on legacy installs). Editing only
`/etc/pulse/.env` will not override those values. A custom
`PULSE_DATA_DIR` changes the generated file's location.
- **Proxmox LXC**: perform the systemd steps inside the Pulse container, not
on the Proxmox host. Use its console or `pct enter <ctid>`.
2. **Prepare a new password hash privately.** Use a trusted local bcrypt tool
that prompts for a password of at least 12 characters, never one that needs
the password in its command arguments. For example, if Apache's `htpasswd`
is installed on your trusted administration machine:
**Systemd**:
```bash
(
set -eu
umask 077
recovery_dir="$(mktemp -d "${TMPDIR:-/tmp}/pulse-password.XXXXXX")"
htpasswd -nB -C 12 pulse-recovery > "$recovery_dir/password-record"
printf 'Private password record saved in %s/password-record\n' "$recovery_dir"
)
```
```bash
sudo rm /etc/pulse/.env
sudo systemctl restart pulse
sudo pulse bootstrap-token
```
This prints only the file location. Open the record in a private editor and
copy the complete 60-character hash after `pulse-recovery:`; that label is
not a new Pulse username. Do not post the record or hash. If the tool fails,
stop rather than saving a partial hash or putting the password in a shell
command.
3. **Back up and edit only the active password setting.** Keep an owner-only
backup of each file you change, then replace `PULSE_AUTH_PASS` in a private
editor. Keep `PULSE_AUTH_USER` and unrelated settings unchanged. In a Pulse
`.env`, put the hash in single quotes with literal `$` characters. Compose
YAML has different interpolation rules; follow the
[authentication guide](CONFIGURATION.md#private-docker-authentication-file)
for the source you actually use. If a systemd drop-in supplies the password,
update it and the generated authentication file consistently so a later
restart or reload does not restore the old value. Preserve file permissions
and ownership; hashes and backups are sensitive too.
4. **Apply through the existing deployment.** For a generated Docker data-file
change, restart the same container. Changing Docker's managed environment
requires its recreate/redeploy operation, not just `docker restart`; preserve
the same image, mounted data and other settings. For systemd, reload the
service manager after a unit or drop-in change, then restart the active Pulse
service during a suitable maintenance window. Do not remove a data volume,
reinstall Pulse or re-enrol agents.
5. **Verify the login.** Use a fresh browser session to sign in with the same
local administrator username and new password, then check that monitoring
and agent connections remain intact. If it still fails, stop and reconcile
the effective source and any lockout; do not delete more state. Restore the
private backup through the same deployment path if you need to undo the
change. Remove the temporary password record after verification,
and retain or dispose of the backup under your normal credential policy.
**Proxmox LXC** (installed from the Proxmox shell):
Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`:
#### A fresh install's bootstrap token was missed
```bash
pct exec <ctid> -- rm /etc/pulse/.env
pct exec <ctid> -- systemctl restart pulse
pct exec <ctid> -- /usr/local/bin/pulse bootstrap-token
```
If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one.
Treat the bootstrap token like a password: enter it only in the Pulse setup
screen for this instance and do not paste it into support requests or issue
reports.
If no local password has ever been set, there is nothing to reset. Follow
[first login](INSTALL.md#step-1-get-the-token) to read the existing bootstrap
token for that instance, using its actual data directory. Enter it only in the
Pulse setup screen, not in command arguments, URLs or issue reports.
### Port change didn't take effect
@@ -190,10 +227,14 @@ installed service must contain `SyslogLevelPrefix=true` and
repair an older generated unit rather than adding a JSON-parsing wrapper.
#### VMs show "-" for disk usage
- Install **QEMU Guest Agent** in the VM.
- Enable "QEMU Guest Agent" in Proxmox VM Options.
- Restart the VM.
- See [VM Disk Monitoring](VM_DISK_MONITORING.md).
- Read the disk value's explanation and observation time first; a dash is not
proof that the agent is missing.
- Check the guest-local service, current VM Options and the configured API
token's read permissions. Schedule any setup change or restart outside backups.
- Do not run guest-agent probes during backup freeze/thaw. An OK backup task
or an absent lock does not confirm thaw.
- See [VM Disk Monitoring](VM_DISK_MONITORING.md) for the passive host preflight
and backup safety precaution; it does not verify a fresh disk poll.
#### Temperature data missing
- Install `lm-sensors` on the host.
@@ -216,6 +257,99 @@ leave the original host unchanged. Follow
[Clone identity recovery](UNIFIED_AGENT.md#duplicate-agents) for the configuration
precedence, systemd example and checks after restart.
#### Excessive CPU, writes or database growth
First distinguish **Pulse server activity**, agent activity and total host or
storage-device activity. A database's size is retained space, not the number of
bytes written: repeated updates can produce high writes without growing the file.
Keep the original running version, uptime, measurement time and workload before
changing anything. A different binary can migrate persistent data; switching
back to an older version does not make that data a clean older-version baseline.
- For CPU, record the measuring tool, allocated CPUs or container quota and
whether its percentage represents one core or the whole allocation. A `ps`
`%CPU` value is an average since process start, not a recent sampling window.
- Note fleet size, polling interval and whether Pulse dashboards are open. If
safe, close all Pulse tabs and compare another equal-length window, without
restarting the server or changing polling. A decrease narrows the workload;
it does not establish the cause or prove monitoring is healthy.
- If the host is unresponsive or storage is nearly full, do not prolong the
failure to collect a benchmark. Keep existing observations and report the
interruption instead. Do not enable Debug or repeatedly export diagnostics
merely to measure performance; those actions add work and can change the result.
For a responsive **Linux systemd / Proxmox LXC** install, the following reads
two process-I/O samples, waiting 60 seconds between them. Run it inside the
Pulse container for LXC, not on the Proxmox host. Substitute the actual service
name (`pulse-backend` on some older installs). Use an account authorised to
read the process counters; no service restart or database access is needed.
```bash
# systemd / Proxmox LXC: bounded process-write samples
(
set -e
for sample in 1 2; do
date -u +'%Y-%m-%dT%H:%M:%SZ'
pid=$(systemctl show pulse --property=MainPID --value)
case "$pid" in
''|0|*[!0-9]*) printf 'No running Pulse PID; sample unavailable.\n' >&2; exit 1 ;;
esac
TZ=UTC ps -p "$pid" -o pid=,lstart=
sudo awk '
$1 == "write_bytes:" || $1 == "cancelled_write_bytes:" { print; fields++ }
END { if (fields != 2) exit 1 }
' "/proc/$pid/io"
if [ "$sample" -eq 1 ]; then sleep 60; fi
done
)
```
Compare `write_bytes` only when both samples have the same PID and process
start time, no restart occurred, and the counter did not decrease. Divide the
byte difference by the **actual elapsed seconds**. This is storage-accounted
process I/O, not filesystem growth or physical SSD wear; cancelled writes and
background writeback can differ from device measurements. Keep
`cancelled_write_bytes` alongside it, not as proof of bytes reaching the drive.
An extrapolated GB/day rate is a projection of that short window, not a measured
day's total. Preserve the window and units with the result.
For **Docker / Compose**, run this on the Docker host, replacing `pulse` with
the running container name. It reads only the start time and selected statistics,
not the container environment or configuration.
```bash
# Docker: bounded container statistics
(
set -e
for sample in 1 2; do
date -u +'%Y-%m-%dT%H:%M:%SZ'
docker inspect --format 'Started={{.State.StartedAt}}' pulse
stats=$(docker stats --no-stream --format \
'CPU={{.CPUPerc}} Memory={{.MemUsage}} BlockIO={{.BlockIO}}' pulse)
if [ -z "$stats" ]; then
printf 'Container statistics unavailable; no zero inferred.\n' >&2
exit 1
fi
printf '%s\n' "$stats"
if [ "$sample" -eq 1 ]; then sleep 60; fi
done
)
```
Docker **BlockIO** is cumulative read / write activity, not bytes per second;
compare its write side only across the same uninterrupted container run. Its
displayed units are rounded. Container, process and whole-device counters have
different scopes and must not be added together or compared as interchangeable
measurements. A failed, denied or empty read is unavailable, not zero activity.
Keep persistent data on durable storage. Do not delete or truncate history,
incident, queue or audit files, remove database indexes, or move the data
directory to tmpfs as a diagnostic workaround. These actions can lose evidence
or protections without fixing the writer. Do not post databases, profiles, full
`/proc` dumps, container configuration or raw environments. Share only the bounded
measurements, workload, versions and a relevant manually redacted error; follow
[Getting Help](#-getting-help) for any additional evidence.
### Notifications
#### No alert when Pulse, power or internet goes down
@@ -320,7 +454,7 @@ consequential, manually redacted error.
### TrueNAS
#### "TrueNAS service unavailable"
- Ensure TrueNAS was added in **Settings → TrueNAS** with a valid HTTPS URL,
- Ensure TrueNAS was added in **Settings → Infrastructure → Platform connections** with a valid HTTPS URL,
API key, and the username that owns the key.
- Check that the TrueNAS system is reachable from the Pulse server (default
HTTPS port).
@@ -331,7 +465,10 @@ consequential, manually redacted error.
#### TrueNAS pools/datasets not appearing
- TrueNAS data appears in the unified resource model and may take one configured
polling cycle (60 seconds by default) to appear.
- Check **Infrastructure** (TrueNAS host), **Storage** (pools/datasets), and **Recovery** (snapshots/replication).
- Open **TrueNAS → Overview** for the appliance, **TrueNAS → Storage**
(`/truenas/storage`) for pools, datasets and disks, and **TrueNAS → Protection**
(`/truenas/protection`) for snapshots and replication. These are tabs within
TrueNAS, not separate top-level Storage or Recovery pages.
- For data that stops refreshing, use the [TrueNAS polling checks](TRUENAS.md#stale-truenas-data)
before testing or restarting. A stale badge is not proof of an invalid key,
and a successful connection test is not proof that collection has recovered.
@@ -339,8 +476,38 @@ consequential, manually redacted error.
### Navigation (v6)
#### Old bookmarks don't work
- Legacy URLs (`/proxmox`, `/docker`, `/kubernetes`, `/hosts`, `/services`) are not supported in v6.
- Update bookmarks to canonical routes. See [Migration Guide](MIGRATION_UNIFIED_NAV.md).
Current Pulse uses platform navigation. `/proxmox`, `/docker` and `/kubernetes`
are supported; do not replace them with the retired task-based routes.
Open Pulse at its base URL and use the menu to find the relevant page:
| Menu | Entry route |
| --- | --- |
| Proxmox | `/proxmox/overview` |
| Docker | `/docker/overview` |
| Kubernetes | `/kubernetes/overview` |
| TrueNAS | `/truenas/overview` |
| vSphere | `/vmware/overview` |
| Machines | `/standalone/machines` |
The short-lived top-level `/workloads`, `/storage` and `/recovery` layout is
retired. `/infrastructure` now opens the default workspace, not the former
unified host page. For old `/hosts` or `/services` bookmarks, select the current
platform or Machines page instead of assuming an automatic redirect. PBS
backups are under **Proxmox → Backups**, and TrueNAS snapshots and replication
are under **TrueNAS → Protection**.
Platform-connected hosts can appear under their platform rather than Machines.
If a menu or expected resource is missing, check its saved connection and last
successful collection in **Settings → Infrastructure**; a missing page alone
does not prove a host was deleted. Do not delete connections or re-enrol agents
just to repair a bookmark.
If menu navigation works but reloading the same URL returns a proxy 404, check
the proxy's route handling using [Reverse Proxy Configuration](REVERSE_PROXY.md).
For the current layout, see [FAQ](FAQ.md#how-is-navigation-organised-in-pulse-v6).
The [unified-navigation migration](MIGRATION_UNIFIED_NAV.md) is historical,
not a guide to the current menu.
### Relay / Mobile
@@ -452,7 +619,12 @@ For PVE 8 only, use `VM.Monitor` instead of the `VM.GuestAgent.*` privileges.
Note: The built-in `PVEAuditor` role cannot be modified. Create a custom role (e.g. `PulseMonitor`) with the above privileges added, and assign it to your Pulse API token. After upgrading to PVE 9, add the `VM.GuestAgent.*` privileges and remove legacy `VM.Monitor` from the custom role.
**Rocky Linux / RHEL VMs**: The default qemu-guest-agent configuration may block file-read RPCs (`guest-file-open`, `guest-file-read`, `guest-file-close`). If memory or disk data is missing for these VMs, check `/etc/sysconfig/qemu-ga` and ensure those operations are not blocked, then restart the agent. Refer to your distro's qemu-guest-agent documentation for the exact config syntax.
**Rocky Linux / RHEL VMs**: File-read restrictions in `/etc/sysconfig/qemu-ga`
can explain missing guest memory; they do not by themselves establish why disk
usage is absent. Review the guest's policy before changing it. Schedule any
allowlist change or agent restart outside backups, following the guest OS's
documentation. See [VM Disk Monitoring](VM_DISK_MONITORING.md) for the distinct
permissions and backup safety boundary.
### Proxmox pending-update access
@@ -479,9 +651,9 @@ success alone does not confirm the Pulse display has recovered.
### Recovery Mode
For a forgotten local password, follow [I forgot my password](#i-forgot-my-password)
above, using the steps for your deployment. Enter the host-only bootstrap token
in that instance's setup screen; do not paste it into a command or a report.
For a forgotten local administrator password, follow
[I forgot my password](#i-forgot-my-password) above to update its active
credential source without deleting configuration or repeating first-time setup.
For OIDC, SAML or proxy login, use the identity-provider or administrator path
described there instead.
+24 -19
View File
@@ -4,8 +4,9 @@ Pulse v6 includes first-class monitoring for **TrueNAS SCALE** and **TrueNAS COR
## Quick Start
1. Go to **Settings → TrueNAS**.
2. Click **Add Connection**.
1. Go to **Settings → Infrastructure**.
2. Click **Add infrastructure** and choose **TrueNAS**. Existing connections
are listed under **Platform connections**.
3. Enter the TrueNAS URL (e.g., `https://truenas.local`), the API key, and the
username that owns the key.
4. Click **Test Connection** → **Save**.
@@ -28,27 +29,27 @@ On your TrueNAS system:
## What Gets Monitored
| Data | Unified Page | Details |
| Data | Pulse page / tab | Details |
|---|---|---|
| System info (hostname, version, uptime) | Infrastructure | CPU, memory, health status |
| Virtual machines | TrueNAS Overview | State, CPU, memory, boot mode, devices, and security flags from the TrueNAS VM API |
| Apps | TrueNAS Overview | Native app state, image/version, ports, volumes, networks, and runtime container details |
| ZFS Pools | Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) |
| ZFS Datasets | Storage | Used/available space, mount status, read-only flag |
| Physical Disks | Storage | Model, serial, size, transport type, rotational flag, temperature, and native SMART failure/counter evidence when TrueNAS reports it |
| ZFS Snapshots | Recovery | Dataset, creation time, size, referenced data |
| Replication Tasks | Recovery | Source/target datasets, direction, last run status |
| System info (hostname, version, uptime) | TrueNAS → Overview | CPU, memory, health status |
| Virtual machines | TrueNAS → VMs | State, CPU, memory, boot mode, devices, and security flags from the TrueNAS VM API |
| Apps | TrueNAS → Apps | Native app state, image/version, ports, volumes, networks, and runtime container details |
| ZFS Pools | TrueNAS → Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) |
| ZFS Datasets | TrueNAS → Storage | Used/available space, mount status, read-only flag |
| Physical Disks | TrueNAS → Storage | Model, serial, size, transport type, rotational flag, temperature, and native SMART failure/counter evidence when TrueNAS reports it |
| ZFS Snapshots | TrueNAS → Protection | Dataset, creation time, size, referenced data |
| Replication Tasks | TrueNAS → Protection | Source/target datasets, direction, last run status |
| TrueNAS Alerts | Alerts | Native TrueNAS alert messages and severity levels |
## Unified Resource Mapping
TrueNAS resources are mapped into the unified resource model:
- **TrueNAS host** → appears as a resource with `source: truenas` on the **Infrastructure** page.
- **TrueNAS VMs** → appear as canonical `vm` workloads on the **TrueNAS** page.
- **TrueNAS apps** → appear as canonical `app-container` workloads on the **TrueNAS** page.
- **ZFS pools and datasets** → appear on the **Storage** page.
- **ZFS snapshots and replication** → appear on the **Recovery** page as recovery points.
- **TrueNAS host** → appears as a resource with `source: truenas` under **TrueNAS → Overview** (`/truenas/overview`).
- **TrueNAS VMs** → appear as canonical `vm` workloads under **TrueNAS → VMs** (`/truenas/vms`).
- **TrueNAS apps** → appear as canonical `app-container` workloads under **TrueNAS → Apps** (`/truenas/apps`).
- **ZFS pools, datasets and physical disks** → appear under **TrueNAS → Storage** (`/truenas/storage`).
- **ZFS snapshots and replication** → appear under **TrueNAS → Protection** (`/truenas/protection`) as recovery points.
- **TrueNAS alerts** → surfaced on the **Alerts** page alongside Proxmox and other platform alerts.
TrueNAS drive-health alerts that identify a specific disk remain disk-health
@@ -60,7 +61,11 @@ typed uncorrectable-error or spare-reserve argument, Pulse also projects that
value into the disk's SMART details. Pulse does not infer counters from alert
text, and current TrueNAS APIs do not expose every raw SMART attribute.
Resources from TrueNAS can be filtered using the **source** filter on any page.
The unified resource model is a backend contract, not a top-level
Infrastructure, Storage or Recovery menu. TrueNAS tabs follow the inventory
Pulse has collected; an absent tab is not proof of successful collection.
For missing or stale data, use the [polling checks](#stale-truenas-data) before
testing the connection or restarting.
## Multiple TrueNAS Systems
@@ -91,7 +96,7 @@ All endpoints require admin authentication.
### Testing and adding a connection (API)
The **Settings → TrueNAS** flow is the simplest option. For an API client,
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.
@@ -260,4 +265,4 @@ Set `PULSE_ENABLE_TRUENAS=false` and restart Pulse. Existing connection data is
JSON-RPC transport, API-key, and TLS requirements
- [Configuration Guide](CONFIGURATION.md#truenas) — environment variables and setup
- [ZFS Monitoring](ZFS_MONITORING.md) — Proxmox-native ZFS pool monitoring
- [Recovery](RECOVERY.md) — TrueNAS snapshots in the recovery view
- [Recovery data](RECOVERY.md) — the snapshot and replication data shown under **TrueNAS → Protection**
+6 -4
View File
@@ -52,10 +52,12 @@ first unless you are intentionally removing that host from Pulse.
### Moving Pulse to a new address
Configuration export/import restores the server-side agent records and API
tokens. It cannot rewrite the primary Pulse URL on remote machines because
agents initiate the connection. Prefer a stable DNS name for the primary URL
so replacing the Pulse host does not require an agent migration.
Configuration export/import restores API-token records, not the server-side
agent inventory, enrolment state, profiles or assignments. See the
[configuration-transfer scope](MIGRATION.md#configuration-transfer) before
retiring the old server. Import cannot rewrite the primary Pulse URL on remote
machines because agents initiate the connection. Prefer a stable DNS name for
the primary URL, but still verify fresh reports and agent admission after a move.
After importing the configuration on a Pulse server with a different address,
retarget each existing standard Linux agent from that agent machine. Download
+87 -27
View File
@@ -1,42 +1,102 @@
# 💾 VM Disk Monitoring
Monitor actual disk usage inside your VMs using the QEMU Guest Agent.
Pulse can read filesystem usage inside a Proxmox VM through the QEMU Guest
Agent. Allocated virtual-disk size is not the same as used space inside the
guest. LXC filesystem collection uses a different path.
## 🚀 Quick Start
## Backup safety
1. **Install Guest Agent**:
* **Linux**: `apt install qemu-guest-agent` (Debian/Ubuntu) or `yum install qemu-guest-agent` (RHEL).
* **Windows**: Install **virtio-win** drivers.
2. **Enable in Proxmox**:
* VM Options → **QEMU Guest Agent** → Enabled.
* Restart the VM.
3. **Verify**:
* Run `qm agent <vmid> ping` on the Proxmox host.
* Check Pulse dashboard for disk usage (e.g., "5.2GB used of 32GB").
**Do not run manual guest-agent probes during a backup, freeze or thaw.** A
guest-agent request is an active command on the same channel used by
freeze-enabled backups, even when it only reads disk or memory information.
There is a [report of a VM remaining frozen despite an OK backup task](https://github.com/rcourtman/Pulse/issues/2439).
The reported command-ID collision is not established as a reproduced cause.
## ⚙️ Requirements
If your installation is affected, stop the Pulse service before the backup
and restart it only after independently confirming the guest has thawed, using
your existing guest console or workload checks. **Pulse monitoring and alerts
are unavailable while it is stopped.** An OK backup task or an absent VM lock
does not prove thaw succeeded. This is a temporary precaution, not a claim
that the monitoring defect is fixed.
* **QEMU Guest Agent**: Must be installed and running inside the VM.
* **Proxmox Permissions**: `VM.Monitor` (Proxmox 8) or `VM.GuestAgent.Audit` + `VM.GuestAgent.FileRead` (Proxmox 9+). Note: `PVEAuditor` is a built-in read-only role that cannot be modified — create a custom role instead.
Do not test freeze/thaw commands, clear backup locks, disable backup freezing
or force-reset a guest as a disk-monitoring diagnostic. Those operations can
affect workloads or backup consistency. Preserve the existing backup task and
redacted error, rather than repeat a potentially harmful backup for a report.
## 🚀 Setup
Plan changes outside backup windows and follow the guest's normal maintenance
procedure; a reboot interrupts its workloads.
1. **Install the guest agent inside the VM.** Use the guest OS's supported
package: `qemu-guest-agent` on Linux, or the QEMU Guest Agent service from
the virtio-win distribution on Windows.
2. **Enable it in Proxmox.** Review **VM Options → QEMU Guest Agent** and apply
any required VM restart in that maintenance window.
3. **Check the existing observations.** In Pulse, hover over the disk dash or
open the resource details for the collection explanation and observation
time. Check the guest-agent service from inside the guest, not by repeatedly
sending `qm agent` commands from the hypervisor. A configured agent is not
proof it responded, and a retained disk value is not proof of a fresh poll.
## ⚙️ Permissions
Use the dedicated account and token actually configured for that Pulse
connection, not a guessed account name. Review their effective privileges for
the affected VM; a host-root diagnostic does not test the API token's access.
| Collection | Proxmox VE 9+ | Proxmox VE 8 |
| --- | --- | --- |
| Filesystem usage and guest information | `VM.GuestAgent.Audit` | `VM.Monitor` |
| Reading guest memory via `/proc/meminfo` | Also `VM.GuestAgent.FileRead` | `VM.Monitor` |
Do not grant Administrator or guest execution/write permissions to diagnose a
missing disk value. The built-in `PVEAuditor` role cannot be modified; use a
custom monitoring role if the required read privileges are absent.
## 🔧 Troubleshooting
| Issue | Solution |
| :--- | :--- |
| **Disk shows "-"** | Hover over the dash for details. Common causes: Agent not running, disabled in config, or permission denied. |
| **Permission Denied** | Ensure your Proxmox token/user has `VM.GuestAgent.Audit` + `VM.GuestAgent.FileRead` (PVE 9+) or `VM.Monitor` (PVE 8). |
| **Rocky Linux / RHEL: memory or disk data missing** | The default qemu-guest-agent config may block file-read RPCs. Check `/etc/sysconfig/qemu-ga` and ensure `guest-file-open`, `guest-file-read`, and `guest-file-close` are not blocked, then restart the agent. See your distro's qemu-guest-agent docs for exact syntax. |
| **Agent Timeout** | Increase timeouts via env vars if network is slow: `GUEST_AGENT_FSINFO_TIMEOUT=10s`. |
| **Windows VMs** | Ensure the **QEMU Guest Agent** service is running in Windows Services. |
| Observation | Useful next check |
| --- | --- |
| **Disk shows “-”** | Read its explanation and observation time. Check the owning host, current VM options, guest-local service and configured API token access. |
| **Permission denied** | Review the token and account's effective read privileges on that VM, including privilege-separated token ACLs. A successful root command would not disprove this error. |
| **Timeout or backup lock** | Check the existing backup/task timeline and guest workload locally. Defer active guest-agent probes; increasing a timeout is not a contention or thaw repair. |
| **Rocky Linux / RHEL memory missing** | Review `/etc/sysconfig/qemu-ga` inside the guest. File-read restrictions can explain memory collection failure; they do not by themselves prove why filesystem usage is absent. Change the guest's allowlist or restart its agent only through your normal maintenance procedure, outside backups. |
| **Windows service stopped** | Check the QEMU Guest Agent service inside Windows. Schedule any restart outside backups. |
### Passive host preflight
The current `scripts/test-vm-disk.sh` helper reads only local VM status and
current configuration, with bounded timeouts. It sends **no guest-agent
commands**, reads no guest files, and changes no service, lock or ACL. Use it
on the VM's owning Proxmox host with an account permitted to read that local
configuration (normally through `sudo`).
Use a reviewed checkout containing this passive helper. Inspect the file
before running it: older copies send `ping` and `get-fsinfo` to the guest. Do
not pipe a network download into a root shell.
### Diagnostic Script
Run this on your Proxmox host to debug specific VMs:
```bash
curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/test-vm-disk.sh | bash
# From the reviewed Pulse checkout; replace 100 with the affected VM ID.
sudo bash ./scripts/test-vm-disk.sh 100
```
The output distinguishes the running/stopped state, agent configuration and
a reported backup/other lock without dumping the full VM configuration. A
missing or unreadable lock is not permission to probe the guest: a backup can
start immediately after the read. A non-zero exit means a required read failed
or the response could not be interpreted; it is not an empty or healthy result.
**A successful preflight does not verify disk freshness, guest-agent
responsiveness or thaw.**
Keep only the relevant time, Pulse and Proxmox versions, observation/error and
backup task sequence for a report. Redact private VM names, paths and addresses;
never include credentials, full VM configuration, service environments or a
whole host journal. See [Getting help](TROUBLESHOOTING.md#-getting-help).
## 📝 Notes
* **Network Mounts**: NFS/SMB mounts are automatically excluded.
* **Databases**: Usage reflects filesystem usage, which may differ from database-internal metrics.
* **Containers**: LXC containers are monitored natively without the guest agent.
- **Network mounts:** NFS/SMB mounts are excluded from filesystem usage.
- **Databases:** Filesystem usage can differ from database-internal metrics.
- **Containers:** LXC collection does not use the VM's QEMU Guest Agent.
+45 -3
View File
@@ -86,16 +86,58 @@ These fields and behaviors are stable; ticket-routing integrations can rely on t
- `X-Pulse-Timestamp`: Unix seconds at send time.
- `X-Pulse-Signature`: `v1=` + hex HMAC-SHA256 over `timestamp + "." + body`, keyed with the shared secret.
To verify: recompute the HMAC over the received timestamp and raw body, compare with constant-time equality, and reject requests whose timestamp is outside your tolerance window (e.g. 5 minutes) to block replays.
Verify before parsing the JSON or performing any action. Read the original body
as bytes and the complete `X-Pulse-Timestamp` and `X-Pulse-Signature` header
values; reject missing or duplicate signing headers. Do not re-serialize JSON,
trim the body, or trust a timestamp from the payload instead of the header.
This Python example accepts timestamps within five minutes of the receiver's
clock, rejects malformed headers, and compares the HMAC in constant time. Keep
both machines' clocks synchronised. Load the shared secret from private receiver
configuration, not from the request, a command argument or a log. Pulse trims
surrounding whitespace from its configured secret; the verifier does the same.
An empty secret must never authenticate a request.
```python
import hashlib, hmac
import hashlib
import hmac
import re
import time
MAX_SKEW_SECONDS = 300
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
expected = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
secret = secret.strip()
if not secret or not isinstance(body, bytes):
return False
if not isinstance(timestamp, str) or re.fullmatch(r"[0-9]{1,12}", timestamp) is None:
return False
if not isinstance(signature, str) or re.fullmatch(r"v1=[0-9a-f]{64}", signature) is None:
return False
if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
return False
expected = "v1=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
```
A time window rejects old captures, **not every replay**: the same signed
request can arrive more than once within that window, and Pulse signs each
retry with its current send time. After verification, deduplicate atomically
before creating a ticket or taking another action. Use a durable key scoped to
the configured Pulse sender and destination, built from the authenticated
payload's alert ID and event (`{{.ID}}` and `{{.Event}}` in your template); keep
it across retries and receiver restarts. An `alert` and its later `resolved`
are different events, not duplicates. The `X-Pulse-Event-ID` header is a useful
correlation hint, but is **not covered by this HMAC**: do not let a changed
header bypass deduplication of an otherwise identical signed event. The sample
PSA payloads below include `alertId` and `event` for this purpose. A valid HMAC
establishes integrity, not that processing succeeded; retain your receiver's
normal durable processing and retry handling. Never log the secret or full
credential-bearing request while diagnosing a rejection.
The secret is write-only through the API: list responses mask it, and an update that echoes the masked placeholder keeps the stored secret.
```http
+62 -8
View File
@@ -1,7 +1,8 @@
# 🩺 Scheduler Health API
**Endpoint**: `GET /api/monitoring/scheduler/health`
**Auth**: Required (`Authorization: Bearer <token>`, `X-API-Token`, or session cookie)
**Auth**: Required. Use a signed-in Pulse browser or an API token in a private
header file; do not copy a session cookie or token into a command.
Returns a real-time snapshot of the adaptive scheduler, including queue state, circuit breakers, and dead-letter tasks.
@@ -64,7 +65,7 @@ Returns a real-time snapshot of the adaptive scheduler, including queue state, c
"firstFailureAt": "2025-10-20T13:05:20Z"
},
"breaker": {
"state": "half_open", // closed, open, half_open
"state": "half_open",
"retryAt": "2025-10-20T13:06:15Z",
"failureCount": 3,
"since": "2025-10-20T12:58:10Z",
@@ -99,20 +100,73 @@ The authoritative source for per-instance health.
## 🛠️ Common Queries (jq)
For a one-off check, open `/api/monitoring/scheduler/health` in your signed-in
browser on that same Pulse instance. For curl, first prepare the private header
file from [API authentication](../API.md#api-token-recommended), using a token
with only the access needed for your monitoring task. These commands require
curl 7.76 or later and jq. They make one read-only request and keep its response
in a new private directory:
```bash
(
set -eu
umask 077
snapshot_dir="$(mktemp -d "${TMPDIR:-/tmp}/pulse-scheduler.XXXXXX")"
snapshot="$snapshot_dir/health.json"
status="$(curl --disable --fail-with-body --connect-timeout 5 --max-time 15 \
--header "@$HOME/.config/pulse/api-header" --output "$snapshot" \
--write-out '%{http_code}' http://127.0.0.1:7655/api/monitoring/scheduler/health)"
if [ "$status" != 200 ]; then
printf 'Scheduler snapshot unavailable (HTTP %s); no health conclusion.\n' "$status" >&2
exit 1
fi
jq -e 'type == "object" and (.updatedAt | type == "string") and
(.enabled | type == "boolean") and (.deadLetter.count | type == "number") and
(.queue.depth | type == "number") and (.instances | type == "array")' \
"$snapshot" >/dev/null
printf 'Saved scheduler snapshot: %s\n' "$snapshot"
)
```
The loopback URL is only for curl running on the Pulse host. Elsewhere, use your
Pulse HTTPS URL with certificate verification enabled, never `--insecure`.
Keep `--disable` first to ignore curl configuration that could enable trace
output. HTTP errors, transport failures and unexpected response shapes stop the
recipe; none is a healthy or empty scheduler result. A 401/403 concerns access,
not polling health. Do not use verbose/trace output or share the header file.
Use the printed filename in the following local queries. They make no further
requests. Keep the raw response private: it includes infrastructure names,
addresses and error text; redact these before sharing selected observations.
Check `enabled` first: a disabled scheduler's empty queue is not a health pass.
`updatedAt` is the snapshot time; per-instance `pollStatus.lastSuccess` records
the last successful poll. Compare freshness with the effective poll intervals,
not a universal queue-depth threshold.
**Summarise the Snapshot:**
```bash
snapshot='/absolute/path/to/health.json' # use the printed filename
jq '{updatedAt, enabled, queueDepth: .queue.depth, deadLetterCount: .deadLetter.count,
instanceCount: (.instances | length)}' "$snapshot"
```
**Find Failing Instances:**
```bash
curl -s http://HOST:7655/api/monitoring/scheduler/health | \
jq '.instances[] | select(.pollStatus.consecutiveFailures > 0) | {key, failures: .pollStatus.consecutiveFailures}'
snapshot='/absolute/path/to/health.json' # use the printed filename
jq '.instances[] | select(.pollStatus.consecutiveFailures > 0) |
{key, lastSuccess: .pollStatus.lastSuccess, failures: .pollStatus.consecutiveFailures}' "$snapshot"
```
**Check Dead Letter Queue:**
```bash
curl -s http://HOST:7655/api/monitoring/scheduler/health | \
jq '.instances[] | select(.deadLetter.present) | {key, reason: .deadLetter.reason}'
snapshot='/absolute/path/to/health.json' # use the printed filename
jq '.instances[] | select(.deadLetter.present) |
{key, reason: .deadLetter.reason}' "$snapshot"
```
**Find Open Breakers:**
```bash
curl -s http://HOST:7655/api/monitoring/scheduler/health | \
jq '.instances[] | select(.breaker.state != "closed") | {key, state: .breaker.state}'
snapshot='/absolute/path/to/health.json' # use the printed filename
jq '.instances[] | select(.breaker.state != "closed") |
{key, state: .breaker.state}' "$snapshot"
```
+9 -3
View File
@@ -90,14 +90,20 @@ services:
- "7655:7655"
volumes:
- pulse_data:/data
environment:
- PULSE_AUTH_USER=admin
- PULSE_AUTH_PASS=secret123
volumes:
pulse_data:
```
Lass `PULSE_AUTH_USER` und `PULSE_AUTH_PASS` bei einer neuen Installation
ungesetzt und richte den Zugang beim ersten Anmelden mit dem Bootstrap-Token
ein. Verwende kein gemeinsames Beispielpasswort. Für automatisierte Einrichtung
nutze eine private, vom Deployment verwaltete Zugangsdatenquelle; siehe
[Configuration](../../CONFIGURATION.md#private-docker-authentication-file).
Passwörter gehören nicht in Befehlsargumente oder eine geteilte Compose-Datei.
Ein Docker-Administrator kann Zugangsdaten in der Container-Umgebung weiterhin
lesen, auch wenn Pulse sie für die Anmeldung hasht.
## Erste Anmeldung
Beim ersten Start musst du ein **Bootstrap Token** abrufen und damit den
+9 -3
View File
@@ -89,14 +89,20 @@ services:
- "7655:7655"
volumes:
- pulse_data:/data
environment:
- PULSE_AUTH_USER=admin
- PULSE_AUTH_PASS=secret123
volumes:
pulse_data:
```
Deja `PULSE_AUTH_USER` y `PULSE_AUTH_PASS` sin configurar en una instalación
nueva y completa el primer acceso con el token de arranque. No uses una
contraseña de ejemplo compartida. Para una configuración automatizada, usa
una fuente privada de credenciales gestionada por el despliegue; consulta
[Configuration](../../CONFIGURATION.md#private-docker-authentication-file).
No pongas contraseñas en argumentos de comandos ni en un archivo Compose
compartido. Un administrador de Docker aún puede leer las credenciales en el
entorno del contenedor, aunque Pulse las convierta en hash para la autenticación.
## Primer inicio de sesión
En el primer arranque debes recuperar un **Bootstrap Token** y usarlo para
+8 -1
View File
@@ -55,7 +55,9 @@ There is currently no dedicated UI for adaptive polling. Use environment variabl
You can also set `adaptivePollingEnabled` (and related interval fields) in `system.json` and restart Pulse.
## 📊 Metrics
Exposed at `:9091/metrics`.
The separate metrics listener defaults to port 9091; `METRICS_PORT` can override
it. Use an existing authorised scrape if available, not a newly exposed port for
diagnostics. A missing scrape is not evidence of zero errors.
| Metric | Type | Description |
| :--- | :--- | :--- |
@@ -84,3 +86,8 @@ Returns:
- Dead-letter tasks.
- Circuit breaker states.
- Per-instance staleness.
Use the [authenticated snapshot and local queries](../api/SCHEDULER_HEALTH.md#-common-queries-jq)
to inspect it without putting credentials in command arguments or repeatedly
requesting the same data. Follow the [rollout and rollback checks](../operations/ADAPTIVE_POLLING_ROLLOUT.md)
when changing the flag; queue depth alone is not a monitoring-health verdict.
+60 -22
View File
@@ -1,32 +1,70 @@
# 🚀 Adaptive Polling Rollout
Safely enable dynamic scheduling.
Adaptive polling is disabled by default. Change it during a maintenance window,
not as an unmeasured remedy for high CPU, excessive writes or a stalled backup.
A Pulse restart interrupts monitoring and notifications until it is running again.
## 📋 Pre-Flight
1. **Snapshot Health**:
```bash
curl -s -H "X-API-Token: $TOKEN" http://localhost:7655/api/monitoring/scheduler/health | jq .
```
2. **Check Metrics**: Ensure `pulse_monitor_poll_queue_depth` is stable.
1. Record the running version, fleet size, current polling settings and whether
Pulse dashboards are open. Save the existing environment overrides and
`adaptivePollingEnabled` value so rollback restores the same configuration.
2. Take one [scheduler health snapshot](../api/SCHEDULER_HEALTH.md#-common-queries-jq).
Use a signed-in browser or the private header-file recipe there. Keep the
response local: instance names, addresses and error text can be sensitive.
3. Establish each instance's last successful poll and current errors before
changing settings. A small or empty queue alone does not prove healthy polls;
scheduled tasks may be waiting for their next run, and polling may be disabled.
## 🟢 Enable
Choose one method:
- **UI**: Not currently exposed in the UI (use CLI or env vars).
- **CLI**:
- systemd/LXC: `jq '.adaptivePollingEnabled=true' /etc/pulse/system.json > /tmp/system.json && sudo mv /tmp/system.json /etc/pulse/system.json`
- Docker/Kubernetes: edit `/data/system.json` in the volume and restart the container/pod
- **Env**: `ADAPTIVE_POLLING_ENABLED=true` (Docker/K8s).
There is no dedicated UI toggle. Choose one configuration source:
- **Managed environment**: Set `ADAPTIVE_POLLING_ENABLED=true` in the existing
service, Compose or Kubernetes deployment configuration, then restart or
redeploy Pulse using that deployment's normal procedure.
- **Persistent settings**: In the instance's actual data directory, edit only
`adaptivePollingEnabled` in `system.json`, preserving its other settings,
ownership and permissions, then restart Pulse. Back up the original privately
first; this file can contain secrets. Do not replace it through a shared
`/tmp/system.json` or replace the whole document with a single property.
An explicit environment value overrides `system.json`. Do not change both
sources at once or assume a file edit took effect while an override remains.
See [configuration](../monitoring/ADAPTIVE_POLLING.md#-configuration) for intervals.
After restart, the health response's `enabled` field confirms the effective flag,
not successful monitoring.
## 🔍 Monitor (First 15m)
Watch for stability:
```bash
watch -n 5 'curl -s http://localhost:9091/metrics | grep pulse_monitor_poll_queue_depth'
```
- **Success**: Queue depth < 50, no permanent errors.
- **Failure**: High queue depth, open breakers.
For a responsive install, take another snapshot after about 1, 5 and 15 minutes;
run the local jq queries against each saved file, not a separate HTTP request per
query. Stop collection if the system becomes unresponsive. Do not add an
unbounded `watch` loop, enable Debug or expose a metrics port for this check.
Compare per-instance `pollStatus.lastSuccess`, `consecutiveFailures`, breaker
state and dead-letter tasks with the baseline. Last-success times should advance
in line with the effective polling intervals. Check that the affected dashboard
data still refreshes. A snapshot's `updatedAt` is its collection time, not a
successful provider poll. There is no fleet-independent queue-depth success
threshold, and an open breaker is a backoff state, not proof of a new regression.
If using an already configured Prometheus scrape, compare the queue and error
metrics too. Missing metrics or a failed read are unavailable evidence, not zero
errors. Do not prolong a failure merely to finish the 15-minute observation.
## ↩️ Rollback
If instability occurs > 10m:
1. **Disable**: Remove the env var override or set `adaptivePollingEnabled=false` in `system.json`.
2. **Restart**: Required if using Env/CLI overrides.
3. **Verify**: Confirm queue drains.
If freshness deteriorates or failures rise after the change:
1. Restore the saved environment and persistent settings. For an originally
disabled install, an explicit `ADAPTIVE_POLLING_ENABLED=false` override disables
the flag; merely removing `true` can uncover a persisted `true` value.
2. Restart or redeploy through the same procedure. This pauses monitoring again.
3. Take a fresh snapshot to confirm the effective flag, then verify successful
polls and dashboard freshness against the baseline. An empty queue or
`enabled: false` alone is not recovery evidence.
Keep the versions, times, changed settings and relevant redacted observations
when reporting a problem. Do not share the header file, raw health response,
deployment environment or `system.json`.
@@ -0,0 +1,154 @@
# Stable demo transaction and acceptance
This continues the existing update/recovery route. It was built for a single
demo workflow and first-success health; the actual host also runs Relay and
Caddy. An immediate healthy sample does not cover a delayed failure, and a
workflow queue does not exclude another repository's host deployment.
## Implemented boundary
- Both existing mutating workflows submit the same reviewed Python engine.
Update still requires the immutable stable activation and customer-promotion
lease and archive verification against the trusted release signing key. Only
the signed Linux/amd64 server binary and VERSION are selected; no installer is
executed. The demo does not serve enrolled agents, so updating its bundled
agents/scripts or updater/service assets is not part of this runtime operation. Verification
only never submits it. Recovery never installs a different executable.
- The independently owned systemd child holds the existing Relay deployment
file lock through capture, atomic runtime/profile work, forward observation,
complete restoration and recovery observation. It does not restart Relay or
Caddy, upgrade a package, lift the Caddy hold or activate frozen commercial copy.
- Billing and `.encryption.key` are read-only to the profile helper. Before the
first service stop or estate mutation, it requires regular, bounded,
unambiguous billing JSON with an already provisioned `demo_fixtures`
capability. Missing capability or unavailable/malformed state refuses the
transaction with fixed diagnostics; it never grants the capability, drops
integrity or rewrites secret-bearing JSON. It rechecks after quiescent capture
before changing the environment. The application still owns HMAC/entitlement
verification, encryption and legacy plaintext migration; this shape check
does not authenticate a licence. Full-state snapshots/recovery preserve the
original private billing/key estate. Native fixtures seed non-secret demo
capability state only while provisioning their fresh empty disposable estate.
- The complete quiescent **chosen footprint**: Pulse executable, VERSION,
unit/drop-in identity and demo data estate is retained
privately before mutation. No backup or database is deleted for space. The
unhealthy demo-only route retains the original generated operational history
before its existing bounded-profile reset. No snapshot is automatically erased.
- Readiness has a separate 60-second limit. Forward, healthy-noop and restored
paths require 300 seconds of actual local/public/Relay HTTP 200, expected
version, stable service PID/restarts and available new-journal observations.
Transport errors, redirects, missing state/journals and new crashes fail.
Recovery uses fresh cursors; a retained forward failure remains a failure.
- Failed/unhealthy/uncertain recovery remains owned and blocks subsequent
mutation. A prior failed baseline is not a healthy rollback. No crash dump is
induced. Receipt fields contain fixed diagnostics and identities, never raw
journal/HTTP bodies, environment values or snapshot contents.
- Phase/terminal receipt I/O failure cannot gate owned stop, restoration or the
full recovery watch. It retains captures and the original failure. Even an
observed full restored window is **unverified** after evidence loss:
`observation_failed`/`recovery_required` blocks a new mutation. A persistently
unwritable receipt may leave only a nonterminal record; missing evidence is
not converted to acceptance or retried as a new request.
- Terminal collection also observes the original systemd child's exit, without
restarting or stopping it. `RemainAfterExit=yes` retains the closed result;
failed units are not collected automatically. A favourable JSON replacement
can become visible before its directory fsync fails, so it is provisional
until the writer exits successfully. Missing, malformed or contradictory
child evidence returns uncertainty, never a successful receipt or replay.
The engine exits **2** on receipt loss, distinct from **1** for an observed
failed operation; a stale `rolled_back` JSON cannot hide that loss. New
attempts also reconcile the retained child of a prior source-bound terminal
that requires this binding. Older receipts gain no missing exit evidence.
- Source-bound intent precedes systemd submission. An identical request reads
its receipt and cannot start another child after a lost response. SSH loss
does not terminate the child. TERM/INT/HUP cannot abort owned recovery. The
45-minute runtime, 20-minute stop, 4,000-second remote observer, 4,100-second
client and 90-minute workflow budgets cover both observation windows.
## Available validation and remaining result
`TestDemoTransactionConnectedRecoveryControls` executes the real engine and
bootstrap using a real private filesystem estate and virtual-clock command,
service, journal and HTTP adapters. Tests retain delayed 55/299-second failure,
changed executable/VERSION/data rollback, cancellation, failed and unhealthy
recovery, headroom refusal, shared-lock contention, source mismatch, journal
failure, cohost restart, source-bound repeated/lost submission and no-op proof.
Receipt faults after stop, after runtime replacement and during restoration
exercise the real filesystem engine, including persistent write loss,
post-replacement fsync loss, cancellation and failed-terminal non-replay. The
unchanged-parent control retains the adverse skipped restoration. Collection
controls wait for writer closure and reject favourable stale terminals.
The production window is not shortened. This is **not native acceptance**.
Connected billing controls also pass actual canonical encrypted and legacy
secret state through the real profile helper, retain every billing/key byte and
metadata, then use the canonical loader to verify encryption/migration and HMAC.
A tampered signature remains rejected by that loader, not laundered by the
helper. Malformed, missing, duplicate-key, nonregular and unprovisioned billing
refuse before service/binary/data mutation. Late failures retain full-state
recovery and sanitised evidence. These are synthetic source tests; they do not
establish production capability provisioning, a green CodeQL check or native
forward/reverse acceptance.
`demo-runtime-native.yml` now carries the next executable acceptance rather
than requiring a worker to obtain system mode: exact PR/main source, a fresh
secret-free public hosted runner, actual systemd/Caddy and disposable trusted
TLS. The successful changed-executable step observes the full 300 seconds. A
different executable and data then fail at 55 seconds; the original observer
is lost, the same retained request observes its child without replay, TERM is
sent during restoration, and the old executable/data must return through a
full real-clock recovery window. Sanitised artifacts bind control/driver/
engine/bootstrap identity, service PID/restart observations and cleanup. The
fixture rebinds only Relay's fixed health URL to its local Caddy endpoint; the
original and fixture hashes remain distinct. This exercises the actual narrow atomic runtime swaps with synthetic executables,
not a signed published archive or customer acceptance. Its source exists;
**no native pass is asserted by this document**. Read the exact-head terminal
job and artifact before judging that result.
Before treating this route as restored, the exact reviewed source must return
native disposable systemd/SSH-loss receipts and signed published-runtime
forward/reverse data/identity results. Then reconcile actual demo workflow and
public browser readback through the release owner; source tests do not establish
an installed recovery. Delivery owns that continuation and the remaining ingress
migration. Unrestricted root/Tailscale administration can still bypass a source
transaction; this candidate is not host enforcement. Workers neither install
policy/credentials nor replay a successful publication to test it.
Retained failed/unknown estates require the operational owner's reconciliation;
do not remove a lock, intent or snapshot merely to permit a fresh sample. A
later frontend/network failure is not authority for a second SSH session to
stop a committed or unrelated service. Keep the signed packet, failure and
native/browser acceptance distinct in the release judgment.
## Returned footprint defect and chosen correction (3 October 2026)
Review of ee451f8c reproduced late failure followed by `rolled_back/verified`
with the candidate `/opt/pulse/VERSION` left installed. The old tests restricted
the synthetic installer to PATHS and missed that distribution mutation.
The actual v6.4.5 installer at 99c8740785dcb6d66f7078112ae673ab45bf1b81
changes the server, VERSION, bundled agents and scripts, binary symlink,
`/usr/local/bin/update-pulse`, `pulse-auto-update.sh`, service/auto-update units,
existing timer assets/activation, config/marker state and sibling configuration
backups (including rotation). Missing-install paths can create a service account
and acquire OS dependencies. Snapshotting only a binary and /etc/pulse cannot
roll all that back; adding more partial snapshots would repeat the defect.
The existing installer and in-process updater were compared. Both include agent
distribution/helper provisioning unnecessary for this synthetic demo runtime;
the in-process updater also lacks this route's cohost exclusion and full-window
recovery. We keep existing signature verification and atomic sibling-swap
semantics but apply **only the two runtime files** and demo profile. Agents,
scripts, helpers, symlinks, backup rotation, service account, packages, unit and
timer provisioning/activation remain untouched. VERSION joins the private
snapshot and both runtime file identities are checked through forward/recovery
watches. Data rollback remains quiescent and retained; startup may naturally
write new data. This is not a claim to restore every general-installer effect.
The CI dispatcher refuses production SSH mutation until the newest matching
main native run passes. It checks ancestry and final content of the engine,
dispatcher, native driver and workflow; an unrelated later main commit does not
invalidate unchanged proof. A matching failure/pending run cannot select an
older favourable sample. Refused/malformed reads have no fallback. Only the
normal recent-first/history-fill inventory rule is used for successful reads.
Native and signed installed acceptance are still separate; absence fails closed.
@@ -8434,6 +8434,17 @@ update commands and credential authority unchanged.
readiness messages so they name Settings > Pulse Mobile. No agent
registration, lifecycle or install path changed.
### Monitor recovery callback ownership
The monitor's external alert wiring uses a captured resolved-occurrence
snapshot for notification cancellation. Re-enrolled or refiring resources keep
their new occurrence's queued notification even when an older recovery callback
arrives late under the same ID. Agent registration, deletion/tombstones,
authorisation and continuity evidence are unchanged. The connected callback
wiring control in `monitor_notification_startup_test.go` verifies the shared
monitor boundary against real alert evaluation and local HTTP delivery; it does
not claim host re-enrolment or installed-agent acceptance.
### AI usage export columns only
`internal/api/ai_handlers.go` and `internal/api/chat_service_adapter.go`
@@ -3203,3 +3203,58 @@ upgrade prompt, and never load or save relay push settings. Licensed instances
keep the minimum-severity control and the link to Pulse Mobile settings, with
the 31 March 2027 retirement date in the copy. Webhook, email, Apprise and
dead-man destinations are unaffected.
### Occurrence-qualified asynchronous recovery delivery
Every resolution funnel passes the actual `ResolvedAlert` to the callback bus.
`SetResolvedAlertCallback` captures a deep-cloned alert plus its resolution time
before asynchronous execution, rather than reconstructing an occurrence from a
reusable ID later. Legacy ID-only subscribers retain their public IDs; a panic
in one recovery consumer does not prevent the other consumers from running.
The live monitor uses this snapshot for AI recovery and notification delivery.
`CancelResolvedAlert` removes only matching ID/start-time grouping entries and
cooldown state. `CancelByAlertOccurrence` applies the same match to persistent
pending, sending, failed and dead-letter firing rows, preserving newer
occurrences, unrelated grouped members and their operational links. Missing
start-time identity is a no-op, never a request for identifier-wide cancellation.
The existing explicit ID-wide cancellation API remains available. Delivery
gates, pending-only suppression counts, destination receipts, attempt history,
quiet hours and best-effort treatment of already-started sends are unchanged.
`operational_contract_test.go` verifies snapshot isolation across a
blocked callback and changed ID-indexed history, legacy identity and panic
isolation. `queue_occurrence_cancellation_test.go` verifies nanosecond-distinct
recurrences, grouping/cooldown preservation, missing identity and restart
readback for all four cancellable row states without deleting failed audits or
new-occurrence destination receipts. The monitor's connected tests use real PBS
evaluation, normal callbacks, the persistent queue and a local HTTP destination;
these receipts are not native provider or installed-release acceptance.
Pending cancellation is not proof that every destination missed the firing.
The monitor delegates recovery eligibility to persisted occurrence/destination
receipts even when an old retry remains or restart has lost RAM cooldown state.
The partial-destination monitor control establishes a real local HTTP 200/503
split and preserves the newer firing and the old recipient's recovery.
### Notification activity reports each evidence source independently
The existing Notifications activity card distinguishes an initial or pending
read from a confirmed empty window. Held/deferred event reads have their own
loading and unavailable state; a failed read withdraws their prior rows and
keeps the warning until a later successful read. Only the current refresh owns
those states, and disposed readers cannot publish late completions. A slow
held-event read does not delay delivery attempts or disable their refresh.
A delivery-attempt failure withdraws only attempt rows, not independently
readable held events. Conversely, missing held evidence does not hide readable
attempts or imply that no notifications were held. The combined empty state
requires both sources to be readable and no refresh pending. Retention, event
limits, queue actions, permissions and notification delivery policy are unchanged.
`useNotificationDeliveryLog.test.tsx`, `useAlertDestinationsTabState.test.tsx`
and `AlertDeliveryLogCard.test.tsx` exercise independent failure, recovery,
initial loading and current-request ownership; the production Notifications
tab browser fixture verifies the connected read/retry states at desktop and
phone widths. These are synthetic frontend controls, not recipient delivery
or containing-release acceptance.
@@ -15,6 +15,106 @@
## Purpose
### Update History consent does not infer the backup's restore scope
The confirmation names the observed running server separately from the selected
entry's pre-/post-update versions. No version threshold or free-form notes stand
in for actual scope: the current History API does not describe the manifest or
active-store layout. Explain that installation-only recovery leaves active stores
in place but may restore independent installation files; legacy recovery can
also replace install-local configuration/data. Do not promise a settings/alerts
rewind. Full-state recovery still requires a stopped service and a consistent
backup of every active store, via the existing shipped recovery instructions.
Opening/cancelling consent sends no rollback request, pending consent rejects
duplicate actions and cancellation, and a rejected request retains the warning
and refreshes history. Mounted UpdateHistory tests and the production-CSS offline
browser fixture exercise those states; neither proves a binary/data rollback.
### Demo transactions share the service host, not just a workflow queue
The demo runtime profile does not grant or rewrite billing entitlements. Before
the first stop/install/profile mutation, the transaction requires regular,
bounded, unambiguous existing billing state containing `demo_fixtures`; missing
capability or malformed/unavailable state refuses without estate mutation. The
helper rechecks before profile writes and leaves all billing/key bytes,
ownership and modes unchanged, including encrypted or legacy plaintext secrets
and integrity. Only the canonical application verifies HMAC/entitlements and
performs encrypted-at-rest migration; the read-only shape check cannot make a
tampered signature valid. Recovery retains the full original private estate.
Synthetic native fixtures provision their non-secret capability before starting
the transaction on their admitted fresh empty runner, not by granting it during
recovery. Connected Python and `TestBillingState_DemoProfilePreservesCanonicalState`
controls cover these boundaries without asserting installed acceptance.
Stable demo update and recovery keep their existing publication/activation,
exact-tag installer signing key, Tailscale, pinned SSH and environment boundaries.
The mutating paths now share the Relay host's existing
`/var/lib/pulse-deploy/relay/deploy.lock` and one retained systemd transaction.
Every successful path, including a healthy no-op, needs a complete 300-second
local/public/Relay health, version, service PID/restart and new-journal-crash
window. Readiness is separate. Missing observations fail closed.
The fixed Relay observation is its existing `/healthz` endpoint, not a guessed
new route; disposable acceptance also rejects an unknown route rather than
responding healthy to every URL.
Before installation or runtime-profile mutation, stop only Pulse and durably
capture its executable, VERSION, unit/drop-ins and entire persistent data estate. Refuse
insufficient space rather than prune backups/databases. A failed healthy change
restores that complete estate and observes another 300 seconds; the original
operation still fails. An unhealthy recovery baseline is never claimed as a
healthy rollback. Retain its original generated demo history, all snapshots and
failed/uncertain terminal evidence privately. An unresolved estate blocks later
mutation. No source-worker production capability, package upgrade, Relay copy
activation or credential is added.
Receipt I/O failure must not skip the owned stop/restore or shorten either
recovery observation window, including an OSError at the phase/terminal writer
interface itself. Physical restoration and complete observations
remain distinct from retained evidence: any phase/terminal write loss yields
failed, unverified `observation_failed` evidence and blocks later mutation,
even when the old runtime/data are restored. The capture remains private and
retained. No OS error text or private path is returned. A missing terminal may
remain nonterminal on disk; it cannot be fabricated as verified.
The bootstrap observes the exact child's closed systemd result before accepting
a terminal, including favourable JSON made visible by replacement before a
failed directory fsync. The child retains `RemainAfterExit=yes` and failed
units are not automatically collected. Running, missing, malformed or
contradictory child/receipt evidence is not acceptance. This is terminal
observation of the existing transaction, not another dispatch/admission route.
Receipt loss exits 2 rather than an ordinary failed-operation exit 1; new
attempts cannot clear a prior child-bound terminal whose exit disagrees. Older
unbound receipts gain no retrospective result.
Connected verification is in `test_demo_runtime_transaction.py`: receipt I/O
faults after stop/replacement and through recovery preserve complete watches,
capture/runtime/data and cancellation ownership; collector controls cover
writer closure, contradictory exits, unavailable state and repeated non-replay.
SSH observes, but never owns or cancels, the systemd child. Durable intent
precedes submission; the exact request identity is not replayed after a lost
response. Forward cancellation initiates restoration; recovery ignores
TERM/INT/HUP until its watch and terminal receipt are retained. Child, stop,
observer and workflow deadlines cover both full windows. A later browser or
network check must not stop a committed or unrelated service in another SSH
session. These connected source controls do not establish native systemd,
signed installed forward/reverse acceptance, ingress enforcement or customer
recovery. See [demo transaction acceptance](../DEMO_RUNTIME_TRANSACTION.md).
The secret-free public `demo-runtime-native.yml` checks the exact PR/push source
on a fresh hosted runner, using actual systemd and Caddy with disposable TLS and
synthetic executable/data fixtures through the real narrow atomic swaps. Its two real-window cases exercise a
changed executable, a 55-second delayed synthetic failure, observer loss and
TERM during full restoration. It records original/fixture source identities,
actual bounded service observations and cleanup; a missing or failed record is
not acceptance. Only the fixed Relay health URL is rebound to that fixture.
This neither exercises a signed published installer nor proves customer or
production recovery. The driver refuses a non-empty or non-disposable estate
before service operations. Neither this driver nor worker source tests install
host policy. Production activation still requires independent review and the
operational owner's acceptance of the native result and published-source path.
### Superseded pull request validation
Build and Test and Core E2E cancel an older run for the same pull request when
@@ -732,6 +832,8 @@ release-latency optimization.
23. `.github/workflows/update-demo-server.yml`
23a. `.github/workflows/recover-demo-server.yml`
23b. `.github/scripts/recover-demo-runtime.sh`
23c. `.github/scripts/demo-runtime-transaction.py`
23d. `.github/scripts/dispatch-demo-runtime.py`
23c. `.github/scripts/resolve-demo-runtime-profile.sh`
24. `.github/workflows/validate-release-assets.yml`
25. `.github/workflows/install-sh-smoke.yml`
@@ -2586,6 +2688,21 @@ artifact-selection behaviour.
## Current State
### Demo runtime footprint qualification
The demo transaction executes no general installer. It verifies the activated
stable archive with the pinned SSH key/namespace, selects only regular bounded
Linux/amd64 server and VERSION members, and rechecks their exact hashes before
atomic sibling swaps. VERSION is in the owned rollback snapshot; runtime hashes
remain fixed through forward and restored 300-second observations. No agent,
script, helper, unit/timer, service-account, package or backup-rotation effect is
claimed or applied. CI production mutation requires a successful main native
run whose engine/dispatcher/driver/workflow final content matches current main,
and refuses newer matching failures, pending runs and unavailable evidence.
The v6.4.5 full-installer VERSION rollback defect and this route's signed/native
limits are retained in DEMO_RUNTIME_TRANSACTION.md.
### Existing-install auto-update consent (1 October 2026)
A manual update, version-pinned rollback or reinstall is not consent to turn
@@ -20,6 +20,158 @@
## Purpose
### Guest disk deferral and retained observations
The existing workload row, disk breakdown tooltip and guest Overview use
one presentation for the fixed guest-read reason vocabulary. Operation locks,
unverified lock clearance, an in-flight request, cooldown, incomplete response
and capacity deferrals are not evidence that QGA is missing. Invalid VM identity
remains distinct. A timeout describes uncertain completion and must not recommend
restarting QGA during a backup or changing freeze settings.
`prev-` marks last-known disk values, not a fresh sample. Keep those values and
their identity visible, with the explanation beside the disk evidence, including
the breakdown tooltip and the phone-readable Overview. Show readable status even
before the first filesystem sample; do not hide it behind an empty disk list or
expose only a raw reason. A same-VM fresh observation withdraws the notice and
updates values without remounting the row. Permission, disabled-agent and actual
unavailable cases retain their own explanations.
Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`,
`StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions,
and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no
previous data and fresh resumption with production components and CSS on desktop
Chromium and touch WebKit. Synthetic observations do not establish the native
command-ID cause, guest thaw, fresh History collection or containing release.
The legacy Workloads `DiskList` is exercised only by tests/fixtures, not a
shipped caller. Its empty-state title is not evidence of readable touch guidance;
the shipped guest Overview supplies the readable pre-sample explanation.
### Completed guest backup evidence versus current activity
The guest Overview keeps the last completed backup age and its existing
threshold-derived tone visible independently of current backup activity.
Starting a backup must not hide absent or old completed evidence or turn it
green. A separate cautionary activity row reports a running backup as not yet
completed. Ending that activity without a new completed timestamp does not
manufacture protection. A new completed timestamp updates the existing drawer
without remounting it. Both values wrap at phone widths, rather than requiring
hover to distinguish them.
Verification: the mounted `GuestDrawer.test.tsx` backup regressions,
`workloadGuestPresentation.test.ts` and production full-drawer fixture
`browser-tests/guest-backup-protection.cjs` cover VM/CT missing, stale and fresh
completion evidence, concurrent activity, stopped activity with no completion,
and later completed observations. They preserve independent disk-lock/timeout
guidance and existing touch/keyboard collapse. Synthetic observations establish
presentation, not native task success, archive restorability, QGA/thaw or release
availability; those require their own containing native and delivery evidence.
### Independent backup inventory read ownership
PVE files/snapshots/tasks and PBS inventory have independent, non-suspending
read owners on the existing Backups page. A pending or failed source is not an
empty successful inventory. Keep the other source's readable artifacts, filters
and server health usable; name the missing source beside the evidence, mark
restore-point totals as incomplete and retry only that source. If neither source
can be read, the shared error action retries both. An initial pending read must
not show “No backups yet” or a measured zero PBS count. Only a fulfilled empty
PBS inventory establishes a zero count.
Pending retries retain independently readable evidence. A settled failed read
withdraws that source's artifacts and counts until it recovers, without clearing
the other source or inferring server-owned protection posture. A retry remains
disabled and its previous warning visible while pending. Organisation switches
withdraw prior inventory, replace/abort the read owners and refetch in the new
API context; late responses cannot repopulate old evidence. Disposal aborts
reads and removes subscriptions. This uses the existing query lifecycle, without
new inventory polling or a remount cache.
Successful HTTP responses still need a readable backup envelope, collections
and consumed row fields. Invalid JSON, missing data and malformed rows produce
a source-local unavailable state with fixed format guidance, never raw response
content, a fabricated empty inventory or a page-level rendering failure that
hides another source. No invalid rows are silently dropped, no completion flags
are coerced, and only a validated response clears the warning. Explicitly nil
PVE collections remain compatible with the existing Go encoding.
`proxmoxBackupInventory.test.ts`, mounted backup-table controls and
`browser-tests/backup-inventory-format.cjs` verify these boundaries and isolated
keyboard/touch recovery, not native backup or released availability.
Verification: mounted `ProxmoxBackupsTable.test.tsx` covers source pending,
partial/both-source failure, isolated retry, fulfilled empty counts, 401/403,
organisation replacement, late response and disposal. Production component,
router, API adapter and stylesheet fixture `browser-tests/backup-inventory.cjs`
checks desktop Chromium and touch WebKit recovery, warning readability, keyboard
or touch retry, source isolation and actual new organisation headers. Synthetic
HTTP results establish frontend truthfulness/lifecycle, not native PBS/PVE
permission, archive restorability, guest thaw or released availability.
### Composed operator tables and conservative rollback consent
Phone storage tables use the canonical weighted-width helper and matching
header/cell gutters. Compact labels shorten disk verdicts and capacity values,
not their underlying health or measured units; full reasons stay in the real
disk header and tooltips. Structured ZFS scan activity supplies a compact badge
only during reported rebuild activity; its complete provider summary remains
available. Stable disk History catalog/organisation/access ownership is unchanged.
Controller expansions carry the supplied kind-specific targets, absolute times,
duration and cluster/namespace when the phone row omits those columns. Proxmox
coverage distinguishes independent backups from guest-local snapshots; By date
retains archive identifiers in hover/search while showing the format as detail.
Phone identity weights leave every configured metric column visible.
UpdateHistory consent names both the requested target and the observed running
version, separately from the version on the selected history entry. The API does
not expose backup restore scope: do not infer it from a version cutoff, notes or
a retained path. Explain installation-only versus legacy recovery and stopped,
consistent full-state recovery without promising later settings will be reverted
or preserved. Opening/cancelling never starts a write, rejection retains consent,
and a pending request cannot be submitted again or cancelled.
Verification: the mounted UpdateHistory regression tests, existing kind-specific
controller/storage/backup/workload tests and production-CSS browser fixture
`frontend-modern/browser-tests/operator-tables-consent.cjs` exercise the composed
source at desktop, intermediate and phone widths. Synthetic HTTP/snapshots do
not establish native collectors, tenancy, installation recovery or release delivery.
### Organisation-scoped single-metric History
Fetched HistoryChart observations belong to the active organisation as well as
the resource type/ID, metric and range. `org_switched` replaces the request owner
even when those chart props are unchanged: withdraw old samples, provenance,
errors and touch/keyboard/group inspection, abort superseded reads and replace
the polling timer before immediately requesting the same selection in the new
API context. Late success or failure from an earlier organisation cannot update
or settle that request. Disposal removes both timer and org subscription.
Same-organisation transport/5xx failures still retain their own successful
observations with explicit refresh warnings. A failed first read in a new org
must not fall back to the previous org. Locked or missing targets do not fetch;
explicit supplied samples remain caller-owned, not silently converted to fetched
history. Matching same-org supplied-sample refreshes retain inspection.
Runtime ownership and keyboard/touch rendering regressions plus the production
`browser-tests/history-org.cjs` HTTP fixture verify these boundaries with the
real API org header and the existing org-safe batch reader as a control. This is
client presentation/isolation proof, not native tenancy, collector or release
acceptance.
### Stable physical-disk History composition
Disk detail composes shared HistoryChart owners from stable disk-family catalog
entries, independently of the latest optional temperature/SMART fields. Matching
snapshots must not recreate chart DOM, reset inspection or restart reads. Stored
series retain their own sample provenance; current collection unavailability is
still reported in Overview, and empty history is not a manufactured zero. The
shared target/range owner continues to cancel old selections and reject late
responses. `DiskDetail.history-collection.test.tsx` and the production
`browser-tests/disk-history-collection.cjs` fixture verify these boundaries with
synthetic data; neither establishes native NAS health or installed acceptance.
Storage pool Configuration key/value rows keep capacity absence distinct from
measured zero: unavailable bytes and percentages render as `n/a`, while valid
independent observations survive a missing total. Live partial snapshots must
@@ -341,7 +493,30 @@ PBS browser runner verifies delayed range and withdrawn-target reads at desktop
and phone widths; neither proof establishes installed collection or #1723 relief.
Failed same-source history refreshes keep valid previously loaded observations
visible with an explicit warning, rather than hiding the entire chart. Initial
visible with an explicit warning, rather than hiding the entire chart. That
retention applies to transient transport/server failures, never a final HTTP
`401` or `403`. Both the batch drawer and shared canvas reader withdraw stored
samples and inspection on access failure; denied history is not an empty,
collecting, or stale-but-still-readable result. Their guidance is bounded by
numeric status, not transport bodies or guessed diagnostic text. The shared
canvas announces its error, and the drawer keeps its scoped, focusable retry.
Only a successful replacement read restores observations.
The retained-value query helper also withdraws its active value on a final
`401`/`403` and clears its shared remount cache. Other resource/range entries
were read under the same access context, so they cannot be resurrected after
that context is denied. Advancing the cache generation rejects cache writes
from already-running pre-denial reads. A superseded denied completion cannot
clear the current target or its cache. This does not broadcast a session reset
or claim revocation of independently mounted consumers; their current response
still owns their presentation. Transient failures keep the existing retained
value, and a newly successful read may populate the cache normally. Verification
uses runtime assertions in `createNonSuspendingQuery.test.tsx`,
`GuestDrawerHistory.refresh.test.tsx`, `useHistoryChartState.test.tsx`,
`HistoryChart.test.tsx`, and `browser-tests/history-access.cjs` (both production
readers/client, real same-origin 403/503 responses, cache/range/remount and
fresh-read recovery on desktop Chromium and touch WebKit). These controls do
not establish appliance authorisation, collection or released acceptance. Initial
or uncached replacement failures show unavailable history, never borrowed points
or a collecting claim. The existing target/range owns every manual refresh;
locked or absent targets expose no refresh control. The control remains mounted
@@ -662,6 +837,9 @@ that manual-channel consequence must stay visible at desktop and narrow widths.
108. `frontend-modern/src/components/shared/summaryInteractionA11y.ts`
109. `frontend-modern/src/components/shared/SummaryRowActionButton.tsx`
110. `frontend-modern/src/hooks/createNonSuspendingQuery.ts`
Shared API read-access classification is owned by
`frontend-modern/src/utils/apiAccessError.ts`; it recognises only final
numeric 401/403 statuses and never renders transport diagnostics.
111a. `frontend-modern/src/utils/storageSummaryCache.ts`
111. `frontend-modern/src/components/shared/TableCardHeader.tsx`
112. `frontend-modern/src/components/shared/UpgradeLink.tsx`
@@ -716,6 +894,28 @@ that manual-channel consequence must stay visible at desktop and narrow widths.
## Shared Boundaries
### Transport-independent operator rows
Proxmox/Storage and Docker presenters retain the shared table/detail, responsive
column weighting, relative-time and governed action primitives. Display names
for Proxmox storage plugins preserve their operator vocabulary (LVM-Thin, NFS,
SMB/CIFS and the other supported acronyms); only explicitly shared storage on a
synthetic cluster/shared location gets the shared label. That label does not
alter canonical identity, parent, History target or pool-to-disk ownership.
The Docker image phone projection pairs hidden headers and cells and gives
measured size and update verdict room without hiding identity or dropping the
existing detail disclosure. The container projection retains its shared 30%
phone identity reservation and five scan signals; off-row restart/host/consumer
evidence remains in detail. Swarm rollout tokens read as words, with compact
Running headings and wrapping rollout states on phones; raw rollout and
placement evidence remain reachable through keyboard/touch row disclosure.
`platformOverviewLayout.guardrails.test.ts`, mounted
Docker table tests and the desktop/touch-phone production-component browser
fixture check these priorities, rather than treating an old percentage as an
immutable primitive contract. No new public route or navigation entry is added.
### Maintenance schedule copy
Operator maintenance banners show local absolute start and end timestamps,
@@ -7806,3 +8006,21 @@ kind normalisation, the kind switch, the email-only payload, and the plain
cover the gating, the reduced form, the pinned fields, and the saved payload.
This is a presentation and form-state boundary; the schedule API contract,
report rendering, and email delivery are unchanged.
### Notifications activity has independent evidence-read states
The Notifications tab composes the delivery-log card with both attempt-read
and held/deferred-event loading and unavailable state from its owned reader.
Pending or failed evidence is never rendered as a confirmed empty activity
window. Each failed source withdraws its own rows without hiding independently
readable rows from the other source. Source-specific warnings have `role="alert"`
and loading text has `role="status"`; a slow held-event read does not block the
existing attempt-refresh button. Recovery clears a warning only after a
successful read, without remounting the destination editors or changing their
unfinished input.
The connected Notifications browser fixture uses the real tab/state/API/card
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.
@@ -17,6 +17,56 @@
## Purpose
### Guest-agent coordination around backups — issue #2439
All PVE QEMU guest-agent reads, including disk diagnostics and legacy meminfo
callers, share fail-fast per-guest admission within this Pulse process. Clients
for the same endpoint and configured cluster aliases coordinate across methods;
VMIDs remain serial through node migration. Other VMs and independent endpoints
remain independent. No guest payload or credentials enter coordination state.
Before each command and before returning its fully received bounded payload,
Pulse reads the operation lock from VM config. Any nonempty lock (not just
backup), missing/malformed config or an unverifiable lock defers the command.
Known locks in node inventory, cluster resources or detailed status suppress
polling enrichment even when status fails and recent agent evidence exists.
Admission is held through the complete response, not just headers. A transport
failure, timeout, wrong-command-ID response or incomplete body defers every
command for that guest for a minute; callers neither immediately retry nor
replay through another cluster endpoint. Coordination retains at most 4,096
active/cooling endpoint-guest entries and expires cooldowns. Explicit API
permission refusals and unsupported commands remain errors, not successes.
Deferrals retain the original metadata/memory cache timestamps and supported-OS
failure evidence. Locked guests retain labelled last-known disks/identity and
previous-snapshot memory, or unavailable memory without trustworthy evidence.
Current non-QGA memory, CPU and I/O may still be observed. History never writes
retained disk values or deferred cached memory as newly observed telemetry;
normal unlocked polling resumes and replaces them with fresh data.
`TestGuestAgentBackupMonitoringContract` exercises healthy → status lock →
resource lock with failed status → node fallback → unlocked resumption through
the real client/builders and History. The client controls cover every read,
unknown lock state, diagnostic overlap, aliases/source/VM isolation, in-flight
lock changes, incomplete bodies, cooldown and absence of failover replay.
These reproduce missing Pulse safety controls, **not** the reported native
PVE/QGA command-ID collision or successful backup thaw. HTTP lock checks cannot
atomically reserve PVE's serial channel against a backup beginning afterwards,
cancel work already running in pvedaemon, detect a guest left frozen after its
lock clears, or coordinate separate Pulse processes. Native freeze/thaw acceptance
on a containing build remains required. Until then, stopping Pulse before a
freeze-enabled backup and restarting only after verified thaw remains the safe
workaround, with monitoring and alerts unavailable during that window.
The [bounded backup observation tools](../../../../../tests/qualification/guest-agent-backup/README.md)
provide an independent guest filesystem writer (file/directory fsync and
synthetic readback over non-QGA stdout) and a fail-closed checker for native
overlap, truthful deferred readings/History and post-task writes/resumption.
An OK task without fresh writes on every covered filesystem cannot pass those
record checks. Local tests are not native freeze/thaw evidence; supplied
platform/artifact provenance and mount completeness still need independent
review. The tools add no runtime interface, recovery action or release gate.
### Exact TrueNAS subscription termination — issue #2396
JSON-RPC stream readers and the subscription-acknowledgement wait recognise
@@ -4641,3 +4691,33 @@ proof lives in `internal/truenas/provider_test.go`
(`TestMaxTrueNASSystemTemperaturePrefersCoresOverSynthesisedAggregate`). This
is source-level proof, not reporter acceptance or appliance confirmation
(#2122).
### Recovery callbacks retain the resolved occurrence
`wireExternalAlertCallbacks` binds the occurrence-qualified recovery callback,
not the legacy ID-only callback. The monitor uses its immutable alert/start/end
snapshot for AI recovery and for exact notification cancellation, so a delayed
recovery cannot remove a newer firing admitted under the same reusable ID.
AI receives a separate clone. An ID-only websocket removal is omitted when the
manager already exposes that ID as active; the regular state stream continues
to own current alert truth. The legacy local ID adapter resolves a snapshot once
and fails closed when no resolved record exists; it is not the live delivery
binding. No browser payload, retention or notification routing contract changes.
`monitor_alert_handling_test.go` and
`monitor_notification_startup_test.go` covers a delayed ID-adapter cancellation
with queue restart/readback, and a real PBS breach/recovery/new breach held at
the callback boundary. Both require the replacement firing's exact ID/start to
reach a loopback HTTP destination with one sent row and one cancelled old row.
No clock, reducer map or configured cooldown is altered. This establishes local
source-connected delivery, not the outstanding natural flapping cooldown check,
a native appliance result or containing stable availability.
Recovery eligibility is destination-receipt-owned, not the cancellation helper's
pending-work count or the RAM cooldown marker. After partial delivery/restart,
a cancelled retry at one destination cannot suppress a recovery to another
that actually accepted the same occurrence. The connected
`TestMonitorDelayedPartialResolutionKeepsDestinationRecovery` retains a real
HTTP 200/503 split, reopens the persistent queue, and requires the old accepted
destination's recovery plus the new firing, with no recovery to the unannounced
destination. Quiet hours and disabled recovery controls still apply.
@@ -15,6 +15,84 @@
## Purpose
### Delivery verdicts survive session cleanup and interrupted diagnostics
A successful final SMTP DATA reply establishes server acceptance. Plain SMTP,
TLS and STARTTLS delivery must not resend an accepted message because QUIT
fails, nor turn that cleanup failure into a terminal delivery audit. Cleanup
still runs, with only a fixed local diagnostic; the provider's reply text is
not copied into it. Missing acknowledgement, certificate failures and rejected
DATA replies remain errors with their existing retry policy. A connection test
without a completed DATA transaction still reports QUIT failure. Server
acceptance is not proof of arrival in a recipient's inbox or exactly-once
recovery after a process crash.
The ntfy plain-text recovery path, like the shared firing sender, retains the
HTTP rejection class when reading the diagnostic response body fails. It
preserves the wrapped read error, existing retry budgets and bounded body read;
401/403/422 remain permanent failures, while transient HTTP verdicts remain
retryable. Incomplete 2xx responses retain the existing failure behaviour.
Historical audit rows are not rewritten and operator retry stays explicit.
Verification: `email_acceptance_test.go` uses actual guest-local plain/TLS/
STARTTLS SMTP sessions for clean, missing and rejected QUIT, permanent and
transient DATA rejection, missing acknowledgement, threaded/attachment sends,
connection-only tests and certificate refusal. Its connected persistent queue
control retains one accepted send and zero terminal failures after reopen.
`ntfy_response_verdict_test.go` exercises real interrupted HTTP bodies, then
queue/audit/telemetry readback after reopen and an explicit repaired-destination
retry without erasing the original rejection. These are synthetic source
controls, not a cause attribution for aggregate fleet counters, installed
provider acceptance or a reporter's confirmation.
### Pending firing groups count occurrences, not callbacks
Before a grouping window expires, repeated firing callbacks with the same full
alert ID and start instant occupy one slot. The newest observed snapshot owns
the whole payload and operational linkage, including a genuine later severity
downgrade. Equal observation times prefer higher severity; an unknown time
cannot replace a known later observation. Missing ID/start identity remains
separate, as do different occurrences of a reusable ID. The index is scoped to
the pending window and discarded on flush, disable, cancellation or stop; callbacks
do not extend the original timer or establish delivery.
`firing_grouping_occurrence_test.go` verifies timestamp/severity ordering,
instant equality across time-zone/monotonic representations, cloned payloads,
concurrent callbacks, occurrence-qualified cancellation, all-destination and
webhook-only disable/zero-window flushes, and notification disable/re-enable.
Stopping the manager cancels its volatile firing timer and snapshots, rather
than letting an abandoned timer send directly after its queue is closed.
Committed queue work retains its existing restart semantics.
`TestPendingFiringGroupStopCancelsTimer` checks actual timer/HTTP silence after stop.
The connected `TestPendingFiringGroupHTTPAcrossRestart` uses actual timer expiry,
persistent queue reopen, the autonomous sender and two loopback receivers: one
snapshot per occurrence/destination, truthful counts/latest content, ordinary
cooldown and independent firing/recovery receipts. Ungrouped admissions,
scheduled escalation, retry budgets, queue schema and delivery policy are
unchanged. This is source acceptance, not a native destination, natural schedule
cycle or a reproduced cause of the existing flood reports.
### Completion order cannot replace a newer occurrence's cooldown
Firing completions still record an independent delivery receipt for their own
occurrence and destination. The reusable-ID cooldown, however, belongs to the
newest known occurrence: a late older completion or unknown legacy start cannot
evict it. Within the same occurrence, successful completion times and delivered
severity only advance. A genuinely newer occurrence begins its own severity
history. No queue schema, retry budget, recovery selection or schedule changes.
`TestNotificationCooldownReceiptOrdering` verifies older, unknown and equal-start
completion ordering, including legacy zero starts. The connected
`TestLateFiringHTTPReceiptPreservesCurrentCooldown` holds an older HTTP response
while the newer firing completes through the persistent sender. Both snapshots
are admitted before ordinary queue activation, so one discovered batch can
complete concurrently without adding another dispatcher. It verifies that
the ordinary repeat adds no queue row, a current severity increase still reaches
the receiver, and both occurrence/destination receipts survive queue reopen.
This is loopback source acceptance, not native delivery, restart cooldown
persistence, a natural schedule cycle or resolution of the original flood reports.
### Queue recovery handler ownership after reload
Router monitor replacement must refresh the existing queue/DLQ handler as well
@@ -1054,3 +1132,27 @@ regression establish frontend control preservation, not provider delivery.
Formatting-only follow-up retains this warning-level contract. The production-component
browser matrix was rerun after formatting at desktop and narrow widths, including
all/critical/warning save/reload, cancel, and warning webhook creation.
### Resolution cancellation is occurrence-qualified
The monitor's delayed recovery path calls `CancelResolvedAlert` with the
captured ID/start-time snapshot. It removes only that occurrence's pending
in-memory grouping members and cooldown record. Persistent
`CancelByAlertOccurrence` shares the existing per-ID delivery gates and
cancellation machinery, but matches both ID and start time: pending, sending,
failed and dead-letter firing rows are cancelled or rewritten without removing
a recurring incident or unrelated members/operational links in a mixed row.
Missing occurrence identity is a no-op. The explicit identifier-wide API, audit
history, destination receipts, retry budget, recovery rows and best-effort
in-flight semantics remain unchanged.
`queue_occurrence_cancellation_test.go` verifies all four row states after a
fresh database reopen, nanosecond-distinct occurrences, operational-link and
failed-attempt preservation, missing-identity safety, grouping/cooldown retention
and the unchanged ID-wide API. Monitor connected controls additionally require
HTTP acceptance of the surviving occurrence through the autonomous queue.
The live monitor does not interpret pending-only cancellation counts as
recipient acceptance. Per-occurrence/per-destination receipts remain the sole
recovery admission proof, including partial delivery and lost RAM markers after
restart; an unannounced destination receives no recovery.
@@ -15,6 +15,34 @@
## Purpose
### Guest disk status updates retain row ownership
Disk deferral copy follows reactive same-VM props in the existing row and drawer,
without new guest commands, client polling, History writes or row remounts. Retained
values are labelled rather than replaced with invented fresh percentages; fresh
observations update values and remove the status. Mounted row/list/drawer controls
and `browser-tests/guest-disk-deferral.cjs` verify same-row ownership in bar and
sparkline modes with desktop and phone CSS. This is client behaviour proof, not
native backup coordination, new History samples or a fleet performance result.
Completed backup presentation follows the existing same-guest snapshot owner.
Running activity and completed protection are independent derived detail rows;
neither starts a command, adds a read/timer nor remounts the drawer. Stopping
activity cannot synthesize a completed timestamp. `GuestDrawer.test.tsx` and
`browser-tests/guest-backup-protection.cjs` verify in-place missing/old/new
completion transitions through the actual drawer. This is ownership/presentation
proof, not an allocation benchmark, native backup or fleet performance claim.
### Phone workload identity weights
The existing phone guest projection allocates 38 percent to the name cell,
which includes disclosure, status and backup controls, while normalising every
remaining visible metric weight. Narrow/mobile/desktop modes and windowing
ownership remain unchanged. Guest column regressions verify the sums and the
production composed-table browser fixture checks identity and all configured
metric tracks at narrow width; this is readability, not a throughput claim.
### Bounded confidence/priority work in broadcast grouping — issue #2199
The general identity matcher retains every confidence, reason, ordering and
@@ -301,6 +329,21 @@ the existing bounded HTTP client and artifact-size checks.
## Shared Boundaries
### Multi-disk Bars summary
The existing vertical-bars workload/node disk cell shows the fullest measured
disk's percentage beside equal-weight per-disk micro-bars. Its title retains
the actual mount and percentage. Unknown/invalid usage cannot produce a new
summary; an aggregate fallback must not masquerade as measured member usage.
The displayed maximum uses the existing shared disk measurement and threshold
logic; no collector, sorting key, metric units, History request, resampling or
performance threshold changes. `StackedDiskBar.test.tsx` and the model branch
controls cover real/unknown measurements and unchanged per-member bars.
`browser-tests/proxmox-docker-rows.cjs` checks the actual label, bars and title
at desktop and phone widths; this is presentation proof, not native collection
or a measured fleet performance improvement.
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
@@ -927,7 +970,17 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
single-observation caption rather than a collecting claim or invented trend.
Successful empty reads say `No stored history in this range`. Only
metrics-store samples may contribute chart geometry, bounds, or hover
inspection, including valid measured zero. This presentation adds no fetch,
inspection, including valid measured zero. Same-source transient failures
retain those observations with a warning, but final HTTP 401/403 responses
withdraw the batch charts, inspection and its retained remount entries.
The shared query cache advances its generation so a pre-denial read cannot
restore a former cached range. Access guidance comes from numeric status,
never raw transport text; the existing target/range-scoped retry remains
focusable while pending, and only a successful read restores history.
Runtime denial/cache/recovery controls are in
`GuestDrawerHistory.refresh.test.tsx` and `createNonSuspendingQuery.test.tsx`,
with real client 403/503 browser controls in `browser-tests/history-access.cjs`.
These are client presentation proofs, not native authorisation or collection. This presentation adds no fetch,
point expansion, polling or retention budget. Verify sparse and mixed-series
geometry and failed-refresh/source-change behaviour in
`GuestDrawerHistory.sparse.test.tsx`, with rendered phone/desktop coverage in
@@ -4515,6 +4515,8 @@
".dockerignore",
".github/ISSUE_TEMPLATE/v6_rc_feedback.yml",
".github/scripts/check-demo-reachability.sh",
".github/scripts/demo-runtime-transaction.py",
".github/scripts/dispatch-demo-runtime.py",
".github/scripts/internal-api-test-seconds.txt",
".github/scripts/record-internal-api-test-seconds.py",
".github/scripts/recover-demo-runtime.sh",
@@ -4522,11 +4524,13 @@
".github/scripts/resolve-demo-runtime-profile.sh",
".github/scripts/select-internal-api-shard.sh",
".github/scripts/setup-demo-ssh.sh",
".github/scripts/tests/demo_runtime_native.py",
".github/workflows/backfill-release-assets.yml",
".github/workflows/build-and-test.yml",
".github/workflows/build-release-candidate.yml",
".github/workflows/compile-release-payload.yml",
".github/workflows/create-release.yml",
".github/workflows/demo-runtime-native.yml",
".github/workflows/dependency-advisory-watch.yml",
".github/workflows/deploy-demo-server.yml",
".github/workflows/helm-pages.yml",
@@ -5167,6 +5171,23 @@
"scripts/installtests/install_sh_test.go",
"scripts/installtests/safe_profile_migration_test.go"
]
},
{
"id": "demo-runtime-transaction",
"label": "demo shared-host transaction and retained recovery proof",
"match_prefixes": [],
"match_files": [
".github/scripts/demo-runtime-transaction.py",
".github/scripts/dispatch-demo-runtime.py",
".github/scripts/tests/demo_runtime_native.py",
".github/workflows/demo-runtime-native.yml"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
".github/scripts/tests/test_demo_runtime_transaction.py",
"scripts/installtests/demo_transaction_test.go"
]
}
],
"match_files": null
@@ -5812,6 +5833,7 @@
"match_prefixes": [],
"match_files": [
"frontend-modern/src/hooks/createNonSuspendingQuery.ts",
"frontend-modern/src/utils/apiAccessError.ts",
"frontend-modern/src/utils/storageSummaryCache.ts"
],
"allow_same_subsystem_tests": false,
@@ -5819,6 +5841,7 @@
"exact_files": [
"frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx",
"frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx",
"frontend-modern/src/utils/__tests__/apiAccessError.test.ts",
"frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts"
]
},
@@ -21,6 +21,85 @@
## Purpose
### Partial backup inventory is not empty recovery evidence
The existing Proxmox Backups view treats PVE and PBS inventories as independent
reads. Fulfilled artifacts remain usable when the other source is loading or
unavailable. The page identifies that source, marks totals as incomplete and
does not claim no backups exist until both inventories have fulfilled. PBS server
backup counts show loading/unavailable rather than a fabricated zero; current
server health and server-owned protection posture remain independent facts.
Isolated retry preserves filters and the other source, while settled failures
withdraw the failed source's artifacts. Both failed reads have a shared retry.
Organisation changes and disposal cancel/replace reads and prevent late responses
from restoring former inventory. No polling, recovery operation, native identity
rule, protection-policy or provider contract is changed.
Verification: `ProxmoxBackupsTable.test.tsx` exercises pending/partial/both-source
failure, retries, measured empty counts, access denial, org-switch races and
disposal. `browser-tests/backup-inventory.cjs` uses production components,
API adapters, router and CSS in Chromium/WebKit with synthetic HTTP responses;
these controls are not native backup, permission, thaw or release acceptance.
An HTTP success alone does not establish readable recovery evidence. The client
validates the data envelope, source collections and consumed row fields before
publishing that inventory. Malformed or undecodable responses invalidate that
source atomically, not a silently truncated list or confirmed empty inventory.
PVE's explicitly nil Go collections remain valid empty observations; absent
fields are not nil collections. Optional unknown facts and additional fields
retain their existing meaning, and completion flags are never coerced.
`proxmoxBackupInventory.test.ts`, the mounted backup-table tests and
`browser-tests/backup-inventory-format.cjs` cover this response boundary, source
isolation and recovery without extending native protection or thaw claims.
### Backup identity, phone storage and History consent composition
The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE
backup files, never guest-local snapshots. Guest snapshots remain listed as
restore evidence but cannot make an old independent backup look current.
Archive detail uses its format (or filename fallback), preserving the full
provider volid for hover and search in both Coverage and By date.
Phone pool/disk rows preserve health and identity while using weighted widths,
consistent gutters and compact capacity/health labels. Full disk risk reasons
move into the existing expanded header; the ZFS activity badge uses supplied
scan data only when rebuild is reported and keeps the full summary as its title.
Collection-independent, organisation-owned disk History remains intact.
The UpdateHistory confirmation is a version-rollback consent, not a full-state
recovery promise. Its current API exposes no verified restore-scope metadata;
copy must retain installation-only and legacy differences and direct full-state
recovery to the stopped-service procedure. Regressions cover these distinctions,
archive/snapshot attribution and storage values; the composed browser fixture
checks real table/drawer rendering, not native or installed recovery.
### Disk History is independent of current collection
The existing physical-disk History catalog is selected by disk family, not by
whether the latest snapshot contains temperature or extended SMART values.
Temperature and ATA reallocated-sector charts, or temperature and NVMe
endurance/spare charts, query only the already-resolved canonical disk target.
A missing current field must not hide stored observations. An empty series is
shown as no history samples, never a fabricated zero or current-value fallback.
Overview continues to report current collection availability; explicit
unavailable/unsupported I/O still suppresses the existing live I/O section.
Catalog entries retain their identity across matching snapshots so ordinary
current-temperature changes cannot remount charts, drop inspection or restart
History reads. The shared chart owner still invalidates reads and inspection
when the explicit target or range changes; a late cancelled response cannot
populate its replacement. This changes neither disk correlation nor collector,
retention, permission or entitlement policy, and proves no appliance recovery.
Verification: `DiskDetail.test.tsx` and `useDiskDetailModel.test.ts` pin the
catalog and matching-snapshot boundary; `DiskDetail.history-collection.test.tsx`
uses the actual chart owner to cover absent current fields, stored zero versus
empty, NVMe/ATA catalogs, request counts, preserved inspection and late former
target responses. `frontend-modern/browser-tests/disk-history-collection.cjs`
checks the production components/client/CSS with synthetic API data on desktop
Chromium and touch/dark phone WebKit, not native NAS or installed acceptance.
### Independent physical disk temperature
Disk Overview displays a finite positive reported temperature even when the
@@ -352,6 +431,20 @@ command-capable profile.
## Shared Boundaries
### Operator storage vocabulary without identity changes
Storage topology names preserve Proxmox plugin vocabulary, including LVM-Thin,
NFS, SMB/CIFS, iSCSI, ZFS and BTRFS. Explicit topology overrides still win.
Only a storage explicitly marked shared whose retained location is the synthetic
cluster/shared marker reads Shared, with the supplied reporting-node count when
more than one node is present. Node-local storage and shared storage with a
real native location keep that location. This is host-label presentation, not
resource consolidation: IDs, parent/node hints, ownership evidence, metrics
targets, alerts, health and recovery authority are unchanged. Adapter/presentation
regressions and the production storage table browser fixture verify labels
without treating shared capacity as a backup or demonstrating native recovery.
- After password configuration has been persisted and the runtime local-admin identity changes, authorizer synchronisation must also occur if subsequent API-token persistence fails. Development first-run reset must clear the configured-admin bypass only on successful auth reset, not on its persistence-failure rollback path. No role-store deletion or migration is part of this recovery.
### PBS host history correlation
@@ -23,6 +23,103 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
### Backup server counts retain their inventory availability
The existing PBS server/datastore row takes backup-inventory read availability
separately from the current canonical resource's health, capacity and identity.
A pending or failed PBS read renders loading/unavailable count text, not zero
backups on an otherwise healthy server. Only a fulfilled inventory establishes
its observed count. Independently readable resources and the existing correlated
drawer target are unchanged. `ProxmoxBackupsTable.test.tsx` covers the combined
count lifecycle; `browser-tests/backup-inventory.cjs` verifies production rows
and source notices on desktop/phone. This changes no resource identity, native
provider assessment or permission policy.
The backup inventory count also remains unavailable when an HTTP200 response
fails the client backup-envelope/row validation; transport success is not an
observed zero. A malformed source cannot remove independently readable canonical
server health or the other source's artifacts. Fixed diagnostics expose no
response body. The inventory parser, mounted table and
`browser-tests/backup-inventory-format.cjs` exercise that combined boundary.
### Guest disk freshness presentation
Existing workload disk evidence retains its source reason through the row,
breakdown and Overview. `prev-` values are explicitly last-known, while a
deferral with no previous disks explains the missing observation without a
measured percentage. Lock/uncertain-completion deferrals must not become an
install/restart recommendation. Human-readable Overview status remains visible
without filesystem rows and is cleared by a same-identity fresh observation.
The shared presentation and mounted regressions plus the production-component
`browser-tests/guest-disk-deferral.cjs` fixture verify this client behaviour;
they do not prove native QGA coordination, History freshness or delivery.
### Narrow controller and backup summaries retain their source facts
Kubernetes controller row expansions expose supplied kind-specific targets,
counts, absolute timestamps, valid elapsed durations and namespace/cluster when
responsive rows hide those columns. Missing facts are omitted and the ordinary
pluralisation model handles singular targets. Job/CronJob timestamp regressions
and the production composed-table browser fixture verify this existing summary.
Proxmox Coverage uses latestBackup for completed independent backup evidence,
with guest-local snapshots retained separately. Responsive backup-server, host
and guest rows allocate space through existing canonical weighted-width helpers;
archive format is presentation only, with the provider identifier retained in
hover and search. The recovery-model, table and guest regressions plus desktop/
phone browser rendering verify attribution, visible facts and source composition.
These frontend improvements change no collector, API authority or native status.
### Resource evidence fallback access boundaries
When the existing resource-facet reader receives a final HTTP 401/403, its
drawer must not substitute embedded snapshot events, counts, capabilities or
relationships for the withdrawn response. A denied filtered read must not
substitute the unfiltered bundle. Denial remains visible during that request's
retry, and fresh success restores its evidence. Independently successful
unfiltered reads retain their own ownership; this is not a global revocation
broadcast. Transient failures still retain applicable snapshots, and compact
snapshot-only rows still make no remote history reads.
Failed change/action reads are unavailable, not “loaded” or empty. The Manage
tab keeps a failed action read's retry visible even without retained rows,
without changing the action client's existing unavailable-plan responses.
The hook access tests and mounted ResourceDetailDrawer.history tests protect
these boundaries; browser-tests/resource-evidence-access.cjs exercises the
production drawer/client/CSS on desktop Chromium and dark touch WebKit. Its
synthetic HTTP responses do not establish native authorisation, a reporter's
cause, installed recovery or release availability.
### Detail snapshot and selection ownership
ResourceDetailDrawer reads the current resource through a reactive getter in
its state, derived identity/telemetry and Overview consumers. Replacing a
same-ID object must update the header, availability evidence and metrics target
without remounting the drawer or discarding its selected History range. This
also preserves the reconciled-store clients used by platform table rows.
Transient loss of a same-resource metrics target withdraws its History panel
and shows the existing unavailable notice; returning the target restores the
selected tab. No target or alias is inferred to fill the gap.
A different canonical ID disposes per-resource tabs, disclosures and outstanding
reads before rendering the new resource. Late completions cannot become the
new resource's facets or intelligence. Discovery source-key replacement does
not retain a former target's identification while the next read is pending.
Availability fleet selection stores an ID, not a clicked snapshot: the open
drawer resolves that ID in the current fleet. Removing the selected check
closes and clears its selection, including when the ID subsequently reappears.
ResourceDetailDrawer.snapshot.test.tsx covers immutable and reconciled-store
updates, target withdrawal/restoration, current availability and late facet
completion. The existing History consumer guard and AvailabilityFleetView
regressions protect composition. The production-components/browser client
fixture browser-tests/drawer-snapshot.cjs exercises PBS History requests and
availability selection on desktop Chromium and phone WebKit with synthetic
snapshots. It proves no native appliance, release or installed recovery result.
Resource identity, policy, permissions, collectors and persistence are unchanged.
### Canonical RAID configured-member evidence — issue #2369
Host RAID metadata and read views retain optional `requiredDevices` (configured
@@ -552,6 +649,29 @@ about the same disk cannot diverge.
## Shared Boundaries
### REST facets and container table projection
The unified-resource REST mapper carries PMG metadata, per-source status,
server action-readiness refusals and technology at the same top-level paths
used by canonical websocket rows. Existing platformData compatibility paths
remain; transport order must not decide whether mail counters or lifecycle
refusal explanations are present. A replaced REST snapshot clears removed
facets instead of retaining old counters or refusal reasons. Mapping does not
invent capabilities or bypass planning, review, server authorisation or execution.
`useUnifiedResources.test.ts` checks actual lifecycle refusal consumers for stale,
failed and server-denied observations alongside a no-invented-grant control.
The Docker phone container projection keeps identity, state when applicable,
CPU, memory and update; restart counts remain in the existing detail expansion
and return at mobile width. Images retain image, size and update verdict on
phones; host and consumers stay reachable in detail. Hidden header/body columns
are symmetric. Swarm rollout tokens render as words with raw state retained in
the title; task starts use the shared relative-time primitive with their exact
timestamp retained. Native table/model/guardrail tests and the production
`browser-tests/proxmox-docker-rows.cjs` exercise these surfaces without executing
a container action or claiming native appliance or release acceptance.
### PBS host history correlation
PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target.
@@ -0,0 +1,94 @@
// Render shipped authentication help through the real Docs/router/Markdown/CSS.
// No credential, proxy, API write or installation is exercised in the browser.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = path.resolve('frontend-modern');
const artifacts = path.join(root, 'node_modules', 'authentication-diagnostic-browser');
fs.mkdirSync(artifacts, { recursive: true });
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(root, 'node_modules', '.vite-authentication-diagnostic'),
server: { host: '127.0.0.1', port: 5253, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
await page.goto('http://127.0.0.1:5253/browser-tests/docs-fragment-navigation.html',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark'));
for (const guide of ['AI_AUTONOMY', 'PROXY_AUTH']) {
await page.getByRole('link', { name: '← All documentation' }).click();
const entry = page.locator(`a[data-doc-link][href="/docs/${guide}"]`).first();
await entry.focus();
await page.keyboard.press('Enter');
const heading = page.getByRole('heading', { name: guide === 'AI_AUTONOMY' ? 'Configuration' : 'Verify Headers', exact: true }).first();
await heading.waitFor();
await heading.scrollIntoViewIfNeeded();
const text = await page.locator('article').innerText();
assert.ok(!text.includes('admin:admin') && !text.includes('curl -H "X-Proxy-Secret'));
const phrases = guide === 'AI_AUTONOMY'
? ['settings:write', 'even for GET', 'changes Patrol mode', 'after an uncertain write']
: ['not expose the backend port', 'HTTP 000', 'old behaviour'];
for (const phrase of phrases)
assert.ok(text.includes(phrase), phrase);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
if (guide === 'AI_AUTONOMY') {
await page.screenshot({ path: path.join(artifacts, `${engine}-${guide.toLowerCase()}.png`) });
const link = page.getByRole('link', { name: 'private header file', exact: true });
await link.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const h = document.getElementById('api-token-recommended');
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/API#api-token-recommended'));
const code = page.locator('pre').filter({ hasText: 'Refusing a symlinked credential path.' });
await code.scrollIntoViewIfNeeded();
assert.ok((await code.innerText()).includes('chmod 700 "$auth_dir"'));
await page.screenshot({ path: path.join(artifacts, `${engine}-private-header.png`) });
} else {
const code = page.locator('pre').filter({ hasText: "--write-out 'HTTP %{http_code}" });
await code.scrollIntoViewIfNeeded();
assert.ok((await code.innerText()).includes('--output /dev/null'));
assert.ok((await code.innerText()).includes('--header "@$header_file"'));
await page.screenshot({ path: path.join(artifacts, `${engine}-${guide.toLowerCase()}.png`) });
}
}
assert.deepEqual(errors, []);
results.push({ engine, browserVersion: browser.version(), width, tone,
keyboardEntryAndPrivateHeaderLink: true, credentialFileRecipeRendered: true,
roleAndTransportLimitsRendered: true, noBodyOverflow: true, errors });
await browser.close();
browser = undefined;
}
const docs = Object.fromEntries(['API', 'AI_AUTONOMY', 'PROXY_AUTH'].map((guide) => [
`frontend-modern/public/docs/${guide}.md`,
createHash('sha256').update(fs.readFileSync(path.join(root, 'public/docs', `${guide}.md`))).digest('hex'),
]));
const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version,
scope: 'rendered help/links; not native proxy, authentication or AI settings acceptance', docs, results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,278 @@
// Production backup readers/table/router/CSS, synthetic HTTP only.
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 phase = 'final';
const root = '/workspace/frontend-modern';
const output = `/workspace/tmp/backup-format-${phase}`;
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const emptyPVE = { data: { backupTasks: [], storageBackups: [], guestSnapshots: [] } };
const emptyPBS = { data: { backups: [] } };
const archive = {
id: 'archive-112',
instance: 'pve-a',
node: 'pve-a',
storage: 'local',
type: 'ct',
vmid: 112,
time: '2026-10-03T01:00:00Z',
format: 'zst',
volid: 'local:backup/vzdump-lxc-112.tar.zst',
};
const snapshot = {
id: 'pbs-main/main/ct/112/2026-10-03T02:00:00Z',
instance: 'pbs-main',
datastore: 'main',
backupType: 'ct',
vmid: '112',
backupTime: '2026-10-03T02:00:00Z',
files: ['index.json.blob'],
};
const pvePayload = { data: { ...emptyPVE.data, storageBackups: [archive] } };
const pbsPayload = { data: { backups: [snapshot] } };
(async () => {
fs.mkdirSync(output, { recursive: true });
process.chdir(root);
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5298, strictPort: true },
});
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [
['chromium-desktop', chromium, false],
['webkit-phone', webkit, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const viewport = phone ? { width: 390, height: 844 } : { width: 1365, height: 900 };
const page = await browser.newPage({
viewport,
isMobile: phone,
hasTouch: phone,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(20000);
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
const errors = [],
requests = [],
offOrigin = [],
screenshots = [],
checks = [];
page.on('pageerror', (error) => errors.push(error.message));
const sources = { pve: emptyPVE, pbs: {} };
let retryGate;
await page.route('**/*', async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5298') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push({ path: url.pathname, method: request.method() });
const key =
url.pathname === '/api/backups/pbs'
? 'pbs'
: url.pathname === '/api/backups/pve'
? 'pve'
: null;
const payload = key ? sources[key] : { data: [], policy: {}, meta: {} };
if (key === 'pbs' && retryGate) await retryGate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(payload),
});
});
const rows = () => page.locator('[data-proxmox-backup-row="recoverable"]');
const notice = (source) =>
page.getByRole('status').filter({
hasText: `${source} backup inventory is unavailable`,
});
const load = async () => {
await page.goto('http://127.0.0.1:5298/browser-tests/backup-inventory.html?view=date', {
waitUntil: 'domcontentloaded',
});
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
};
const capture = async (suffix) => {
await page.evaluate(() =>
Promise.all(
document
.getAnimations()
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
.map((animation) => animation.finished.catch(() => undefined)),
),
);
const file = path.join(output, `${name}-${suffix}.png`);
await page.screenshot({ path: file, fullPage: true });
screenshots.push({ path: path.relative('/workspace', file), sha256: sha256(file) });
};
await load();
if (phase === 'baseline') {
await page.getByText('No backups yet', { exact: true }).waitFor();
assert.equal(await notice('PBS').count(), 0);
checks.push('HTTP200 missing PBS data is incorrectly shown as No backups yet');
} else {
await notice('PBS').waitFor();
await page.getByText('Backup inventory is incomplete', { exact: true }).waitFor();
assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0);
await page.getByText(/The response format is invalid/).waitFor();
if (!phone) await page.locator('[title="PBS backup inventory is unavailable"]').waitFor();
checks.push(
'HTTP200 missing data remains incomplete with a fixed format explanation, not empty/zero',
);
}
await capture('missing-data');
sources.pve = pvePayload;
sources.pbs = { data: { backups: {} }, message: 'SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY' };
await load();
if (phase === 'baseline') {
await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor();
assert.equal(await rows().count(), 0);
checks.push(
'HTTP200 non-array PBS inventory removes independently readable PVE evidence at ErrorBoundary',
);
} else {
await notice('PBS').waitFor();
await rows().first().waitFor();
assert.equal(await rows().count(), 1);
assert.match(await rows().first().innerText(), /PVE file/);
assert.equal(await page.getByRole('alert').count(), 0);
assert.equal(await page.getByText('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY').count(), 0);
checks.push(
'Malformed PBS is source-local; readable PVE remains and raw response content is not displayed',
);
}
await capture('pbs-invalid');
sources.pbs = pbsPayload;
sources.pve = { data: { ...emptyPVE.data, backupTasks: {} } };
await load();
if (phase === 'baseline') {
await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor();
assert.equal(await rows().count(), 0);
checks.push(
'HTTP200 non-array PVE tasks removes independently readable PBS evidence at ErrorBoundary',
);
} else {
await notice('PVE').waitFor();
await rows().first().waitFor();
assert.equal(await rows().count(), 1);
await rows().first().getByText('PBS', { exact: true }).waitFor();
await rows().first().getByText('Snapshot', { exact: true }).waitFor();
assert.equal(await page.getByRole('alert').count(), 0);
checks.push('Malformed PVE is source-local and independent PBS evidence remains usable');
}
await capture('pve-invalid');
if (phase === 'final') {
sources.pve = pvePayload;
sources.pbs = { data: { backups: [{ ...snapshot, inProgress: 'true' }] } };
await load();
await notice('PBS').waitFor();
await rows().first().waitFor();
assert.equal(
await rows().count(),
1,
'a mistyped completion flag must not become recovery evidence',
);
const before = requests.filter((r) => r.path === '/api/backups/pve').length;
sources.pbs = pbsPayload;
let release;
retryGate = new Promise((resolve) => (release = resolve));
const retry = page.getByRole('button', { name: 'Retry PBS inventory', exact: true });
if (phone) await retry.tap();
else {
await retry.focus();
await page.keyboard.press('Enter');
}
assert.equal(await retry.isDisabled(), true);
await notice('PBS').waitFor();
assert.equal(await rows().count(), 1);
release();
await page.waitForFunction(
() => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2,
);
retryGate = null;
assert.equal(await notice('PBS').count(), 0);
assert.equal(requests.filter((r) => r.path === '/api/backups/pve').length, before);
await capture('recovered');
checks.push(
'Mistyped incomplete flag is rejected; warning persists through keyboard/touch isolated retry until valid recovery',
);
sources.pve = { data: { backupTasks: null, storageBackups: null, guestSnapshots: null } };
sources.pbs = emptyPBS;
await load();
await page.getByText('No backups yet', { exact: true }).waitFor();
assert.equal(await page.getByRole('status').filter({ hasText: 'inventory' }).count(), 0);
checks.push('Explicit Go nil PVE collections remain a valid empty observation');
}
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth + 1,
);
assert.equal(overflow, false);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert(requests.every((r) => r.method === 'GET'));
observations.push({
name,
browserVersion: browser.version(),
viewport,
checks,
requests,
errors,
offOrigin,
overflow,
screenshots,
});
await browser.close();
browser = null;
}
const runtime = [
'src/features/proxmox/ProxmoxBackupsTable.tsx',
'src/features/proxmox/proxmoxBackupInventory.ts',
].filter((p) => fs.existsSync(path.join(root, p)));
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
phase,
playwrightVersion,
runtimeSha256: Object.fromEntries(runtime.map((p) => [p, sha256(path.join(root, p))])),
observations,
},
null,
2,
) + '\n',
);
console.log(`${phase}: ${observations.length} browser contexts passed; receipts at ${output}`);
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,318 @@
// Production backup page, router, API adapters and CSS; synthetic HTTP only.
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 sha256 = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/backup-inventory-proof';
const emptyPVE = { data: { storageBackups: [], guestSnapshots: [], backupTasks: [] } };
const emptyPBS = { data: { backups: [] } };
const pvePayload = {
data: {
...emptyPVE.data,
storageBackups: [
{
id: 'archive-112',
instance: 'pve-a',
node: 'pve-a',
storage: 'local',
type: 'ct',
vmid: 112,
time: '2026-10-03T01:00:00Z',
format: 'zst',
volid: 'local:backup/vzdump-lxc-112.tar.zst',
},
],
},
};
const pbsPayload = {
data: {
backups: [
{
id: 'pbs-main/main/ct/112/2026-10-03T02:00:00Z',
instance: 'pbs-main',
datastore: 'main',
backupType: 'ct',
vmid: '112',
backupTime: '2026-10-03T02:00:00Z',
files: ['index.json.blob'],
},
],
},
};
const reply = (body, status = 200) => ({ body, status });
const held = (body, status = 200) => {
let release;
const gate = new Promise((resolve) => (release = resolve));
return { ...reply(body, status), gate, release };
};
(async () => {
fs.mkdirSync(output, { recursive: true });
process.chdir(root);
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5297, strictPort: true },
});
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [
['chromium-desktop', chromium, false],
['webkit-phone', webkit, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const viewport = phone ? { width: 390, height: 844 } : { width: 1365, height: 900 };
const page = await browser.newPage({
viewport,
isMobile: phone,
hasTouch: phone,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(20000);
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
const errors = [],
requests = [],
offOrigin = [],
screenshots = [],
checks = [];
page.on('pageerror', (error) => errors.push(error.message));
const missing = held({}, 503);
const sources = { pve: reply(emptyPVE), pbs: missing };
await page.route('**/*', async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5297') {
offOrigin.push(url.origin);
return route.abort();
}
// Do not intercept Vite's source modules under /src/api/.
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push({
path: url.pathname,
method: request.method(),
org: request.headers()['x-pulse-org-id'] ?? null,
});
const key =
url.pathname === '/api/backups/pbs'
? 'pbs'
: url.pathname === '/api/backups/pve'
? 'pve'
: null;
const selected = key ? sources[key] : reply({ data: [], policy: {}, meta: {} });
if (selected.gate) await selected.gate;
await route.fulfill({
status: selected.status,
contentType: 'application/json',
body: JSON.stringify(selected.body),
});
});
const rows = () => page.locator('[data-proxmox-backup-row="recoverable"]');
const notice = (source, status) =>
page.getByRole('status').filter({ hasText: `${source} backup inventory is ${status}` });
const capture = async (suffix) => {
await page.evaluate(() =>
Promise.all(
document
.getAnimations()
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
.map((animation) => animation.finished.catch(() => undefined)),
),
);
const file = path.join(output, `${name}-${suffix}.png`);
await page.screenshot({ path: file, fullPage: true });
screenshots.push({ path: path.relative('/workspace', file), sha256: sha256(file) });
};
const activate = async (button) => {
if (phone) await button.tap();
else {
await button.focus();
await page.keyboard.press('Enter');
}
};
const switchOrg = (org) => page.evaluate((value) => window.__switchBackupOrg(value), org);
await page.goto('http://127.0.0.1:5297/browser-tests/backup-inventory.html?view=date', {
waitUntil: 'domcontentloaded',
});
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
await notice('PBS', 'loading').waitFor();
assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0);
await page.getByText('Backup inventory is incomplete', { exact: true }).waitFor();
if (!phone) await page.locator('[title="PBS backup inventory is loading"]').waitFor();
await capture('pending');
missing.release();
await notice('PBS', 'unavailable').waitFor();
assert.equal(await page.getByRole('alert').count(), 0);
const style = await notice('PBS', 'unavailable').evaluate((node) => {
const rect = node.getBoundingClientRect();
return {
color: getComputedStyle(node).color,
background: getComputedStyle(node).backgroundColor,
width: rect.width,
scrollWidth: node.scrollWidth,
clientWidth: node.clientWidth,
};
});
assert.equal(style.scrollWidth <= style.clientWidth + 1, true);
assert(
!style.color.startsWith('rgba') || style.color.endsWith(', 1)'),
'warning text must be opaque',
);
const retry = page.getByRole('button', { name: 'Retry PBS inventory', exact: true });
const recovering = held(pbsPayload);
sources.pbs = recovering;
await activate(retry);
assert.equal(await retry.isDisabled(), true);
await notice('PBS', 'unavailable').waitFor();
await capture('retrying');
recovering.release();
await rows().first().waitFor();
assert.equal(await rows().count(), 1);
assert.equal(await notice('PBS', 'unavailable').count(), 0);
assert.equal(
requests.filter((r) => r.path === '/api/backups/pve').length,
1,
'isolated PBS retry does not reread PVE',
);
await capture('recovered');
checks.push(
'PBS loading never reads empty/zero; 503 remains contained, warning persists through disabled keyboard/touch retry, and success restores its point',
);
const newPVE = held(pvePayload);
sources.pve = newPVE;
sources.pbs = reply({}, 403);
await switchOrg('fixture-other-org');
await notice('PBS', 'unavailable').waitFor();
assert.equal(await rows().count(), 0, 'org switch withdraws previous inventory');
assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0);
await page.getByText(/Access denied\. Check your permissions/).waitFor();
newPVE.release();
await rows().first().waitFor();
assert.equal(await rows().count(), 1);
assert.match(await rows().first().innerText(), /PVE file/);
if (!phone) await page.locator('[title="PBS backup inventory is unavailable"]').waitFor();
const pveRow = await rows().first().elementHandle();
sources.pbs = held(pbsPayload);
await activate(page.getByRole('button', { name: 'Retry PBS inventory', exact: true }));
await notice('PBS', 'unavailable').waitFor();
assert.equal(
await page.evaluate(
(node) => node === document.querySelector('[data-proxmox-backup-row="recoverable"]'),
pveRow,
),
true,
'isolated retry preserves readable row DOM',
);
sources.pbs.release();
await page.waitForFunction(
() => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2,
);
checks.push(
'403 removes old PBS points/counts while PVE evidence survives; only success restores PBS, without remounting the independent row',
);
sources.pve = reply({}, 500);
sources.pbs = reply({}, 503);
await switchOrg('fixture-final-org');
await page.getByText('Could not load Proxmox backup inventory', { exact: true }).waitFor();
assert.equal(await rows().count(), 0);
const finalPVE = held(pvePayload);
sources.pve = finalPVE;
sources.pbs = reply(pbsPayload);
await activate(page.getByRole('button', { name: 'Refresh', exact: true }));
await notice('PVE', 'unavailable').waitFor();
assert.equal(await rows().count(), 1, 'PBS recovery is independent of a pending PVE retry');
finalPVE.release();
await page.waitForFunction(
() => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2,
);
assert.equal(
await page.getByRole('status').filter({ hasText: 'Counts are incomplete' }).count(),
0,
);
await activate(page.getByRole('link', { name: 'Coverage', exact: true }));
await page.locator('[data-proxmox-backups-table="coverage"]').waitFor();
assert.equal(
await page
.getByRole('link', { name: 'Coverage', exact: true })
.getAttribute('aria-current'),
'page',
);
await capture('coverage');
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1),
true,
);
assert.equal(await page.evaluate(() => window.innerWidth), viewport.width);
for (const org of ['fixture-other-org', 'fixture-final-org']) {
assert(requests.some((r) => r.path === '/api/backups/pbs' && r.org === org));
assert(requests.some((r) => r.path === '/api/backups/pve' && r.org === org));
}
assert(requests.every((r) => r.method === 'GET'));
assert.deepEqual(offOrigin, []);
assert.deepEqual(errors, []);
checks.push(
'both failed inventories retry, first recovered source renders immediately, healthy Coverage navigation remains usable, GET-only new organisation headers and no page overflow/errors',
);
observations.push({
name,
viewport,
browser_version: browser.version(),
style,
requests,
checks,
screenshots,
});
await page.close();
await browser.close();
browser = null;
}
const paths = [
'frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx',
'frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx',
];
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
playwright_version: playwrightVersion,
content_sha256: Object.fromEntries(
paths.map((p) => [p, sha256(path.join('/workspace', p))]),
),
observations,
limits: [
'Synthetic responses and organisation contexts, not native permissions, PBS/PVE monitoring, guest thaw, restore verification or release acceptance.',
'Browser uses production components and stylesheet through offline Vite; exact production/embed build is separately source-proved.',
],
},
null,
2,
),
);
console.log(
'PASS: independent backup read states, source retry, 403/org withdrawal, recovered Coverage and desktop/phone warning layout.',
);
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(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>Backup inventory read verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./backup-inventory.tsx"></script>
</body>
</html>
@@ -0,0 +1,64 @@
// Real router/table/API reader/styles; all HTTP evidence is synthetic.
import { ErrorBoundary, onCleanup, onMount } from 'solid-js';
import { render } from 'solid-js/web';
import { Route, Router } from '@solidjs/router';
import { ProxmoxBackupsTable } from '../src/features/proxmox/ProxmoxBackupsTable';
import { eventBus } from '../src/stores/events';
import { setOrgID } from '../src/utils/apiClient';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
const guest = {
id: 'ct-112',
type: 'system-container',
name: 'backup-guest',
platformId: 'pve-a',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'running',
lastSeen: Date.parse('2026-10-03T12:00:00Z'),
proxmox: { vmid: 112, node: 'pve-a', instance: 'pve-a' },
} as Resource;
const server = {
id: 'pbs-main',
type: 'pbs',
name: 'pbs-main',
platformId: 'pbs-main',
platformType: 'proxmox-pbs',
sourceType: 'api',
status: 'online',
lastSeen: Date.parse('2026-10-03T12:00:00Z'),
pbs: {
instanceId: 'pbs-main',
connectionHealth: 'healthy',
datastores: [{ name: 'main', total: 10_000, used: 4_000, available: 6_000 }],
},
} as Resource;
function Fixture() {
onMount(() => {
Object.assign(window, {
__switchBackupOrg: (orgID: string) => {
setOrgID(orgID);
eventBus.emit('org_switched', orgID);
},
});
onCleanup(() => Reflect.deleteProperty(window, '__switchBackupOrg'));
});
return (
<main class="p-3 sm:p-6">
<ErrorBoundary fallback={<div role="alert">Backup view failed</div>}>
<ProxmoxBackupsTable emptyIcon={<span />} workloads={[guest]} servers={[server]} />
</ErrorBoundary>
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -2,6 +2,7 @@
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,333 @@
// Production orchestration/table/CSS with synthetic read-only HTTP evidence.
// Confirms combined Tailwind rendering and source attribution, not native backup recovery.
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');
(async () => {
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/composed-backups-proof';
fs.mkdirSync(output, { recursive: true });
process.chdir(root);
const playwright = require('playwright/package.json').version;
assert.equal(
playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
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: 5295, strictPort: true },
});
const runtime = [
'frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx',
'frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx',
];
const content_sha256 = Object.fromEntries(
runtime.map((p) => [
p,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', p)))
.digest('hex'),
]),
);
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [
['chromium-desktop', chromium, false],
['webkit-phone', webkit, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 },
hasTouch: phone,
isMobile: phone,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(20000);
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
const errors = [],
requests = [],
offOrigin = [],
checks = [],
screenshots = [];
const now = '2026-10-03T12:00:00Z';
const workload = {
id: 'ct-310',
type: 'system-container',
name: 'artifact-cache-310',
displayName: 'artifact-cache-310',
platformId: 'fixture-estate',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'running',
lastSeen: now,
proxmox: { vmid: 310, node: 'pve-edge', instance: 'homelab' },
};
const archive = {
id: 'file-310',
storage: 'local',
node: 'pve-edge',
instance: 'homelab',
type: 'ct',
vmid: 310,
time: '2026-09-20T02:00:00Z',
ctime: Date.parse('2026-09-20T02:00:00Z') / 1000,
size: 1048576,
format: 'tar.zst',
protected: false,
volid: 'local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst',
isPBS: false,
verified: false,
};
const snapshot = {
id: 'snap-310',
name: 'fresh-local-snapshot',
node: 'pve-edge',
instance: 'homelab',
type: 'ct',
vmid: 310,
time: '2026-10-03T11:30:00Z',
vmstate: false,
};
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', (route) => {
const request = route.request(),
url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5295') {
offOrigin.push(url.origin);
return route.abort();
}
if (url.pathname === '/fixture/workloads') return route.fulfill({ json: [workload] });
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push({ path: url.pathname, method: request.method() });
assert.equal(request.method(), 'GET');
if (url.pathname === '/api/backups/pve')
return route.fulfill({
json: {
data: { backupTasks: [], storageBackups: [archive], guestSnapshots: [snapshot] },
},
});
if (url.pathname === '/api/backups/pbs')
return route.fulfill({ json: { data: { backups: [] } } });
if (url.pathname === '/api/recovery/postures')
return route.fulfill({
json: {
data: [],
policy: {
freshnessWindowSeconds: 0,
verificationWindowSeconds: 0,
requireVerification: false,
},
meta: { page: 1, limit: 200, total: 0, totalPages: 0 },
},
});
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: { data: [], enabled: false },
});
});
try {
await page.goto('http://127.0.0.1:5295/browser-tests/backups.html', {
waitUntil: 'domcontentloaded',
timeout: 120000,
});
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
const capture = async (state) => {
await page.waitForTimeout(100);
const dimensions = await page.evaluate(() => ({
width: innerWidth,
scroll: document.documentElement.scrollWidth,
}));
assert.equal(
dimensions.width,
phone ? 390 : 1365,
'actual CSS viewport, not only Playwright emulation',
);
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
const file = `${name}-${state}.png`;
await page.screenshot({ path: path.join(output, file), fullPage: true });
screenshots.push({ file, dimensions });
};
await page.getByRole('link', { name: 'By date', exact: true }).click();
const byDate = page.locator('[data-proxmox-backups-table="recoverable"]');
await byDate.getByText('artifact-cache-310', { exact: true }).first().waitFor();
await capture('by-date-diagnostic');
fs.writeFileSync(
path.join(output, `${name}-by-date-diagnostic.json`),
JSON.stringify({ text: await byDate.innerText(), requests, errors }, null, 2),
);
if (!phone) {
await byDate.getByText('tar.zst', { exact: true }).waitFor();
assert.ok(await byDate.locator(`[title="${archive.volid}"]`).count());
}
checks.push(
'Explicit by-date navigation retains source, archive format and complete wide identifier',
);
await capture('by-date');
await page.getByRole('link', { name: 'Coverage', exact: true }).click();
const coverage = page.locator('[data-proxmox-backups-table="coverage"]');
await coverage.getByText('artifact-cache-310', { exact: true }).waitFor();
const columns = await coverage
.locator('colgroup col')
.evaluateAll((elements) => elements.map((e) => e.dataset.proxmoxBackupsColumn));
const row = coverage.locator('tr[data-proxmox-backup-row="coverage"]').first();
const backupAge = await row.locator('td').nth(columns.indexOf('latest')).innerText();
assert.ok(backupAge.includes('13d'), backupAge);
assert.ok(!backupAge.includes('30m'), backupAge);
if (columns.includes('snapshot'))
assert.ok(
(await row.locator('td').nth(columns.indexOf('snapshot')).innerText()).includes('30m'),
);
checks.push('Fresh local guest snapshot does not masquerade as a recent backup');
await capture('coverage');
const search = page.getByPlaceholder('Search backups by workload, node, source, or status');
await search.fill('non-existent-fixture');
await page
.getByText('No workload coverage rows match current filters', { exact: true })
.waitFor();
await search.fill('artifact-cache');
await coverage.getByText('artifact-cache-310', { exact: true }).waitFor();
checks.push('Coverage route query filters and recovery preserve the target');
if (phone && !(await page.getByRole('combobox', { name: 'Filter', exact: true }).count()))
await page.getByRole('button', { name: /^Filters/ }).click();
await page
.getByRole('combobox', { name: 'Filter', exact: true })
.selectOption({ label: 'Backup location: homelab / pve-edge' });
await page.waitForFunction(
() => new URL(location.href).searchParams.get('location') === 'snapshot:homelab:pve-edge',
);
const scopedColumns = await coverage
.locator('colgroup col')
.evaluateAll((elements) => elements.map((e) => e.dataset.proxmoxBackupsColumn));
const scopedAge = row.locator('td').nth(scopedColumns.indexOf('latest'));
await scopedAge.getByText('None', { exact: true }).waitFor();
assert.ok(
(await scopedAge.locator('span[title]').getAttribute('title')).includes(
'Guest snapshots alone do not prove independent recovery.',
),
);
checks.push(
'Snapshot-only location scope reports no independent backup, not a fresh restore promise',
);
await capture('snapshot-only-location');
if (
phone &&
!(await page
.getByRole('button', { name: 'Remove Backup location filter', exact: true })
.count())
)
await page.getByRole('button', { name: /^Filters/ }).click();
await page
.getByRole('button', { name: 'Remove Backup location filter', exact: true })
.click();
await page.waitForFunction(() => !new URL(location.href).searchParams.has('location'));
await row
.locator('td')
.nth(columns.indexOf('latest'))
.getByText(phone ? '13d' : '13d ago', { exact: true })
.waitFor();
checks.push('Removing location scope restores the independently dated archive');
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert.ok(
requests.some((r) => r.path === '/api/backups/pve') &&
requests.some((r) => r.path === '/api/backups/pbs'),
);
observations.push({
name,
browser: browser.version(),
viewport: page.viewportSize(),
checks,
requests,
errors,
offOrigin,
screenshots,
});
} catch (error) {
fs.writeFileSync(
path.join(output, `${name}-failure.json`),
JSON.stringify(
{
error: error.message,
text: await page.locator('body').innerText(),
url: page.url(),
requests,
errors,
offOrigin,
},
null,
2,
),
);
await page.screenshot({ path: path.join(output, `${name}-failure.png`), fullPage: true });
throw error;
}
await browser.close();
browser = null;
}
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
passed: true,
playwright,
content_sha256,
harness_sha256: crypto
.createHash('sha256')
.update(fs.readFileSync(__filename))
.digest('hex'),
fixture_sha256: Object.fromEntries(
['backups.html', 'backups.tsx'].map((file) => [
file,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join(root, 'browser-tests', file)))
.digest('hex'),
]),
),
observations,
limitation:
'Synthetic production component rendering, not native backup, QGA/thaw, installation or shipment.',
},
null,
2,
),
);
console.log(
JSON.stringify({
passed: true,
browsers: observations.length,
checks: observations.reduce((n, o) => n + o.checks.length, 0),
}),
);
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,313 @@
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/composed-disk-labels-settled';
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
const runtime = [
'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
'frontend-modern/src/components/Workloads/useStackedDiskBarState.ts',
'frontend-modern/src/components/Workloads/GuestRow.tsx',
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
'frontend-modern/src/components/Workloads/DiskList.tsx',
'frontend-modern/src/utils/workloadGuestPresentation.ts',
'frontend-modern/src/utils/format.ts',
'frontend-modern/src/index.css',
];
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(
runtime.map((file) => [file, sha256(fs.readFileSync(path.join('/workspace', file)))]),
);
assert.equal(
hashes[runtime[0]],
'9f50f630df8379673032052970e7cc6d287c02ecd212b3df5d16f1032be42413',
);
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: 5301, strictPort: true },
});
const results = [],
screenshots = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['chromium-desktop', chromium, 1365, false],
['webkit-phone', webkit, 390, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const context = await browser.newContext({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
const page = await context.newPage();
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
const errors = [],
requests = [],
checks = [],
fits = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('request', (request) =>
requests.push({
method: request.method(),
pathname: new URL(request.url()).pathname,
origin: new URL(request.url()).origin,
}),
);
await page.route('http://127.0.0.1:5301/api/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: [],
alerts: [],
anomalies: [],
config: null,
}),
}),
);
await page.addInitScript((value) => {
document.addEventListener('DOMContentLoaded', () => {
if (value) document.documentElement.classList.add('dark');
});
}, dark);
const capture = async (state) => {
const file = path.join(output, `${name}-${state}.png`);
await page.screenshot({ path: file, fullPage: true });
screenshots.push({
path: path.relative('/workspace', file),
sha256: sha256(fs.readFileSync(file)),
});
};
const settle = () =>
page.evaluate(
() =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
);
await page.goto('http://127.0.0.1:5301/browser-tests/composed-disk-labels.html');
await page.waitForFunction(() => window.__composedDiskLabels);
await settle();
const checkFit = async (expectedWidth, roomy) => {
await page.waitForFunction(
(value) =>
Math.abs(
document.querySelector('[data-fit="single"]').getBoundingClientRect().width - value,
) < 0.5,
expectedWidth,
);
await settle();
const state = await page.evaluate(() => {
const boxes = [...document.querySelectorAll('[data-fit]')].map((box) => {
const id = box.getAttribute('data-fit');
const labels = [...box.querySelectorAll('span.whitespace-nowrap')];
return {
id,
width: box.getBoundingClientRect().width,
text: box.innerText,
labels: labels.map((label) => ({
text: label.textContent,
scroll: label.scrollWidth,
client: label.clientWidth,
})),
sublabels: box.querySelectorAll('.metric-sublabel').length,
};
});
return { boxes, presentation: window.__composedDiskLabels.presentation() };
});
for (const box of state.boxes) {
for (const label of box.labels)
assert(
label.scroll <= label.client + 1,
`${name} ${box.id} ${expectedWidth}: rendered label must fit, not be ellipsised`,
);
}
for (const id of ['single', 'anomaly', 'aggregate']) {
const box = state.boxes.find((item) => item.id === id);
assert.equal(box.sublabels, roomy ? 1 : 0, `${id}: optional detail matches actual width`);
}
assert.equal(state.presentation.single.displayLabel, '60%');
assert.equal(state.presentation.anomaly.anomalyRatio, '2.5x');
assert.equal(state.presentation.aggregate.showDiskCount, true);
assert.equal(
await page.locator('[data-fit="vertical"] [data-stacked-disk-max-label]').innerText(),
'67%',
);
assert.equal(
await page.locator('[data-fit="vertical"] [data-stacked-disk-fill="vertical"]').count(),
2,
);
fits.push({ width: expectedWidth, boxes: state.boxes });
};
await checkFit(100, false);
const compactInline = await page.locator('[data-fit="inline"]').innerText();
assert(!compactInline.includes('MMMM'));
assert(compactInline.includes('iiii 67%'));
await capture('compact');
const roomyButton = page.getByRole('button', { name: 'Use roomy bars', exact: true });
if (engine === chromium) {
await roomyButton.focus();
await page.keyboard.press('Enter');
} else await roomyButton.tap();
await checkFit(320, true);
assert((await page.locator('[data-fit="inline"]').innerText()).includes('MMMM 50%'));
assert((await page.locator('[data-fit="inline"]').innerText()).includes('iiii 67%'));
await capture('roomy');
checks.push(
'ResizeObserver drops optional detail at 100px and shows complete un-clipped single/anomaly/max/count and inline labels at 320px; vertical max remains truthful',
);
if (engine === chromium) {
await page.locator('[data-fit="single"] [data-stacked-disk-trigger]').hover();
const tip = page.locator('[data-tooltip-portal="true"]');
await tip.waitFor({ state: 'visible' });
assert((await tip.innerText()).includes('Guest reads paused while a backup is running.'));
assert((await tip.innerText()).includes('/data'));
await page.locator('h1').hover();
await page.getByRole('button', { name: 'Mark observation fresh' }).click();
await page.locator('[data-fit="single"] [data-stacked-disk-trigger]').hover();
await tip.waitFor({ state: 'visible' });
assert(!(await tip.innerText()).includes('Guest reads paused'));
assert((await tip.innerText()).includes('/data'));
await page.locator('h1').hover();
checks.push(
'Caller-owned paused-read tooltip survives measured model; fresh same-disk update removes only status',
);
}
// Exercise the existing production guest callers, but only the combined
// stale-to-fresh boundary, not an unchanged eight-reason acceptance run.
await page.goto('http://127.0.0.1:5301/browser-tests/guest-disk-deferral.html');
await page.waitForFunction(() => window.__guestDiskEvidence);
const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]');
const cell = row.locator('[data-workload-col="disk"]');
if (engine === chromium) await row.click();
else
// WebKit's trusted tap emitted no click on this production row in the
// separately retained input diagnostic. Check rendering via a mouse
// click, without asserting touch acceptance or masking that failure.
await row
.locator('[data-workload-col="name"]')
.getByText('backup-guest', { exact: true })
.click();
const drawer = page.getByRole('region', { name: 'Guest Overview' });
await drawer.waitFor({ state: 'visible' });
const paused = await cell.getAttribute('title');
assert(paused.startsWith('Using last known disk stats. '));
assert(paused.includes('VM operation lock, such as a backup.'));
assert((await drawer.innerText()).includes(paused));
assert(
(await page.getByRole('region', { name: 'Guest disk list' }).innerText()).includes(paused),
);
assert((await drawer.innerText()).includes('/data'));
if (engine === chromium) {
await page.locator('h1').hover();
await cell.locator('[data-stacked-disk-trigger]').hover();
await page.locator('[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
assert((await page.locator('[data-tooltip-portal="true"]').innerText()).includes(paused));
await page.locator('h1').hover();
}
await capture('guest-paused');
await page.evaluate(() => {
window.__composedOriginalRow = document.querySelector(
'[data-guest-id="fixture-pve:pve-a:101"]',
);
window.__guestDiskEvidence.update({ reason: '', data: true, usage: 75 });
});
await page.waitForFunction(
() => !document.querySelector('[data-workload-col="disk"]').hasAttribute('title'),
);
assert(!(await drawer.innerText()).includes('Using last known disk stats.'));
assert.equal(await drawer.getByRole('progressbar').getAttribute('aria-valuenow'), '75');
assert.equal(
await page.evaluate(
() =>
window.__composedOriginalRow ===
document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]'),
),
true,
);
// AnimatedNumber must finish; do not capture an intermediate old percent
// beside the new capacity and claim that it is a settled observation.
await page.waitForFunction(() =>
document.querySelector('[data-workload-col="disk"]').textContent.includes('75%'),
);
await capture('guest-fresh');
checks.push(
'Production GuestRow, Overview and disk list retain paused provenance; same-VM resumption clears notice and updates to 75% without remount',
);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
true,
);
assert.deepEqual(errors, []);
assert(
requests.every(
(request) => request.origin === 'http://127.0.0.1:5301' && request.method === 'GET',
),
);
results.push({
name,
browserVersion: browser.version(),
viewport: { width, height: width === 390 ? 844 : 900 },
dark,
checks,
fits,
errors,
requestCount: requests.length,
});
await context.close();
await browser.close();
browser = null;
}
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
result: 'passed',
verifiedAt: new Date().toISOString(),
playwrightVersion,
runtimeHashes: hashes,
results,
screenshots,
limitations:
'Synthetic component/CSS/browser composition only; not native QGA, thaw, appliance acceptance, installation, release qualification or availability.',
},
null,
2,
),
);
process.stdout.write(
`COMPOSED_DISK_LABELS_PASSED ${results.length} browsers / ${screenshots.length} screenshots\n`,
);
} finally {
if (browser) await browser.close();
await server.close();
}
})().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>Composed disk label verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./composed-disk-labels.tsx"></script>
</body>
</html>
@@ -0,0 +1,93 @@
// Production disk model, shell, ResizeObserver and CSS; synthetic observations.
// This fixture verifies composition, not collection, thaw or native recovery.
import { createSignal, For } from 'solid-js';
import { render } from 'solid-js/web';
import { StackedDiskBar } from '../src/components/Workloads/StackedDiskBar';
import {
buildStackedDiskBarPresentation,
type StackedDiskBarProps,
} from '../src/components/Workloads/stackedDiskBarModel';
import type { Disk } from '../src/types/api';
import type { AnomalyReport } from '../src/types/aiIntelligence';
import '../src/index.css';
const GiB = 1024 ** 3;
const disk = (mountpoint: string, total: number, used: number): Disk => ({
mountpoint,
total: total * GiB,
used: used * GiB,
free: (total - used) * GiB,
usage: (used / total) * 100,
type: 'ext4',
});
const single = [disk('/data', 440, 265)];
const multiple = [disk('/MMMM', 180, 90), disk('/iiii', 260, 175)];
const anomaly: AnomalyReport = {
resource_id: 'fixture-pve:pve-a:101',
resource_name: 'backup-guest',
resource_type: 'vm',
metric: 'disk',
current_value: 60,
baseline_mean: 24,
baseline_std_dev: 4,
z_score: 9,
severity: 'high',
description: 'Synthetic disk anomaly',
};
const cases: { id: string; label: string; props: StackedDiskBarProps }[] = [
{ id: 'single', label: 'Single filesystem', props: { disks: single } },
{ id: 'anomaly', label: 'Filesystem with anomaly', props: { disks: single, anomaly } },
{
id: 'aggregate',
label: 'Aggregate with fullest disk and count',
props: { disks: multiple, mode: 'aggregate', showDiskCount: true },
},
{ id: 'inline', label: 'Equal slots, different glyph widths', props: { disks: multiple } },
{
id: 'vertical',
label: 'Host micro-bars and fullest disk',
props: { disks: multiple, mode: 'vertical-bars' },
},
];
function Fixture() {
const [width, setWidth] = createSignal(100);
const [status, setStatus] = createSignal(
'Using last known disk stats. Guest reads paused while a backup is running.',
);
(window as any).__composedDiskLabels = {
width: setWidth,
presentation: () =>
Object.fromEntries(
cases.map((item) => [item.id, buildStackedDiskBarPresentation(item.props, width())]),
),
};
return (
<main class="min-h-screen space-y-5 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Composed disk evidence</h1>
<div class="flex flex-wrap gap-2">
<button class="rounded-sm border border-border p-2" onClick={() => setWidth(100)}>
Use compact bars
</button>
<button class="rounded-sm border border-border p-2" onClick={() => setWidth(320)}>
Use roomy bars
</button>
<button class="rounded-sm border border-border p-2" onClick={() => setStatus('')}>
Mark observation fresh
</button>
</div>
<For each={cases}>
{(item) => (
<section aria-label={item.label} class="space-y-2">
<h2 class="text-sm font-medium">{item.label}</h2>
<div data-fit={item.id} style={{ width: `${width()}px` }}>
<StackedDiskBar {...item.props} statusMessage={status()} />
</div>
</section>
)}
</For>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -0,0 +1,151 @@
// Run from the assigned root with pulse-worker-browser. This checks the real
// dialog/flow and shipped guides, not a native migration or release delivery.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const workspace = process.cwd();
const root = path.join(workspace, 'frontend-modern');
const output = path.join(workspace, 'tmp', 'configuration-migration');
fs.mkdirSync(output, { recursive: true });
const binding = JSON.parse(fs.readFileSync(
path.join(workspace, 'tmp', 'configuration-migration-binding.json'), 'utf8'));
assert.match(binding.sourceSha, /^[0-9a-f]{40}$/);
for (const [file, digest] of Object.entries(binding.contentSha256))
assert.equal(createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex'), digest, file);
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'),
plugins: [{ name: 'migration-doc-harness', configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (/^\/docs\/[^?]+$/.test(req.url || '') && !req.url.endsWith('.md'))
req.url = '/browser-tests/configuration-migration.html';
next();
});
} }],
server: { host: '127.0.0.1', port: 5264, strictPort: true },
});
const observations = [];
let browser;
try {
await server.listen();
for (const [engine, width, height] of [['chromium', 1440, 1000], ['webkit', 390, 844]]) {
browser = await (engine === 'chromium' ? chromium : webkit).launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const context = await browser.newContext({ viewport: { width, height }, acceptDownloads: true,
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [], operations = [];
await context.route('**/api/**', async (route) => {
const req = route.request();
if (req.url().endsWith('/api/config/export')) {
operations.push({ path: '/api/config/export', method: req.method(), body: req.postDataJSON() });
await route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ status: 'success', data: 'synthetic-archive-not-a-real-backup' }) });
} else if (req.url().endsWith('/api/config/import')) {
operations.push({ path: '/api/config/import', method: req.method(), body: req.postDataJSON() });
// Deliberately reject this synthetic archive: warning, error and
// cancel paths must remain available without pretending to migrate.
await route.fulfill({ status: 400, contentType: 'text/plain', body: 'Synthetic rejected archive' });
} else await route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ hasAuthentication: false }) });
});
context.on('page', (p) => p.on('pageerror', (err) => errors.push(err.message)));
const page = await context.newPage();
await page.goto('http://127.0.0.1:5264/browser-tests/configuration-migration.html');
if (engine === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
await page.getByRole('button', { name: 'Create Backup', exact: true }).click();
const exporting = page.getByRole('dialog', { name: 'Export configuration', exact: true });
await exporting.waitFor();
await exporting.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)));
let text = await exporting.innerText();
for (const phrase of ['Configuration only:', 'SSO settings', 'API-token records',
'not history, TrueNAS/vSphere connections', 'Local login credentials and sessions are not included'])
assert.ok(text.includes(phrase), phrase);
assert.ok(await exporting.getByRole('button', { name: 'Export', exact: true }).isDisabled());
const guide = exporting.getByRole('link', { name: 'migration guide', exact: true });
await guide.scrollIntoViewIfNeeded();
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
const warning = exporting.locator('strong', { hasText: 'Configuration only:' }).locator('..');
assert.ok(await warning.isVisible(), 'configuration scope warning is visible');
const iconWidth = await exporting.locator('svg').first().evaluate((el) => el.getBoundingClientRect().width);
assert.ok(iconWidth >= 15, 'Tailwind4 warning icon does not shrink');
await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-export.png`) });
await guide.focus();
const popupPromise = page.waitForEvent('popup');
await page.keyboard.press('Enter');
const docs = await popupPromise;
await docs.locator('article').waitFor();
const article = await docs.locator('article').innerText();
for (const phrase of ['not a full backup', 'TrueNAS, vSphere and Machine Availability',
'SSO configuration is included', 'Stop Pulse', 'does not merge', 'reload/apply failure'])
assert.ok(article.includes(phrase), phrase);
assert.ok(docs.url().endsWith('/docs/MIGRATION'));
const scope = docs.locator('#configuration-transfer');
await scope.scrollIntoViewIfNeeded();
assert.ok(await docs.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await docs.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-scope.png`) });
const retarget = docs.getByRole('link', { name: 'agent retargeting guide', exact: true });
await retarget.focus();
await docs.keyboard.press('Enter');
await docs.waitForFunction(() => document.activeElement?.id === 'moving-pulse-to-a-new-address');
assert.ok((await docs.locator('article').innerText()).includes('restores API-token records, not the server-side'));
assert.ok(docs.url().endsWith('/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address'));
await docs.close();
await exporting.getByLabel('Encryption Passphrase', { exact: true }).fill('synthetic-migration-passphrase');
const downloadPromise = page.waitForEvent('download');
await exporting.getByRole('button', { name: 'Export', exact: true }).click();
const download = await downloadPromise;
assert.deepEqual(JSON.parse(fs.readFileSync(await download.path(), 'utf8')),
{ status: 'success', data: 'synthetic-archive-not-a-real-backup' });
await page.getByRole('button', { name: 'Restore Configuration', exact: true }).click();
const importing = page.getByRole('dialog', { name: 'Import configuration', exact: true });
await importing.waitFor();
await importing.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)));
text = await importing.innerText();
for (const phrase of ['Back up the destination first', 'not the whole installation',
'inventory, enrolment state, profiles and assignments are not', 'verify fresh admission'])
assert.ok(text.includes(phrase), phrase);
assert.ok(!text.includes('restores server-side agent records'));
await importing.getByRole('link', { name: 'migration guide' }).scrollIntoViewIfNeeded();
await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-import.png`) });
await importing.getByLabel('Backup Password', { exact: true }).fill('synthetic-migration-passphrase');
await importing.getByLabel('Configuration File', { exact: true }).setInputFiles({ name: 'synthetic-backup.json',
mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ data: 'synthetic-archive-not-a-real-backup' })) });
const rejectedResponse = page.waitForResponse((r) => r.url().endsWith('/api/config/import'));
await importing.getByRole('button', { name: 'Import', exact: true }).click();
await rejectedResponse;
assert.equal(operations.length, 2);
assert.deepEqual(operations[1], { path: '/api/config/import', method: 'POST',
body: { passphrase: 'synthetic-migration-passphrase', data: 'synthetic-archive-not-a-real-backup' } });
assert.ok(await importing.isVisible(), 'failed import retains the dialog');
await importing.getByRole('button', { name: 'Cancel', exact: true }).click();
await importing.waitFor({ state: 'hidden' });
assert.equal(operations.length, 2, 'copy/navigation/cancel never send another operation');
assert.deepEqual(errors, []);
observations.push({ engine, browserVersion: browser.version(), width, height,
warningScope: true, keyboardGuideAndRetargetLinks: true, syntheticExportAndRejectedImport: true,
noPageOverflow: true, warningIconWidth: iconWidth, errors });
await browser.close();
browser = undefined;
}
const files = ['frontend-modern/src/components/Settings/BackupTransferDialogs.tsx',
'frontend-modern/public/docs/MIGRATION.md', 'frontend-modern/public/docs/UNIFIED_AGENT.md'];
const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version,
sourceSha: binding.sourceSha, baseSha: binding.sourceSha,
dependencySnapshot: binding.dependencySnapshot,
manifestSha256: binding.manifestSha256, verifiedAt: new Date().toISOString(),
scope: 'Real dialog/flow/production Docs rendering with synthetic API; no native migration or shipment',
contentSha256: Object.fromEntries(files.map((file) => [file,
createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex')])), observations };
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((err) => { console.error(err); 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>Configuration transfer safety</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/configuration-migration.tsx"></script>
</body>
</html>
@@ -0,0 +1,32 @@
// Actual transfer dialogs and flow, plus the production Docs renderer/router.
// The browser driver supplies only synthetic API responses; no server or
// operational credentials are involved in this copy/interaction proof.
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { BackupTransferDialogs } from '../src/components/Settings/BackupTransferDialogs';
import { useBackupTransferFlow } from '../src/components/Settings/useBackupTransferFlow';
import Docs from '../src/pages/Docs';
import '../src/index.css';
function Transfer() {
const securityStatus = () => null;
const flow = useBackupTransferFlow({ securityStatus });
return (
<main class="p-6">
<h1>Configuration transfer</h1>
<button onClick={() => flow.setShowExportDialog(true)}>Create Backup</button>
<button onClick={() => flow.setShowImportDialog(true)}>Restore Configuration</button>
<BackupTransferDialogs {...flow} securityStatus={securityStatus} />
</main>
);
}
render(
() => (
<Router>
<Route path="/docs/*docPath" component={Docs} />
<Route path="*" component={Transfer} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,105 @@
// Production Docs/router and real shipped Markdown; no backend or deployment.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules', 'deployment-update-help-browser');
fs.mkdirSync(artifacts, { recursive: true });
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(root, 'node_modules', '.vite-deployment-update-help'),
server: { host: '127.0.0.1', port: 5250, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
browser = await { chromium, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
});
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.goto('http://127.0.0.1:5250/browser-tests/docs-fragment-navigation.html',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.getByRole('link', { name: '← All documentation' }).click();
await page.getByRole('link', { name: 'Deployment models', exact: true }).click();
await page.getByRole('heading', { name: 'Updates by Model', exact: true }).waitFor();
if (tone === 'dark') await page.evaluate(async () => {
document.documentElement.classList.add('dark');
await new Promise((resolve) => requestAnimationFrame(resolve));
await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
const text = await page.locator('article').innerText();
for (const phrase of ['defaults, not a backup inventory', 'SQLite sidecar files',
'snapshot is not necessarily a complete data backup', 'Persist the exact target',
'Paid Pro installs must keep their private image', 'only the Pulse service',
'stopping if the pull fails', 'existing Helm release and namespace',
'current PVC, keys and settings']) assert.ok(text.includes(phrase), phrase);
for (const unsafe of ['docker pull rcourtman/pulse:latest', 'docker compose up -d',
'helm upgrade pulse pulse/pulse', 'swapping binaries/config safely'])
assert.ok(!text.includes(unsafe), unsafe);
await page.getByRole('heading', { name: 'Updates by Model', exact: true })
.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-overview.png`) });
// Keyboard activation must reach the actual maintained procedure/anchor.
const docker = page.getByRole('link', { name: 'Docker server updates', exact: true });
await docker.focus();
await page.keyboard.press('Enter');
await page.getByRole('heading', { name: '🔄 Updates', exact: true }).waitFor();
await page.waitForFunction(() => {
const heading = document.getElementById('-updates');
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/DOCKER#-updates'));
const dockerText = await page.locator('article').innerText();
for (const phrase of ['persist PULSE_IMAGE', 'docker compose pull pulse',
'docker compose up -d --no-deps pulse', 'paid Pro installs must keep the private image'])
assert.ok(dockerText.includes(phrase), phrase);
await page.screenshot({ path: path.join(artifacts, `${engine}-docker.png`) });
await page.getByRole('link', { name: 'rollback and backup scope', exact: true }).click();
await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor();
assert.ok(page.url().endsWith('/docs/AUTO_UPDATE#rollback'));
assert.ok((await page.locator('article').innerText()).includes('Copy errors can leave a partial snapshot'));
await page.goBack();
await page.goBack();
await page.getByRole('heading', { name: 'Updates by Model', exact: true }).waitFor();
await page.getByRole('link', { name: 'Kubernetes deployment settings', exact: true }).click();
await page.waitForFunction(() => location.pathname === '/docs/KUBERNETES' &&
document.querySelector('article')?.textContent.includes('Kubernetes cluster'));
await page.goBack();
await page.getByRole('link', { name: 'Metrics storage', exact: true }).click();
await page.getByRole('heading', { name: 'Storage Location', exact: true }).waitFor();
assert.ok(page.url().endsWith('/docs/METRICS_HISTORY#storage-location'));
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width, tone,
overviewLimits: true, keyboardDockerLink: true, scopedDockerProcedure: true,
rollbackLink: true, kubernetesLink: true, metricsLink: true, errors });
await browser.close();
browser = undefined;
}
const result = { playwright: require('playwright/package.json').version, results };
fs.writeFileSync(path.join(artifacts, 'result.json'), `${JSON.stringify(result, null, 2)}\n`);
process.stdout.write(`${JSON.stringify(result)}\n`);
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { process.stderr.write(`${error.stack}\n`); process.exitCode = 1; });
@@ -0,0 +1,303 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
const parent = false;
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(
root,
'node_modules',
parent ? 'disk-history-parent' : 'disk-history-proof',
);
fs.mkdirSync(artifacts, { recursive: true });
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: fs.mkdtempSync(path.join(artifacts, 'vite-')),
server: { host: '127.0.0.1', port: 5251, strictPort: true },
});
const playwright = require('playwright/package.json').version;
const expected = JSON.parse(
fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8'),
).packages['node_modules/@playwright/test'].version;
assert.equal(playwright, expected);
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [
['chromium-desktop', chromium, false],
['webkit-phone', webkit, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 },
hasTouch: phone,
isMobile: phone,
});
const requests = [],
errors = [],
checks = [];
const observedAt = Date.now() - 60000;
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5251') return route.abort();
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname === '/api/metrics-store/history') {
const query = Object.fromEntries(url.searchParams);
requests.push(query);
const value =
query.metric === 'smart_temp'
? query.resourceId.includes('nas-b')
? 55
: 42
: query.metric === 'smart_percentage_used'
? 12
: query.metric === 'smart_available_spare'
? 97
: 0;
const empty = query.range === '1h' && query.metric === 'smart_temp';
return route.fulfill({
json: {
resourceType: query.resourceType,
resourceId: query.resourceId,
metric: query.metric,
range: query.range,
start: Date.now() - 60000,
end: Date.now(),
source: 'store',
points: empty ? [] : [{ timestamp: observedAt, value, min: value, max: value }],
},
});
}
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: { enabled: false, data: [] },
});
});
const check = async (label, predicate) => {
try {
await predicate();
checks.push({ label, passed: true });
} catch (error) {
checks.push({ label, passed: false, error: error.message });
}
};
const screenshot = async (state) => {
const file = `${name}-${state}.png`;
await page.screenshot({
path: path.join(artifacts, file),
fullPage: true,
animations: 'disabled',
});
observations.push({
browser: name,
state,
screenshot: file,
visibleCharts: await page.locator('canvas[role="img"]').allTextContents(),
selectedTabs: await page
.getByRole('tab')
.evaluateAll((tabs) =>
tabs.map((tab) => ({
label: tab.textContent,
selected: tab.getAttribute('aria-selected'),
border: getComputedStyle(tab).borderBottomColor,
})),
),
overflow: await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
),
body: await page.locator('main').innerText(),
});
};
const thermal = () => page.getByRole('img', { name: 'Temperature chart' });
const description = async () =>
thermal().evaluate(
(el) => document.getElementById(el.getAttribute('aria-describedby')).textContent,
);
await page.goto('http://127.0.0.1:5251/browser-tests/disk-history-collection.html');
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
await page.getByRole('tab', { name: 'History' }).click();
await page.waitForTimeout(300);
await check('Stored ATA history is accessible before any current fields', async () => {
assert.equal(await page.locator('canvas[role="img"]').count(), 2);
assert.match(await description(), /42°C/);
});
await check('History selection matches its visible content', async () => {
assert.equal(
await page.getByRole('tab', { name: 'History' }).getAttribute('aria-selected'),
'true',
);
});
await screenshot('missing-current');
await page.getByRole('button', { name: 'Restore current fields' }).click();
await thermal().waitFor();
await page.getByRole('combobox', { name: 'Disk history range' }).selectOption('7d');
await page.waitForFunction(
() =>
document
.querySelector('canvas[aria-label="Temperature chart"]')
?.getAttribute('aria-describedby') &&
document
.getElementById(
document
.querySelector('canvas[aria-label="Temperature chart"]')
.getAttribute('aria-describedby'),
)
.textContent.includes('42°C'),
);
if (phone) await thermal().tap({ position: { x: 80, y: 60 } });
else {
await thermal().focus();
await thermal().press('End');
}
await page.waitForTimeout(100);
await check('Stored zero and exact temperature can be inspected', async () => {
assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 2);
assert.match(
await page
.locator('[data-history-chart-tooltip]')
.allTextContents()
.then((parts) => parts.join(' ')),
/0 sectors/,
);
});
await thermal().evaluate((el) => (el.dataset.proofOwner = 'original'));
const reads = requests.length;
await page.getByRole('button', { name: 'Update current fields' }).click();
await page.waitForTimeout(200);
await check('Same-ID snapshot keeps mounted chart and makes no extra reads', async () => {
assert.equal(await thermal().getAttribute('data-proof-owner'), 'original');
assert.equal(requests.length, reads);
assert.equal(await page.getByRole('combobox').inputValue(), '7d');
});
await screenshot('updated-current');
await page.getByRole('button', { name: 'Lose current fields' }).click();
await page.waitForTimeout(200);
await check(
'Missing current fields do not withdraw stored readings or invent live I/O',
async () => {
assert.equal(await thermal().count(), 1);
assert.equal(await thermal().getAttribute('data-proof-owner'), 'original');
assert.match(await description(), /42°C/);
assert.equal(requests.length, reads);
assert.equal(await page.getByText('Live I/O (30m)').count(), 0);
},
);
await screenshot('missing-again');
if (!parent) {
await page.getByRole('tab', { name: 'Overview' }).click();
await check(
'Overview reports unavailable collection rather than the old temperature',
async () => {
assert.equal(
await page
.getByText('Temperature is temporarily unavailable: collection deadline exceeded')
.isVisible(),
true,
);
assert.equal(await page.getByText('42°C', { exact: true }).count(), 0);
},
);
await page.getByRole('tab', { name: 'History' }).click();
await page.getByRole('combobox').selectOption('1h');
await page.getByText('No history samples in this time range.').waitFor();
await check(
'Empty temperature is not zero and reallocated zero remains stored',
async () => {
assert.doesNotMatch(await description(), /0°C/);
assert.equal(await page.locator('canvas[role="img"]').count(), 2);
},
);
await screenshot('empty-temperature');
await page.getByRole('combobox').selectOption('7d');
await page.getByRole('button', { name: 'Select NVMe B' }).click();
await page.getByRole('img', { name: 'Available Spare chart' }).waitFor();
await page.waitForTimeout(200);
await check(
'NVMe History uses explicit B target and its own metrics without live fields',
async () => {
assert.equal(await page.locator('canvas[role="img"]').count(), 3);
assert.equal(
await page.getByRole('img', { name: 'Reallocated Sectors chart' }).count(),
0,
);
assert.match(await description(), /55°C/);
const own = requests.filter((r) => r.resourceId === 'disk:nas-b:nvme0n1');
assert.equal(
own.some((r) => r.metric === 'smart_available_spare'),
true,
);
assert.equal(
own.every((r) => r.resourceType === 'disk'),
true,
);
},
);
await screenshot('nvme');
const beforeClear = requests.length;
await page.getByRole('button', { name: 'Clear identity' }).click();
await page.waitForTimeout(200);
await check(
'Unresolvable identity withdraws History and does not guess a target',
async () => {
assert.equal(await page.getByRole('tab', { name: 'History' }).count(), 0);
assert.equal(await page.locator('canvas[role="img"]').count(), 0);
assert.equal(requests.length, beforeClear);
},
);
await screenshot('no-identity');
}
await check('No page errors, off-origin data reads or horizontal overflow', async () => {
assert.deepEqual(errors, []);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth),
false,
);
assert.equal(
requests.some((r) => ['diskread', 'diskwrite', 'disk'].includes(r.metric)),
false,
);
});
observations.push({ browser: name, version: browser.version(), checks, requests, errors });
await browser.close();
browser = undefined;
}
} finally {
if (browser) await browser.close();
await server.close();
fs.writeFileSync(
path.join(artifacts, 'result.json'),
JSON.stringify({ parent, playwright, expected, observations }, null, 2),
);
}
const failed = observations.flatMap((o) => o.checks ?? []).filter((c) => !c.passed);
console.log(
JSON.stringify({
parent,
checks: observations.flatMap((o) => o.checks ?? []).length,
failed: failed.map((c) => c.label),
}),
);
if (!parent) assert.equal(failed.length, 0);
})().catch((error) => {
console.error(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>Disk History collection verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/disk-history-collection.tsx"></script>
</body>
</html>
@@ -0,0 +1,75 @@
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { DiskDetail } from '../src/components/Storage/DiskDetail';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
// Only snapshots are synthetic: detail, history, client, inspection and CSS are production code.
const disk = (missing = false, nvme = false, hot = false): Resource => ({
id: nvme ? 'disk-b' : 'disk-a',
name: nvme ? 'NVMe B' : 'Archive A',
displayName: nvme ? 'NVMe B' : 'Archive A',
type: 'physical_disk',
platformId: nvme ? 'nas-b' : 'nas-a',
platformType: 'truenas',
sourceType: 'api',
status: 'online',
lastSeen: Date.now(),
identity: { hostname: nvme ? 'nas-b' : 'nas-a' },
metricsTarget: {
resourceType: 'disk',
resourceId: nvme ? 'disk:nas-b:nvme0n1' : 'disk:nas-a:sda',
},
physicalDisk: {
devPath: nvme ? '/dev/nvme0n1' : '/dev/sda',
model: nvme ? 'NVMe B' : 'Archive HDD A',
serial: nvme ? 'SERIAL-B' : 'SERIAL-A',
diskType: nvme ? 'nvme' : 'hdd',
temperature: missing ? undefined : hot ? 65 : 42,
smart: missing
? undefined
: nvme
? { percentageUsed: 12, availableSpare: 97 }
: { reallocatedSectors: 0 },
collection: {
temperature: {
state: missing ? 'unavailable' : 'available',
reason: missing ? 'collection deadline exceeded' : undefined,
},
io: { state: 'unsupported', reason: 'per-member counters unavailable' },
},
},
});
const [snapshot, setSnapshot] = createSignal(disk(true));
const controls = [
['Restore current fields', () => setSnapshot(disk())],
['Update current fields', () => setSnapshot(disk(false, false, true))],
['Lose current fields', () => setSnapshot(disk(true))],
['Select NVMe B', () => setSnapshot(disk(true, true))],
[
'Clear identity',
() =>
setSnapshot({
...disk(true),
id: '',
metricsTarget: undefined,
physicalDisk: { ...disk(true).physicalDisk!, serial: '', wwn: '' },
}),
],
] as const;
render(
() => (
<main class="mx-auto max-w-5xl bg-surface p-3 text-base-content">
<h1 class="mb-3 text-lg font-semibold">Physical disk History</h1>
<div class="mb-4 flex flex-wrap gap-2">
{controls.map(([label, action]) => (
<button class="min-h-11 rounded border border-border px-3 text-sm" onClick={action}>
{label}
</button>
))}
</div>
<DiskDetail disk={snapshot()} nodes={[]} />
</main>
),
document.getElementById('root')!,
);
@@ -0,0 +1,211 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
const parent = false;
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules/drawer-snapshot-proof');
fs.mkdirSync(artifacts, { recursive: true });
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: fs.mkdtempSync(path.join(artifacts, 'vite-')),
server: { host: '127.0.0.1', port: 5242, strictPort: true },
});
const playwright = require('playwright/package.json').version;
const expected = JSON.parse(
fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8'),
).packages['node_modules/@playwright/test'].version;
assert.equal(playwright, expected);
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [
['chromium-desktop', chromium, false],
['webkit-phone', webkit, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 },
hasTouch: phone,
isMobile: phone,
});
const requests = [],
errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5242') return route.abort();
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname === '/api/metrics-store/history') {
const query = Object.fromEntries(url.searchParams);
requests.push(query);
const value =
query.resourceId === 'history-a-current'
? 77
: query.resourceId === 'history-b'
? 25
: 12;
return route.fulfill({
json: {
resourceType: 'agent',
resourceId: query.resourceId,
range: query.range,
start: Date.now() - 60000,
end: Date.now(),
source: 'store',
metrics: {
cpu: [0, 1].map((i) => ({
timestamp: Date.now() - (1 - i) * 60000,
value,
min: value,
max: value,
})),
},
},
});
}
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: {
capabilities: [],
relationships: [],
recentChanges: [],
audits: [],
count: 0,
available: false,
data: [],
enabled: false,
},
});
});
await page.goto('http://127.0.0.1:5242/browser-tests/drawer-snapshot.html');
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
const detail = page.getByRole('region', { name: 'PBS detail verification' });
const fleet = page.getByRole('region', { name: 'Availability fleet' });
const capture = async (phase, scope) => {
const dimensions = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
width: innerWidth,
}));
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
const screenshot = `${parent ? 'parent-' : ''}${name}-${phase}.png`;
await page.screenshot({ path: path.join(artifacts, screenshot), fullPage: true });
observations.push({
name,
phase,
browser: browser.version(),
text: await scope.innerText(),
dimensions,
screenshot,
});
};
await detail.getByRole('tab', { name: 'History', exact: true }).click();
await detail.getByRole('combobox', { name: 'History range' }).selectOption('7d');
await detail.getByText('12.0%', { exact: true }).waitFor();
const history = await detail.getByTestId('resource-metrics-history-tab').elementHandle();
await page.getByRole('button', { name: 'Update PBS A', exact: true }).click();
await detail
.getByRole('heading', { name: parent ? 'PBS A' : 'PBS A current', exact: true })
.waitFor();
await detail.getByText(parent ? '12.0%' : '77.0%', { exact: true }).waitFor();
assert.equal(
await detail.getByRole('combobox', { name: 'History range' }).inputValue(),
'7d',
);
assert.ok(await history.evaluate((element) => element.isConnected));
await capture('pbs-replacement', detail);
await page.getByRole('button', { name: 'Withdraw target', exact: true }).click();
if (parent) assert.equal(await detail.getByTestId('resource-metrics-history-tab').count(), 1);
else await detail.getByText('Metrics history is unavailable.', { exact: true }).waitFor();
await capture('pbs-withdrawn', detail);
await page.getByRole('button', { name: 'Restore target', exact: true }).click();
await detail.getByTestId('resource-metrics-history-tab').waitFor();
assert.equal(
await detail
.getByRole('tab', { name: 'History', exact: true })
.getAttribute('aria-selected'),
'true',
);
await page.getByRole('button', { name: 'Select PBS B', exact: true }).click();
await detail
.getByRole('heading', { name: parent ? 'PBS A' : 'PBS B', exact: true })
.waitFor();
assert.equal(
await detail
.getByRole('tab', { name: parent ? 'History' : 'Overview', exact: true })
.getAttribute('aria-selected'),
'true',
);
if (!parent) await detail.getByRole('tab', { name: 'History', exact: true }).click();
await detail.getByText(parent ? '12.0%' : '25.0%', { exact: true }).waitFor();
await capture('pbs-selected-b', detail);
const targets = [...new Set(requests.map((r) => r.resourceId))];
assert.deepEqual(
targets,
parent ? ['history-a'] : ['history-a', 'history-a-current', 'history-b'],
);
assert.ok(
requests.every(
(r) => !('metric' in r) && r.resourceType === 'agent' && r.maxPoints === '240',
),
);
await fleet.getByRole('button', { name: 'Open details for Check A', exact: true }).click();
const availability = fleet.getByTestId('availability-probe-status');
await availability.getByText('Up', { exact: true }).waitFor();
await page.getByRole('button', { name: 'Fail check A', exact: true }).click();
await availability.getByText(parent ? 'Up' : 'Down', { exact: true }).waitFor();
await capture('availability-replacement', fleet);
await fleet.getByRole('button', { name: 'Open details for Check B', exact: true }).click();
await fleet
.getByRole('heading', { name: parent ? 'Check A' : 'Check B', exact: true })
.waitFor();
await capture('availability-selected-b', fleet);
await page.getByRole('button', { name: 'Remove check B', exact: true }).click();
assert.equal(
await fleet
.getByRole('heading', { name: parent ? 'Check A' : 'Check B', exact: true })
.count(),
parent ? 1 : 0,
);
await page.getByRole('button', { name: 'Restore check B', exact: true }).click();
assert.equal(
await fleet.locator('[id^="resource-detail-drawer-heading-"]').count(),
parent ? 1 : 0,
);
await capture('availability-removed-restored', fleet);
assert.deepEqual(errors, []);
observations.push({ name, phase: 'requests', requests, errors });
await browser.close();
browser = null;
}
fs.writeFileSync(
path.join(artifacts, parent ? 'parent.json' : 'result.json'),
JSON.stringify({ playwright, expected, parent, observations }, null, 2),
);
console.log(JSON.stringify({ result: 'passed', parent, observations: observations.length }));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(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>Detail snapshot verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/drawer-snapshot.tsx"></script>
</body>
</html>
@@ -0,0 +1,90 @@
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
import { AvailabilityFleetView } from '../src/features/standalone/AvailabilityFleetView';
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
// Synthetic replacement snapshots; the drawers, API client, fleet and CSS are production code.
syncAIRuntimeSettings({ discovery_enabled: false });
const pbs = (current = false, other = false, missing = false): Resource => ({
id: other ? 'pbs-b' : 'pbs-a',
name: other ? 'PBS B' : 'PBS A',
displayName: other ? 'PBS B' : current ? 'PBS A current' : 'PBS A',
type: 'pbs',
platformId: other ? 'pbs-b' : 'pbs-a',
platformType: 'proxmox-pbs',
sourceType: 'api',
status: current ? 'offline' : 'online',
lastSeen: Date.now(),
cpu: { current: current ? 77 : other ? 25 : 12 },
metricsTarget: missing
? undefined
: {
resourceType: 'agent',
resourceId: other ? 'history-b' : current ? 'history-a-current' : 'history-a',
},
});
const check = (other = false, down = false): Resource => ({
id: other ? 'check-b' : 'check-a',
name: other ? 'Check B' : 'Check A',
displayName: other ? 'Check B' : 'Check A',
type: 'network-endpoint',
platformId: other ? 'check-b' : 'check-a',
platformType: 'availability',
sourceType: 'api',
status: down ? 'offline' : 'online',
lastSeen: Date.now(),
availability: {
targetId: other ? 'check-b' : 'check-a',
address: other ? 'check-b.invalid' : 'check-a.invalid',
protocol: 'https',
path: '/health',
enabled: true,
available: !down,
latencyMillis: down ? undefined : 12,
lastChecked: new Date().toISOString(),
pollIntervalSeconds: 60,
},
});
const [resource, setResource] = createSignal(pbs());
const [checks, setChecks] = createSignal([check(), check(true)]);
const controls = [
['Update PBS A', () => setResource(pbs(true))],
['Withdraw target', () => setResource(pbs(true, false, true))],
['Restore target', () => setResource(pbs(true))],
['Select PBS B', () => setResource(pbs(false, true))],
['Fail check A', () => setChecks([check(false, true), check(true)])],
['Remove check B', () => setChecks([check(false, true)])],
['Restore check B', () => setChecks([check(false, true), check(true)])],
] as const;
render(
() => (
<WebSocketContext.Provider value={{ state: { pmg: [] }, connected: () => true } as any}>
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Detail snapshot verification</h1>
<p>Synthetic PBS and availability snapshots; no appliance or release acceptance.</p>
<nav aria-label="Snapshot controls" class="flex flex-wrap gap-2">
{controls.map(([name, action]) => (
<button class="min-h-11 rounded border border-border px-3" onClick={action}>
{name}
</button>
))}
</nav>
<section aria-label="PBS detail verification" class="rounded border border-border p-3">
<ResourceDetailDrawer resource={resource()} presentation="table-row" />
</section>
<AvailabilityFleetView
resources={checks()}
historyByTarget={new Map()}
historyLoading={false}
/>
</main>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
),
document.getElementById('root')!,
);
@@ -0,0 +1,255 @@
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 baseline = false;
const root = '/workspace/frontend-modern';
const output = `/workspace/tmp/guest-backup-protection-${baseline ? 'parent' : 'final'}`;
const runtime = [
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
'frontend-modern/src/utils/workloadGuestPresentation.ts',
'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
'frontend-modern/src/components/Workloads/useGuestDrawerState.ts',
'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
];
const sha256 = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(runtime.map((p) => [p, sha256(path.join('/workspace', p))]));
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5296, strictPort: true },
});
const results = [],
screenshots = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['desktop', chromium, 1365, false],
['phone', webkit, 390, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
for (const kind of ['qemu', 'lxc']) {
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
const errors = [],
writes = [],
checks = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.route('**/*', (route) => {
const request = route.request();
const url = new URL(request.url());
assert.equal(
url.origin,
'http://127.0.0.1:5296',
'only guest-local fixture requests are allowed',
);
if (url.pathname.startsWith('/api/')) {
if (request.method() !== 'GET') writes.push(`${request.method()} ${url.pathname}`);
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: [],
alerts: [],
anomalies: [],
config: null,
}),
});
}
return route.continue();
});
await page.goto(
`http://127.0.0.1:5296/browser-tests/guest-backup-protection.html?kind=${kind}`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(() => window.__backupProtection);
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
assert.equal(await page.evaluate(() => window.innerWidth), width, 'actual CSS viewport');
const details = page.getByTestId('guest-technical-details');
await details.waitFor({ state: 'visible' });
await page.evaluate(() => {
window.__originalGuestDetails = document.querySelector(
'[data-testid="guest-technical-details"]',
);
window.__originalGuestHeading = document.querySelector('h3');
});
const update = async (lastBackup, running, reason = '') => {
await page.evaluate((value) => window.__backupProtection.update(value), {
lastBackup,
running,
reason,
});
assert.equal(
await page.evaluate(
() =>
document.querySelector('[data-testid="guest-technical-details"]') ===
window.__originalGuestDetails,
),
true,
);
assert.equal(
await page.evaluate(() => window.__backupProtection.identity()),
'fixture-pve:pve-a:101',
);
};
const capture = async (suffix) => {
const p = path.join(output, `${name}-${kind}-${suffix}.png`);
await page.screenshot({ path: p, fullPage: true });
screenshots.push({ path: path.relative('/workspace', p), sha256: sha256(p) });
};
const oldBackup = new Date('2026-09-23T12:00:00Z').getTime();
if (baseline) {
await update(0, true, kind === 'qemu' ? 'prev-vm-locked' : '');
const activity = details.getByText('Backup running', { exact: true });
await activity.waitFor({ state: 'visible' });
assert((await activity.locator('..').getAttribute('class')).includes('text-emerald-700'));
assert.equal(await details.getByText('No backup found', { exact: true }).count(), 0);
checks.push(
'parent: running backup hides missing completed protection and appears green',
);
if (kind === 'qemu') await capture('running-with-none');
await update(oldBackup, true);
assert.equal(await details.getByText('10d ago', { exact: true }).count(), 0);
await update(oldBackup, false);
await details.getByText('10d ago', { exact: true }).waitFor({ state: 'visible' });
checks.push('parent: running backup hides existing ten-day-old completed evidence');
} else {
const missing = details.getByText('No completed backup found', { exact: true });
const activity = details.getByText('Running · not completed yet', { exact: true });
await missing.waitFor({ state: 'visible' });
assert((await missing.locator('..').getAttribute('class')).includes('text-rose-700'));
assert.equal(await details.getByText('Backup activity', { exact: true }).count(), 0);
await update(0, true, kind === 'qemu' ? 'prev-vm-locked' : '');
await activity.waitFor({ state: 'visible' });
await missing.waitFor({ state: 'visible' });
assert((await activity.locator('..').getAttribute('class')).includes('text-amber-700'));
assert.equal(await details.getByText('Backup running', { exact: true }).count(), 0);
if (kind === 'qemu')
await details
.getByText(/Using last known disk stats\. Guest reads paused/)
.waitFor({ state: 'visible' });
checks.push(
'absent completion stays missing/danger while separate activity is cautionary; VM lock notice survives',
);
if (kind === 'qemu') await capture('running-with-none');
await update(0, false);
await missing.waitFor({ state: 'visible' });
assert.equal(await activity.count(), 0);
checks.push(
'activity stopping without new completion evidence never manufactures protection',
);
await update(oldBackup, true);
const age = details.getByText('10d ago', { exact: true });
await age.waitFor({ state: 'visible' });
assert((await age.locator('..').getAttribute('class')).includes('text-amber-700'));
await activity.waitFor({ state: 'visible' });
checks.push('stale completed age and caution tone remain visible during a new backup');
if (kind === 'qemu') await capture('running-with-old');
await update(oldBackup, false, kind === 'qemu' ? 'prev-agent-timeout' : '');
await age.waitFor({ state: 'visible' });
assert.equal(await activity.count(), 0);
if (kind === 'qemu')
await details
.getByText(/Do not restart the guest agent during a backup/)
.waitFor({ state: 'visible' });
checks.push('ended activity preserves old completion and independent timeout safety');
const freshBackup = new Date('2026-10-03T10:00:00Z').getTime();
await update(freshBackup, false);
const today = details.getByText('Today', { exact: true });
await today.waitFor({ state: 'visible' });
assert((await today.locator('..').getAttribute('class')).includes('text-emerald-700'));
assert.equal(await missing.count(), 0);
await update(freshBackup, true);
await today.waitFor({ state: 'visible' });
await activity.waitFor({ state: 'visible' });
checks.push(
'only new completed timestamp updates protection; fresh age stays separate from further activity',
);
await update(freshBackup, false);
const close = page.getByRole('button', { name: 'Collapse backup-guest details' });
if (name === 'phone') await close.tap();
else {
await close.focus();
await page.keyboard.press('Enter');
}
assert.equal(await page.evaluate(() => window.__backupProtection.closeCount()), 1);
checks.push(
'existing collapse remains touch/keyboard usable and observations trigger no write',
);
assert.equal(
await details.getByText('Last completed backup', { exact: true }).count(),
1,
);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
true,
);
if (kind === 'qemu') await capture('fresh-completion');
}
assert.deepEqual(writes, []);
assert.deepEqual(errors, []);
results.push({
name,
kind,
browser_version: browser.version(),
viewport: { width, height: width === 390 ? 844 : 900 },
checks,
page_errors: errors,
writes,
});
await page.close();
}
await browser.close();
browser = null;
}
const receipt = {
version: 1,
result: baseline ? 'defect-reproduced' : 'passed',
verified_at: new Date().toISOString(),
content_sha256: hashes,
playwright_version: playwrightVersion,
results,
screenshots,
limitation:
'Actual GuestDrawer and production CSS with synthetic VM/CT observations. Not a native backup, usable archive, QGA/thaw, installation or release result.',
};
fs.writeFileSync(
path.join(output, 'browser-result.json'),
JSON.stringify(receipt, null, 2) + '\n',
);
process.stdout.write(JSON.stringify(receipt) + '\n');
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((e) => {
process.stderr.write(e.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>Guest backup protection verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./guest-backup-protection.tsx"></script>
</body>
</html>
@@ -0,0 +1,82 @@
// Synthetic observations through the actual GuestDrawer, its state model and
// production CSS. This fixture neither starts a backup nor establishes thaw.
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 [observation, setObservation] = createSignal({
lastBackup: 0,
running: false,
reason: '',
});
const [closeCount, setCloseCount] = createSignal(0);
const type = new URLSearchParams(window.location.search).get('kind') === 'lxc' ? 'lxc' : 'qemu';
const guest = (): WorkloadGuest => ({
id: 'fixture-pve:pve-a:101',
vmid: 101,
name: 'backup-guest',
node: 'pve-a',
instance: 'fixture-pve',
status: 'running',
type,
cpu: 0.1,
cpus: 2,
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
disks: [
{ mountpoint: '/data', type: 'ext4', total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
],
diskStatusReason: observation().reason,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: observation().lastBackup,
backupInProgress: observation().running,
tags: [],
lock: observation().running ? 'backup' : '',
lastSeen: '2026-10-03T12:00:00Z',
});
(window as any).__backupProtection = {
update: setObservation,
closeCount,
identity: () => guest().id,
};
return (
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Guest backup protection</h1>
<p class="text-xs text-muted">Synthetic observations; no backup command is sent.</p>
<section aria-label="Guest details" class="mx-auto max-w-4xl">
<GuestDrawer guest={guest()} onClose={() => setCloseCount((count) => count + 1)} />
</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,273 @@
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/guest-disk-deferral-proof';
const runtime = [
'frontend-modern/src/utils/workloadGuestPresentation.ts',
'frontend-modern/src/components/Workloads/GuestRow.tsx',
'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
'frontend-modern/src/components/Workloads/DiskList.tsx',
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
];
const deferrals = [
['vm-locked', 'VM operation lock'],
['lock-unverified', 'cannot verify that the VM is unlocked'],
['agent-busy', 'earlier guest request is still in progress'],
['agent-cooldown', 'after the cooldown'],
['agent-response-incomplete', 'previous response was incomplete'],
['agent-capacity', 'guest-read capacity'],
['invalid-guest-key', 'VM identity is invalid'],
['agent-timeout', 'Do not restart the guest agent during a backup'],
];
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(
runtime.map((p) => [
p,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', p)))
.digest('hex'),
]),
);
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: 5294, strictPort: true },
});
const results = [],
screenshots = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['desktop', chromium, 1365, false],
['phone', webkit, 390, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
const errors = [],
checks = [];
page.on('pageerror', (e) => {
errors.push(e.message);
process.stderr.write(`BROWSER_PAGE_ERROR ${e.message}\n`);
});
page.on('console', (m) => {
if (m.type() === 'error')
process.stderr.write(`BROWSER_CONSOLE ${m.text().slice(0, 1500)}\n`);
});
page.on('response', (r) => {
if (r.status() >= 400)
process.stderr.write(`BROWSER_HTTP ${r.status()} ${new URL(r.url()).pathname}\n`);
});
await page.route('http://127.0.0.1:5294/api/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: [],
alerts: [],
anomalies: [],
config: null,
}),
}),
);
await page.goto('http://127.0.0.1:5294/browser-tests/guest-disk-deferral.html', {
waitUntil: 'domcontentloaded',
});
try {
await page.waitForFunction(() => window.__guestDiskEvidence, null, { timeout: 15000 });
} catch (error) {
fs.writeFileSync(
path.join(output, 'failure.json'),
JSON.stringify({ phase: 'fixture-readiness', errors }, null, 2),
);
throw error;
}
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]');
const cell = row.locator('[data-workload-col="disk"]');
await row.click();
const drawer = page.getByRole('region', { name: 'Guest Overview' });
await drawer.waitFor({ state: 'visible' });
await page.evaluate(
() =>
(window.__originalDiskRow = document.querySelector(
'[data-guest-id="fixture-pve:pve-a:101"]',
)),
);
const update = async (reason, data, usage = 50) => {
await page.evaluate((value) => window.__guestDiskEvidence.update(value), {
reason,
data,
usage,
});
await page.waitForFunction(
() =>
document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]') ===
window.__originalDiskRow,
);
assert.equal(
await page.evaluate(() => window.__guestDiskEvidence.identity()),
'fixture-pve:pve-a:101',
);
};
const capture = async (suffix) => {
const p = path.join(output, `${name}-${suffix}.png`);
await page.screenshot({ path: p, fullPage: true });
screenshots.push({
path: path.relative('/workspace', p),
sha256: crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'),
});
};
for (const [reason, expected] of deferrals) {
await update(`prev-${reason}`, true);
await page.waitForFunction(
(text) => document.querySelector('[data-workload-col="disk"]').title.includes(text),
expected,
);
const title = await cell.getAttribute('title');
assert(title.startsWith('Using last known disk stats. '));
assert(title.includes(expected));
assert(
!title.includes('may not be installed') && !title.includes('may need to be restarted'),
);
assert((await drawer.innerText()).includes(title));
const statusBox = await drawer.getByText(title, { exact: true }).boundingBox();
assert(
statusBox.width >= 100 && statusBox.height < 220,
'status explanation fits its card',
);
assert((await drawer.innerText()).includes('/data'));
assert(
(await page.getByRole('region', { name: 'Guest disk list' }).innerText()).includes(title),
);
if (name === 'desktop') {
// The disclosure click can land on this bar and dismiss its hover
// overlay on pointerdown. Leave it before exercising a new hover.
await page.locator('h1').hover();
await cell.locator('[data-stacked-disk-trigger]').hover();
await page.locator('[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
assert((await page.locator('[data-tooltip-portal="true"]').innerText()).includes(title));
await page.locator('h1').hover();
}
checks.push(`${reason}: retained row, tooltip, drawer, list and identity`);
if (reason === 'vm-locked') await capture('retained-backup');
await update(reason, false);
await page.waitForFunction(
(text) => document.querySelector('[data-workload-col="disk"]').title.includes(text),
expected,
);
assert(!(await cell.getAttribute('title')).includes('Using last known'));
assert((await drawer.innerText()).includes(expected));
assert(!(await drawer.innerText()).includes('/data'));
assert.equal(await cell.locator('[data-stacked-disk-trigger]').count(), 0);
checks.push(`${reason}: no previous disk values and no fresh percentage`);
if (reason === 'agent-timeout') await capture('uncertain-timeout');
}
for (const [reason, expected] of [
['permission-denied', 'Permission denied'],
['agent-disabled', 'disabled in VM configuration'],
['agent-not-running', 'Guest agent not running'],
['no-data', 'No disk data available'],
['vm-stopped', 'VM is stopped'],
['no-status', 'could not read the VM status'],
['agent-error', 'Error communicating with guest agent'],
]) {
await update(reason, false);
await page.waitForFunction(
(text) => document.querySelector('[data-workload-col="disk"]').title.includes(text),
expected,
);
assert((await drawer.innerText()).includes(expected));
checks.push(`${reason}: existing unavailable case remains distinct`);
}
await update('prev-agent-cooldown', true);
await page.evaluate(() => window.__guestDiskEvidence.mode('sparklines'));
assert((await cell.getAttribute('title')).includes('Using last known'));
await update('', true, 75);
await page.waitForFunction(
() => !document.querySelector('[data-workload-col="disk"]').hasAttribute('title'),
);
assert(!(await drawer.innerText()).includes('Using last known'));
assert(!(await drawer.innerText()).includes('Guest reads'));
assert.equal(await drawer.getByRole('progressbar').getAttribute('aria-valuenow'), '75');
await page.evaluate(() => window.__guestDiskEvidence.mode('bars'));
assert.equal(
await page.evaluate(
() =>
document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]') ===
window.__originalDiskRow,
),
true,
);
checks.push(
'same-VM fresh resumption removes notice and updates values in both row modes without remount',
);
await capture('fresh-resumption');
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
true,
);
assert.deepEqual(errors, []);
results.push({
name,
browser_version: browser.version(),
viewport: { width, height: width === 390 ? 844 : 900 },
checks,
page_errors: errors,
});
await browser.close();
browser = null;
}
const receipt = {
version: 1,
result: 'passed',
verified_at: new Date().toISOString(),
content_sha256: hashes,
playwright_version: playwrightVersion,
results,
screenshots,
limitation:
'Synthetic same-VM observations through production components and CSS; no native QGA/backup, History sample, installation or release acceptance.',
};
fs.writeFileSync(
path.join(output, 'browser-result.json'),
JSON.stringify(receipt, null, 2) + '\n',
);
process.stdout.write(JSON.stringify(receipt) + '\n');
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((e) => {
process.stderr.write(e.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>Guest disk deferral verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./guest-disk-deferral.tsx"></script>
</body>
</html>
@@ -0,0 +1,127 @@
// Production row, disk tooltip, list and disclosed Overview with synthetic
// same-VM observations. No collector, QGA, native backup or release proof.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { GuestRow } from '../src/components/Workloads/GuestRow';
import { GuestDrawerOverview } from '../src/components/Workloads/GuestDrawerOverview';
import { DiskList } from '../src/components/Workloads/DiskList';
import type { VM } from '../src/types/api';
import { DarkModeContext } from '../src/contexts/appRuntime';
import '../src/index.css';
function Fixture() {
const [observation, setObservation] = createSignal({
reason: 'prev-vm-locked',
data: true,
usage: 50,
});
const [expanded, setExpanded] = createSignal(false);
const [mode, setMode] = createSignal<'bars' | 'sparklines'>('bars');
const guest = (): VM => ({
id: 'fixture-pve:pve-a:101',
vmid: 101,
name: 'backup-guest',
node: 'pve-a',
instance: 'fixture-pve',
status: 'running',
type: 'qemu',
cpu: 0.1,
cpus: 2,
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
disk: {
total: 10 * 1024 ** 3,
used: observation().data ? (observation().usage / 100) * 10 * 1024 ** 3 : 0,
usage: observation().data ? observation().usage : -1,
},
disks: observation().data
? [
{
mountpoint: '/data',
type: 'ext4',
total: 10 * 1024 ** 3,
used: (observation().usage / 100) * 10 * 1024 ** 3,
usage: observation().usage,
},
]
: [],
diskStatusReason: observation().reason,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: 0,
tags: [],
lock: observation().reason.endsWith('vm-locked') ? 'backup' : '',
backupInProgress: observation().reason.endsWith('vm-locked'),
lastSeen: '2026-10-03T14:00:00Z',
});
(window as any).__guestDiskEvidence = {
update: setObservation,
mode: setMode,
identity: () => guest().id,
};
return (
<main class="min-h-screen space-y-4 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Guest disk observation</h1>
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={guest()}
visibleColumnIds={['name', 'disk']}
onClick={() => setExpanded(!expanded())}
isExpanded={expanded()}
metricDisplayMode={mode()}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
/>
</tbody>
</table>
<Show when={expanded()}>
<section aria-label="Guest Overview">
<GuestDrawerOverview
guest={guest()}
guestOsSummary="Linux"
agentHeading="Guest agent"
agentLabel=""
agentTitle=""
hasAgentInfo={false}
hasFilesystemDetails={guest().disks!.length > 0}
hasNetworkInterfaces={false}
hasOsInfo={false}
hasWorkloadActionAgent={false}
showInGuestAgentInstallCue={false}
ipAddresses={[]}
networkInterfaces={[]}
normalizedTags={[]}
backupPresentation={null}
workloadActionAgentTitle=""
/>
</section>
</Show>
<section aria-label="Guest disk list" class="max-w-xl">
<h2 class="text-sm font-medium">Filesystems</h2>
<DiskList disks={guest().disks!} diskStatusReason={guest().diskStatusReason} />
</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,325 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
const parent = false;
(async () => {
const root = parent
? '/workspace/tmp/history-access-parent/frontend-modern'
: '/workspace/frontend-modern';
process.chdir(root);
const artifacts = `/workspace/tmp/history-access-${parent ? 'parent' : 'final'}-proof`;
fs.mkdirSync(artifacts, { recursive: true });
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: fs.mkdtempSync(path.join(artifacts, 'vite-')),
server: { host: '127.0.0.1', port: 5261, strictPort: true },
});
const playwright = require('playwright/package.json').version;
const expected = JSON.parse(
fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8'),
).packages['node_modules/@playwright/test'].version;
assert.equal(playwright, expected);
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [
['chromium-desktop', chromium, false],
['webkit-phone', webkit, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 },
hasTouch: phone,
isMobile: phone,
});
page.setDefaultTimeout(15000);
await page.clock.install({ time: new Date('2026-10-03T12:00:00Z') });
let mode = 'success',
value = 42;
const requests = [],
errors = [],
offOrigin = [],
held = [],
checks = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5261') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname === '/api/metrics-store/history') {
const query = Object.fromEntries(url.searchParams);
requests.push({ ...query, responseMode: mode });
if (mode === 'hold') await new Promise((resolve) => held.push(resolve));
try {
if (mode === '503' || mode === '403')
return await route.fulfill({
status: Number(mode),
json: { error: 'fixture-only diagnostic body' },
});
const points = [0, 1].map((index) => ({
timestamp: 1791028740000 + index * 60000,
value: value - 1 + index,
min: value - 1 + index,
max: value - 1 + index,
}));
return await route.fulfill({
json: {
resourceType: query.resourceType,
resourceId: query.resourceId,
range: query.range,
metric: query.metric,
start: 1791028740000,
end: 1791028800000,
source: 'store',
...(query.metric ? { points } : { metrics: { cpu: points, temperature: points } }),
},
});
} catch {
/* An explicitly superseded browser read may already be aborted. */
}
return;
}
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: { enabled: false, data: [] },
});
});
const check = async (label, predicate) => {
try {
await predicate();
checks.push({ label, passed: true });
} catch (error) {
checks.push({ label, passed: false, error: error.message });
}
};
const screenshot = async (state) => {
const file = `${name}-${state}.png`;
await page.screenshot({
path: path.join(artifacts, file),
fullPage: true,
animations: 'disabled',
});
observations.push({
browser: name,
state,
screenshot: file,
body: await page.locator('main').innerText(),
overflow: await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),
});
};
const disk = page.getByTestId('disk'),
service = page.getByTestId('service');
const thermal = disk.getByRole('img', { name: 'Temperature chart' });
const description = () =>
thermal.evaluate(
(el) => document.getElementById(el.getAttribute('aria-describedby')).textContent,
);
const storedPaths = () => service.locator('[data-testid="guest-history-plot"] path').count();
await page.goto('http://127.0.0.1:5261/browser-tests/history-access.html');
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
await service.getByRole('slider', { name: 'Inspect Utilization history' }).waitFor();
await page.waitForFunction(
() =>
document.querySelector('[data-testid="disk"] canvas')?.getAttribute('aria-describedby') &&
document
.getElementById(
document
.querySelector('[data-testid="disk"] canvas')
.getAttribute('aria-describedby'),
)
.textContent.includes('42°C'),
);
await service.getByRole('combobox', { name: 'History range' }).selectOption('24h');
await service.getByRole('slider', { name: 'Inspect Utilization history' }).waitFor();
if (phone) await thermal.tap({ position: { x: 100, y: 80 } });
else {
await thermal.focus();
await thermal.press('End');
}
await check('Both readers show only their successful stored samples', async () => {
assert.match(await description(), /42°C/);
assert.equal(await storedPaths(), 2);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1);
});
await screenshot('loaded');
mode = '503';
await page.clock.fastForward(30_000);
await service
.getByRole('status', { name: 'History refresh status' })
.getByText(/previously loaded/)
.waitFor();
await check('503 retains observations with a warning, not a collection claim', async () => {
assert.match(await description(), /42°C/);
assert.equal(await storedPaths(), 2);
assert.match(
await disk.getByRole('status', { name: 'History refresh status' }).innerText(),
/last successful result/,
);
});
await screenshot('transient');
// Re-establish active inspection immediately before access changes.
if (phone) await thermal.tap({ position: { x: 100, y: 80 } });
else {
await thermal.focus();
await thermal.press('End');
}
mode = '403';
const beforeDenied = requests.length;
await page.clock.fastForward(30_000);
await page.waitForTimeout(250);
await check('The real API client receives a final 403 for both exact targets', async () => {
const denied = requests.slice(beforeDenied).filter((r) => r.responseMode === '403');
assert(denied.some((r) => r.resourceId === 'disk:nas-a:sda'));
assert(denied.some((r) => r.resourceId === 'pbs-service-a' && !r.metric));
});
await check('403 withdraws canvas values, tooltip and announced inspection', async () => {
assert.doesNotMatch(await description(), /42°C/);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0);
assert.match(await disk.getByRole('alert').innerText(), /Access denied/);
assert.equal(
await disk.getByRole('status', { name: 'History refresh status' }).innerText(),
'',
);
});
await check('403 withdraws batch charts, sliders and current-only legends', async () => {
assert.equal(await storedPaths(), 0);
assert.equal(await service.getByRole('slider').count(), 0);
assert.equal(await service.locator('[data-history-current]').count(), 0);
assert.match(
await service.getByRole('status', { name: 'History refresh status' }).innerText(),
/Access denied/,
);
});
await screenshot('denied');
mode = 'hold';
const retry = service.getByRole('button', { name: 'Retry history' });
const beforeRetry = requests.length;
await retry.focus();
await retry.click();
await retry.press('Enter');
await page.waitForTimeout(150);
await check(
'One busy keyboard-safe retry remains scoped and shows no denied samples',
async () => {
assert.equal(
requests.slice(beforeRetry).filter((r) => r.resourceId === 'pbs-service-a').length,
1,
);
assert.equal(await retry.getAttribute('aria-disabled'), 'true');
assert.equal(await retry.evaluate((el) => el === document.activeElement), true);
assert.equal(await storedPaths(), 0);
},
);
await screenshot('retry-pending');
await service.getByRole('combobox', { name: 'History range' }).selectOption('1h');
await page.waitForTimeout(150);
await check('An earlier cached range cannot resurrect denied observations', async () =>
assert.equal(await storedPaths(), 0),
);
await service.getByRole('button', { name: 'Hide service History' }).click();
await service.getByRole('button', { name: 'Show service History' }).click();
await page.waitForTimeout(150);
await check('A remount cannot resurrect the earlier cached range', async () =>
assert.equal(await storedPaths(), 0),
);
await screenshot('remount-pending');
mode = 'success';
value = 12;
held.splice(0).forEach((resolve) => resolve());
await page.clock.fastForward(30_000);
await page.waitForFunction(() =>
document
.getElementById(
document.querySelector('[data-testid="disk"] canvas').getAttribute('aria-describedby'),
)
.textContent.includes('12°C'),
);
await service.getByRole('slider', { name: 'Inspect Utilization history' }).waitFor();
await check(
'Only a new successful response restores readings and clears access errors',
async () => {
assert.equal(await storedPaths(), 2);
assert.equal(await disk.getByRole('alert').count(), 0);
assert.equal(
await service.getByRole('status', { name: 'History refresh status' }).innerText(),
'',
);
assert.doesNotMatch(await description(), /42°C/);
assert(
requests.some(
(r) =>
r.resourceId === 'pbs-service-a' &&
r.range === '1h' &&
r.responseMode === 'success',
),
);
},
);
await screenshot('recovered');
await check(
'No diagnostic body, page error, off-origin request or horizontal overflow',
async () => {
assert.doesNotMatch(
await page.locator('main').innerText(),
/fixture-only diagnostic body/,
);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),
false,
);
},
);
observations.push({
browser: name,
version: browser.version(),
checks,
requests,
errors,
offOrigin,
});
await browser.close();
browser = undefined;
}
} finally {
if (browser) await browser.close();
await server.close();
fs.writeFileSync(
path.join(artifacts, 'result.json'),
JSON.stringify({ parent, playwright, expected, observations }, null, 2),
);
}
const failed = observations.flatMap((o) => o.checks ?? []).filter((c) => !c.passed);
console.log(
JSON.stringify({
parent,
checks: observations.flatMap((o) => o.checks ?? []).length,
failed: failed.map((c) => c.label),
}),
);
if (!parent) assert.equal(failed.length, 0);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>History access failure verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/history-access.tsx"></script>
</body>
</html>
@@ -0,0 +1,57 @@
// Production readers/client/styles with synthetic same-origin HTTP responses.
// This fixture proves presentation, not appliance authorisation or collection.
import { Show, createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import type { HistoryTimeRange } from '../src/api/charts';
import {
GuestDrawerHistory,
GuestDrawerHistoryRangeSelect,
} from '../src/components/Workloads/GuestDrawerHistory';
import { HistoryChart } from '../src/components/shared/HistoryChart';
import { HOST_METRICS_HISTORY_GROUPS } from '../src/components/shared/hostMetricsHistoryModel';
import '../src/index.css';
function Fixture() {
const [range, setRange] = createSignal<HistoryTimeRange>('1h');
const [visible, setVisible] = createSignal(true);
return (
<main class="mx-auto min-h-screen max-w-5xl space-y-5 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">History access failure verification</h1>
<section data-testid="disk" class="rounded border border-border p-3">
<h2 class="mb-3 font-semibold">Disk History</h2>
<HistoryChart
resourceType="disk"
resourceId="disk:nas-a:sda"
metric="smart_temp"
label="Temperature"
unit="C"
range="1h"
height={160}
hideSelector
compact
/>
</section>
<section data-testid="service" class="space-y-3">
<h2 class="font-semibold">PBS service History</h2>
<div class="flex flex-wrap items-center justify-between gap-3">
<button
class="min-h-11 rounded border border-border px-3"
onClick={() => setVisible(!visible())}
>
{visible() ? 'Hide service History' : 'Show service History'}
</button>
<GuestDrawerHistoryRangeSelect range={range()} onRangeChange={setRange} />
</div>
<Show when={visible()}>
<GuestDrawerHistory
target={{ resourceType: 'agent', resourceId: 'pbs-service-a' }}
range={range()}
groups={HOST_METRICS_HISTORY_GROUPS}
currentMetrics={{ cpu: 99, memory: 98 }}
/>
</Show>
</section>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -0,0 +1,388 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
const parent = false;
(async () => {
const root = parent
? '/workspace/tmp/history-org-parent/frontend-modern'
: '/workspace/frontend-modern';
process.chdir(root);
const artifacts = `/workspace/tmp/history-org-${parent ? 'parent' : 'final'}-proof`;
fs.mkdirSync(artifacts, { recursive: true });
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: fs.mkdtempSync(path.join(artifacts, 'vite-')),
server: { host: '127.0.0.1', port: 5264, strictPort: true },
});
const playwright = require('playwright/package.json').version;
assert.equal(
playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8')).packages[
'node_modules/@playwright/test'
].version,
);
const results = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [
['chromium-desktop', chromium, false],
['webkit-phone', webkit, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 },
hasTouch: phone,
isMobile: phone,
});
page.setDefaultTimeout(30_000);
await page.clock.install({ time: new Date('2026-10-03T12:00:00Z') });
const modes = {
'org-a': { mode: 'success', value: 42, source: 'memory' },
'org-b': { mode: 'hold', value: 80, source: 'store' },
'org-c': { mode: '503' },
'org-d': { mode: 'success', value: 61, source: 'store' },
};
const requests = [],
held = [],
checks = [],
states = [],
errors = [],
offOrigin = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5264') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname === '/api/metrics-store/history') {
const query = Object.fromEntries(url.searchParams);
const org = (await route.request().allHeaders())['x-pulse-org-id'];
let response = { ...modes[org] };
const record = { ...query, org, responseMode: response.mode };
requests.push(record);
if (response.mode === 'hold') {
response = await new Promise((resolve) => held.push({ org, resolve }));
}
const points = [0, 1].map((index) => ({
timestamp: 1791028740000 + index * 60_000,
value: response.value - 1 + index,
min: response.value - 1 + index,
max: response.value - 1 + index,
}));
try {
await route.fulfill({
status: response.mode === '503' ? 503 : 200,
json:
response.mode === '503'
? { error: 'synthetic outage' }
: {
resourceType: query.resourceType,
resourceId: query.resourceId,
range: query.range,
metric: query.metric,
source: response.source,
start: 1791028740000,
end: 1791028800000,
...(query.metric ? { points } : { metrics: { cpu: points, memory: points } }),
},
});
record.fulfilled = true;
} catch {
// Explicitly aborted/superseded browser requests can no longer be fulfilled.
record.fulfilled = false;
}
return;
}
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: { enabled: false, data: [] },
});
});
const check = async (label, fn) => {
try {
await fn();
checks.push({ label, passed: true });
} catch (error) {
checks.push({ label, passed: false, error: error.message });
}
};
const screenshot = async (state) => {
const file = `${name}-${state}.png`;
await page.screenshot({
path: path.join(artifacts, file),
fullPage: true,
animations: 'disabled',
});
states.push({
state,
screenshot: file,
body: await page.locator('main').innerText(),
overflow: await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),
});
};
const release = (org, response) => {
const matches = held.filter((entry) => entry.org === org);
for (const entry of matches) {
held.splice(held.indexOf(entry), 1);
entry.resolve(response);
}
};
const bootRequests = [],
failedRequests = [];
page.on('request', (r) => {
if (bootRequests.length < 300) bootRequests.push(r.url());
});
page.on('requestfailed', (r) => failedRequests.push({ url: r.url(), failure: r.failure() }));
try {
await page.goto('http://127.0.0.1:5264/browser-tests/history-org.html', {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
} catch (error) {
fs.writeFileSync(
path.join(artifacts, 'boot-error.json'),
JSON.stringify(
{
parent,
playwright,
name,
error: error.message,
bootRequests,
failedRequests,
errors,
requests,
body: await page
.locator('body')
.innerText()
.catch(() => ''),
},
null,
2,
),
);
throw error;
}
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
const disk = page.getByTestId('disk'),
pool = page.getByTestId('pool'),
service = page.getByTestId('service');
const thermal = disk.getByRole('img', { name: 'Temperature chart' });
const description = () =>
thermal.evaluate(
(el) => document.getElementById(el.getAttribute('aria-describedby')).textContent,
);
const waitThermal = async (value) => {
await page.waitForFunction((reading) => {
const canvas = document.querySelector('[data-testid="disk"] canvas');
return document
.getElementById(canvas.getAttribute('aria-describedby'))
.textContent.includes(reading);
}, `${value}°C`);
};
const inspect = async () => {
if (phone) await thermal.tap({ position: { x: 100, y: 80 } });
else {
await thermal.focus();
await thermal.press('End');
}
};
await waitThermal(42);
await service.getByRole('slider', { name: 'Inspect Utilization history' }).waitFor();
await inspect();
await check(
'Initial org-a observations and provenance are real rendered reader results',
async () => {
assert.match(await description(), /42°C/);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1);
assert.match(await disk.innerText(), /Buffer/i);
assert(requests.some((r) => r.org === 'org-a' && r.resourceType === 'disk'));
},
);
await screenshot('org-a-loaded');
modes['org-a'] = { mode: 'hold', value: 99, source: 'memory' };
await page.clock.fastForward(30_000);
await page.waitForTimeout(100);
await check(
'Ordinary same-org pending polling retains inspection and stored readings',
async () => {
assert(held.some((entry) => entry.org === 'org-a'));
assert.match(await description(), /42°C/);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1);
},
);
const beforeSwitch = requests.length;
await page.getByRole('button', { name: 'Switch to org-b' }).click();
await page.waitForTimeout(150);
await check('All exact targets are immediately reread with the new org header', async () => {
const next = requests.slice(beforeSwitch);
for (const id of ['disk:nas:sda', 'storage:nas:pool', 'pbs-service']) {
assert(next.some((r) => r.org === 'org-b' && r.resourceId === id));
}
assert(next.every((r) => r.org === 'org-b'));
});
await check(
'Pending org-b removes org-a values, min/max, provenance and grouped inspection',
async () => {
assert.doesNotMatch(await description(), /42°C/);
assert.doesNotMatch(await disk.innerText(), /Buffer|42°C/i);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0);
assert.equal(await pool.locator('[data-history-chart-tooltip]').count(), 0);
assert.equal(await service.getByRole('slider').count(), 0);
assert.equal((await disk.locator('[aria-live="polite"]').innerText()).trim(), '');
},
);
await screenshot('org-b-pending');
release('org-a', { mode: 'success', value: 99, source: 'memory' });
await page.waitForTimeout(150);
await check(
'Late pre-switch completion cannot resurrect readings or settle the new org',
async () => {
assert.doesNotMatch(await description(), /42°C|99°C/);
assert.doesNotMatch(await disk.innerText(), /Buffer|99°C/i);
assert.equal(await service.getByRole('slider').count(), 0);
},
);
await screenshot('late-org-a');
modes['org-b'] = { mode: 'success', value: 80, source: 'store' };
release('org-b', modes['org-b']);
await page.waitForTimeout(150);
await check(
'Only successful org-b observations restore History without old inspection',
async () => {
assert.match(await description(), /80°C/);
assert.doesNotMatch(await description(), /99°C|42°C/);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0);
assert.equal(await pool.locator('[data-history-chart-tooltip]').count(), 0);
assert.equal(await service.getByRole('slider').count(), 1);
},
);
// Allow the unchanged parent's next poll to catch up before testing the
// independent transient-outage control, instead of relying on its stale state.
await page.clock.fastForward(30_000);
await waitThermal(80);
await inspect();
await screenshot('org-b-loaded');
modes['org-b'] = { mode: '503' };
await page.clock.fastForward(30_000);
await page.waitForTimeout(300);
await check(
'A same-org 503 still retains its own successful readings with a warning',
async () => {
assert.match(await description(), /80°C/);
assert.match(
await disk.getByRole('status', { name: 'History refresh status' }).innerText(),
/last successful result/,
);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1);
},
);
await screenshot('org-b-outage');
await page.getByRole('button', { name: 'Switch to org-c' }).click();
await page.clock.fastForward(30_000);
await page.waitForTimeout(300);
await check(
'Failure in org-c cannot fall back to org-b values or its refresh warning',
async () => {
assert.doesNotMatch(await description(), /80°C|99°C|42°C/);
assert.match(await disk.getByRole('alert').innerText(), /Failed to load history data/);
assert.equal(
await disk.getByRole('status', { name: 'History refresh status' }).innerText(),
'',
);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0);
assert.equal(await service.getByRole('slider').count(), 0);
},
);
await screenshot('org-c-failed');
await page.getByRole('button', { name: 'Switch to org-d' }).click();
await page.clock.fastForward(30_000);
await waitThermal(61);
await check(
'New successful org-d data recovers; all requests keep their target/range/metric scope',
async () => {
assert.equal(await disk.getByRole('alert').count(), 0);
assert.equal(
await disk.getByRole('status', { name: 'History refresh status' }).innerText(),
'',
);
assert(requests.every((r) => r.range === '1h'));
assert(
requests
.filter((r) => r.resourceId === 'disk:nas:sda')
.every((r) => r.metric === 'smart_temp' && r.resourceType === 'disk'),
);
assert(
requests
.filter((r) => r.resourceId === 'storage:nas:pool')
.every((r) => r.metric === 'disk' && r.resourceType === 'storage'),
);
assert(
requests
.filter((r) => r.resourceId === 'pbs-service')
.every((r) => !r.metric && r.maxPoints === '240'),
);
},
);
await screenshot('org-d-recovered');
await check(
'Light desktop/dark touch phone states fit, with no page errors or off-origin data',
async () => {
assert(states.every((state) => !state.overflow));
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
},
);
results.push({
name,
engineVersion: browser.version(),
checks,
states,
requests,
errors,
offOrigin,
});
for (const entry of held) entry.resolve({ mode: '503' });
await browser.close();
browser = undefined;
}
fs.writeFileSync(
path.join(artifacts, 'result.json'),
JSON.stringify({ parent, playwright, results }, null, 2),
);
const failed = results.flatMap((result) => result.checks.filter((check) => !check.passed));
console.log(
JSON.stringify({
parent,
checks: results.flatMap((r) => r.checks).length,
failed: failed.length,
artifacts,
}),
);
if (failed.length) process.exitCode = 1;
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(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>History organisation boundary verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/history-org.tsx"></script>
</body>
</html>
@@ -0,0 +1,76 @@
// Production readers, org event/client and styles; all observations are synthetic.
// A stable resource ID across organisations must not preserve fetched History.
import { For } from 'solid-js';
import { render } from 'solid-js/web';
import { GuestDrawerHistory } from '../src/components/Workloads/GuestDrawerHistory';
import { HistoryChart, HistoryChartHoverGroup } from '../src/components/shared/HistoryChart';
import { HOST_METRICS_HISTORY_GROUPS } from '../src/components/shared/hostMetricsHistoryModel';
import { eventBus } from '../src/stores/events';
import { setOrgID } from '../src/utils/apiClient';
import '../src/index.css';
setOrgID('org-a');
function Fixture() {
const switchOrg = (org: string) => {
// Same ordering as useAppRuntimeState.handleOrgSwitch, with no authentication bypass.
setOrgID(org);
eventBus.emit('org_switched', org);
};
return (
<main class="mx-auto min-h-screen max-w-5xl space-y-5 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">History organisation boundary verification</h1>
<div class="flex flex-wrap gap-2">
<For each={['org-b', 'org-c', 'org-d']}>
{(org) => (
<button
class="min-h-11 rounded border border-border px-3"
onClick={() => switchOrg(org)}
>
Switch to {org}
</button>
)}
</For>
</div>
<HistoryChartHoverGroup>
<section data-testid="disk" class="rounded border border-border p-3">
<h2 class="mb-3 font-semibold">Disk History</h2>
<HistoryChart
resourceType="disk"
resourceId="disk:nas:sda"
metric="smart_temp"
label="Temperature"
unit="C"
range="1h"
height={160}
hideSelector
compact
/>
</section>
<section data-testid="pool" class="rounded border border-border p-3">
<h2 class="mb-3 font-semibold">Pool History</h2>
<HistoryChart
resourceType="storage"
resourceId="storage:nas:pool"
metric="disk"
label="Usage"
unit="%"
range="1h"
height={160}
hideSelector
compact
/>
</section>
</HistoryChartHoverGroup>
<section data-testid="service" class="space-y-3">
<h2 class="font-semibold">PBS service History (existing org-safe batch control)</h2>
<GuestDrawerHistory
target={{ resourceType: 'agent', resourceId: 'pbs-service' }}
range="1h"
groups={HOST_METRICS_HISTORY_GROUPS}
/>
</section>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -0,0 +1,94 @@
// Exercise the Installation Guide in the production Docs/router/Markdown/CSS.
// This checks rendered instructions and navigation, not native Docker startup.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = path.resolve('frontend-modern');
const artifacts = path.join(root, 'node_modules', 'installation-bootstrap-browser');
fs.mkdirSync(artifacts, { recursive: true });
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(root, 'node_modules', '.vite-installation-bootstrap'),
server: { host: '127.0.0.1', port: 5250, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
await page.goto('http://127.0.0.1:5250/browser-tests/docs-fragment-navigation.html',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark'));
await page.getByRole('link', { name: '← All documentation' }).click();
const installLink = page.getByRole('link', { name: 'Install Pulse', exact: true });
await installLink.focus();
await page.keyboard.press('Enter');
await page.getByRole('heading', { name: 'Docker Compose', exact: true }).waitFor();
const text = await page.locator('article').innerText();
for (const phrase of ['Leave authentication overrides unset for a new install',
'do not reset authentication', 'deployment-supplied password takes precedence',
'not a Compose interpolation recipe', 'Never share full docker inspect'])
assert.ok(text.includes(phrase), phrase);
assert.ok(!text.includes('secret123'));
const compose = page.locator('pre').filter({ hasText: 'services:' }).first();
const code = await compose.innerText();
for (const phrase of ['${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}',
'pulse_data:/data', 'PULSE_DEPLOYMENT_METHOD=docker_compose'])
assert.ok(code.includes(phrase), phrase);
assert.ok(!code.includes('PULSE_AUTH_'));
await compose.scrollIntoViewIfNeeded();
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-compose.png`) });
const warning = page.getByText('For an existing installation, preserve its image', { exact: false });
await warning.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(artifacts, `${engine}-existing-install.png`) });
const bootstrap = page.getByRole('link', { name: 'bootstrap-token setup', exact: true }).last();
await bootstrap.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const h = document.getElementById('step-1-get-the-token');
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/INSTALL#step-1-get-the-token'));
assert.ok((await page.locator('article').innerText()).includes('docker exec pulse /app/pulse bootstrap-token'));
await page.screenshot({ path: path.join(artifacts, `${engine}-bootstrap.png`) });
const auth = page.getByRole('link', { name: 'authentication guide', exact: true });
await auth.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const h = document.getElementById('private-docker-authentication-file');
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file'));
assert.ok((await page.locator('article').innerText()).includes('not out of Docker\'s container environment'));
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width, tone,
noSharedComposeCredentials: true, paidImageAndDataPreserved: true,
existingInstallWarning: true, keyboardBootstrapAndPrivateFileLinks: true, errors });
await browser.close();
browser = undefined;
}
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'rendered installation guidance and links; not native Docker or password changes', results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,123 @@
// Production Docs/router/styles with the actual shipped Markdown; no backend.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules', 'navigation-help-browser');
fs.mkdirSync(artifacts, { recursive: true });
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(root, 'node_modules', '.vite-navigation-help'),
server: { host: '127.0.0.1', port: 5247, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
browser = await { chromium, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
});
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.goto('http://127.0.0.1:5247/browser-tests/docs-fragment-navigation.html?scenario=troubleshooting',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.getByRole('heading', { name: "Old bookmarks don't work", exact: true }).waitFor();
if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark'));
const routes = page.locator('article table').filter({ hasText: '/standalone/machines' });
assert.equal(await routes.count(), 1);
const entryRoutes = await routes.locator('tbody tr').evaluateAll((rows) =>
rows.map((row) => Array.from(row.cells, (cell) => cell.textContent.trim())),
);
assert.deepEqual(entryRoutes, [
['Proxmox', '/proxmox/overview'], ['Docker', '/docker/overview'],
['Kubernetes', '/kubernetes/overview'], ['TrueNAS', '/truenas/overview'],
['vSphere', '/vmware/overview'], ['Machines', '/standalone/machines'],
]);
const article = await page.locator('article').innerText();
assert.ok(article.includes('are supported; do not replace them with the retired task-based routes.'));
assert.ok(article.includes('Do not delete connections or re-enrol agents'));
await routes.scrollIntoViewIfNeeded();
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-routes.png`) });
await page.getByRole('link', { name: 'FAQ', exact: true }).click();
const faq = page.getByRole('heading', { name: 'How is navigation organised in Pulse v6?', exact: true });
await faq.waitFor();
await page.waitForFunction(() => {
const heading = document.getElementById('how-is-navigation-organised-in-pulse-v6');
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/FAQ#how-is-navigation-organised-in-pulse-v6'));
await page.getByRole('link', { name: '← All documentation' }).click();
await page.getByRole('link', { name: 'TrueNAS SCALE and CORE', exact: true }).click();
const inventory = page.locator('article table').filter({ hasText: 'Pulse page / tab' });
await inventory.waitFor();
const destinations = await inventory.locator('tbody tr td:nth-child(2)').allTextContents();
assert.deepEqual(destinations, ['TrueNAS → Overview', 'TrueNAS → VMs', 'TrueNAS → Apps',
'TrueNAS → Storage', 'TrueNAS → Storage', 'TrueNAS → Storage',
'TrueNAS → Protection', 'TrueNAS → Protection', 'Alerts']);
const mapping = await page.locator('article').innerText();
for (const route of ['/truenas/overview', '/truenas/vms', '/truenas/apps', '/truenas/storage', '/truenas/protection'])
assert.ok(mapping.includes(route));
await inventory.scrollIntoViewIfNeeded();
const geometry = await inventory.evaluate((table) => ({
localScroll: table.parentElement.dataset.docTableScroll !== undefined,
viewport: innerWidth,
outer: document.documentElement.scrollWidth,
table: table.parentElement.scrollWidth,
wrapper: table.parentElement.clientWidth,
}));
assert.ok(geometry.localScroll);
assert.ok(geometry.outer <= geometry.viewport + 1);
if (width === 390 && geometry.table > geometry.wrapper + 1) {
const movement = await inventory.evaluate((table) => {
table.parentElement.scrollLeft = 180;
return table.parentElement.scrollLeft;
});
assert.ok(movement > 0, 'wide tables must remain locally scrollable');
} else {
assert.ok(geometry.table <= geometry.wrapper + 1, 'wrapped table must fit its container');
}
await page.screenshot({ path: path.join(artifacts, `${engine}-truenas.png`) });
await page.getByRole('link', { name: '← All documentation' }).click();
await page.getByRole('link', { name: 'Proxmox Backup Server', exact: true }).click();
const pbs = page.getByRole('heading', { name: 'Data Source Indicator', exact: true });
await pbs.waitFor();
const pbsText = await page.locator('article').innerText();
assert.ok(pbsText.includes('Proxmox → Backups (/proxmox/backups)'));
assert.ok(pbsText.includes('There is no current top-level Recovery page'));
await pbs.scrollIntoViewIfNeeded();
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-pbs.png`) });
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width, tone, routes: 6, destinations,
geometry, faqFragment: true, pbsPlacement: true, errors });
await browser.close();
browser = undefined;
}
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'shipped help rendering and navigation, not appliance collection', results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,407 @@
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 output = '/workspace/tmp/notification-evidence-proof';
const root = '/workspace/frontend-modern';
const runtime = [
'frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx',
'frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts',
'frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts',
'frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx',
'frontend-modern/src/utils/alertDestinationsPresentation.ts',
];
const log = (entries = []) => ({
entries,
window_days: 30,
completed_retention_days: 7,
dead_letter_retention_days: 30,
});
const sent = {
notificationId: 'synthetic-attempt',
type: 'email',
outcome: 'sent',
alertIds: ['synthetic-disk-alert'],
alertCount: 1,
attempts: 1,
success: true,
timestamp: '2026-10-03T10:00:00Z',
};
const held = [
{
id: 42,
type: 'notification_deferred',
alertId: 'synthetic-held-alert',
resourceName: 'synthetic-nas',
alertType: 'usage',
reason: 'quiet_hours:performance',
occurredAt: '2026-10-03T10:01:00Z',
},
];
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(
runtime.map((p) => [
p,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', p)))
.digest('hex'),
]),
);
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: 5291, strictPort: true },
});
const results = [],
screens = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['desktop', chromium, 1365, false],
['phone', webkit, 390, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(20000);
const errors = [],
offOrigin = [],
writes = [],
requests = [],
checks = [],
releases = [];
let attemptMode = 'hold-empty',
heldMode = 'hold-empty';
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', async (route) => {
const request = route.request(),
url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5291') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (request.method() !== 'GET') {
writes.push({ method: request.method(), path: url.pathname });
return route.abort();
}
if (
url.pathname === '/api/notifications/delivery-log' ||
url.pathname === '/api/alerts/events'
) {
const isHeld = url.pathname === '/api/alerts/events';
const mode = isHeld ? heldMode : attemptMode;
requests.push({ path: url.pathname, query: url.search, mode });
if (mode.startsWith('hold-'))
await new Promise((resolve) => releases.push({ isHeld, resolve }));
if (mode.endsWith('denied') || mode.endsWith('unavailable'))
return route.fulfill({
status: isHeld && mode.endsWith('denied') ? 403 : 503,
json: { error: 'Synthetic unavailable evidence' },
});
return route.fulfill({
json: isHeld
? mode.endsWith('known')
? held
: []
: log(mode.endsWith('known') ? [sent] : []),
});
}
if (url.pathname === '/api/notifications/health')
return route.fulfill({
json: {
overall_healthy: true,
queue: {
pending: 0,
sending: 0,
sent: 1,
failed: 0,
dlq: 0,
healthy: true,
status: 'healthy',
attention_required: 0,
reason_codes: [],
completed_retention_days: 7,
dead_letter_retention_days: 30,
counts_are_retention_bounded: true,
},
},
});
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
},
});
if (url.pathname === '/api/license/entitlements')
return route.fulfill({ json: { entitlements: [] } });
if (url.pathname === '/api/security/status')
return route.fulfill({ json: { hasAuthentication: true, requiresAuth: false } });
return route.fulfill({ json: [] });
});
const check = async (label, action) => {
await action();
checks.push({ label, passed: true });
};
const activity = page.locator('#notification-delivery-activity');
const empty = activity.getByText(/No alert deliveries were attempted/);
const heldWarning = activity.getByText(/could not read held or deferred notifications/);
const attemptWarning = activity.getByText(/could not read the delivery log/);
const refresh = activity.getByRole('button', { name: 'Refresh delivery status' });
const release = (isHeld) => {
for (const pending of releases.splice(0).filter((item) => {
if (item.isHeld === isHeld) {
item.resolve();
return false;
}
return true;
}))
releases.push(pending);
};
const capture = async (state) => {
const filename = `${name}-${state}.png`;
await activity.screenshot({ path: path.join(output, filename) });
screens.push({
file: filename,
state,
viewport: { width, height: width === 390 ? 844 : 900 },
sha256: crypto
.createHash('sha256')
.update(fs.readFileSync(path.join(output, filename)))
.digest('hex'),
});
};
try {
await page.goto('http://127.0.0.1:5291/browser-tests/notification-evidence.html');
await page.evaluate(
(dark) => document.documentElement.classList.toggle('dark', dark),
dark,
);
await check('Initial reads are loading, not empty', async () => {
await activity.getByText('Loading delivery attempts...').waitFor();
await activity.getByText('Loading held and deferred notifications...').waitFor();
assert.equal(await empty.count(), 0);
});
await check(
'A slow held read does not block the successful attempts read or its refresh',
async () => {
release(false);
await activity.getByText('Loading delivery attempts...').waitFor({ state: 'hidden' });
assert.equal(await refresh.isEnabled(), true);
assert.equal(await empty.count(), 0);
await activity.getByText('Loading held and deferred notifications...').waitFor();
},
);
await capture('held-pending');
await check('Only two completed empty reads establish the empty window', async () => {
release(true);
await empty.waitFor();
assert.equal(await activity.getByRole('status').count(), 0);
});
await check(
'Keyboard or touch refresh shows attempts and held events from their real API adapters',
async () => {
attemptMode = heldMode = 'known';
if (width === 390) await refresh.tap();
else {
await refresh.focus();
await page.keyboard.press('Enter');
}
await activity.getByText('Delivered', { exact: true }).waitFor();
await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor();
assert.equal(await empty.count(), 0);
},
);
await check(
'The CI locator matches the 503 warning after the held row is withdrawn',
async () => {
heldMode = 'unavailable';
await refresh.focus();
await page.keyboard.press('Enter');
await heldWarning.waitFor();
const broadTexts = await page.getByText('Deferred').allTextContents();
const exactBadgeCount = await activity.getByText('Deferred', { exact: true }).count();
assert.deepEqual(broadTexts, [
'Pulse could not read held or deferred notifications. Refresh to try again.',
]);
assert.equal(exactBadgeCount, 0);
assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1);
checks.push({ label: 'Observed broad versus exact status match', broadTexts, exactBadgeCount });
},
);
await capture('held-503-ci-locator');
await check('A healthy refresh restores the held row before permission withdrawal', async () => {
heldMode = 'known';
await refresh.click();
await activity.getByText('Deferred', { exact: true }).waitFor();
await heldWarning.waitFor({ state: 'hidden' });
});
await check(
'Held-event 403 withdraws held rows and warns without hiding delivered evidence',
async () => {
heldMode = 'denied';
await refresh.click();
await heldWarning.waitFor();
assert.equal(
await activity.getByText('synthetic-nas (usage)', { exact: true }).count(),
0,
);
assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1);
assert.equal(await empty.count(), 0);
},
);
await capture('held-unavailable');
await check('The held warning persists during a pending retry', async () => {
heldMode = 'hold-empty';
await refresh.click();
await activity.getByText('Loading held and deferred notifications...').waitFor();
assert.equal(await heldWarning.count(), 1);
await refresh.waitFor({ state: 'visible' });
await page.waitForFunction(
() => !document.querySelector('#notification-delivery-activity button').disabled,
);
assert.equal(await empty.count(), 0);
release(true);
await heldWarning.waitFor({ state: 'hidden' });
});
await check(
'An attempt-read failure keeps independently readable held evidence',
async () => {
attemptMode = 'denied';
heldMode = 'known';
await refresh.click();
await attemptWarning.waitFor();
await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor();
assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 0);
assert.equal(await empty.count(), 0);
},
);
await capture('attempts-unavailable');
await check('Both failed sources are reported, not called empty', async () => {
heldMode = 'denied';
await refresh.click();
await heldWarning.waitFor();
assert.equal(await attemptWarning.count(), 1);
assert.equal(await activity.getByRole('listitem').count(), 0);
assert.equal(await empty.count(), 0);
});
await check(
'Both successful reads clear the warnings and restore a genuine empty state',
async () => {
attemptMode = heldMode = 'empty';
await refresh.click();
await empty.waitFor();
assert.equal(await activity.getByRole('alert').count(), 0);
assert.equal(await activity.getByRole('status').count(), 0);
},
);
await check(
'No horizontal clipping, JavaScript page error, off-origin request or mutation',
async () => {
assert.equal(
await activity.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
true,
);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert.deepEqual(writes, []);
assert(
requests
.filter((r) => r.path === '/api/alerts/events')
.every(
(r) => r.query.includes('limit=100') && r.query.includes('notification_deferred'),
),
);
assert(
requests
.filter((r) => r.path === '/api/notifications/delivery-log')
.every((r) => r.query === '?limit=200'),
);
},
);
results.push({
name,
browser_version: browser.version(),
dark,
checks,
requests,
errors,
offOrigin,
writes,
});
} finally {
for (const pending of releases) pending.resolve();
await browser.close();
browser = null;
}
}
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
passed: true,
playwright_version: playwrightVersion,
content_sha256: hashes,
changed_paths: runtime,
results,
screenshots: screens,
},
null,
2,
) + '\n',
);
console.log(
JSON.stringify({
passed: true,
checks: results.reduce((n, r) => n + r.checks.length, 0),
screenshots: screens.length,
playwright_version: playwrightVersion,
results: results.map((r) => ({ name: r.name, browser_version: r.browser_version })),
}),
);
} catch (error) {
fs.writeFileSync(
path.join(output, 'failed.json'),
JSON.stringify(
{ passed: false, error: error.message, results, screenshots: screens },
null,
2,
) + '\n',
);
throw error;
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(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>Notification evidence verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./notification-evidence.tsx"></script>
</body>
</html>
@@ -0,0 +1,69 @@
// The production Notifications tab and API readers with synthetic read responses.
// No notification is sent, retried or dismissed by this fixture.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { DestinationsTab } from '../src/features/alerts/tabs/DestinationsTab';
import type { UIEmailConfig, UIAppriseConfig } from '../src/features/alerts/types';
import '../src/index.css';
function Fixture() {
const [emailConfig, setEmailConfig] = createSignal<UIEmailConfig>({
enabled: false,
provider: 'smtp',
server: '',
port: 587,
username: '',
password: '',
from: '',
to: [],
tls: true,
startTLS: true,
replyTo: '',
maxRetries: 3,
retryDelay: 5,
rateLimit: 60,
});
const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>({
enabled: false,
mode: 'cli',
targetsText: '',
cliPath: '',
timeoutSeconds: 20,
serverUrl: '',
configKey: '',
apiKey: '',
apiKeyHeader: 'X-API-KEY',
skipTlsVerify: false,
hasApiKey: false,
});
return (
<main class="mx-auto max-w-4xl p-3 text-base-content">
<h1 class="mb-4 text-xl font-semibold">Notifications</h1>
<DestinationsTab
emailConfig={emailConfig}
setEmailConfig={setEmailConfig}
appriseConfig={appriseConfig}
setAppriseConfig={setAppriseConfig}
setHasUnsavedChanges={() => {}}
configLoadError={() => null}
isRetrying={() => false}
isLoadingDestinations={() => false}
onRetryLoad={() => {}}
webhooks={() => []}
deadManPingUrl={() => ''}
setDeadManPingUrl={() => {}}
pushMinimumSeverity={() => 'all'}
setPushMinimumSeverity={() => {}}
/>
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,419 @@
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');
(async () => {
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/operator-tables-proof';
fs.mkdirSync(output, { recursive: true });
process.chdir(root);
const playwright = require('playwright/package.json').version;
assert.equal(
playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const paths = JSON.parse(fs.readFileSync(path.join(output, 'runtime-paths.json')));
const hashes = Object.fromEntries(
paths.map((p) => [
p,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', p)))
.digest('hex'),
]),
);
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: 5271, strictPort: true },
});
const results = [],
screens = [];
let browser;
try {
await server.listen();
for (const [name, engine, width] of [
['desktop', chromium, 1365],
['phone', webkit, 390],
['intermediate', chromium, 768],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
const errors = [],
offOrigin = [],
checks = [],
requests = [];
let rollbackMode = 'reject',
finishRollback;
page.on('pageerror', (e) => errors.push(e.message));
page.setDefaultTimeout(20000);
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5271') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname === '/api/updates/history')
return route.fulfill({
json: [
{
event_id: 'fixture-update-1',
timestamp: '2026-10-02T12:00:00Z',
action: 'update',
channel: 'stable',
version_from: '6.4.5',
version_to: '6.4.6',
deployment_type: 'systemd',
initiated_by: 'user',
initiated_via: 'ui',
status: 'success',
duration_ms: 30000,
backup_path: '/private/fixture-backup',
notes: 'untrusted restore-everything note',
},
],
});
if (url.pathname === '/api/version')
return route.fulfill({
json: {
version: '6.6.0-dev',
build: 'synthetic',
runtime: 'community',
isDocker: false,
isSourceBuild: true,
isDevelopment: false,
},
});
if (url.pathname === '/api/updates/rollback') {
requests.push({ method: route.request().method(), body: route.request().postDataJSON() });
if (rollbackMode === 'hold')
await new Promise((resolve) => {
finishRollback = resolve;
});
return rollbackMode === 'accept'
? route.fulfill({
json: { status: 'accepted', message: 'Synthetic acceptance, not recovery' },
})
: route.fulfill({
status: 409,
json: { error: 'Synthetic incompatible backup scope' },
});
}
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
},
});
if (url.pathname === '/api/license/entitlements')
return route.fulfill({ json: { entitlements: [] } });
if (url.pathname.includes('/facets'))
return route.fulfill({
json: { facets: [], capabilities: [], relationships: [], recentChanges: [] },
});
if (url.pathname === '/api/security/status')
return route.fulfill({
json: {
hasAuthentication: true,
requiresAuth: true,
settingsCapabilities: { authenticationWrite: true },
},
});
if (url.pathname === '/api/metrics-store/history')
return route.fulfill({ json: { points: [], metrics: {}, source: 'store' } });
return route.fulfill({
json: {
enabled: false,
data: [],
alerts: {},
facets: [],
capabilities: [],
relationships: [],
recentChanges: [],
},
});
});
const check = async (label, run) => {
try {
await run();
checks.push({ label, passed: true });
} catch (e) {
checks.push({ label, passed: false, error: e.message });
}
};
const select = async (name) => {
await page
.getByRole('navigation', { name: 'Fixture views' })
.getByRole('button', { name, exact: true })
.click();
};
const capture = async (state) => {
// Await the production entrance animation instead of capturing translucent frames.
if (await page.getByRole('dialog').count()) {
await page.waitForFunction(() => {
const panel = document.querySelector('[role=dialog]');
return panel && Number(getComputedStyle(panel).opacity) >= 0.999;
});
}
await page.waitForTimeout(100); // ResizeObserver layout, not freshness proof.
const p = `${name}-${state}.png`;
await page.screenshot({ path: path.join(output, p), fullPage: true });
const dimensions = await page.evaluate(() => ({
width: innerWidth,
scroll: document.documentElement.scrollWidth,
}));
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
screens.push({ file: p, state, browser: name, width, dimensions });
};
await page.goto('http://127.0.0.1:5271/browser-tests/operator-tables-consent.html', {
waitUntil: 'domcontentloaded',
timeout: 120000,
});
if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark'));
await check('Pool state and measured capacity survive the phone projection', async () => {
const pool = page.getByRole('region', { name: 'Pool table' });
await pool.getByText('archive-tank', { exact: true }).waitFor();
await pool.getByText('Degraded', { exact: true }).waitFor();
const state = pool.locator('tbody td[data-storage-column="state"]').first();
const bounds = await state.evaluate((e) => ({
width: e.clientWidth,
scroll: e.scrollWidth,
text: e.textContent,
}));
assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
if (width >= 544) {
await pool.getByText('Resilvering 45%', { exact: true }).waitFor();
await pool.getByText('Scrubbing 45%', { exact: true }).waitFor();
assert.ok(
await pool
.locator('[title="ZFS pool archive-tank resilver is running (45.2%)"]')
.count(),
);
} else {
assert.ok((await pool.getByText('59%', { exact: true }).count()) >= 2);
}
await capture('pools');
});
if (width !== 768) {
await select('Disks');
await check('Disk row and expanded header retain the full SMART reason', async () => {
await page.getByText('Archive HDD', { exact: true }).waitFor();
await page
.locator('[data-row-id="disk-sda"]')
.getByText('Archive HDD', { exact: true })
.click();
const health = page.getByTestId('disk-detail-health');
await health.getByText('Replace Now', { exact: true }).waitFor();
assert.ok(
(await health.innerText()).includes('SMART failed. The full reason remains readable'),
);
const bounds = await health.evaluate((e) => ({
width: e.clientWidth,
scroll: e.scrollWidth,
}));
assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
await capture('disk-reason');
});
await select('Controllers');
await check(
'Job target and absolute started/completed times are reachable from a narrow row',
async () => {
await page.getByText('nightly-import', { exact: true }).click();
const details = page.getByTestId('resource-kubernetes-controller-section');
await details.getByText('Started', { exact: true }).waitFor();
await details.getByText('Completed', { exact: true }).waitFor();
await details.getByText('5m', { exact: true }).waitFor();
await details.getByText('1 completion', { exact: true }).waitFor();
assert.ok((await details.innerText()).includes('2026'));
await capture('job-times');
},
);
await check(
'CronJob retains last-run, last-success, schedule and suspension in its expansion',
async () => {
await page.getByText('billing-rollup', { exact: true }).click();
const details = page.getByTestId('resource-kubernetes-controller-section');
await details.getByText('Last run', { exact: true }).waitFor();
await details.getByText('Last success', { exact: true }).waitFor();
await details.getByText('*/5 * * * *', { exact: true }).waitFor();
await details.getByText('Suspended', { exact: true }).waitFor();
await capture('cron-times');
},
);
}
await select('Backups');
await check(
'Coverage last backup remains older than a fresh local guest snapshot',
async () => {
const coverage = page.locator('[data-proxmox-backups-table="coverage"]');
await coverage.waitFor();
const columns = await coverage
.locator('colgroup col')
.evaluateAll((els) => els.map((e) => e.dataset.proxmoxBackupsColumn));
const row = coverage.locator('tr[data-proxmox-backup-row=coverage]');
const backupAge = await row.locator('td').nth(columns.indexOf('latest')).innerText();
assert.ok(backupAge.includes('13d'), backupAge);
assert.ok(!backupAge.includes('30m'), backupAge);
if (columns.includes('snapshot')) {
assert.ok(
(await row.locator('td').nth(columns.indexOf('snapshot')).innerText()).includes(
'30m',
),
);
}
await capture('backup-attribution');
},
);
await check(
'By date keeps archive format visible and full identifier available at wide widths',
async () => {
const byDate = page.locator('[data-proxmox-backups-table="recoverable"]');
await byDate.waitFor();
if (width >= 1120) {
await byDate.getByText('tar.zst', { exact: true }).waitFor();
assert.ok(
await byDate
.locator('[title="local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst"]')
.count(),
);
}
assert.ok((await page.getByText('artifact-cache-310', { exact: true }).count()) >= 2);
},
);
if (width !== 768) {
await select('Consent');
await check(
'Consent separates running/backup versions and does not promise active-data restoration',
async () => {
const action = page.getByRole('button', { name: 'Roll back', exact: true });
await action.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Confirm rollback' });
await dialog.getByText('Roll back to Pulse v6.4.5?', { exact: true }).waitFor();
await dialog.getByText('Running now: Pulse v6.6.0-dev.', { exact: true }).waitFor();
const text = await dialog.innerText();
assert.ok(text.includes('Restore scope is not reported for this backup.'));
assert.ok(text.includes('Older recovery can also replace install-local data'));
assert.ok(text.includes('Do not assume this will undo settings or alerts.'));
assert.ok(!text.includes('/private/fixture-backup'));
assert.ok(!text.includes('untrusted restore-everything note'));
assert.equal(
await dialog
.getByRole('link', { name: 'Read the recovery instructions' })
.getAttribute('href'),
'/docs/AUTO_UPDATE#manual-rollback',
);
assert.equal(requests.length, 0);
await capture('consent');
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
assert.equal(await page.getByRole('dialog').count(), 0);
assert.equal(requests.length, 0);
assert.equal(await action.evaluate((e) => e === document.activeElement), true);
},
);
await check(
'Rejection retains consent and pending submission cannot be duplicated or cancelled',
async () => {
await page.getByRole('button', { name: 'Roll back', exact: true }).click();
await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
await page
.getByText('Synthetic incompatible backup scope', { exact: true })
.first()
.waitFor();
assert.equal(requests.length, 1);
assert.ok(
(await page.getByRole('dialog').innerText()).includes(
'Restore scope is not reported',
),
);
await capture('consent-rejected');
rollbackMode = 'hold';
await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
await page.waitForFunction(
() => document.querySelector('button:disabled')?.textContent,
);
assert.ok(
await page
.getByRole('button', { name: 'Starting rollback...', exact: true })
.isDisabled(),
);
assert.ok(await page.getByRole('button', { name: 'Cancel', exact: true }).isDisabled());
await page.keyboard.press('Escape');
assert.equal(await page.getByRole('dialog').count(), 1);
while (!finishRollback) await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(requests.length, 2);
rollbackMode = 'accept';
finishRollback();
await page.getByRole('dialog').waitFor({ state: 'hidden' });
assert.deepEqual(requests, [
{ method: 'POST', body: { eventId: 'fixture-update-1' } },
{ method: 'POST', body: { eventId: 'fixture-update-1' } },
]);
},
);
}
await check('No uncaught application errors or external requests', async () => {
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
});
results.push({
name,
width,
browser: browser.version(),
checks,
errors,
offOrigin,
rollbackRequests: requests,
});
await page.close();
await browser.close();
browser = undefined;
}
} finally {
if (browser) await browser.close();
await server.close();
}
const result = {
result: results.every((r) => r.checks.every((c) => c.passed)) ? 'passed' : 'failed',
playwright,
base_sha: '697ad83e19fa5e8afee634c212c945600d2292b4',
source_content_sha256: hashes,
results,
screens,
};
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
console.log(
JSON.stringify({
result: result.result,
checks: results.flatMap((r) => r.checks).length,
failed: results.flatMap((r) => r.checks.filter((c) => !c.passed)),
screens: screens.length,
}),
);
if (result.result !== 'passed') process.exitCode = 1;
})().catch((e) => {
console.error(e);
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>Operator tables and rollback consent verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./operator-tables-consent.tsx"></script>
</body>
</html>
@@ -0,0 +1,399 @@
// Production components and CSS with synthetic observations. No native, released or
// installed recovery claim; the current history API does not describe restore scope.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable';
import { DiskList } from '../src/components/Storage/DiskList';
import { KubernetesControllersTable } from '../src/features/kubernetes/KubernetesControllersTable';
import { ProxmoxCoverageTable } from '../src/features/proxmox/ProxmoxCoverageTable';
import { ProxmoxRecoverableTable } from '../src/features/proxmox/ProxmoxRecoverableTable';
import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable';
import { ProxmoxNodesTable } from '../src/features/proxmox/ProxmoxNodesTable';
import { GuestRow } from '../src/components/Workloads/GuestRow';
import { Table, TableBody } from '../src/components/shared/Table';
import { ToastContainer } from '../src/components/Toast/Toast';
import { UpdateHistorySection } from '../src/components/Settings/UpdateHistorySection';
import { buildProxmoxBackupRecoveryModel } from '../src/features/proxmox/proxmoxBackupRecoveryModel';
import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore';
import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState';
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
import { updateStore } from '../src/stores/updates';
import type { Resource } from '../src/types/resource';
import type { StorageRecord } from '../src/features/storageBackups/models';
import '../src/index.css';
const now = Date.parse('2026-10-03T12:00:00Z');
const resource = (id: string, type: Resource['type'], extra: Partial<Resource> = {}): Resource => ({
id,
type,
name: id,
displayName: id,
platformId: 'fixture-estate',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'online',
lastSeen: now,
...extra,
});
const pools: StorageRecord[] = [
['archive-tank', 'DEGRADED', 'resilver'],
['backup-pool', 'ONLINE', 'scrub'],
].map(([name, state, scan], index) => ({
id: name,
name,
category: 'pool',
health: index ? 'healthy' : 'warning',
location: { label: index ? 'backup-nas' : 'archive-nas', scope: 'host' },
source: {
platform: 'truenas',
family: 'onprem',
origin: 'resource',
adapterId: 'resource-storage',
},
capacity: { totalBytes: 2e12, usedBytes: 1.18e12, freeBytes: 0.82e12, usagePercent: 59 },
capabilities: ['capacity', 'health'],
observedAt: now,
protectionLabel: `ZFS pool ${name} ${scan} is running (45.2%)`,
protectionSummary: `ZFS pool ${name} ${scan} is running (45.2%)`,
rebuildInProgress: true,
details: {
zfsPool: {
state,
scan: `${scan} in progress`,
scanDetails: {
function: scan.toUpperCase(),
state: 'SCANNING',
percentage: 45.2,
},
devices: [],
},
},
}));
const disks = [
resource('disk-sda', 'physical_disk', {
identity: { hostname: 'archive-nas' },
canonicalIdentity: { hostname: 'archive-nas' },
physicalDisk: {
devPath: '/dev/sda',
model: 'Archive HDD',
serial: 'FIXTURE-1',
diskType: 'hdd',
sizeBytes: 2e12,
health: 'FAILED',
temperature: 42,
storageRole: 'data',
risk: {
level: 'critical',
reasons: [
{
code: 'smart-failed',
severity: 'critical',
summary: 'SMART failed. The full reason remains readable in the expanded disk header.',
},
],
},
},
}),
resource('disk-nvme', 'physical_disk', {
identity: { hostname: 'backup-nas' },
canonicalIdentity: { hostname: 'backup-nas' },
physicalDisk: {
devPath: '/dev/nvme0n1',
model: 'Backup SSD',
serial: 'FIXTURE-2',
diskType: 'nvme',
sizeBytes: 1e12,
health: 'PASSED',
temperature: 44,
wearout: 2,
storageRole: 'cache',
},
}),
];
const controllers = [
resource('nightly-import', 'k8s-job', {
platformType: 'kubernetes',
kubernetes: {
resourceKind: 'Job',
clusterName: 'prod',
namespace: 'batch',
startTime: '2026-10-03T11:00:00Z',
completionTime: '2026-10-03T11:05:00Z',
desiredReplicas: 1,
succeeded: 1,
active: 0,
failed: 0,
},
}),
resource('billing-rollup', 'k8s-cronjob', {
platformType: 'kubernetes',
kubernetes: {
resourceKind: 'CronJob',
clusterName: 'prod',
namespace: 'batch',
schedule: '*/5 * * * *',
lastScheduleTime: '2026-10-03T11:00:00Z',
lastSuccessfulTime: '2026-10-03T11:05:00Z',
suspend: true,
active: 0,
},
}),
resource('node-exporter', 'k8s-daemonset', {
platformType: 'kubernetes',
kubernetes: {
resourceKind: 'DaemonSet',
clusterName: 'prod',
namespace: 'observability',
desiredNumberScheduled: 3,
currentNumberScheduled: 3,
numberReady: 2,
numberUnavailable: 1,
},
}),
resource('database', 'k8s-statefulset', {
platformType: 'kubernetes',
kubernetes: {
resourceKind: 'StatefulSet',
clusterName: 'prod',
namespace: 'apps',
desiredReplicas: 1,
readyReplicas: 1,
serviceName: 'database-headless',
},
}),
];
const workload = resource('ct-310', 'system-container', {
name: 'artifact-cache-310',
displayName: 'artifact-cache-310',
status: 'running',
proxmox: { vmid: 310, node: 'pve-edge', instance: 'homelab' },
});
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload],
pbsBackups: [],
tasks: [],
nowMs: now,
archives: [
{
id: 'file-310',
storage: 'local',
node: 'pve-edge',
instance: 'homelab',
type: 'ct',
vmid: 310,
time: '2026-09-20T02:00:00Z',
ctime: Date.parse('2026-09-20T02:00:00Z') / 1000,
size: 1048576,
format: 'tar.zst',
protected: false,
volid: 'local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst',
isPBS: false,
verified: false,
},
],
snapshots: [
{
id: 'snap-310',
name: 'fresh-local-snapshot',
node: 'pve-edge',
instance: 'homelab',
type: 'ct',
vmid: 310,
time: '2026-10-03T11:30:00Z',
vmstate: false,
},
],
});
const pbs = resource('pbs-archive', 'pbs', {
platformType: 'proxmox-pbs',
cpu: { current: 14 },
memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
pbs: {
instance: 'pbs-archive',
datastores: [
{
name: 'archive',
total: 2e12,
used: 1.18e12,
free: 0.82e12,
usage: 59,
status: 'available',
},
],
},
});
const node = resource('pve-edge', 'node', {
cpu: { current: 12, cores: 4 },
memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
disk: { current: 30, total: 1e12, used: 0.3e12, free: 0.7e12 },
proxmox: { node: 'pve-edge', instance: 'homelab', uptime: 86400 },
});
function Fixture() {
const [view, setView] = createSignal('Pools');
const [disk, setDisk] = createSignal<string | null>(null);
const [pool, setPool] = createSignal<string | null>(null);
const [expanded, setExpanded] = createSignal(new Set<string>());
const ws = {
state: { pmg: [], pbs: [], nodes: [], resources: [], vms: [], containers: [] },
activeAlerts: () => ({}),
connected: () => true,
initialDataReceived: () => true,
};
return (
<WebSocketContext.Provider value={ws as any}>
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<ToastContainer />
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Operator tables and rollback consent</h1>
<nav aria-label="Fixture views" class="flex flex-wrap gap-2">
{['Pools', 'Disks', 'Controllers', 'Backups', 'Consent'].map((name) => (
<button
type="button"
class="min-h-11 rounded border border-border px-3"
onClick={() => setView(name)}
>
{name}
</button>
))}
</nav>
<Show when={view() === 'Pools'}>
<section aria-label="Pool table">
<StoragePoolsTable
groupedRecords={groupStorageRecords(pools, 'none')}
groupBy="none"
expandedGroups={new Set(['All'])}
toggleGroup={() => {}}
expandedPoolId={pool()}
setExpandedPoolId={setPool}
storageGrowthBySeriesId={new Map()}
storageGrowthColumnLabel="Growth"
physicalDisks={[]}
nodeOnlineByLabel={new Map()}
highlightedRecordId={null}
getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
isLoading={false}
/>
</section>
</Show>
<Show when={view() === 'Disks'}>
<section aria-label="Disk table">
<DiskList
disks={disks}
nodes={[]}
selectedNode={null}
searchTerm=""
selectedDiskId={disk()}
onSelectedDiskChange={setDisk}
/>
</section>
</Show>
<Show when={view() === 'Controllers'}>
<section aria-label="Controller table">
<KubernetesControllersTable
resources={controllers}
emptyIcon={<span />}
emptyTitle="No controllers"
emptyDescription="No controllers"
showToolbar={false}
/>
</section>
</Show>
<Show when={view() === 'Backups'}>
<section aria-label="Backup tables" class="space-y-4">
<h2>Coverage</h2>
<ProxmoxCoverageTable
rows={model.coverageRows}
hasAnyRows
emptyIcon={<span />}
emptyTitle="No backups"
emptyDescription="No backups"
sortKey={() => 'posture'}
sortDirection={() => 'asc'}
onSort={() => {}}
expandedKeys={expanded()}
onToggleExpand={(key) =>
setExpanded((current) =>
current.has(key)
? new Set([...current].filter((item) => item !== key))
: new Set([...current, key]),
)
}
showPbsColumn
showArchiveColumn
showSnapshotColumn
showTaskColumn={false}
/>
<h2>By date</h2>
<ProxmoxRecoverableTable
artifacts={model.recoverableArtifacts}
hasAnyArtifacts
emptyIcon={<span />}
emptyTitle="No artifacts"
emptyDescription="No artifacts"
sortKey={() => 'created'}
sortDirection={() => 'desc'}
onSort={() => {}}
sizeMaxBytes={1048576}
/>
<h2>Backup servers</h2>
<ProxmoxBackupServersTable servers={[pbs]} />
<h2>Proxmox nodes</h2>
<ProxmoxNodesTable
nodes={[node]}
guests={[workload]}
emptyIcon={<span />}
emptyTitle="No nodes"
emptyDescription="No nodes"
/>
<h2>Workload row</h2>
<Table>
<TableBody>
<GuestRow
guest={
{
id: 'ct-310',
vmid: 310,
name: 'artifact-cache-310',
node: 'pve-edge',
instance: 'homelab',
status: 'running',
type: 'lxc',
cpu: 0.12,
cpus: 2,
memory: { usage: 30, total: 1e9, used: 0.3e9, free: 0.7e9 },
disk: { usage: 20, total: 1e10, used: 2e9, free: 8e9 },
uptime: 86400,
template: false,
lastBackup: 0,
tags: null,
lock: '',
lastSeen: new Date(now).toISOString(),
} as any
}
visibleColumnIds={['name', 'cpu', 'memory', 'disk', 'uptime']}
workloadTableLayoutMode={innerWidth < 440 ? 'phone' : 'compact'}
/>
</TableBody>
</Table>
</section>
</Show>
<Show when={view() === 'Consent'}>
<section aria-label="Rollback history">
<UpdateHistorySection />
</section>
</Show>
</main>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
);
}
void updateStore.checkForUpdates();
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,91 @@
// Render actual shipped help through the production Docs/router/Markdown/CSS.
// No API, Docker, systemd or real credential is used here.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = path.resolve('frontend-modern');
const artifacts = path.join(root, 'node_modules', 'password-recovery-browser');
fs.mkdirSync(artifacts, { recursive: true });
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(root, 'node_modules', '.vite-password-recovery'),
server: { host: '127.0.0.1', port: 5252, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
await page.goto('http://127.0.0.1:5252/browser-tests/docs-fragment-navigation.html',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark'));
for (const guide of ['DOCKER', 'FAQ']) {
await page.getByRole('link', { name: '← All documentation' }).click();
const entry = page.locator(`a[data-doc-link][href="/docs/${guide}"]`).first();
await entry.focus();
await page.keyboard.press('Enter');
const link = page.getByRole('link', { name: 'password recovery guide', exact: true });
await link.waitFor();
await link.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const h = document.getElementById('i-forgot-my-password');
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/TROUBLESHOOTING#i-forgot-my-password'));
const text = await page.locator('article').innerText();
for (const phrase of ['Do not delete .env', 'setup replaces the primary API token',
'pulse.service.d/override.conf', 'not just docker restart',
'existing sessions and API tokens need separate review', 'If it still fails, stop'])
assert.ok(text.includes(phrase), phrase);
assert.ok(!text.includes('rm /data/.env') && !text.includes('rm /etc/pulse/.env'));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-${guide.toLowerCase()}-recovery.png`) });
}
const code = page.locator('pre').filter({ hasText: 'htpasswd -nB -C 12' });
await code.scrollIntoViewIfNeeded();
assert.ok((await code.innerText()).includes('> "$recovery_dir/password-record"'));
await page.screenshot({ path: path.join(artifacts, `${engine}-private-hash.png`) });
const auth = page.getByRole('link', { name: 'authentication guide', exact: true });
await auth.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const h = document.getElementById('private-docker-authentication-file');
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file'));
assert.deepEqual(errors, []);
results.push({ engine, browserVersion: browser.version(), width, tone,
keyboardDockerAndFAQLinks: true, fragmentInView: true, sourcePrecedenceWarning: true,
privateHashRecipeRendered: true, configurationLink: true, noBodyOverflow: true, errors });
await browser.close();
browser = undefined;
}
const docs = Object.fromEntries(['DOCKER', 'FAQ', 'TROUBLESHOOTING'].map((guide) => [
`frontend-modern/public/docs/${guide}.md`,
createHash('sha256').update(fs.readFileSync(path.join(root, 'public/docs', `${guide}.md`))).digest('hex'),
]));
const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version,
scope: 'rendered recovery guidance/links; not native deployment/password/session recovery', docs, results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,128 @@
// Verify shipped setup guidance in the production Docs/router/Markdown/CSS.
// No backend, Docker daemon, credentials or container startup is exercised.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = path.resolve('frontend-modern');
const artifacts = path.join(root, 'node_modules', 'private-auth-help-browser');
fs.mkdirSync(artifacts, { recursive: true });
// Tailwind resolves its config/content from cwd, as the normal npm build
// does. The tool is still invoked from the assigned repository root.
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(root, 'node_modules', '.vite-private-auth-help'),
server: { host: '127.0.0.1', port: 5249, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
await page.goto('http://127.0.0.1:5249/browser-tests/docs-fragment-navigation.html',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
if (tone === 'dark') await page.evaluate(async () => {
document.documentElement.classList.add('dark');
await new Promise((resolve) => requestAnimationFrame(resolve));
});
await page.getByRole('link', { name: '← All documentation' }).click();
await page.getByRole('link', { name: 'Configure Pulse', exact: true }).click();
const privateHeading = page.getByRole('heading', { name: 'Private Docker authentication file', exact: true });
await privateHeading.waitFor();
let text = await page.locator('article').innerText();
for (const phrase of ['complete bcrypt hash', 'no $$ substitution', 'Do not source the file',
'Deployment-supplied environment values take precedence', 'not out of Docker\'s',
'does not scrub its original value', 'this new-container example is not an upgrade procedure'])
assert.ok(text.includes(phrase), phrase);
assert.ok(!text.includes('secret123'));
// The production Docs sanitizer removes Markdown language classes.
const shell = await page.locator('pre code').allTextContents();
assert.ok(shell.some((block) => block.includes('--env-file "$HOME/.config/pulse/docker-auth.env"')));
assert.ok(shell.every((block) => !/-e\s+PULSE_AUTH_(PASS|USER)=/.test(block)));
const firstLogin = page.getByRole('link', { name: 'First login', exact: true });
await firstLogin.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const h = document.getElementById('step-1-get-the-token');
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/INSTALL#step-1-get-the-token'));
await page.goBack();
await privateHeading.waitFor();
await privateHeading.evaluate((h) => h.scrollIntoView({ block: 'start' }));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-private-file.png`) });
const launchCode = page.locator('pre').filter({ hasText: 'docker run -d' });
await launchCode.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(artifacts, `${engine}-docker-credentials.png`) });
await page.getByRole('link', { name: '← All documentation' }).click();
await page.getByRole('link', { name: 'Docker and Podman', exact: true }).click();
await page.getByRole('heading', { name: '📦 Docker Compose', exact: true }).waitFor();
text = await page.locator('article').innerText();
assert.ok(!text.includes('secret123'));
assert.ok(text.includes('Leave authentication overrides unset'));
assert.ok(text.includes('remain in the container environment and deployment file'));
const privateLink = page.getByRole('link', { name: 'private Docker authentication file', exact: true });
await privateLink.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const h = document.getElementById('private-docker-authentication-file');
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file'));
for (const [locale, label, heading, safeguard] of [
['de', 'Deutsch', 'Erste Anmeldung', 'Verwende kein gemeinsames Beispielpasswort'],
['es', 'Español', 'Primer inicio de sesión', 'No uses una contraseña de ejemplo compartida'],
]) {
await page.getByRole('link', { name: '← All documentation' }).click();
const link = page.getByRole('link', { name: label, exact: true });
await link.focus();
await page.keyboard.press('Enter');
await page.getByRole('heading', { name: heading, exact: true }).waitFor();
text = await page.locator('article').innerText();
assert.ok(!text.includes('secret123'));
assert.ok(!text.includes('PULSE_AUTH_PASS='));
assert.ok(text.includes('docker exec pulse /app/pulse bootstrap-token'));
assert.ok(text.includes(safeguard));
const compose = page.locator('pre').filter({ hasText: 'services:' });
await compose.scrollIntoViewIfNeeded();
assert.ok(!(await compose.innerText()).includes('PULSE_AUTH_'));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-${locale}-bootstrap.png`) });
const target = page.locator('a[href="/docs/CONFIGURATION#private-docker-authentication-file"]');
await target.focus();
await page.keyboard.press('Enter');
await privateHeading.waitFor();
assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file'));
}
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width, tone, firstLoginKeyboard: true,
privateFileRendering: true, noCommandCredential: true, envVisibilityWarning: true,
dockerFragmentKeyboard: true, translatedBootstrap: ['de', 'es'], errors });
await browser.close();
browser = undefined;
}
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'shipped setup help and links, not native Docker or password changes', results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,497 @@
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 now = '2026-10-03T12:00:00Z';
const base = (id, type, extra = {}) => ({
id,
type,
name: id,
status: 'online',
lastSeen: now,
sources: ['docker'],
...extra,
});
const observations = (mode) => [
base('mail-gateway-eu', 'pmg', {
sources: ['pmg'],
technology: 'pmg',
uptime: 86400,
sourceStatus: { pmg: { status: 'online', lastSeen: now } },
...(mode === 'missing'
? {}
: {
pmg: {
instanceId: 'pmg-eu',
hostname: 'mail-gateway-eu',
version: '8.1-2',
nodeCount: 2,
mailCountTotal: 2740,
spamIn: 321,
virusIn: 14,
quarantine: 3,
queueTotal: mode === 'fresh' ? 19 : 11,
queueDeferred: 5,
},
}),
}),
base('nfs-iso', 'storage', {
sources: ['proxmox'],
parentName: 'cluster',
parentId: 'pve-cluster-a',
technology: 'nfs',
metrics: { disk: { total: 1e12, used: 3e11, percent: 30 } },
storage: { type: 'nfs', shared: true, nodes: ['pve1', 'pve2', 'pve3'] },
}),
base('local-lvm', 'storage', {
sources: ['proxmox'],
parentName: 'pve2',
parentId: 'pve2',
metrics: { disk: { total: 1e12, used: 6e11, percent: 60 } },
storage: { type: 'lvmthin', shared: false },
}),
base('edge-web', 'app-container', {
status: 'running',
technology: 'docker',
sourceStatus: { docker: { status: 'online', lastSeen: now } },
actionReadiness: [{ name: 'restart', available: false, reason: 'Command agent disconnected' }],
metrics: { cpu: { percent: 42 }, memory: { percent: 50, used: 5e8, total: 1e9 } },
docker: {
agentId: 'agent-edge',
hostSourceId: 'agent-edge',
containerId: 'native-edge-web',
hostname: 'edge-long-hostname',
runtime: 'docker',
runtimeVersion: '27.5.1',
image: 'nginx:latest',
containerState: 'running',
restartCount: 7,
updateStatus: {
updateAvailable: true,
currentDigest: 'sha256:current',
latestDigest: 'sha256:latest',
lastChecked: now,
},
},
}),
base('edge-cache', 'app-container', {
status: 'offline',
metrics: { cpu: { percent: 0 }, memory: { percent: 0 } },
docker: {
agentId: 'agent-edge',
hostname: 'edge-long-hostname',
runtime: 'podman',
runtimeVersion: '5.2.1',
image: 'redis:7.4',
containerState: 'exited',
restartCount: 0,
},
}),
base('nginx:latest', 'docker-image', {
docker: {
hostname: 'edge-long-hostname',
imageId: 'image-nginx',
repoTags: ['nginx:latest'],
sizeBytes: 2e8,
containers: ['edge-web'],
containerCount: 1,
},
}),
base('web-service', 'docker-service', {
docker: {
hostname: 'edge-long-hostname',
image: 'nginx:latest',
serviceMode: 'replicated',
desiredTasks: 4,
runningTasks: 2,
mode: 'replicated',
serviceUpdate: { state: 'rollback_started', message: 'Synthetic rollout observation' },
},
}),
base('web.2', 'docker-task', {
docker: {
hostname: 'edge-long-hostname',
serviceName: 'web-service',
nodeName: 'worker-1',
currentState: 'running',
desiredState: 'running',
startedAt: '2026-10-03T11:30:00.123456Z',
},
}),
];
(async () => {
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/proxmox-docker-rows-final-source';
fs.mkdirSync(output, { recursive: true });
process.chdir(root);
const playwright = require('playwright/package.json').version;
assert.equal(
playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
// Keep the fixture runnable from clean committed source, without requiring
// an invocation-specific untracked runtime-paths input.
const paths = [
'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
'frontend-modern/src/features/docker/DockerImagesTable.tsx',
'frontend-modern/src/features/docker/DockerServicesTable.tsx',
'frontend-modern/src/features/docker/DockerTasksTable.tsx',
'frontend-modern/src/features/docker/dockerContainerTableModel.ts',
'frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts',
'frontend-modern/src/features/storageBackups/storageAdapters.ts',
'frontend-modern/src/hooks/useUnifiedResources.ts',
];
const hashes = Object.fromEntries(
paths.map((p) => [
p,
crypto
.createHash('sha256')
.update(fs.readFileSync(path.join('/workspace', p)))
.digest('hex'),
]),
);
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: 5272, strictPort: true },
});
const results = [],
screenshots = [];
let browser;
try {
await server.listen();
for (const [name, engine, width] of [
['desktop', chromium, 1440],
['phone', webkit, 390],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
await page.clock.setFixedTime(new Date(now));
page.setDefaultTimeout(15000);
const checks = [],
errors = [],
offOrigin = [],
mutations = [];
let mode = 'initial';
page.on('pageerror', (e) => errors.push(e.message));
await page.route('**/*', async (route) => {
const request = route.request(),
url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5272') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (request.method() !== 'GET') {
mutations.push({ path: url.pathname, method: request.method() });
return route.fulfill({ status: 409, json: { error: 'No fixture mutation' } });
}
if (url.pathname === '/api/resources')
return route.fulfill({ json: { data: observations(mode) } });
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
blocked_capabilities: [],
runtime: { build: 'community' },
},
});
if (url.pathname === '/api/security/status')
return route.fulfill({ json: { hasAuthentication: true, requiresAuth: true } });
return route.fulfill({
json: {
enabled: false,
data: [],
capabilities: [],
facets: [],
relationships: [],
recentChanges: [],
alerts: {},
},
});
});
const check = async (label, run) => {
try {
await run();
checks.push({ label, passed: true });
} catch (e) {
checks.push({ label, passed: false, error: e.message });
}
};
const select = (view) =>
page
.getByRole('navigation', { name: 'Fixture views' })
.getByRole('button', { name: view, exact: true })
.click();
const capture = async (state) => {
await page.waitForTimeout(180); // ResizeObserver and entrance layout, not freshness evidence.
const dimensions = await page.evaluate(() => ({
width: innerWidth,
scroll: document.documentElement.scrollWidth,
}));
checks.push({
label: `No horizontal page overflow: ${state}`,
passed: dimensions.scroll <= dimensions.width + 1,
dimensions,
});
const file = `${name}-${state}.png`;
await page.screenshot({ path: path.join(output, file), fullPage: true });
screenshots.push({ file, state, engine: name, width, dimensions });
};
await page.goto('http://127.0.0.1:5272/browser-tests/proxmox-docker-rows.html', {
waitUntil: 'domcontentloaded',
timeout: 120000,
});
if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark'));
await page.getByText('8 REST-mapped rows', { exact: true }).waitFor();
await check(
'REST-first PMG counters and facets are present before any websocket hydration',
async () => {
const table = page.getByRole('region', { name: 'Mail table' });
await table.getByText('2,740', { exact: true }).waitFor();
await table.getByText('11', { exact: true }).waitFor();
const row = await page.evaluate(() =>
window.__operatorRows.rows().find((r) => r.type === 'pmg'),
);
assert.equal(row.technology, 'pmg');
assert.equal(row.sourceStatus.pmg.status, 'online');
assert.equal(row.pmg.queueTotal, 11);
assert.deepEqual(row.pmg, row.platformData.pmg);
if (width > 390) await table.getByText('8.1-2', { exact: true }).waitFor();
},
);
await capture('rest-mail');
await check(
'Canonical store hydration followed by REST refresh keeps the new PMG counters',
async () => {
await page.evaluate(() => window.__operatorRows.seedWebsocket());
mode = 'fresh';
await page.evaluate(() => window.__operatorRows.refresh());
await page
.getByRole('region', { name: 'Mail table' })
.getByText('19', { exact: true })
.waitFor();
},
);
await capture('refreshed-mail');
await check('A removed REST PMG facet does not retain obsolete queue evidence', async () => {
mode = 'missing';
await page.evaluate(() => window.__operatorRows.refresh());
assert.equal(
await page
.getByRole('region', { name: 'Mail table' })
.getByText('19', { exact: true })
.count(),
0,
);
const row = await page.evaluate(() =>
window.__operatorRows.rows().find((r) => r.type === 'pmg'),
);
assert.equal(row.pmg, undefined);
mode = 'fresh';
await page.evaluate(() => window.__operatorRows.refresh());
});
await select('Storage');
await check('Shared storage vocabulary preserves native locations and IDs', async () => {
const table = page.getByRole('region', { name: 'Storage table' });
await table.getByText('NFS', { exact: true }).waitFor({ state: 'attached' });
await table.getByText('LVM-Thin', { exact: true }).waitFor({ state: 'attached' });
await table.getByText('Shared · 3 nodes', { exact: true }).waitFor({ state: 'attached' });
await table.getByText('pve2', { exact: true }).waitFor({ state: 'attached' });
const rows = await page.evaluate(() =>
window.__operatorRows.rows().filter((r) => r.type === 'storage'),
);
assert.deepEqual(
rows.map((r) => [r.id, r.parentId]),
[
['nfs-iso', 'pve-cluster-a'],
['local-lvm', 'pve2'],
],
);
});
await capture('storage');
await select('Containers');
await check(
'REST lifecycle refusal remains available; phone row keeps update and moves restarts to detail',
async () => {
await page
.locator('[data-lifecycle-refusal]')
.getByText('Command agent disconnected', { exact: true })
.waitFor();
const table = page.getByRole('region', { name: 'Container table' });
const update = table.locator('.docker-container-update-cell button');
await update.waitFor();
assert.match(await update.innerText(), /Update/);
const bounds = await update.evaluate((e) => ({
w: e.getBoundingClientRect().width,
cell: e.closest('td').getBoundingClientRect().width,
scroll: e.scrollWidth,
client: e.clientWidth,
}));
assert.ok(
bounds.w <= bounds.cell + 1 && bounds.scroll <= bounds.client + 1,
JSON.stringify(bounds),
);
const headers = await table.locator('th:visible').allTextContents();
if (width === 390) {
assert.ok(!headers.some((h) => h.includes('Restarts')));
assert.ok(headers.some((h) => h.includes('State')));
}
},
);
await capture('containers');
await check(
'Restart count and native identity remain reachable via row disclosure',
async () => {
const row = page.locator('[data-docker-container-row="edge-web"]');
if (width === 390) await row.locator('td').first().tap();
else await row.getByRole('button', { name: /details/i }).click();
const detail = page.getByTestId('resource-docker-container-section');
await detail.getByText('Restarts', { exact: true }).waitFor();
await detail.getByText('7', { exact: true }).waitFor();
},
);
await capture('container-detail');
await select('Images');
await check('Image verdict fits; phone hidden headers and cells are symmetric', async () => {
const table = page.getByRole('region', { name: 'Image table' });
await table.getByText('nginx:latest', { exact: true }).waitFor();
const headers = await table.locator('th:visible').allTextContents();
const row = table.locator('[data-docker-image-row]');
assert.equal(await row.locator('td:visible').count(), headers.length);
if (width === 390) {
assert.ok(!headers.some((h) => h.includes('Host') || h.includes('Used by')));
assert.ok(headers.some((h) => h.includes('Size')));
}
});
await capture('images');
await select('Swarm');
await check(
'Swarm state and started time use operator wording with raw evidence retained',
async () => {
const state = page.getByText('Rollback started', { exact: true });
await state.waitFor({ state: 'attached' });
assert.match(
await state.locator('xpath=ancestor-or-self::*[@title][1]').getAttribute('title'),
/rollback_started/,
);
const visibleState = await state.evaluate((element) => {
const cell = element.closest('td').getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(element);
return [...range.getClientRects()].every(
(line) =>
line.left >= cell.left &&
line.right <= cell.right &&
line.top >= cell.top &&
line.bottom <= cell.bottom,
);
});
assert.equal(visibleState, true, 'Every word of the rollout state must fit its cell');
if (width === 390) {
await page.getByText('Run', { exact: true }).waitFor({ state: 'visible' });
}
const started = page.locator('[title="2026-10-03T11:30:00.123456Z"]');
await started.waitFor({ state: 'attached' });
assert.match(await started.innerText(), /ago/);
await page
.getByRole('region', { name: 'Swarm tasks' })
.getByTitle('running', { exact: true })
.waitFor();
},
);
await capture('swarm');
await check(
'Swarm touch or keyboard disclosure exposes raw rollout and placement evidence',
async () => {
const row = page.locator('[data-docker-service-row="web-service"]');
if (width === 390) await row.locator('td').first().tap();
else {
const toggle = row.getByRole('button', {
name: 'Expand details for web-service',
exact: true,
});
await toggle.focus();
await page.keyboard.press('Enter');
}
const detail = page.locator('[data-inline-docker-service-detail-for="web-service"]');
await detail
.getByText('rollback_started | Synthetic rollout observation', { exact: true })
.waitFor();
await detail.getByText('edge-long-hostname', { exact: true }).waitFor();
await detail.getByText('4', { exact: true }).waitFor();
await detail.getByText('2', { exact: true }).waitFor();
},
);
await capture('swarm-detail');
await select('Disk');
await check(
'Multi-disk Bars show the measured fullest disk and unchanged equal-weight bars',
async () => {
const label = page.locator('[data-stacked-disk-max-label]');
assert.equal(await label.innerText(), '92%');
assert.equal(await label.getAttribute('title'), 'Highest usage: /data 92%');
assert.equal(await page.locator('[data-stacked-disk-fill="vertical"]').count(), 2);
},
);
await capture('disks');
checks.push({
label: 'No page errors, external requests or mutation requests',
passed: !errors.length && !offOrigin.length && !mutations.length,
errors,
offOrigin,
mutations,
});
results.push({ engine: name, version: browser.version(), width, checks });
await browser.close();
browser = undefined;
}
} finally {
if (browser) await browser.close();
await server.close();
}
const result = {
playwright,
verified_at: new Date().toISOString(),
paths,
hashes,
results,
screenshots,
};
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
const failures = results.flatMap((r) =>
r.checks.filter((c) => !c.passed).map((c) => ({ engine: r.engine, ...c })),
);
console.log(
JSON.stringify({
checked: results.reduce((n, r) => n + r.checks.length, 0),
screenshots: screenshots.length,
failures,
}),
);
assert.equal(failures.length, 0, JSON.stringify(failures));
})().catch((error) => {
console.error(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>Transport facets and operator rows verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./proxmox-docker-rows.tsx"></script>
</body>
</html>
@@ -0,0 +1,182 @@
// Synthetic REST observations through the real hook, tables, detail disclosures
// and CSS. No real websocket, appliance or installed/released acceptance claim.
import { createSignal, Show } from 'solid-js';
import { createStore, unwrap } from 'solid-js/store';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { useUnifiedResources } from '../src/hooks/useUnifiedResources';
import { ProxmoxMailGatewayTable } from '../src/features/proxmox/ProxmoxMailGatewayTable';
import { DockerContainersTable } from '../src/features/docker/DockerContainersTable';
import { DockerImagesTable } from '../src/features/docker/DockerImagesTable';
import { DockerServicesTable } from '../src/features/docker/DockerServicesTable';
import { DockerTasksTable } from '../src/features/docker/DockerTasksTable';
import { StackedDiskBar } from '../src/components/Workloads/StackedDiskBar';
import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable';
import { Table, TableBody, TableRow, TableCell } from '../src/components/shared/Table';
import { getDockerContainerLifecycleDisabledReason } from '../src/features/docker/dockerContainerLifecycleActions';
import { buildStorageRecords } from '../src/features/storageBackups/storageAdapters';
import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore';
import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState';
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
const [wsState, setWsState] = createStore({
resources: [] as Resource[],
lastUpdate: 0,
nodes: [],
pbs: [],
pmg: [],
vms: [],
containers: [],
});
const [connected, setConnected] = createSignal(false);
const ws = {
state: wsState,
connected,
initialDataReceived: () => true,
resourceChange: () => ({ version: wsState.lastUpdate, changedIds: null, changedKeys: null }),
activeAlerts: () => ({}),
shutdown: () => {},
};
window.__pulseWsStore = ws as any;
function Fixture() {
const [view, setView] = createSignal('Mail');
const [expandedPool, setExpandedPool] = createSignal<string | null>(null);
const rows = useUnifiedResources({ query: '', cacheKey: 'operator-rows-browser' });
const ofType = (type: string) => rows.resources().filter((row) => row.type === type);
const container = () => ofType('app-container')[0];
const records = () =>
buildStorageRecords({ state: wsState as any, resources: ofType('storage') });
const grouped = () => groupStorageRecords(records(), 'none');
(window as any).__operatorRows = {
rows: () => JSON.parse(JSON.stringify(unwrap(rows.resources()))),
refresh: () => rows.refetch(),
seedWebsocket: () => {
setWsState('resources', JSON.parse(JSON.stringify(unwrap(rows.resources()))));
setWsState('lastUpdate', Date.now());
setConnected(true);
},
};
const nativeProps = {
emptyIcon: <span />,
emptyTitle: 'No fixture rows',
emptyDescription: 'No fixture rows',
showToolbar: false,
};
return (
<WebSocketContext.Provider value={ws as any}>
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Transport facets and operator rows</h1>
<nav aria-label="Fixture views" class="flex flex-wrap gap-2">
{['Mail', 'Storage', 'Containers', 'Images', 'Swarm', 'Disk'].map((name) => (
<button
type="button"
class="min-h-11 rounded border border-border px-3"
onClick={() => setView(name)}
>
{name}
</button>
))}
</nav>
<p data-fixture-loaded>{rows.resources().length} REST-mapped rows</p>
<Show when={view() === 'Mail'}>
<section aria-label="Mail table">
<ProxmoxMailGatewayTable
resources={ofType('pmg')}
emptyTitle="No mail"
emptyDescription="No mail"
/>
</section>
</Show>
<Show when={view() === 'Storage'}>
<section aria-label="Storage table">
<StoragePoolsTable
groupedRecords={grouped()}
groupBy="none"
expandedGroups={new Set(['All'])}
toggleGroup={() => {}}
expandedPoolId={expandedPool()}
setExpandedPoolId={setExpandedPool}
storageGrowthBySeriesId={new Map()}
storageGrowthColumnLabel="Growth"
physicalDisks={[]}
nodeOnlineByLabel={new Map()}
highlightedRecordId={null}
getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
isLoading={false}
/>
</section>
</Show>
<Show when={view() === 'Containers'}>
<section aria-label="Container table">
<p data-lifecycle-refusal>
{container() && getDockerContainerLifecycleDisabledReason(container(), 'restart')}
</p>
<DockerContainersTable resources={ofType('app-container')} {...nativeProps} />
</section>
</Show>
<Show when={view() === 'Images'}>
<section aria-label="Image table">
<DockerImagesTable resources={ofType('docker-image')} {...nativeProps} />
</section>
</Show>
<Show when={view() === 'Swarm'}>
<section aria-label="Swarm services">
<DockerServicesTable resources={ofType('docker-service')} {...nativeProps} />
</section>
<section aria-label="Swarm tasks">
<DockerTasksTable resources={ofType('docker-task')} {...nativeProps} />
</section>
</Show>
<Show when={view() === 'Disk'}>
<section aria-label="Multi-disk Bars">
<Table>
<TableBody>
<TableRow>
<TableCell>pve-edge</TableCell>
<TableCell>
<StackedDiskBar
mode="vertical-bars"
disks={[
{
device: '/dev/sda',
mountpoint: '/',
usage: 24,
total: 100,
used: 24,
free: 76,
type: 'ext4',
},
{
device: '/dev/sdb',
mountpoint: '/data',
usage: 92,
total: 100,
used: 92,
free: 8,
type: 'zfs',
},
]}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</section>
</Show>
</main>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,109 @@
// Shipped Recovery -> Troubleshooting navigation, with the production Docs page.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules', 'recovery-safe-reporting-browser');
fs.mkdirSync(artifacts, { recursive: true });
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(root, 'node_modules', '.vite-recovery-safe-reporting'),
server: { host: '127.0.0.1', port: 5249, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
let browser;
const results = [];
try {
await server.listen();
for (const [engine, width, dark] of [
['chromium', 1280, false],
['webkit', 390, true],
]) {
browser = await { chromium, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
});
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('http://127.0.0.1:5249/browser-tests/docs-fragment-navigation.html', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
// The fixture mounts the actual /docs router. Navigate without mocking assets.
await page.evaluate(() => {
history.pushState({}, '', '/docs/RECOVERY');
dispatchEvent(new PopStateEvent('popstate'));
});
const link = page.getByRole('link', { name: 'safe issue reporting', exact: true });
await link.waitFor();
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
await link.focus();
await page.keyboard.press('Enter');
const verifyDestination = async () => {
await page.waitForFunction(() => {
const heading = document.getElementById('-getting-help');
if (!heading) return false;
const rect = heading.getBoundingClientRect();
return document.activeElement === heading && rect.top >= 0 && rect.top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/TROUBLESHOOTING#-getting-help'));
assert.equal(await page.locator('h2#-getting-help').textContent(), '🆘 Getting Help');
const article = await page.locator('article').innerText();
for (const text of [
'Export for GitHub (sanitized)',
'Review before posting',
'Never post bootstrap/recovery',
'Do not refile information you have already supplied',
])
assert.ok(article.includes(text), text);
assert.ok(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
);
};
await verifyDestination();
await page.screenshot({ path: path.join(artifacts, `${engine}-safe-reporting.png`) });
assert.deepEqual(errors, []);
results.push({
engine,
version: browser.version(),
width,
dark,
keyboardNavigation: true,
targetFocusedAndVisible: true,
safeGuidance: true,
noHorizontalOverflow: true,
errors,
});
await browser.close();
browser = undefined;
}
const result = {
result: 'passed',
playwright: require('playwright/package.json').version,
scope: 'shipped help navigation, not native recovery or release availability',
results,
};
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,100 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
const parent = false;
(async () => {
const root = parent ? '/workspace/tmp/resource-access-parent/frontend-modern' : '/workspace/frontend-modern';
const artifacts = `/workspace/tmp/resource-evidence-${parent ? 'parent' : 'final'}-proof`;
fs.mkdirSync(artifacts, { recursive: true });
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: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5267, strictPort: true, fs: { allow: ['/workspace'] } } });
const playwright = require('playwright/package.json').version;
assert.equal(playwright, JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version);
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [['chromium-desktop', chromium, false], ['webkit-phone', webkit, true]]) {
browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
const page = await browser.newPage({ viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, hasTouch: phone, isMobile: phone });
page.setDefaultTimeout(60000);
let facetMode = 'success', actionMode = 'success', actor = 'Authorised response actor';
const requests = [], errors = [], offOrigin = [], checks = [], held = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('**/*', async route => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5267') { offOrigin.push(url.origin); return route.abort(); }
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname.endsWith('/facets')) {
const mode = facetMode;
requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), mode });
if (mode === 'hold') await new Promise(resolve => held.push(resolve));
if (mode === '403' || mode === '503') return route.fulfill({ status: Number(mode), json: { error: 'Fixture service unavailable' } });
return route.fulfill({ json: { capabilities: [], relationships: [], counts: { recentChanges: 1 }, recentChanges: [{ id: 'remote-event', resourceId: 'pbs-a', observedAt: '2026-10-03T03:00:00Z', kind: 'restart', sourceType: 'platform_event', confidence: 'high', actor }] } });
}
if (url.pathname === '/api/audit/actions') {
requests.push({ path: url.pathname, mode: actionMode });
return actionMode === '503' ? route.fulfill({ status: 503, json: { error: 'Fixture actions unavailable' } }) : route.fulfill({ json: { audits: [], count: 0 } });
}
return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } : { capabilities: [], relationships: [], recentChanges: [], audits: [], count: 0, data: [], enabled: false } });
});
const record = (name, pass) => { checks.push({ name, pass }); };
const screenshot = state => page.screenshot({ path: path.join(artifacts, `${name}-${state}.png`), fullPage: true });
const changes = page.getByTestId('resource-change-history-section');
const actions = page.getByTestId('resource-action-history-section');
await page.goto('http://127.0.0.1:5267/browser-tests/resource-evidence-access.html', { waitUntil: 'domcontentloaded', timeout: 120000 });
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
await changes.getByText('Authorised response actor', { exact: true }).waitFor();
record('successful remote evidence replaces snapshot', !(await changes.getByText('Embedded snapshot actor').count()));
await screenshot('loaded');
facetMode = '403'; actionMode = '503';
await page.getByRole('button', { name: 'Remount details' }).click();
await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor();
await page.waitForTimeout(500);
record('denial withdraws embedded evidence', !(await changes.getByText('Embedded snapshot actor').count()) && !(await changes.getByText('Authorised response actor').count()));
record('bounded denial guidance, not empty history', (await changes.getByText('Access denied. Check your permissions and license plan.', { exact: true }).count()) === 1 && !(await changes.getByText('No events yet.', { exact: true }).count()));
record('denial is labelled unavailable', (await changes.getByText('Changes unavailable', { exact: true }).count()) === 1);
await page.getByRole('tab', { name: 'Manage', exact: true }).click();
record('failed first action read has visible retry, not empty history', (await actions.count()) === 1 && (await actions.getByRole('button', { name: 'Retry', exact: true }).count()) === 1 && !(await actions.getByText('No actions yet.', { exact: true }).count()));
await screenshot('action-failed');
await page.getByRole('tab', { name: 'Overview', exact: true }).click();
await screenshot('denied');
facetMode = 'hold';
await changes.getByRole('button', { name: 'Retry', exact: true }).focus();
await changes.getByRole('button', { name: 'Retry', exact: true }).press('Enter');
await changes.getByText('Refreshing changes...', { exact: true }).waitFor();
record('retry keeps denied evidence withdrawn', !(await changes.getByText('Embedded snapshot actor').count()) && !(await changes.getByText('Authorised response actor').count()));
await screenshot('retry-pending');
actor = 'Fresh response actor'; facetMode = 'success';
held.splice(0).forEach(resolve => resolve());
await changes.getByText('Fresh response actor', { exact: true }).waitFor();
record('fresh successful retry restores evidence and clears guidance', !(await changes.getByText('Access denied. Check your permissions and license plan.').count()));
await screenshot('recovered');
await changes.getByRole('button', { name: 'Filter history', exact: true }).click();
facetMode = '403';
await changes.getByLabel('Change kind', { exact: true }).selectOption('restart');
await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor();
record('filtered denial cannot substitute authorised unfiltered events', !(await changes.getByText('Fresh response actor').count()) && (await changes.getByText('Filtered changes unavailable', { exact: true }).count()) === 1);
await screenshot('filtered-denied');
await changes.getByRole('button', { name: 'Clear filters', exact: true }).click();
await changes.getByText('Fresh response actor', { exact: true }).waitFor();
record('clearing filter restores independently authorised base request', true);
facetMode = '503';
await page.getByRole('button', { name: 'Remount details' }).click();
await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor();
record('transient failure preserves embedded snapshot evidence', (await changes.getByText('Embedded snapshot actor', { exact: true }).count()) === 1);
await screenshot('transient');
record('no page errors or off-origin requests', errors.length === 0 && offOrigin.length === 0);
observations.push({ name, browser: browser.version(), viewport: page.viewportSize(), requests, errors, offOrigin, checks });
await browser.close(); browser = null;
}
} finally {
if (browser) await browser.close();
await server.close();
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify({ parent, playwright, observations }, null, 2));
}
console.log(JSON.stringify({ parent, checks: observations.flatMap(o => o.checks).length, failures: observations.flatMap(o => o.checks).filter(c => !c.pass) }));
if (!parent) assert.ok(observations.flatMap(o => o.checks).every(c => c.pass));
})().catch(error => { console.error(error.stack); process.exitCode = 1; });
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Resource evidence access verification</title></head><body><div id="root"></div><script type="module" src="/browser-tests/resource-evidence-access.tsx"></script></body></html>
@@ -0,0 +1,62 @@
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
// Synthetic snapshots and HTTP responses; production drawer, query/client and CSS.
syncAIRuntimeSettings({ discovery_enabled: false });
const resource: Resource = {
id: 'pbs-a',
name: 'PBS A',
displayName: 'PBS A',
type: 'pbs',
platformId: 'pbs-a',
platformType: 'proxmox-pbs',
sourceType: 'api',
status: 'online',
lastSeen: Date.now(),
cpu: { current: 12 },
recentChanges: [
{
id: 'embedded-event',
resourceId: 'pbs-a',
observedAt: '2026-10-03T03:00:00Z',
kind: 'restart',
sourceType: 'platform_event',
confidence: 'high',
actor: 'Embedded snapshot actor',
},
],
facetCounts: { recentChanges: 7 },
};
const [mounted, setMounted] = createSignal(true);
render(
() => (
<WebSocketContext.Provider value={{ state: { pmg: [] }, connected: () => true } as any}>
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Resource evidence access verification</h1>
<p>
Synthetic PBS evidence; no appliance, release or installed authorisation acceptance.
</p>
<button
class="min-h-11 rounded border border-border px-3"
onClick={() => {
setMounted(false);
setTimeout(() => setMounted(true), 0);
}}
>
Remount details
</button>
<Show when={mounted()}>
<ResourceDetailDrawer resource={resource} />
</Show>
</main>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
),
document.getElementById('root')!,
);
@@ -0,0 +1,122 @@
// Real shipped Docs/router/CSS, with no backend, proxy or real credential.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const workspace = process.cwd();
const root = path.join(workspace, 'frontend-modern');
const output = path.join(workspace, 'tmp', 'reverse-proxy-help');
fs.mkdirSync(output, { recursive: true });
const binding = JSON.parse(fs.readFileSync(path.join(output, 'binding.json'), 'utf8'));
assert.match(binding.sourceSha, /^[0-9a-f]{40}$/);
for (const [file, digest] of Object.entries(binding.contentSha256)) {
assert.equal(
createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex'),
digest,
file,
);
}
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(root, 'node_modules', '.vite-reverse-proxy-help'),
server: { host: '127.0.0.1', port: 5268, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const observations = [];
let browser;
const inViewport = (id) => {
const heading = document.getElementById(id);
return heading && heading.getBoundingClientRect().top >= 0 &&
heading.getBoundingClientRect().top < innerHeight;
};
try {
await server.listen();
for (const [engine, width, height, tone] of [
['chromium', 1280, 1000, 'light'], ['webkit', 390, 844, 'dark'],
]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({
viewport: { width, height },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
});
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('http://127.0.0.1:5268/browser-tests/docs-fragment-navigation.html');
if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark'));
await page.getByRole('link', { name: '← All documentation' }).click();
const entry = page.locator('a[data-doc-link][href="/docs/REVERSE_PROXY"]').first();
await entry.focus();
await page.keyboard.press('Enter');
const heading = page.locator('#before-configuring-the-proxy');
await heading.waitFor();
await heading.scrollIntoViewIfNeeded();
let text = await page.locator('article').innerText();
for (const phrase of [
'configured immediate peer', 'PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32',
'Wildcard ranges', 'rejected at startup', 'not an authentication bypass',
'Adding the header alone is not sufficient',
]) assert.ok(text.includes(phrase), phrase);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(output, `${engine}-trust.png`) });
const nginx = page.locator('pre').filter({ hasText: 'proxy_set_header X-Forwarded-For $remote_addr;' });
await nginx.scrollIntoViewIfNeeded();
const copied = await nginx.innerText();
assert.ok(copied.includes('proxy_set_header X-Forwarded-Proto $scheme;'));
assert.ok(copied.includes('proxy_set_header Forwarded "";'));
assert.ok(!copied.includes('$proxy_add_x_forwarded_for'));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(output, `${engine}-nginx.png`) });
const configuration = page.getByRole('link', { name: 'configuration overrides', exact: true });
await configuration.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(inViewport, 'common-overrides-environment-variables');
text = await page.locator('article').innerText();
assert.ok(text.includes('forwarded client IP, scheme, host and port'));
const back = page.getByRole('link', { name: 'Reverse Proxy', exact: true });
await back.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(inViewport, 'before-configuring-the-proxy');
assert.ok(page.url().endsWith('/docs/REVERSE_PROXY#before-configuring-the-proxy'));
const authentication = page.getByRole('link', { name: 'proxy authentication guide', exact: true });
await authentication.focus();
await page.keyboard.press('Enter');
const boundary = page.getByRole('heading', { name: '⚠️ Header Trust Boundary', exact: true });
await boundary.waitFor();
assert.ok(page.url().endsWith('/docs/PROXY_AUTH'));
await boundary.scrollIntoViewIfNeeded();
assert.ok((await page.locator('article').innerText()).includes('never append to them'));
assert.deepEqual(errors, []);
observations.push({
engine, width, height, tone, browserVersion: browser.version(),
trustWarningRendered: true, safeNginxBlockRendered: true,
keyboardConfigurationRoundTrip: true, keyboardAuthenticationBoundary: true,
noPageOverflow: true, errors,
});
await browser.close();
browser = undefined;
}
const result = {
result: 'passed', playwrightVersion: require('playwright/package.json').version,
sourceSha: binding.sourceSha, verifiedAt: new Date().toISOString(),
contentSha256: binding.contentSha256, dependencySnapshot: binding.dependencySnapshot,
scope: 'Production Docs rendering/navigation; not native proxy configuration, TLS or SSO acceptance',
observations,
};
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,152 @@
// Actual shipped Markdown in the production Docs/router/styles; no backend.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules', 'update-recovery-help-browser');
fs.mkdirSync(artifacts, { recursive: true });
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(root, 'node_modules', '.vite-update-recovery-help'),
server: { host: '127.0.0.1', port: 5248, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
browser = await { chromium, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
});
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.goto('http://127.0.0.1:5248/browser-tests/docs-fragment-navigation.html',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.getByRole('link', { name: '← All documentation' }).click();
await page.getByRole('link', { name: 'Automatic updates', exact: true }).click();
await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor();
if (tone === 'dark') await page.evaluate(async () => {
document.documentElement.classList.add('dark');
await new Promise((resolve) => requestAnimationFrame(resolve));
await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
const article = await page.locator('article').innerText();
for (const text of ['Roll back from Update History', 'successful in-app update',
'not necessarily a complete backup', 'Copy errors can leave a partial snapshot',
'matching .encryption.key', 'SQLite sidecar files', 'keep reversible copies',
'An image change alone is not a data rollback', 'published v6.4.5 and v6.4.6-rc.1',
'Update History is not a full-state recovery tool',
'Do not assume later settings and alert changes will be reverted',
'not permission to rewind live runtime stores']) assert.ok(article.includes(text), text);
assert.ok(!article.includes('There is no rollback UI'));
assert.ok(!article.includes('sudo rm -rf'));
const scope = page.getByRole('heading', { name: 'What an update snapshot contains', exact: true });
await scope.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-scope.png`) });
const manual = page.getByRole('heading', { name: 'Manual Rollback', exact: true });
await manual.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
await page.waitForFunction(() => {
const heading = document.getElementById('manual-rollback');
// Respect the product's heading scroll-margin rather than demanding
// alignment at zero pixels above its normal reading inset.
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 200;
});
await page.screenshot({ path: path.join(artifacts, `${engine}-manual.png`) });
await page.getByRole('link', { name: 'audit storage and safe recovery', exact: true }).click();
await page.waitForFunction(() => {
const heading = document.getElementById('storage');
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/AUDIT_LOGGING#storage'));
await page.getByRole('link', { name: '← All documentation' }).click();
await page.getByRole('link', { name: 'Automatic updates', exact: true }).click();
await page.getByRole('link', { name: 'Docker server updates', exact: true }).click();
const updates = page.getByRole('heading', { name: '🔄 Updates', exact: true });
await updates.waitFor();
await page.waitForFunction(() => {
const heading = document.getElementById('-updates');
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/DOCKER#-updates'));
const dockerText = await page.locator('article').innerText();
for (const text of ['persist PULSE_IMAGE', 'has no effect on a hardcoded image line',
'docker compose pull pulse', 'docker compose up -d --no-deps pulse',
'paid Pro installs must keep the private image', 'no need to bring the whole Compose project down'])
assert.ok(dockerText.includes(text), text);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-docker.png`) });
await page.getByRole('link', { name: 'rollback and backup scope', exact: true }).click();
await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor();
assert.ok(page.url().endsWith('/docs/AUTO_UPDATE#rollback'));
await page.getByRole('link', { name: '← All documentation' }).click();
const installLink = page.getByRole('link', { name: 'Install Pulse', exact: true });
await installLink.focus();
await page.keyboard.press('Enter');
const installRollback = page.getByRole('heading', { name: 'Rollback', exact: true });
await installRollback.waitFor();
const installText = await page.locator('article').innerText();
for (const text of ['Neither is guaranteed to include all active data',
'Update History is not a full-state recovery tool', 'matching data and keys together',
'preserve reversible copies', 'Do not replace live runtime data'])
assert.ok(installText.includes(text), text);
assert.ok(!installText.includes('auto-restores on failure'));
await installRollback.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
await page.waitForFunction(() => {
const heading = document.getElementById('rollback');
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 200;
});
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-install-rollback.png`) });
const snapshotLink = page.getByRole('link', { name: 'version-specific snapshot scope', exact: true });
await snapshotLink.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const heading = document.getElementById('what-an-update-snapshot-contains');
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/AUTO_UPDATE#what-an-update-snapshot-contains'));
await page.goBack();
await installRollback.waitFor();
const stoppedLink = page.getByRole('link', { name: 'stopped-service recovery procedure', exact: true });
await stoppedLink.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const heading = document.getElementById('manual-rollback');
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/AUTO_UPDATE#manual-rollback'));
assert.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width, tone,
snapshotScope: true, noDestructiveRecipe: true, scopedCompose: true,
auditLink: true, dockerFragment: true, rollbackLink: true,
versionQualifiedScope: true, noFullStatePromise: true,
installRollback: true, keyboardSnapshotLink: true, keyboardStoppedRecoveryLink: true, errors });
await browser.close();
browser = undefined;
}
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'shipped help rendering and links, not installation or recovery', results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,93 @@
// Exercise production Docs/router/Markdown. No Proxmox or guest is contacted.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = path.resolve('frontend-modern');
const artifacts = path.join(root, 'node_modules', 'vm-disk-safety-browser');
fs.mkdirSync(artifacts, { recursive: true });
const guides = Object.fromEntries(['VM_DISK_MONITORING', 'TROUBLESHOOTING'].map(name =>
[name, fs.readFileSync(path.join(root, 'public/docs', `${name}.md`), 'utf8')]));
const recipe = guides.VM_DISK_MONITORING.match(/```bash\n([\s\S]*?)```/)[1].trim();
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(root, 'node_modules', '.vite-vm-disk-safety'),
server: { host: '127.0.0.1', port: 5265, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
await page.goto('http://127.0.0.1:5265/browser-tests/docs-fragment-navigation.html',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark'));
await page.getByRole('link', { name: '← All documentation' }).click();
const entry = page.locator('a[data-doc-link][href="/docs/VM_DISK_MONITORING"]').first();
await entry.focus();
await page.keyboard.press('Enter');
const safety = page.getByRole('heading', { name: 'Backup safety', exact: true });
await safety.waitFor();
await safety.scrollIntoViewIfNeeded();
let text = await page.locator('article').innerText();
for (const phrase of ['Do not run manual guest-agent probes', 'monitoring and alerts',
'does not prove thaw', 'temporary precaution', 'not established as a reproduced cause',
'clear backup locks', 'host-root diagnostic does not test']) {
assert.ok(text.replace(/\s+/g, ' ').includes(phrase), phrase);
}
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-backup-safety.png`) });
const passive = page.getByRole('heading', { name: 'Passive host preflight', exact: true });
await passive.scrollIntoViewIfNeeded();
const code = page.locator('pre').filter({ hasText: 'sudo bash ./scripts/test-vm-disk.sh 100' });
assert.equal((await code.locator('code').textContent()).trim(), recipe);
const section = text.replace(/\s+/g, ' ').split('Passive host preflight')[1];
for (const phrase of ['no guest-agent commands', 'older copies', 'bounded timeouts',
'non-zero exit', 'does not verify disk freshness', 'before running it']) {
assert.ok(section.includes(phrase), phrase);
}
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-passive-preflight.png`) });
const related = page.getByRole('link', { name: 'Getting help', exact: true });
await related.focus();
await page.keyboard.press('Enter');
await page.getByRole('heading', { name: '🆘 Getting Help', exact: true }).waitFor();
text = (await page.locator('article').innerText()).replace(/\s+/g, ' ');
for (const phrase of ['Do not run guest-agent probes during backup freeze/thaw',
'passive host preflight', 'they do not by themselves establish why disk usage is absent']) {
assert.ok(text.includes(phrase), phrase);
}
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
assert.deepEqual(errors, []);
results.push({ engine, browserVersion: browser.version(), width, tone,
keyboardEntry: true, backupPrecautionAndOutage: true, exactCopiedShell: true,
passiveLimits: true, relatedHelpNavigation: true, noBodyOverflow: true, errors });
await browser.close();
browser = undefined;
}
const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version,
scope: 'rendered production help and copied recipe; no native guest or backup acceptance',
docs: Object.fromEntries(Object.entries(guides).map(([name, content]) =>
[`frontend-modern/public/docs/${name}.md`, createHash('sha256').update(content).digest('hex')])),
results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,81 @@
// Verify the copyable receiver example through production Docs/router/Markdown.
// This does not send a webhook or exercise a native receiver or deduplication.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { chromium, webkit } = require('playwright');
(async () => {
const root = path.resolve('frontend-modern');
const artifacts = path.join(root, 'node_modules', 'webhook-verification-browser');
fs.mkdirSync(artifacts, { recursive: true });
const guide = fs.readFileSync(path.join(root, 'public/docs/WEBHOOKS.md'), 'utf8');
const example = guide.match(/```python\n([\s\S]*?)```/)[1].trim();
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(root, 'node_modules', '.vite-webhook-verification'),
server: { host: '127.0.0.1', port: 5255, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
const results = [];
let browser;
try {
await server.listen();
for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) {
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true });
const page = await browser.newPage({ viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
const errors = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
await page.goto('http://127.0.0.1:5255/browser-tests/docs-fragment-navigation.html',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark'));
await page.getByRole('link', { name: '← All documentation' }).click();
const entry = page.locator('a[data-doc-link][href="/docs/WEBHOOKS"]').first();
await entry.focus();
await page.keyboard.press('Enter');
const heading = page.getByRole('heading', { name: '📦 Delivery Contract', exact: true });
await heading.waitFor();
const code = page.locator('pre').filter({ hasText: 'MAX_SKEW_SECONDS = 300' });
await code.scrollIntoViewIfNeeded();
assert.equal((await code.locator('code').textContent()).trim(), example);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-verifier.png`) });
const warning = page.locator('p').filter({ hasText: 'A time window rejects old captures' });
await warning.scrollIntoViewIfNeeded();
const text = await page.locator('article').innerText();
for (const phrase of ['within five minutes', 'empty secret', 'not every replay',
'deduplicate atomically', 'receiver restarts', 'not covered by this HMAC',
'missing or duplicate signing headers', 'before parsing the JSON']) {
assert.ok(text.includes(phrase), phrase);
}
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-deduplication.png`) });
const payloadHeading = page.getByRole('heading', { name: 'Sample PSA payloads', exact: true });
await payloadHeading.scrollIntoViewIfNeeded();
assert.ok((await page.locator('pre').filter({ hasText: '"alertId": "{{.ID | jsonString}}"' })
.textContent()).includes('"event": "{{.Event}}"'));
assert.deepEqual(errors, []);
results.push({ engine, browserVersion: browser.version(), width, tone,
keyboardEntry: true, exactCopiedPython: true, freshnessAndReplayLimits: true,
payloadIdentity: true, noBodyOverflow: true, errors });
await browser.close();
browser = undefined;
}
const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version,
scope: 'rendered guide and exact copied code; not webhook delivery or receiver processing',
docs: { 'frontend-modern/public/docs/WEBHOOKS.md':
createHash('sha256').update(guide).digest('hex') }, results };
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,53 @@
{
"version": 1,
"base_sha": "f5925942f49b088689695678b98e703af9ead9ef",
"verified_at": "2026-10-03T21:03:10.375831Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "8e1113c7c9e263d284c86e4c3373381b3d71f6a0161897d9dcc782ce225478d5",
"frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts": "deccbf62dcdbafd447cca0f33b516bf98fddb8c726515a30b85b614d26a37095"
},
"routes": [
"/browser-tests/backup-inventory.html?view=date"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"HTTP200 missing PBS envelope stays incomplete, never no-backups/zero",
"Malformed PBS/PVE collections preserve independent source and server health without a rendering failure",
"Mistyped incomplete flags are unavailable, not completed restore points",
"Explicit Go nil PVE collections remain valid empty observations"
],
"interactions": [
"Desktop Enter and phone touch isolated retry keep warnings and independent rows while pending, clear only after validated success",
"Eight settled final screenshots and two baseline screenshots opened and inspected; light desktop/dark phone warnings and rows readable, no page overflow"
],
"playwright_version": "1.56.1",
"browser_versions": [
{
"engine": "chromium-desktop",
"version": "141.0.7390.37"
},
{
"engine": "webkit-phone",
"version": "26.0"
}
],
"dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82",
"limitations": [
"Synthetic source responses through production table/router/API/CSS, not native provider collection, permission, restore, QGA/thaw or installed/released acceptance.",
"Production/embed build is separately source-proved; no new route, polling, source cache or authority."
]
}
@@ -0,0 +1,58 @@
{
"version": 1,
"base_sha": "6140b24bc2bc6e0f272f77bc445758107ab48f45",
"verified_at": "2026-10-03T20:20:56.516012Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "3aba53c4eb3019fd47eed67cb1556644fba45b0d7909f30b03c3039806fa2f38",
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b7ab96c153e4b2c84c3afef4c913265b2598bcbb32dd44a5cf9cb612499a2355"
},
"routes": [
"/browser-tests/backup-inventory.html?view=date",
"/proxmox/backups/coverage"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"PBS loading with fulfilled empty PVE: incomplete inventory, not no backups or zero PBS count",
"PBS 503 isolated with visible warning during disabled retry and recovered PBS evidence",
"Organisation replacement: previous artifacts withdrawn before new reads; PBS 403 does not hide independent PVE evidence",
"Both inventories fail then recover independently; canonical Coverage navigation and current server health preserved"
],
"interactions": [
"Desktop keyboard Enter and phone touch retry only the failed source",
"Both-source Refresh and new organisation GET headers; partial results usable without waiting for the other source",
"Inspect eight settled-animation screenshots at desktop/light and phone/dark widths; warning/count/empty explanation readable, no page overflow"
],
"playwright_version": "1.56.1",
"browser_versions": [
{
"engine": "chromium-desktop",
"version": "141.0.7390.37"
},
{
"engine": "webkit-phone",
"version": "26.0"
}
],
"dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82",
"evidence": [
"Current Web outcome retains complete semantic observations, settled-animation capture subset, baseline, inspected screenshots and exact-source receipts."
],
"limitations": [
"Synthetic responses and organisation contexts, not native permissions, PBS/PVE monitoring, guest thaw, restore verification or release acceptance.",
"Browser uses production components and stylesheet through offline Vite; exact production/embed build is separately source-proved."
]
}

Some files were not shown because too many files have changed in this diff Show More