diff --git a/docs/API.md b/docs/API.md index 46d3d3cbec..d0658a037f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -34,7 +34,7 @@ X-API-Token: Then make a read-only request (curl 7.76 or later): ```bash -curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \ +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ http://127.0.0.1:7655/api/state/summary ``` @@ -43,7 +43,8 @@ access, use your Pulse HTTPS URL and keep certificate verification enabled; do not use `--insecure`. On an authentication-enabled instance, the protected summary checks token access; `/api/health` is public and does not verify authentication. `--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and -403, while retaining the error response. +403, while retaining the error response. Keep `--disable` first: it ignores +local curl configuration that could otherwise enable credential-bearing trace output. Do not paste tokens into command lines, URLs or issue reports. Keep the header file private and outside shared repositories and diagnostics; do not use curl @@ -93,6 +94,8 @@ Some endpoints require admin privileges and/or scopes. Common scopes include: - `settings:write` - `agent:config:read` - `agent:manage` +- `actions:plan`, `actions:approve`, `actions:execute` +- `audit:read` Endpoints that require admin access are noted below. @@ -311,77 +314,129 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels. -CLI adapter: +Use the private header file prepared in [Authentication](#-authentication), +with `settings:read` on the token: + ```bash -PULSE_API_TOKEN=your-token pulse fleet connections \ - --api-url http://localhost:7655 +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/connections ``` ### Unified Action Planning -`POST /api/actions/plan` -Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`. -This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything. +The existing action API separates planning, approval and execution. Run these +steps separately, inspecting each response before moving on; do not paste the +whole sequence as an unattended recovery script. Use the private header file +from [Authentication](#-authentication), not a token in a command or environment +assignment. For remote access, replace the loopback origin with your Pulse HTTPS +URL and keep certificate verification enabled. -`POST /api/actions/{id}/decision` -Records an explicit `approved` or `rejected` decision for a persisted `pending_approval` action. It does not execute the action. +| Endpoint | Token scope and access | +| --- | --- | +| `GET /api/agent/resource-capabilities/{id}` | `monitoring:read`; lists the resource's advertised capabilities and parameter schemas. | +| `POST /api/actions/plan` | `actions:plan` and permission to plan actions; creates a plan but does not approve or execute it. | +| `POST /api/actions/{id}/decision` | `actions:approve` and permission to approve actions; records an explicit `approved` or `rejected` decision for a pending action, without executing it. | +| `POST /api/actions/{id}/execute` | `actions:execute` and permission to execute actions; dispatches only an approved action or an approval-free executable plan. Dry-run-only plans cannot execute. | +| `GET /api/audit/actions` | `audit:read`, audit-log read permission and licensed audit logging. | +| `GET /api/audit/actions/{id}/events` | The same audit access; returns the action's lifecycle evidence. | -`POST /api/actions/{id}/execute` -Starts execution only for an approved action or an approval-free executable plan, records `executing` before dispatch, and records the terminal result afterward. Dry-run-only plans are rejected and cannot be executed through this endpoint. +The legacy `ai:execute` scope also permits planning, approval and execution, +but use the narrower action scopes when possible. Approval and execution tokens +must be bound to an authorised user; a scope alone does not bypass approval +policy, separation of duties or step-up requirements. Audit reads through a +browser session additionally require admin access. A `monitoring:read` token is +not an action-control or audit token. + +1. **Inspect capabilities.** Choose the canonical resource `id` from + `GET /api/resources`, not a display name or a VM number. Replace `vm:42` + throughout these examples and URL-encode it in request paths (`vm%3A42` here). + An empty capabilities list means there is nothing to plan for that resource. -CLI adapter: ```bash -PULSE_API_TOKEN=your-token pulse actions capabilities \ - --api-url http://localhost:7655 \ - --resource-id vm:42 - -PULSE_API_TOKEN=your-token pulse actions plan \ - --api-url http://localhost:7655 \ - --request-id agent-run-123 \ - --resource-id vm:42 \ - --capability restart \ - --param mode=graceful \ - --reason "Recover after confirmed outage" \ - --requested-by agent:oncall-helper - -PULSE_API_TOKEN=your-token pulse actions decide \ - --api-url http://localhost:7655 \ - --action-id act_... \ - --outcome approved \ - --reason "Inside maintenance window" - -PULSE_API_TOKEN=your-token pulse actions execute \ - --api-url http://localhost:7655 \ - --action-id act_... \ - --reason "Execute approved recovery" - -PULSE_API_TOKEN=your-token pulse actions audit \ - --api-url http://localhost:7655 \ - --resource-id vm:42 \ - --limit 10 - -PULSE_API_TOKEN=your-token pulse actions events \ - --api-url http://localhost:7655 \ - --action-id act_... +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42 ``` -Request: -```json +2. **Plan only.** Use an advertised capability and its actual parameter schema; + `restart` and `mode=graceful` are examples, not universal capabilities. Choose + a request ID for this specific intent. Pulse derives the actor from the + authenticated credential, not a caller-supplied `requestedBy` value. + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/plan <<'JSON' { - "requestId": "agent-run-123", + "requestId": "manual-recovery-123", "resourceId": "vm:42", "capabilityName": "restart", "params": { "mode": "graceful" }, - "reason": "Recover after confirmed outage", - "requestedBy": "agent:oncall-helper" + "reason": "Recover after confirmed outage" } +JSON ``` +Inspect the returned approval policy, blast radius, expiry and preflight checks. +Keep its `actionId` and reviewed `planHash`; replace `act_...` and `sha256:...` +below with those returned values. Planning is not proof that execution is safe +or currently available. + +3. **Decide only if approval is required.** Use an authorised approver's private + header file. Approve only the reviewed plan; stop on a refusal or stale-plan + response rather than weakening the policy or creating another recovery action. + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON' +{ + "outcome": "approved", + "reason": "Inside maintenance window", + "planHash": "sha256:..." +} +JSON +``` + +4. **Execute separately.** Only after reviewing a successful decision, or an + approval-free executable plan, use the executor's authorised header file: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON' +{ + "reason": "Execute approved recovery", + "planHash": "sha256:..." +} +JSON +``` + +If the response is lost or times out, inspect the existing action and target +before retrying: losing the connection does not prove that execution stopped. +An HTTP success is not a substitute for checking the terminal result and the +resource's observed state. + +5. **Read the audit and events**, using a private header file with audit access: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + 'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10' +``` + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/audit/actions/act_.../events +``` + +When audit logging is unavailable, use Pulse's action detail in the signed-in +UI to inspect the existing action; do not infer success or repeat execution +from an unavailable audit response. + Response: ```json { "actionId": "act_...", - "requestId": "agent-run-123", + "requestId": "manual-recovery-123", "allowed": true, "requiresApproval": true, "approvalPolicy": "admin", @@ -525,9 +580,11 @@ Request body: ### Setup Script (Public) `GET /api/setup-script` Returns the Proxmox/PBS setup script as a shell-script download. Accepts an -optional temporary setup token in the `setup_token` query for embedded -non-interactive bootstrap; otherwise the script prompts for the one-time setup -token at runtime. Canonical callers must send a supported `type` of `pve` or +optional legacy `setup_token` query for compatibility. Current downloads +contain no token: paste the separately revealed token only at the silent +terminal prompt, or supply `PULSE_SETUP_TOKEN_FILE` pointing to a mode-0600 +regular file in a mode-0700 directory owned by the script's user. Never put a +token in a copied command or URL. Canonical callers must send a supported `type` of `pve` or `pbs` plus non-empty `host` and `pulse_url`; the route no longer generates placeholder-host scripts for later repair or reconstructs Pulse identity from the request origin. The route now shares the same canonical type boundary as @@ -557,20 +614,23 @@ authorize the request itself. Pulse-managed Proxmox monitor-token names on the setup/bootstrap path derive from the canonical Pulse endpoint, not request-local host fallbacks, so setup-script and turnkey node-add flows stay on one deterministic `pulse-` identity per Pulse instance. -`setupToken` remains bootstrap transport data for `/api/setup-script` and -`/api/auto-register`, while `tokenHint` is the operator-facing display field -for quick-setup surfaces and must stay masked instead of exposing the full -one-time token in UI copy. Shared frontend consumers may validate -`setupToken`, but they should not retain or display it once the returned -bootstrap artifact and `tokenHint` are available; visible quick-setup previews -should use the non-secret `commandWithoutEnv` form while copy actions keep -using the token-bearing `commandWithEnv` artifact, and manual download flows -should use the token-bearing `downloadURL` artifact instead of rebuilding a -plain setup-script URL from non-secret preview state. Non-frontend bootstrap -consumers such as the runtime-side Unified Agent bootstrap flow and shell installer must fail closed on that -same full artifact contract too, rejecting missing or mismatched -`downloadURL`, `tokenHint`, or expired `expires` values instead of accepting a -reduced setup-token-only response shape. +The `command`, `commandWithEnv`, and `commandWithoutEnv` fields now contain +identical credential-free commands. They download the complete script before +running it and prompt silently in the root-or-sudo process. The token crosses +the installer boundary through a private file, not process arguments or an +exported secret. `downloadURL` equals the tokenless `url`, so a manual download +also needs the separately revealed token at runtime. `setupToken` is used for +`/api/auto-register`; `tokenHint` remains masked on the setup page. Settings +reveals the token in a separate dialog: run the command first, then copy and +paste the token only at its prompt. The artifact is reused only for the same +host and options while its five-minute expiry is live, and discarded when the +setup modal closes. +Non-frontend consumers must validate the complete artifact, including the +canonical host, type, URLs, filename, masked hint and live expiry. Current +Unified Agents and the shell installer also accept the coherent older-server +artifact during upgrades, but never execute its command text. For new +Proxmox agent enrolment against a newer server, use its current installer; +already enrolled agents keep reporting normally. ### Auto-Register (Public) `POST /api/auto-register` @@ -1123,7 +1183,7 @@ Returns organizations accessible to the authenticated user. ```json { "id": "acme-corp", "displayName": "Acme Corporation" } ``` -The creator becomes the owner and first member. Organization IDs must be lowercase alphanumeric with hyphens, 3-64 characters. +The creator becomes the owner and first member. Organization IDs use letters, digits, periods, underscores or hyphens, 1–64 characters, but cannot be `.` or `..`. Prefer a simple lowercase/hyphen ID such as the example. ### Get Organization `GET /api/orgs/{id}` (requires `settings:read`) @@ -1149,7 +1209,7 @@ Returns all members with their roles. User must be a member of the org. ```json { "userId": "jane", "role": "editor" } ``` -Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. Setting role to `owner` transfers ownership (only current owner can do this). Default org members cannot be managed. +Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. A new user receives a pending invitation (`202`) and must accept it in Pulse before gaining membership. Posting an existing member's `userId` updates their role; there is no member PATCH endpoint. Setting role to `owner` transfers ownership only to an existing member, by the current owner after fresh sign-in. Default org members cannot be managed. ### Remove Member `DELETE /api/orgs/{id}/members/{userId}` (requires `settings:write`, session auth only) @@ -1174,7 +1234,7 @@ Returns resources shared inbound to this organization from other organizations. "accessRole": "viewer" } ``` -Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg`. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. +Share a resource with another organization, using the resource type and ID returned by Pulse. Supported types include `vm`, `system-container`, `agent`, `node`, `docker-host`, `storage`, `pbs` and `pmg`; generic `host` and `container` types are not supported. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. The share is pending until a target-org admin or owner accepts it in Pulse; changing its access role requires acceptance again. ### Delete Share `DELETE /api/orgs/{id}/shares/{shareId}` (requires `settings:write`, session auth only) diff --git a/docs/AUDIT_LOGGING.md b/docs/AUDIT_LOGGING.md index b6d397f85f..403e918003 100644 --- a/docs/AUDIT_LOGGING.md +++ b/docs/AUDIT_LOGGING.md @@ -55,18 +55,31 @@ The audit log panel shows events in reverse chronological order with filtering b ### API +Use a token with `audit:read`, bound to a user permitted to read audit logs, +and the licensed `audit_logging` capability. Prepare the private header file +in [API authentication](API.md#-authentication); never paste the token or a +session cookie into a command. For a one-off read, you can instead open the +API path in your signed-in Pulse browser. + +The loopback URLs below apply on the Pulse host. For remote access, use your +Pulse HTTPS URL with certificate verification enabled. Use curl 7.76 or later, +keeping `--disable` first to ignore local trace/verbose defaults. +`--fail-with-body` returns a non-zero exit on HTTP failures, including 401, +402 and 403. Share only the relevant redacted error, not the header file or +whole audit response: events can contain usernames, client addresses and paths. + ```bash # List recent events -curl http://localhost:7655/api/audit?limit=50 \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + 'http://127.0.0.1:7655/api/audit?limit=50' # Filter by event type and date range -curl "http://localhost:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false" \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + 'http://127.0.0.1:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false' # Get audit summary -curl http://localhost:7655/api/audit/summary \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/audit/summary ``` ### Query Parameters @@ -87,12 +100,17 @@ curl http://localhost:7655/api/audit/summary \ Export the audit log for external analysis or compliance archival: ```bash -curl http://localhost:7655/api/audit/export \ - -H "Authorization: Bearer $TOKEN" \ - -o audit-export.json +umask 077 +export_dir="$(mktemp -d)" && +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --output "$export_dir/audit-export.json" \ + http://127.0.0.1:7655/api/audit/export && +printf 'Saved private export to %s\n' "$export_dir/audit-export.json" ``` -The export includes all events matching the current filter criteria. +This creates a new private directory and prints the file's location only on +success. It does not reuse filters selected in the UI. Treat the export as +sensitive data; keep it outside shared repositories and issue attachments. --- @@ -101,8 +119,8 @@ The export includes all events matching the current filter criteria. Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified: ```bash -curl http://localhost:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify ``` Response: diff --git a/docs/MULTI_TENANT.md b/docs/MULTI_TENANT.md index 6213f7c397..4e7e4d070d 100644 --- a/docs/MULTI_TENANT.md +++ b/docs/MULTI_TENANT.md @@ -51,9 +51,30 @@ Each member has a role within an organization: Organizations can share specific resources with other organizations: -- Share a VM, container, host, or storage resource with another org. +- Share a VM, container, machine agent, storage, PBS, or PMG resource with another org. - Assign an access role (`viewer`, `editor`, or `admin`) to the share. -- The receiving org sees shared resources alongside their own, with a share badge. +- The receiving org must accept the share before it sees the resource alongside its own. + +## Before Using the API + +Create organizations and manage members and shares in the signed-in Pulse UI. +These changes require **session-based user authentication** and the relevant +organization role; API tokens are rejected with `403 session_required`, even +when they have `settings:write`. The UI handles the session and CSRF protection. +Do not extract a session cookie or CSRF token into a shell command. + +The read-only curl examples use an org-bound token with `settings:read` and +the private header file from [API authentication](API.md#-authentication). +Keep the token out of command lines, URLs and reports. A token's organization +binding is an access boundary: changing a URL or `X-Pulse-Org-ID` header does +not grant it access to another organization. + +Use curl 7.76 or later. Keep `--disable` first to ignore local trace/verbose +defaults; `--fail-with-body` makes HTTP failures return a non-zero exit. The +loopback URLs apply on the Pulse host; remotely, use your Pulse HTTPS URL and +keep certificate verification enabled. Replace the example organization ID +`production-datacenter` with your own. Run requests separately, and share only +the relevant redacted error, not whole member or infrastructure responses. ## Managing Organizations @@ -61,14 +82,16 @@ Organizations can share specific resources with other organizations: **UI:** Settings → Organization → Create Organization -**API:** -```bash -curl -X POST http://localhost:7655/api/orgs \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name": "Production Datacenter", "description": "EU production infrastructure"}' +**API contract:** `POST /api/orgs`, session authentication only: +```json +{"id": "production-datacenter", "displayName": "Production Datacenter"} ``` +The creator becomes the owner. `id` is a stable ID of 1–64 characters using +letters, digits, periods, underscores or hyphens, but not `.` or `..`; use a +simple lowercase/hyphen ID such as the example. `displayName` is the name +shown in Pulse. `name` and `description` are not the creation fields. + ### Switching Organizations Use the **Org Switcher** dropdown in the header. When you switch: @@ -81,45 +104,58 @@ Use the **Org Switcher** dropdown in the header. When you switch: **UI:** Settings → Organization → Access -**API:** +**Read-only API:** ```bash -# List members -curl http://localhost:7655/api/orgs/{orgId}/members \ - -H "Authorization: Bearer $TOKEN" - -# Add a member -curl -X POST http://localhost:7655/api/orgs/{orgId}/members \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"userId": "user-id", "role": "editor"}' - -# Update role -curl -X PATCH http://localhost:7655/api/orgs/{orgId}/members/{userId} \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"role": "admin"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/orgs/production-datacenter/members ``` +**Invite or update a member:** use the Access panel as an owner or admin. +Its API contract is `POST /api/orgs/{id}/members`, session authentication only: +```json +{"userId": "user-id", "role": "editor"} +``` + +For a new member, the response is `202` with a pending invitation; the user +must accept it in Pulse before gaining access. An existing member's role is +updated by posting their `userId` and new role to the same endpoint, not by +PATCHing a member URL: +```json +{"userId": "user-id", "role": "admin"} +``` + +Only the current owner can transfer ownership, and only to an existing member +after fresh sign-in. The owner cannot be demoted or removed as an ordinary +member update. Default-organization members cannot be managed here. + ### Sharing Resources **UI:** Settings → Organization → Sharing -**API:** -```bash -# Create a share -curl -X POST http://localhost:7655/api/orgs/{orgId}/shares \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "targetOrgId": "other-org-id", - "resourceType": "host", - "resourceId": "resource-id", - "role": "viewer" - }' +Create the share as an owner or admin of the source organization. An owner or +admin of the target organization then accepts it in **Sharing → Incoming**. +Until acceptance, the share is pending and does not grant access. -# View incoming shares -curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \ - -H "Authorization: Bearer $TOKEN" +**API contract:** `POST /api/orgs/{id}/shares`, session authentication only: +```json +{ + "targetOrgId": "other-org-id", + "resourceType": "vm", + "resourceId": "vm:101", + "accessRole": "viewer" +} +``` + +Use `accessRole`, not `role`, and the resource type and ID returned by Pulse, +not a display name. Supported types include `vm`, `system-container`, `agent`, +`node`, `docker-host`, `storage`, `pbs` and `pmg`; the generic types `host` and +`container` are not supported. Changing a share's access role makes it pending +again, so the target must accept the new grant. + +**Read-only incoming shares:** +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/orgs/production-datacenter/shares/incoming ``` ## Monitoring Multiple Internal Estates @@ -158,15 +194,16 @@ When multi-tenant is enabled, **Settings → Organization** shows: | `GET` | `/api/orgs` | List organizations the current user can access | | `POST` | `/api/orgs` | Create a new organization | | `GET` | `/api/orgs/{id}` | Get organization details | -| `PATCH` | `/api/orgs/{id}` | Update organization | +| `PUT` | `/api/orgs/{id}` | Update organization (session only) | | `DELETE` | `/api/orgs/{id}` | Delete organization | | `GET` | `/api/orgs/{id}/members` | List members | -| `POST` | `/api/orgs/{id}/members` | Add a member | -| `PATCH` | `/api/orgs/{id}/members/{userId}` | Update member role | +| `POST` | `/api/orgs/{id}/members` | Invite a member or update an existing member's role (session only) | | `DELETE` | `/api/orgs/{id}/members/{userId}` | Remove a member | +| `POST` | `/api/org-invitations/{id}/accept` | Accept your pending invitation (session only) | | `GET` | `/api/orgs/{id}/shares` | List outgoing shares | | `GET` | `/api/orgs/{id}/shares/incoming` | List incoming shares | -| `POST` | `/api/orgs/{id}/shares` | Create a share | +| `POST` | `/api/orgs/{id}/shares` | Create or update a share (session only) | +| `POST` | `/api/orgs/{id}/shares/incoming/{shareId}/accept` | Accept an incoming share (session only) | | `DELETE` | `/api/orgs/{id}/shares/{shareId}` | Remove a share | ### Tenant Context @@ -177,6 +214,9 @@ All data-fetching endpoints respect the active organization context. The active 2. Session cookie (browser) 3. Falls back to the `default` organization +Neither the active context nor a token scope overrides organization membership +or a token's organization binding. + ## Storage - The **default** org uses the root data directory (backward compatible). @@ -202,7 +242,7 @@ Activate an Enterprise license with the `multi_tenant` capability in **Settings ### Shared resources not appearing -1. Verify the share exists: **Settings → Organization → Sharing → Incoming**. +1. Verify the share exists and has been accepted: **Settings → Organization → Sharing → Incoming**. 2. Confirm the share role grants sufficient access. 3. Check that the source org's resources are online. diff --git a/docs/PBS.md b/docs/PBS.md index 6093098e27..922ed75833 100644 --- a/docs/PBS.md +++ b/docs/PBS.md @@ -8,7 +8,8 @@ Pulse can monitor PBS backups in two ways: ### 1. Direct PBS Connection (Recommended) -Connect directly to your PBS server for full monitoring capabilities: +Connect to the PBS API with a dedicated read-only token. An agent is not +required for these API-backed readings: **Benefits:** - ✅ Deduplication factor and storage efficiency stats @@ -29,73 +30,100 @@ If your PVE cluster has PBS storage configured, Pulse automatically fetches back - ❌ Can be slow for encrypted PBS storage - ❌ Limited metadata per backup -**Recommendation:** If you see a banner in the Recovery page (formerly Backups) suggesting you add PBS directly, following this guide will significantly improve your monitoring experience. +**Recommendation:** Start with a direct API connection when you need PBS +datastore, job or server status beyond the PVE passthrough. Add a host agent +only for extra local telemetry such as SMART and temperatures; see +[Agent Security](AGENT_SECURITY.md#proxmox-deployment-choices). --- ## Setting Up Direct PBS Connection -### Method 1: Unified Agent Install (Recommended for Bare Metal) +### Method 1: API-Only Connection (Recommended) -Install the unified agent directly on your PBS server for automatic setup: +Use a dedicated PBS monitoring user and token, not a root password. On a new +setup, run these commands in an administrator shell **on the PBS server**: ```bash -# Run on your PBS server -curl -fsSL http://:7655/install.sh | \ - sudo bash -s -- --url http://:7655 --token --enable-proxmox --proxmox-type pbs -``` - -The agent will: -1. Detect it's running on a PBS server -2. Create a `pulse-monitor@pbs` user with read-only access -3. Generate an API token -4. Register the PBS node with Pulse automatically - -### Method 2: API-Only Setup Script (Best for PBS in Containers) ⭐ - -Use this when you can run a command on the PBS host but do not want to install the agent. - -From Pulse's Settings page: -1. Go to **Settings → Infrastructure**. -2. Click **Add infrastructure**. -3. Choose **Proxmox Backup Server**. -4. Use the API-only setup path and enter your PBS server's URL. -5. Click copy to get the setup command. -6. Run the command on your PBS server. - -Example (what the UI generates): -```bash -curl -fsSL "http://:7655/api/setup-script?type=pbs&host=https://:8007&pulse_url=http://:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; } -``` - -Pulse generates that full command for you from **Settings → Infrastructure**, including -the one-time setup token. The script creates a `pulse-monitor@pbs` user, -generates a scoped API token, and registers the server with Pulse. - -> **Note**: API-only mode does not include temperature monitoring or AI command execution. Use **Agent Install** for full functionality. - -> **Tip**: The installer now auto-detects Proxmox mode (`pve` or `pbs`) when possible, but keeping `--proxmox-type pbs` explicit is recommended for predictable PBS onboarding. - -### Method 3: Manual Token Creation - -If you prefer manual setup: - -```bash -# SSH into your PBS server - -# 1. Create a dedicated monitoring user proxmox-backup-manager user create pulse-monitor@pbs --comment "Pulse monitoring" - -# 2. Grant read-only access (Audit role) -proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs - -# 3. Generate an API token (save the output!) proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token +proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs +proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token' ``` -Copy the token value and enter it in Pulse: -- **Token ID:** `pulse-monitor@pbs!pulse-token` -- **Token Value:** The UUID shown after running the command +PBS tokens have their own permissions, limited by their owning user's +permissions. Grant `Audit` to **both the user and the token**. If either already +exists, inspect and reuse it instead of deleting it or rerunning token creation. +Do not disable privilege separation or grant `Admin` to work around a missing +permission. + +The token secret is shown once. Save it privately and enter it only in Pulse's +**Token Value** field; do not paste it into a shell command, URL, screenshot or +issue report. Keep any terminal recording containing that output private. + +In Pulse: +1. Open **Settings → Infrastructure → Add infrastructure** and choose + **Proxmox Backup Server**. +2. Enter the PBS HTTPS URL, normally `https://pbs.example.com:8007`. +3. Select **API Token** and **Manual Token Setup**. +4. Enter **Token ID** `pulse-monitor@pbs!pulse-token` and its secret in + **Token Value**. +5. Keep certificate verification enabled. For a self-signed certificate, use + an independently verified **SSL Fingerprint**; see the TLS guidance below. +6. Test and save the connection. Check that the expected datastores and backups + are visible, not just that the connection test succeeds. + +### Method 2: Optional Host Agent + +Use this only when you also need host-local telemetry. The Linux agent normally +runs as root; it is not required to fix API authentication or missing backup +permissions. Review [Agent Security](AGENT_SECURITY.md) first. + +Create a separate Pulse token in **API Access**, using the **Agent host** preset +for reporting, configuration reads and agent management. This is a **Pulse** +token, not the **PBS** token used above. Do not enable command execution just +for monitoring. + +On the PBS host, enter an administrator root shell before preparing the private +file below. In the editor, save only the Pulse agent token, with no header or +quotes; never put the secret in a command argument: + +```bash +umask 077 +mkdir -p "$HOME/.config/pulse" +chmod 700 "$HOME/.config/pulse" +touch "$HOME/.config/pulse/pbs-agent-token" +chmod 600 "$HOME/.config/pulse/pbs-agent-token" +vi "$HOME/.config/pulse/pbs-agent-token" +``` + +Download the agent installer from **your Pulse server's HTTPS address**, then +inspect the saved script before running it. Replace the example Pulse URL in +both commands. Stop if the download fails; run the next command only after a +successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs +the Pulse server, not the agent. + +```bash +curl --fail --silent --show-error \ + --output "$HOME/.config/pulse/pbs-agent-install.sh" \ + https://pulse.example.com/install.sh +``` + +```bash +bash "$HOME/.config/pulse/pbs-agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/pbs-agent-token" \ + --enable-proxmox --proxmox-type pbs --enable-docker=false +``` + +Do not bypass certificate checks to fetch or run the installer. Use a trusted +Pulse certificate or a CA file you verified separately. See +[Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles. +After installation, check a fresh agent report and the host's +`pulse-agent --version`; installation or hardware capacity alone does not prove +that every CPU, memory or History reading is available. Keep the token file +private and remove the bootstrap copy when no longer needed; do not remove the +installed agent's runtime credential. --- @@ -133,18 +161,65 @@ If you have multiple PBS servers, add each one separately in Settings. Pulse wil ### "Connection Failed" Error -1. **Check URL:** Ensure the PBS URL is correct (default port is 8007) - - Format: `https://pbs.example.com:8007` +Check from the Pulse host or container's network, if possible. A request from +another machine does not prove that Pulse can reach PBS. -2. **Verify token:** Test authentication: - ```bash - curl -sk -H "Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:YOUR_TOKEN" \ - https://your-pbs:8007/api2/json/version - ``` +- **Address/network:** use the PBS HTTPS URL and port `8007`; check DNS, routing + and the firewall before changing credentials. +- **TLS:** keep verification enabled. Use a certificate trusted by the Pulse + runtime, or set its **SSL Fingerprint** after verifying the SHA-256 value + through the PBS console or another already-trusted administrative channel. + Do not accept a fingerprint solely from the failed connection. For example, + on the PBS server you can inspect its public certificate (not its private key): -3. **Network access:** Ensure Pulse can reach PBS on port 8007 + ```bash + openssl x509 -in /etc/proxmox-backup/proxy.pem -noout -fingerprint -sha256 + ``` -4. **SSL verification:** If using self-signed certificates, disable SSL verification in the node settings +- **Authentication/permissions:** test a datastore request, not just `/version`. + Prepare a private header file on the machine running curl: + + ```bash + umask 077 + mkdir -p "$HOME/.config/pulse" + chmod 700 "$HOME/.config/pulse" + touch "$HOME/.config/pulse/pbs-header" + chmod 600 "$HOME/.config/pulse/pbs-header" + vi "$HOME/.config/pulse/pbs-header" + ``` + + In the editor, save this line, replacing `` with the secret + for the PBS token being tested: + + ```text + Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token: + ``` + + Then use curl 7.76 or later, with your PBS hostname: + + ```bash + curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \ + --header "@$HOME/.config/pulse/pbs-header" \ + https://pbs.example.com:8007/api2/json/admin/datastore + ``` + + For curl with a private CA or self-signed certificate, add + `--cacert "$HOME/.config/pulse/pbs-ca.pem"` using a public certificate obtained + and verified through a trusted channel. Its hostname must still match. Do not + use `--insecure` or `-k`. + + `401` means authentication was rejected; `403` means the requested access was + refused. Both return a non-zero curl exit while retaining the error body. + Inspect the existing token and both `Audit` grants before replacing anything. + A `200` response listing the expected datastore establishes access to that + list, not successful collection of every backup, job or History graph. An + empty list is not proof that all permissions are correct. + +Keep the header file outside shared repositories and diagnostics. Do not share +verbose/trace curl output, full infrastructure responses, token values or +private keys. If help is needed, provide only the HTTP status, relevant redacted +error and which expected reading is missing. Do not clear History or recreate a +working connection to make missing data look resolved. ### Slow Backup Loading @@ -155,10 +230,12 @@ If you notice slow loading for PBS storage accessed via PVE: ### Duplicate Backups -If you see the same backup twice: -- This shouldn't happen—Pulse deduplicates by VMID and timestamp -- If it does occur, the direct PBS version takes priority -- Check console for debug logs: `localStorage.setItem('debug-pmg', 'true')` +Check the source, datastore, namespace, guest type/ID and backup time on each +entry. Independent PVE installations can reuse a guest ID; matching VMIDs alone +are not proof of a duplicate. Keep both records while checking their origin. +If the same backup remains listed twice, report those redacted distinctions and +whether each entry came from direct PBS or PVE passthrough. Do not delete backups +or change retention to hide a display problem. --- @@ -169,7 +246,9 @@ In the Recovery view, PBS backups show a data source indicator: - **"PBS"** badge alone = Direct PBS connection (full data) - **"PBS via PVE"** = Passthrough via PVE storage (limited data) -Adding your PBS server directly will remove the "via PVE" indicator and unlock full monitoring capabilities. +When the same backup is reconciled across both sources, Pulse prefers the +direct PBS observation. Check the actual source and expected readings after +adding the connection; the presence of a badge alone is not collection proof. --- diff --git a/docs/RBAC.md b/docs/RBAC.md index 3162eaa43e..ca9b5d1ccd 100644 --- a/docs/RBAC.md +++ b/docs/RBAC.md @@ -46,42 +46,65 @@ user merely because no local administrator is configured. ## Managing Roles +### Before Using the API + +Prefer **Settings → Security → Access Control** for one-off administration. +The API examples require the licensed `rbac` capability and permission to +administer users. With RBAC enabled, role and user administration requires a +full-access (`*`) API token bound to an authorised administrator; a monitoring +token is not enough. Do not widen an agent's token for this job. + +If you already use an administration token, prepare its private header file +as described in [API authentication](API.md#-authentication). The examples read +`$HOME/.config/pulse/api-header`; never paste the token or a session cookie into +a command. Keep this file outside shared repositories and diagnostics. + +The loopback URLs work on the Pulse host. For remote use, substitute your +Pulse HTTPS URL and keep certificate verification enabled. Use curl 7.76 or +later; `--disable` must remain first to ignore local trace/verbose defaults, +and `--fail-with-body` makes HTTP failures return a non-zero exit. Run each +change separately and check its response before continuing. + ### Creating a Role **UI:** Settings → Security → Access Control → Create Role +Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`, +`auditor`) cannot be modified or deleted. These examples use `alert-manager`. + **API:** ```bash -curl -X POST http://localhost:7655/api/admin/roles \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "operator", - "name": "Operator", +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST \ + --data-binary @- http://127.0.0.1:7655/api/admin/roles <<'JSON' + { + "id": "alert-manager", + "name": "Alert Manager", "description": "Can view and manage alerts", "permissions": [ {"action": "read", "resource": "alerts"}, {"action": "write", "resource": "alerts"}, {"action": "read", "resource": "nodes"} ] - }' + } +JSON ``` ### Listing Roles ```bash -curl http://localhost:7655/api/admin/roles \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/admin/roles ``` ### Updating a Role ```bash -curl -X PUT http://localhost:7655/api/admin/roles/operator \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Operator", +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request PUT \ + --data-binary @- http://127.0.0.1:7655/api/admin/roles/alert-manager <<'JSON' + { + "name": "Alert Manager", "description": "Updated description", "permissions": [ {"action": "read", "resource": "alerts"}, @@ -89,14 +112,15 @@ curl -X PUT http://localhost:7655/api/admin/roles/operator \ {"action": "read", "resource": "nodes"}, {"action": "read", "resource": "ai"} ] - }' + } +JSON ``` ### Deleting a Role ```bash -curl -X DELETE http://localhost:7655/api/admin/roles/operator \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager ``` --- @@ -106,8 +130,8 @@ curl -X DELETE http://localhost:7655/api/admin/roles/operator \ ### Listing Users and Their Roles ```bash -curl http://localhost:7655/api/admin/users \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/admin/users ``` SSO users are displayed using the latest configured username claim and email @@ -118,20 +142,27 @@ principal used for authorization. Role assignments are set as a complete list — the user's roles are replaced with the provided set: +Use the stable `username` returned by the user list, not the display name or +email. URL-encode it as one path segment (for example, `:` becomes `%3A` for an +SSO principal). `jane` below is an example local username. Create the custom +role before assigning it; do not run the deletion example first. + ```bash -curl -X PUT http://localhost:7655/api/admin/users/jane/roles \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"roleIds": ["operator", "viewer"]}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request PUT \ + --data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON' +{"roleIds": ["alert-manager", "viewer"]} +JSON ``` -To remove all custom roles from a user, send an empty list: +To clear a user's entire role assignment, including built-in roles, send an empty list: ```bash -curl -X PUT http://localhost:7655/api/admin/users/jane/roles \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"roleIds": []}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request PUT \ + --data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON' +{"roleIds": []} +JSON ``` Note: Users cannot modify their own role assignments (self-escalation prevention). @@ -139,8 +170,8 @@ Note: Users cannot modify their own role assignments (self-escalation prevention ### Removing User Access ```bash -curl -X DELETE http://localhost:7655/api/admin/users/jane \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request DELETE http://127.0.0.1:7655/api/admin/users/jane ``` This removes the Pulse identity and all role assignments and revokes its active diff --git a/docs/TEMPERATURE_MONITORING.md b/docs/TEMPERATURE_MONITORING.md index 4f727e2e04..bc1063438a 100644 --- a/docs/TEMPERATURE_MONITORING.md +++ b/docs/TEMPERATURE_MONITORING.md @@ -9,13 +9,22 @@ If you are upgrading from older releases that used `pulse-sensor-proxy`, see the ## Recommended: Pulse Agent (Proxmox) -The unified agent runs on each Proxmox host and reports temperatures locally with no SSH keys needed. +The unified agent runs on each Proxmox host and reports temperatures locally +with no SSH keys needed. First complete the +[private-file preparation](UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas) +on that host: protect the Pulse agent token, download the installer from your +Pulse server over verified HTTPS, and inspect it. Then use the same Pulse +address and add the Proxmox collector flag: ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-proxmox +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-proxmox ``` +The Pulse agent token is not a Proxmox API token. Keep the secret out of +command arguments and leave command execution disabled for monitoring. + Notes: - Install `lm-sensors` on each host (`apt install lm-sensors && sensors-detect --auto`). - Temperatures appear automatically once the agent reports. @@ -104,11 +113,25 @@ ssh -i /path/to/key root@node "cat /sys/class/thermal/thermal_zone0/temp" ## Legacy Cleanup (If Upgrading) -If you still have the old sensor proxy installed from prior releases, remove it from each **Proxmox host** (not the Pulse container) with the supported cleanup helper: +If you still have the old sensor proxy installed from prior releases, remove it +from each **Proxmox host** (not the Pulse container) with the supported cleanup +helper. In that host's administrative shell, download it to a private directory: ```bash -curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge --local-only +umask 077 +mkdir -p "$HOME/.config/pulse" +chmod 700 "$HOME/.config/pulse" +curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \ + --output "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \ + https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh +``` + +Stop if the download fails and inspect the saved script before running it. +Do not pipe a web response into a privileged shell: + +```bash +bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \ + --uninstall --purge --local-only ``` `--local-only` avoids cluster SSH entirely; run the command once on every @@ -122,8 +145,8 @@ remote portion fail after local cleanup completes. If you also want to remove the old `pulse-monitor@pam` API user and tokens before re-adding the node, include `--remove-proxmox-access`: ```bash -curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge --remove-proxmox-access --local-only +bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \ + --uninstall --purge --remove-proxmox-access --local-only ``` Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log. diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index ff7435ca9d..4ef1f23ae1 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -27,18 +27,25 @@ preventing duplicate root-SSH sensor polling between cluster peers. ## Quick Start -Generate an installation command in the UI: +Choose the host profile and create a monitoring token in the UI: **Settings → Infrastructure → Install on a host** Choose a target profile in that screen when you want explicit install flags for Docker, Kubernetes, Proxmox VE, or Proxmox Backup Server. -The generated command is not tied to a single machine. For a Proxmox VE -cluster, one API connection already provides cluster-wide inventory; the agent -is per host, so run the same generated command on each cluster node where you -want agent-provided telemetry (temperatures, SMART, host identity). Each agent -registers itself and attaches to its own cluster member. +Keep the token out of shell commands, history, URLs and screenshots. Use the +private-file installation steps below; the command arguments contain only the +file's path. A Pulse agent token is not a Proxmox or PBS API token. Monitoring +does not require command execution: leave that option off unless you intend +to grant it. See [Agent Security](AGENT_SECURITY.md) before choosing a root +host agent instead of an API connection or the opt-in Linux safe profile. -The same generated command is also the supported v5-to-v6 agent upgrade path. +For a Proxmox VE cluster, one API connection already provides cluster-wide +inventory; the agent is per host. Repeat the private-file installation on each +cluster node where you want agent-provided telemetry (temperatures, SMART, host +identity), using the intended profile. Each agent registers itself and attaches +to its own cluster member. + +The same installer is also the supported v5-to-v6 agent upgrade path. Run it on the host that already has the v5 `pulse-agent` service to replace the binary and service configuration in place; do not uninstall the old service first unless you are intentionally removing that host from Pulse. @@ -51,24 +58,29 @@ agents initiate the connection. Prefer a stable DNS name for the primary URL so replacing the Pulse host does not require an agent migration. After importing the configuration on a Pulse server with a different address, -retarget each existing standard Linux agent from that agent machine: +retarget each existing standard Linux agent from that agent machine. Download +and inspect the installer from the **new** Pulse address using the HTTPS +preparation below; retargeting reuses the saved credential, so do not create +another token just for this operation: ```bash -curl -fsSL https://pulse.example.com:7655/install.sh | \ - sudo bash -s -- --retarget --url https://pulse.example.com:7655 +bash "$HOME/.config/pulse/agent-install.sh" \ + --retarget --url https://pulse.example.com ``` The retarget operation recovers the existing token, agent ID, enabled collectors, and other service options. It does not carry the old endpoint's TLS bypass, custom CA, or certificate fingerprint to the new address. Supply -`--cacert`, `--server-fingerprint`, or (only on a trusted network) -`--insecure` explicitly when the new endpoint requires it. The script must -come from the new server so it supports the retarget operation. A newly -generated full installation command from **Settings → Infrastructure → Install -on a host** remains the fallback. +`--cacert` or an independently verified `--server-fingerprint` explicitly +when the new agent endpoint requires it. The installer download itself must +use a trusted certificate or a separately verified CA file; an agent +fingerprint option does not verify that earlier download. The script must +come from the new server so it supports retargeting. If retargeting is not +supported on that host, use the private-file installation below with the +intended profile, rather than uninstalling the existing service first. -On Windows, run the full generated PowerShell installation command from the -new Pulse server as Administrator, including the desired collector options. +On Windows, use the PowerShell private-file installation below from the new +Pulse server as Administrator, including the desired collector options. Do not expect the configuration import itself to make agent-only machines appear at the new address. @@ -83,42 +95,96 @@ the agent has reported, and confirm the host-local version with This is the agent installer served by your Pulse server. It is separate from the top-level GitHub `install.sh`, which installs or updates the Pulse server itself. -### Linux (systemd) -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token -``` +### Private-file installation: Linux, macOS and NAS -### macOS -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token -``` +Use an administrative shell **on the host being monitored**, not the Pulse +server or an unrelated container. Linux/systemd, macOS, Synology and TrueNAS +use the same preparation. TrueNAS SCALE uses systemd; CORE uses rc.d. An API +connection is usually enough for TrueNAS inventory and usage; an agent is +optional for host-local data. + +1. In that shell, protect the file before opening the editor. Save only the + Pulse agent token, with no quotes or header. Use your own private directory + and regular files, not shared paths or symlinks: + + ```bash + umask 077 + mkdir -p "$HOME/.config/pulse" + chmod 700 "$HOME/.config/pulse" + touch "$HOME/.config/pulse/agent-token" + chmod 600 "$HOME/.config/pulse/agent-token" + vi "$HOME/.config/pulse/agent-token" + ``` + +2. Replace `https://pulse.example.com` with your Pulse server's HTTPS address + in both the download and installation commands. Download to a file: + + ```bash + curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \ + --output "$HOME/.config/pulse/agent-install.sh" \ + https://pulse.example.com/install.sh + ``` + + **Stop if the download fails.** Inspect the saved script before executing + it. Do not bypass certificate verification or pipe an unchecked response + into a privileged shell. For a private CA, add curl's `--cacert` with the + separately verified CA file, and supply the installer's `--cacert` option + for the agent connection as well. Do not substitute GitHub's top-level + `install.sh`: that installs the Pulse server, not the agent. + +3. Install with the private token file, adding a profile from + [Installation Options](#installation-options) when needed: + + ```bash + bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" + ``` + +Keep the bootstrap token file private and remove that copy when no longer +needed; do not delete the installed agent's runtime credential. Check a fresh +report in Pulse and `pulse-agent --version` on the host. A started service or +hardware capacity alone does not prove that every requested metric is present. ### Windows (PowerShell, run as Administrator) + +Use a **new** setup directory under your own Windows profile; if it already +exists, choose another name. The following preparation grants access only to +your account and SYSTEM. Stop if directory creation or the ACL command fails: + ```powershell -irm http://:7655/install.ps1 | iex +$setupDir = Join-Path $env:USERPROFILE 'PulseAgentSetup' +New-Item -ItemType Directory -Path $setupDir -ErrorAction Stop | Out-Null +$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +icacls.exe $setupDir /inheritance:r /grant:r "*${userSid}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' +if ($LASTEXITCODE -ne 0) { throw 'Could not protect the setup directory' } +$tokenFile = Join-Path $setupDir 'agent-token.txt' +New-Item -ItemType File -Path $tokenFile -ErrorAction Stop | Out-Null +Start-Process -FilePath notepad.exe -ArgumentList "`"$tokenFile`"" -Wait ``` -With environment variables: +In Notepad, save only the Pulse agent token, with no quotes or header. Never +paste it into a PowerShell command or assign a literal secret to an environment +variable. Replace the example HTTPS address in both commands below. Download +and inspect the script first; do not use `Invoke-Expression` on a web response: + ```powershell -$env:PULSE_URL="http://:7655" -$env:PULSE_TOKEN="" -irm http://:7655/install.ps1 | iex +$installerFile = Join-Path $setupDir 'install.ps1' +Invoke-WebRequest -Uri 'https://pulse.example.com/install.ps1' -OutFile $installerFile -ErrorAction Stop ``` -### Synology NAS -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token +Only after a successful download and inspection, run the saved script. Clear +an old `PULSE_TOKEN` environment value so it cannot override the file: + +```powershell +Remove-Item Env:PULSE_TOKEN -ErrorAction SilentlyContinue +& $installerFile -Url 'https://pulse.example.com' -TokenFile $tokenFile ``` -### TrueNAS SCALE/CORE -TrueNAS SCALE and TrueNAS CORE are both supported. The installer auto-detects the platform and configures the appropriate service manager (systemd for SCALE, rc.d for CORE). -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token -``` +Keep the setup directory private and remove the bootstrap copy when no longer +needed, not the installed service's token. Verify a fresh report and the +installed agent version; the remaining platform trust trade-offs are in +[Agent Security](AGENT_SECURITY.md). ## Features @@ -550,46 +616,58 @@ operators are comfortable with Proxmox-side guest probing. ## Installation Options +These commands use the protected token file and successfully downloaded, +inspected installer from [Private-file installation](#private-file-installation-linux-macos-and-nas). +Use the same HTTPS address in preparation and installation. The flags below +choose collectors; they do not grant command execution. + ### Simple Install (host + Docker auto-detect) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" ``` ### Proxmox VE Node (explicit profile) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-proxmox --proxmox-type pve +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pve ``` ### Proxmox Backup Server Node (explicit profile) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-proxmox --proxmox-type pbs +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pbs ``` ### Force Enable Docker (if auto-detection fails) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-docker +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-docker ``` ### Disable Docker (even if detected) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-docker=false +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-docker=false ``` ### Host + Kubernetes Monitoring ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-kubernetes +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-kubernetes ``` ### Docker Monitoring Only ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-host=false --enable-docker +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-host=false --enable-docker ``` ### Exclude Specific Disks from Monitoring @@ -693,9 +771,10 @@ installer path instead of relying on a plain-HTTP first hop. To disable auto-updates: ```bash -# During installation -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --disable-auto-update +# After private-file preparation and successful installer inspection +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --disable-auto-update # Or set environment variable PULSE_DISABLE_AUTO_UPDATE=true @@ -718,8 +797,11 @@ See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md) for supported keys ## Uninstall +Download and inspect the agent installer using the HTTPS preparation above +before running this on the agent host. Uninstallation needs no new token: + ```bash -curl -fsSL http://:7655/install.sh | bash -s -- --uninstall +bash "$HOME/.config/pulse/agent-install.sh" --uninstall ``` This removes: @@ -805,10 +887,13 @@ Unraid), `/tmp` and `/usr/local/bin` share that filesystem, so both the staged and installed copy must fit at once. If the check fails because `/tmp` is on a constrained root, point `TMPDIR` at a -directory on a data volume and re-run the installer: +directory on a data volume and re-run the already downloaded and inspected +agent installer from the private-file preparation above: ```bash -TMPDIR=/share/CACHEDEV1_DATA/tmp bash install.sh --url http://pulse --token +TMPDIR=/share/CACHEDEV1_DATA/tmp bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" ``` (`mktemp` honours `TMPDIR`, so this moves the staging copy off the RAM root. diff --git a/docs/release-control/internal/CONTROL_PLANE.md b/docs/release-control/internal/CONTROL_PLANE.md index 129ba0d394..167bb7c07f 100644 --- a/docs/release-control/internal/CONTROL_PLANE.md +++ b/docs/release-control/internal/CONTROL_PLANE.md @@ -242,16 +242,16 @@ user language should update the control plane. ## Current State 1. v6 is the current active release profile. -2. `v6-product-lane-expansion` is the current active engineering target. - Pulse v6 GA and the initial 6.0.x patch line have shipped; active - development and stable release preparation now run on `main`. +2. `v6-release-reliability` is the current active engineering target. + Pulse v6 has shipped. Active development runs on `main`; release preparation + uses the exact source line resolved by `control_plane.json`. 3. `v6-ga-promotion` is complete. Its release records remain historical evidence and must not keep pre-GA branch, checkout, or readiness posture active in current lane state. -4. Candidate lanes and coverage gaps now route post-GA product expansion. - Release-blocking correctness work may still override that default queue - when a shipped customer contract can drift across billing, entitlements, - runtime behavior, or public copy. +4. Reported defects, dependable delivery and release qualification take + priority over new product expansion. Candidate lanes and coverage gaps + support that work; billing, entitlements, runtime behavior and public copy + remain customer contracts that must not drift. 5. `v6-rc-stabilization` is completed after the shipped RCs established the current monitored-first floor and the active objective moved to stable promotion. @@ -259,8 +259,10 @@ user language should update the control plane. 7. The existing v6 control surfaces are still live, but they now sit underneath an evergreen Pulse control plane rather than pretending to be the whole long-term system. -8. Both prerelease and stable v6 promotions resolve to `main` via - `control_plane.json`. +8. The v6 profile defaults to `main`; explicit version overrides in + `control_plane.json` bind existing releases to `release/v6.4` and + `release/v6.5`. A new checkpoint's mapping is verified during governed + preparation, not inferred from the profile default or this page. 9. Legacy maintenance releases that still feed governed automation outside the active v6 line must also resolve through `control_plane.json`. Right now the remaining `5.1.x` stable maintenance line resolves to `main` diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 37839089d1..03af0d5301 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -1021,9 +1021,20 @@ Companion drill: `.github/scripts/check-demo-reachability.sh` `scripts/trigger-stable-patch.sh` - Automated proof: + `go test ./scripts/installtests -run 'TestStablePatchReleaseNotes' -count=1` + `cd scripts/release_control && python3 -m unittest render_release_body_test` + `cd scripts/release_control && python3 -m unittest secure_runtime_rootful_attestation_v1_test release_note_visuals_test documentation_currentness_test` + - The rootful source-closure test must cover the current filesystem probe and + shared filesystem evidence packages as well as the compiled harness. Its + complete missing-package list is a failed boundary check, not qualification. `cd scripts/release_control && python3 -m unittest resolve_release_promotion_test release_promotion_policy_test` `go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1` - Manual scenario: + - Compare the exact selected release notes with the install-metadata verdict. + Authored grouped notes must retain version identity, unsigned-Windows and + publisher warning, mobile compatibility and the exact stable rollback. A + wording mismatch is not a product-runtime diagnosis. Do not mutate a shipped + release or skip functional checks to obtain a passing watchdog. 1. Push the exact candidate commit to the governed stable branch. 2. For a no-public-release rehearsal, dispatch `./scripts/trigger-stable-patch.sh --dry-run `. diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 69e9c7318f..2c16d51b43 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -3452,6 +3452,11 @@ Agent` secondary handoff against the live setup wizard instead of relying ## Current State +### Credential-safe Proxmox bootstrap (1 October 2026) + +Proxmox initial bootstrap keeps credentials out of copied shell source. The current PVE/PBS agent command fetches a complete installer, runs its credential-free preflight, then privately prompts inside the root/sudo Bash child. Its 0700 directory and 0600 token file are owned by that child, passed only by path and removed on exit or handled signals. The UI reveals credentials separately, clears cached setup material on close and discards late issuance after close/reset. No exec scope is added. Existing fleet reporting is unchanged; newly enrolling an older agent with a newer server requires the current installer. New agents and the root installer still validate coherent old-server artifacts during rolling upgrades, but never execute their command strings. + + ### Update progress stream delivery Server update progress delivery is owned by internal/updates and its API diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e1030c0536..9298790eed 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -4579,6 +4579,11 @@ auto-register mutation boundary. ## Current State +### Credential-safe Proxmox bootstrap (1 October 2026) + +The current Proxmox setup artifact supersedes the former token-bearing command/download contract: `command`, `commandWithEnv` and `commandWithoutEnv` are identical credential-free, single-line transports. `downloadURL` equals the tokenless `url`; downloads embed no setup token. `setupToken` (or agent-install `token`) is returned separately through the authenticated issuance response for an explicit reveal, never interpolated by the consumer. Settings uses the existing token-reveal dialog and tells users to run the command before pasting at its silent terminal prompt. Setup cache remains bound to endpoint/mode/live five-minute expiry and is discarded on modal close. The rendered scripts accept a bounded private `PULSE_SETUP_TOKEN_FILE` before mutation, unexport the secret, and put registration credentials in stdin, not process arguments. Legacy explicit query-token downloads remain accepted for compatibility, but no current artifact generates those URLs. Failed attempted registration returns nonzero and never echoes the response body. Runtime and root-installer consumers validate either the complete new artifact or the coherent old-server artifact; mixed transports fail closed. + + ### Update progress stream delivery `GET /api/updates/stream` answers `text/event-stream` with diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 5cb31ea930..d0fb606611 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1925,9 +1925,23 @@ artifact-selection behaviour. duplicate appended `Installation` / `Promotion Metadata` sections verbatim. From the release after `v6.4.0-rc.1` onward, that renderer also owns a customer-facing communication contract. Public notes lead with one short - outcome paragraph, use a scannable `What's improved` section with no more - than six concrete items, keep fixes symptom-led, and reserve `Before you + outcome paragraph, use scannable symptom-led changes, and reserve `Before you upgrade` or `Known issues` for information users must act on or understand. + From v6.4.6 onward, the authoring tools and renderer accept a short plain-text + `Highlights` list and grouped customer sections (alerts, storage, PBS, NAS, + updates, Pro, service health, security or other improvements). A narrow patch + may retain `What's improved`. Groups must be non-empty and not duplicate the + same change, bullets remain bounded, and internal status headings stay out. + The install-metadata checks bind the current version in the notes title and + retain Windows signing/warning, companion compatibility and exact stable + rollback disclosures. Stable maturity and promotion lineage remain bound by + the changelog and release metadata, not a mandatory public boilerplate + sentence. Historical published notes are not edited or re-rendered to fit a + later authoring template. + The rootful qualification source manifest retains the complete compiled + install-test package, including the release-notes helper and its exact + authored-copy fixture. Accepting grouped notes must not silently narrow + that attested source boundary. Qualification counts, readiness assertions, release gates, workflow narration, artifact identity, and promotion metadata stay in governed workflow summaries and evidence records rather than the public changelog. @@ -2556,6 +2570,11 @@ artifact-selection behaviour. ## Current State +### Credential-safe Proxmox bootstrap (1 October 2026) + +Current PVE auto-registration metadata accepts the credential-free setup artifact: all command aliases use a private-file handoff and `downloadURL` equals the tokenless script URL. This replaces earlier requirements to embed setup tokens in commands/URLs. The older coherent server artifact is accepted read-only during upgrades, never executed. Host, type, canonical filename/URL, masked hint and live expiry remain required. Root-installer JSON parsing and registration pass secrets through descriptor/stdin input rather than Python/curl argv, and the setup response is no longer persisted as a plaintext /tmp diagnostic. No install source, API scope, trust exception, release selector or success condition is widened. + + ### Update progress stream delivery `GET /api/updates/stream` is the in-app updater's progress feed and must never @@ -6201,3 +6220,13 @@ rewriting the author's instructions or selecting another rollback version. distant-scope rejection, and valid generated and inline ownership conditions. This changes publication validation, not installer behaviour, immutable release contents or proof of an installed upgrade/rollback. + +## Rootful qualification source closure (1 October 2026) + +The rootful source manifest includes `internal/filesystemprobe` and +`pkg/agents/filesystem`: both are compiled dependencies of the current collector +and qualification harness. Omitting them would leave the attested packet +unbound to filesystem-observation code that it actually executes. The closure +test enumerates all repository-local dependencies of the install-test binary, +collector and helper and reports every missing package together. No qualification +gate, source exclusion or production permission is relaxed by this correction. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c3bac1b5c9..2eec02ad85 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -336,10 +336,23 @@ These proofs establish presentation/recovery, not installed collection or delive Shared workload, node, Docker-host, and resource-drawer history presentation keeps current readings separate from stored samples. A current metric may -populate the legend while history is still being collected, but it must never -be expanded into synthetic timestamps or chart geometry. An empty stored -series renders the shared collecting-history state; zero remains a valid +populate the legend only with the explicit `current` label, never as a stored +reading, timestamp or chart geometry. A successful empty read says `No stored +history in this range`; it does not imply that collection is running or that +changing provider configuration will produce a sample. Zero remains a valid reported reading, while an absent metric remains unavailable. +A lone stored series renders one point at its actual timestamp and value, +including measured zero, never an invented line. A group with one observation +time exposes that date visibly with `Single observation. No trend yet.` and +retains the existing accessible observation description. Multiple series with +separate single observations remain separate points, inspectable at their +actual times through the existing native control. Failed same-source refreshes +retain these points; source/range replacement clears them along with paths and +captions. `GuestDrawerHistory.sparse.test.tsx` checks geometry, units, provenance, +invalid data, refresh and replacement. `browser-tests/history-window.cjs` also +checks sparse/empty/current states through the production PBS drawer and direct +shared renderer. This is presentation proof, not provider collection or #1723 +installed acceptance. Shared history-chart gridlines must carry numeric labels derived from the plotted scale rather than semantic `Avg` / `Max` placeholders. Byte and byte-rate axes include their human-readable unit at each gridline, and the @@ -401,9 +414,20 @@ priority. Expandable platform summary rows use `getPlatformResourceDetailRowInteractionProps` (or -`createPlatformResourceDetailState`, which owns the same state contract) for -whole-row pointer activation, Enter/Space keyboard activation, focus treatment, -`aria-expanded` / `aria-controls`, and exclusion of embedded links and controls. +`createPlatformResourceDetailState` for their expansion state) for whole-row +pointer activation and exclusion of embedded links and controls. The nested +native disclosure button owns keyboard activation, focus and +`aria-expanded` / `aria-controls`; static table rows are not duplicate controls. +The shared `TableRow` installs a non-activating native click target only while +an `onClick` action exists. WebKit touch must be able to synthesize that row's +click, but actions remain document-delegated so embedded controls can stop +propagation before the row action runs. Do not move the action into the native +compatibility listener or add touch-end activation that opens rows during +scrolling. Explicit caller-owned native listeners remain caller-owned. +`Table.test.tsx` covers native binding, removal, single activation, child +isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance. +The production PBS table/drawer browser fixture exercises first-tap disclosure +and History in WebKit phone emulation, not installed collection or devices. `PlatformResourceDetailToggleButton` is the desktop disclosure affordance and is visually removed on phone layouts where the complete row is the touch target; provider tables must not add a second mobile chevron. When row activation @@ -697,7 +721,7 @@ the synthetic estate continues updating, plus the alert timeline and note form. The Backups surface passes its complete deduplicated route inventory to the PBS table. Only PBS servers render as rows; other resources supply correlation inputs. Preserve PBS drawer identity and use the correlated canonical history target. Missing disk utilisation does not gate CPU/memory history. -One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no host series and renders the collecting-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness. +One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no stored host series and renders the no-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness. A live snapshot can briefly omit the correlated host row while the PBS server row remains, for example while a realtime refresh replaces the merged estate. The correlation must retain the last resolved host per PBS server across that omission instead of falling back to the PBS service target, so the drawer's Discovery and Metrics Target rows and its History series do not flicker. Reuse the remembered host only while it is still fresh relative to the server, and drop it once stale so a removed or replaced host is not advertised indefinitely; a host row that is present but ambiguous still declines. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index ed0ab8c5ec..50dd1df948 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -876,11 +876,17 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. the migration boundary. 11. Extend workload drawer derivations and runtime wiring through `frontend-modern/src/components/Workloads/guestDrawerModel.ts` and `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Workloads/GuestDrawer.tsx` Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`. - A current metric may remain visible in a chart legend while history is - accumulating, but it must never be expanded into synthetic timestamps or a - flat line. Fewer than two stored points remains `Collecting history` and - only metrics-store samples may contribute chart geometry, bounds, or hover - inspection. Current-value legends must preserve the same availability + A current metric may remain visible in a chart legend only when labelled + `current`; it must never be expanded into synthetic timestamps or a flat + line. One stored sample is a visible point at its actual time, with a dated + 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, + 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 + `browser-tests/history-window.cjs`. Current-value legends preserve the same availability semantics as workload rows: the poller's negative unknown-disk sentinel is unavailable and renders `-`, never a fabricated negative percentage. History cards must let the plot area stretch to the card height instead of diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b6cb3524f9..b87b8abc12 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5241,6 +5241,7 @@ "frontend-modern/src/components/SetupWizard/SetupWizard.tsx", "frontend-modern/src/components/Toast/Toast.tsx", "frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx", + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", "frontend-modern/src/components/Workloads/nodeDrawerModel.ts", "frontend-modern/src/features/docker/dockerHostDrawerModel.ts", "frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts", @@ -5947,6 +5948,24 @@ "frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts", "frontend-modern/src/pages/__tests__/Docs.test.ts" ] + }, + { + "id": "drawer-metric-history", + "label": "shared drawer stored/current history proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.inspection.test.tsx", + "frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx", + "frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx", + "frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.source-isolation.test.tsx", + "frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx", + "frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx" + ] } ], "match_files": null diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index fe2083f3b8..1f6e4df2cb 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2640,6 +2640,11 @@ vdev layout is reported` in ## Current State +### Credential-safe Proxmox bootstrap (1 October 2026) + +The shared Proxmox setup boundary now receives its bootstrap credential from private terminal input via a bounded 0700/0600 file, before native setup mutation, rather than from copied shell literals or newly generated token-bearing download URLs. Existing registration grant binding, PVE/PBS monitoring scopes, and explicit non-rotating Audit/Repair remain unchanged. A private-file validation failure stops setup, and failed attempted registration exits nonzero with fixed guidance rather than echoed credential-bearing responses. This supersedes historical shared-boundary requirements for token-bearing commands and manual download URLs; it does not change storage permissions, recovery or retention. + + ### Update progress stream delivery Update progress delivery changes observation only. Streaming or polling a diff --git a/docs/releases/RELEASE_NOTES_TEMPLATE.md b/docs/releases/RELEASE_NOTES_TEMPLATE.md index c2b24234c1..53470c263b 100644 --- a/docs/releases/RELEASE_NOTES_TEMPLATE.md +++ b/docs/releases/RELEASE_NOTES_TEMPLATE.md @@ -3,6 +3,22 @@ One short paragraph explaining the customer outcome of the release. Lead with what feels better or works now, not how it was implemented. +## Highlights + +- One to three short plain-text outcomes. Keep each under 140 characters with + no Markdown, links or issue references so the in-app highlights stay readable. + +## Proxmox, PBS and backups + +- Explain a change in plain language, where users notice it and why it matters. + +From v6.4.6 onward, group related changes under the relevant reader-facing +headings: Alerts and notifications, Disks and storage, Proxmox, PBS and backups, +TrueNAS, vSphere and Docker, Install, updates and agents, Updates and agents, +Pulse Pro, AI and hosted, Monitoring and service health, Security, and Other +improvements. Omit unused groups. A narrow patch can keep the simpler section +below instead. Do not repeat a change between groups or add empty sections. + ## What's improved - **Short outcome** - Explain where users notice it and why it matters. @@ -12,7 +28,7 @@ what feels better or works now, not how it was implemented. Use a concise set of meaningful improvements. A narrow patch may use fewer rather than padding the notes with internal work. Each user-visible change -belongs in this list exactly once. Prefer observable behavior over component +belongs in the grouped changes exactly once. Prefer observable behavior over component names, group related implementation work into one user-recognizable theme, and use plain language. Each complete bullet, including Markdown links, must be no more than 260 characters. New release notes must not contain semicolons or em @@ -33,6 +49,12 @@ constraints, and optional per-pass traces. Do not add a separate `Fixes` section. That shape encourages the same change to be described twice as both an improvement and a fix. +## Known issues + +Describe remaining user symptoms and an actionable workaround or next step. +Omit this section when there are none. Do not substitute internal verification +status for information users can act on. + ## Before you upgrade Include only compatibility, migration, signing, companion-app, known-risk, or diff --git a/frontend-modern/browser-tests/history-window.cjs b/frontend-modern/browser-tests/history-window.cjs index ea0a9bce7e..c04342a8e1 100644 --- a/frontend-modern/browser-tests/history-window.cjs +++ b/frontend-modern/browser-tests/history-window.cjs @@ -138,7 +138,15 @@ const { chromium, firefox, webkit } = require('playwright'); } if (state === 'failure') return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } }); - return route.fulfill({ json: response(type, id, range, state === 'empty') }); + const result = response(type, id, range, state === 'empty'); + if (state === 'sparse') + result.metrics = { + cpu: [point(5, 0)], + netin: [point(5, 0)], + diskwrite: [point(5, 0)], + temperature: [point(5, 50)], + }; + return route.fulfill({ json: result }); } if (url.pathname === '/api/license/runtime-capabilities') return route.fulfill({ @@ -336,14 +344,54 @@ const { chromium, firefox, webkit } = require('playwright'); ); await detail.screenshot({ path: path.join(artifacts, `one-hour-${theme}.png`) }); progress('replacement-window-verified', { theme }); - state = 'empty'; + state = 'sparse'; await detail.getByRole('button', { name: 'Refresh history' }).click(); await page.waitForFunction( () => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 0, ); + await detail.screenshot({ path: path.join(artifacts, `sparse-${theme}.png`) }); + const sparseObservation = { + theme, + pointCount: await detail.locator('[data-history-observation]').count(), + collectingClaims: await detail.getByText('Collecting history', { exact: true }).count(), + descriptions: await detail + .getByTestId('guest-history-plot') + .evaluateAll((plots) => + plots.map( + (plot) => document.getElementById(plot.getAttribute('aria-describedby'))?.textContent, + ), + ), + }; + progress('sparse-observation', sparseObservation); + assert.equal(sparseObservation.pointCount, 4, 'each lone series must be visible'); + assert.equal(sparseObservation.collectingClaims, 0); + assert.equal(await detail.getByRole('slider').count(), 0); + assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 4); + for (const dot of await detail.locator('[data-history-observation]').all()) { + assert.ok(Math.abs(Number(await dot.getAttribute('cx')) - expectedX(5, '1h')) < 0.01); + assert.ok(Number.isFinite(Number(await dot.getAttribute('cy')))); + } + await verifyWindow(detail, '1h'); + state = 'failure'; + await detail.getByRole('button', { name: 'Refresh history' }).click(); + await detail + .getByText('History refresh failed. Showing previously loaded history.') + .waitFor(); + assert.equal(await detail.locator('[data-history-observation]').count(), 4); + assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 4); + state = 'empty'; + await detail.getByRole('button', { name: 'Retry history' }).click(); + await page.waitForFunction( + () => document.querySelectorAll('[data-history-observation]').length === 0, + ); await verifyWindow(detail, '1h'); assert.equal(await detail.getByRole('slider').count(), 0); - assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 4); + assert.equal( + await detail.getByText('No stored history in this range', { exact: true }).count(), + 4, + ); + assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 0); + await detail.screenshot({ path: path.join(artifacts, `empty-${theme}.png`) }); state = 'success'; await detail.getByRole('combobox', { name: 'History range' }).selectOption('7d'); await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor(); @@ -354,6 +402,23 @@ const { chromium, firefox, webkit } = require('playwright'); assert.equal(requests.length, readsBeforeLock); assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0); assert.equal(errors.length, 0, errors.join('\n')); + // The direct production renderer deliberately supplies current readings. + // A live fallback must be labelled, never plotted or attributed to a stored time. + state = 'empty'; + await page.goto('http://127.0.0.1:5225/browser-tests/pbs-history-refresh.html'); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + detail = page.getByTestId('history-refresh-fixture'); + await detail.getByText('No stored history in this range', { exact: true }).first().waitFor(); + const liveCPU = detail.locator( + '[data-history-group="utilization"] [data-history-current="cpu"]', + ); + assert.match((await liveCPU.innerText()).replace(/\s+/g, ' '), /CPU\s*42\.0%\s*current/); + assert.equal(await detail.locator('[data-history-observation]').count(), 0); + assert.equal(await detail.getByTestId('guest-history-plot').locator('path').count(), 0); + await detail.screenshot({ path: path.join(artifacts, `current-empty-${theme}.png`) }); observations.push({ theme, requests, errors, utilizationX, networkX }); progress('completed-theme', { theme }); await page.close(); diff --git a/frontend-modern/browser-tests/pbs-history-refresh.cjs b/frontend-modern/browser-tests/pbs-history-refresh.cjs index 2eb3133e97..9a61945e5b 100644 --- a/frontend-modern/browser-tests/pbs-history-refresh.cjs +++ b/frontend-modern/browser-tests/pbs-history-refresh.cjs @@ -209,7 +209,10 @@ const { chromium } = require('playwright'); await page.getByRole('button', { name: 'Switch to service target', exact: true }).click(); await status.getByText('Failed to load history data', { exact: true }).waitFor(); assert.equal(await plots.count(), 0); - assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 0); + assert.equal( + await detail.getByText('No stored history in this range', { exact: true }).count(), + 0, + ); assert.equal(await detail.getByText(/previously loaded/).count(), 0); await checkLayout(); await screenshot('replacement-failed'); diff --git a/frontend-modern/browser-tests/proxmox-private-input.cjs b/frontend-modern/browser-tests/proxmox-private-input.cjs new file mode 100644 index 0000000000..abc346a213 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-private-input.cjs @@ -0,0 +1,238 @@ +// Browser checks only; server responses are synthetic, not installed bootstrap. +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 { 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'), + server: { host: '127.0.0.1', port: 5237, strictPort: true }, + }); + const engine = process.argv.includes('--phone') ? 'webkit' : 'chromium'; + const width = engine === 'webkit' ? 390 : 1280; + const artifacts = path.join(root, 'node_modules', `proxmox-private-${engine}`); + fs.mkdirSync(artifacts, { recursive: true }); + let browser; + const observations = []; + try { + await server.listen(); + browser = await { chromium, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const type of ['pve', 'pbs']) { + 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)); + await page.addInitScript(() => { + window.__copies = []; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: async (value) => { + window.__copies.push(value); + }, + }, + }); + }); + let count = 0; + let holdEndpoint, releaseHeld, sawHeld; + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === holdEndpoint) { + holdEndpoint = undefined; + await new Promise((resolve) => { + releaseHeld = resolve; + sawHeld(); + }); + } + if (url.pathname === '/api/setup-script-url') { + count++; + const body = route.request().postDataJSON(); + assert.equal(body.type, type); + const scriptURL = `http://127.0.0.1:5237/api/setup-script?type=${type}`; + const command = `( curl -fsSL '${scriptURL}' -o "$install_script"; sudo bash -c 'read -r -s pulse_token; PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1"'; )`; + return route.fulfill({ + json: { + type, + host: body.host, + url: scriptURL, + downloadURL: scriptURL, + scriptFileName: `pulse-setup-${type}.sh`, + command, + commandWithEnv: command, + commandWithoutEnv: command, + setupToken: 'a'.repeat(32), + tokenHint: 'aaa…aaa', + expires: Math.floor(Date.now() / 1000) + 300, + }, + }); + } + if (url.pathname === '/api/agent-install-command') { + const body = route.request().postDataJSON(); + assert.equal(body.type, type); + assert.equal(body.enableCommands, false); + assert.equal(body.insecure, false); + return route.fulfill({ + json: { + command: + '( bash "$install_script" --preflight-only; sudo bash -c \'read -r -s pulse_token; bash "$1" --token-file "$token_file" --enable-proxmox\'; )', + token: 'b'.repeat(32), + }, + }); + } + if (url.pathname === '/api/setup-script') { + assert.ok(!url.searchParams.has('setup_token')); + return route.fulfill({ + status: 200, + contentType: 'text/x-shellscript; charset=utf-8', + headers: { 'Content-Disposition': `attachment; filename="pulse-setup-${type}.sh"` }, + body: '#!/bin/bash\n# no credential in the download\n', + }); + } + return route.fulfill({ json: { data: [] } }); + }); + await page.goto( + `http://127.0.0.1:5237/browser-tests/proxmox-private-input.html?type=${type}`, + ); + await page.locator('h1').waitFor(); + if (engine === 'webkit') + await page.evaluate(() => document.documentElement.classList.add('dark')); + console.log( + JSON.stringify({ + type, + errors, + buttons: await page.getByRole('button').allTextContents(), + body: (await page.locator('body').innerText()).slice(0, 300), + }), + ); + const copy = page.getByRole('button', { name: 'Copy command', exact: true }); + await copy.first().click(); + let dialog = page.getByRole('dialog', { name: 'API token ready' }); + await dialog.waitFor(); + await page.waitForFunction(() => { + const panel = document.querySelector('[role="dialog"]'); + return panel && getComputedStyle(panel).opacity === '1'; + }); + assert.ok((await dialog.innerText()).includes('silent “Pulse setup token” prompt')); + assert.ok(!(await page.evaluate(() => window.__copies[0])).includes('a'.repeat(32))); + await page.screenshot({ path: path.join(artifacts, `${type}-setup.png`), fullPage: true }); + const tokenButton = dialog.getByRole('button', { name: 'Copy token', exact: true }); + await tokenButton.focus(); + await page.keyboard.press('Enter'); + assert.equal(await page.evaluate(() => window.__copies.at(-1)), 'a'.repeat(32)); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + await copy.first().click(); + await dialog.waitFor(); + assert.equal(count, 1, 'live setup artifact must be reused'); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await page.getByRole('button', { name: 'Close setup', exact: true }).click(); + assert.equal(await page.getByTestId('cache-state').innerText(), 'empty'); + await page.getByRole('button', { name: 'Reopen setup', exact: true }).click(); + await copy.first().click(); + await dialog.waitFor(); + assert.equal(count, 2, 'closed modal must not retain its setup secret'); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await page.getByText('Alternative: Download script manually', { exact: true }).click(); + await page.getByRole('button', { name: 'Download setup script', exact: true }).click(); + await dialog.waitFor(); + assert.equal(count, 2, 'download must reuse the live credential-free artifact'); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await page.getByRole('button', { name: 'Host Telemetry Agent', exact: true }).click(); + await page + .getByRole('button', { + name: type === 'pbs' ? 'Copy to clipboard' : 'Copy command', + exact: true, + }) + .first() + .click(); + await dialog.waitFor(); + await page.waitForFunction(() => { + const panel = document.querySelector('[role="dialog"]'); + return panel && getComputedStyle(panel).opacity === '1'; + }); + assert.ok((await dialog.innerText()).includes('silent “Pulse agent token” prompt')); + assert.ok(!(await page.evaluate(() => window.__copies.at(-1))).includes('b'.repeat(32))); + await page.screenshot({ path: path.join(artifacts, `${type}-agent.png`), fullPage: true }); + await dialog.getByRole('button', { name: 'Copy token', exact: true }).click(); + assert.equal(await page.evaluate(() => window.__copies.at(-1)), 'b'.repeat(32)); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + // A late issuance must not repopulate a closed modal's secret cache + // or reopen the global reveal dialog after the user has left the flow. + for (const endpoint of ['/api/setup-script-url', '/api/agent-install-command']) { + const closeSetup = page.getByRole('button', { name: 'Close setup', exact: true }); + if (await closeSetup.count()) await closeSetup.click(); + await page.getByRole('button', { name: 'Reopen setup', exact: true }).click(); + if (endpoint.includes('agent-install')) + await page.getByRole('button', { name: 'Host Telemetry Agent', exact: true }).click(); + else await page.getByRole('button', { name: /Connect via API/ }).click(); + holdEndpoint = endpoint; + let deadline; + const held = new Promise((resolve, reject) => { + sawHeld = resolve; + deadline = setTimeout(() => reject(new Error('held request not observed')), 15000); + }); + const nextCopy = page + .getByRole('button', { + name: + endpoint.includes('agent-install') && type === 'pbs' + ? 'Copy to clipboard' + : 'Copy command', + exact: true, + }) + .first(); + await nextCopy.click(); + await held; + clearTimeout(deadline); + await page.getByRole('button', { name: 'Close setup', exact: true }).click(); + const completed = page.waitForResponse( + (response) => new URL(response.url()).pathname === endpoint, + ); + releaseHeld(); + await completed; + await page.waitForTimeout(200); // allow the actual promise continuation, not an API retry + assert.equal(await page.getByTestId('cache-state').innerText(), 'empty'); + assert.equal(await page.getByRole('dialog').count(), 0); + } + assert.deepEqual(errors, []); + observations.push({ + type, + engine, + width, + setupRequests: count, + credentialFreeCopies: true, + separateTokenCopy: true, + cacheClose: true, + lateIssuanceDiscarded: true, + commandsEnabled: false, + insecure: false, + }); + await page.close(); + } + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify( + { + playwright: require('playwright/package.json').version, + browser: browser.version(), + observations, + }, + null, + 2, + ), + ); + console.log(JSON.stringify({ engine, browser: browser.version(), observations, artifacts })); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})(); diff --git a/frontend-modern/browser-tests/proxmox-private-input.html b/frontend-modern/browser-tests/proxmox-private-input.html new file mode 100644 index 0000000000..b88f0ce1e9 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-private-input.html @@ -0,0 +1,12 @@ + + + + + + Proxmox private input fixture + + +
+ + + diff --git a/frontend-modern/browser-tests/proxmox-private-input.tsx b/frontend-modern/browser-tests/proxmox-private-input.tsx new file mode 100644 index 0000000000..cf4b982d69 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-private-input.tsx @@ -0,0 +1,54 @@ +// Real settings state, setup section and existing token dialog; synthetic APIs. +import { createSignal, Show } from 'solid-js'; +import type { NodeConfig } from '../src/types/nodes'; +import { render } from 'solid-js/web'; +import { useNodeModalState } from '../src/components/Settings/useNodeModalState'; +import { NodeModalSetupGuideSection } from '../src/components/Settings/NodeModalSetupGuideSection'; +import { TokenRevealDialog } from '../src/components/TokenRevealDialog'; +import '../src/index.css'; + +const Fixture = () => { + const type = new URLSearchParams(location.search).get('type') === 'pbs' ? 'pbs' : 'pve'; + const [open, setOpen] = createSignal(true); + const props = { + get isOpen() { + return open(); + }, + nodeType: type, + prefillNode: { + type, + name: `${type} fixture`, + host: `https://${type}.example:${type === 'pbs' ? 8007 : 8006}`, + verifySSL: true, + } as NodeConfig, + onClose() { + setOpen(false); + }, + onSave: async () => {}, + }; + const state = useNodeModalState(props); + return ( +
+

Connect a Proxmox server

+ + + + + + {state.quickSetupCommandReady() ? 'ready' : 'empty'} + + +
+ ); +}; +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-tests/table-row-touch.cjs b/frontend-modern/browser-tests/table-row-touch.cjs new file mode 100644 index 0000000000..025a5cd043 --- /dev/null +++ b/frontend-modern/browser-tests/table-row-touch.cjs @@ -0,0 +1,245 @@ +// Real production table rows and PBS drawers; synthetic bounded HTTP data. +// Do not instrument rows with native listeners: doing so masks the WebKit bug. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, firefox, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const engine = process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'webkit'; + const theme = process.argv.includes('--dark') ? 'dark' : 'light'; + const width = process.argv.includes('--desktop') ? 1365 : 390; + assert.ok(['chromium', 'firefox', 'webkit'].includes(engine)); + const out = path.join(root, 'node_modules', `table-row-touch-${engine}-${width}-${theme}`); + fs.mkdirSync(out, { recursive: true }); + fs.chmodSync(out, 0o755); + 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(out, 'vite-cache-')), + server: { host: '127.0.0.1', port: 5227, strictPort: true }, + }); + const sourceHashes = Object.fromEntries( + ['src/components/shared/Table.tsx', 'package-lock.json'].map((file) => [ + file, + createHash('sha256') + .update(fs.readFileSync(path.join(root, file))) + .digest('hex'), + ]), + ); + 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, + ); + let browser, page; + const errors = [], + requests = [], + observations = []; + const snapshot = async (name) => { + const file = path.join(out, name + '.png'); + await page.screenshot({ path: file }); + fs.chmodSync(file, 0o644); + }; + const activate = (locator) => (width === 390 ? locator.tap() : locator.click()); + const noOverflow = async () => + assert.ok( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + 'document overflow', + ); + try { + await server.listen(); + browser = await { chromium, firefox, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + locale: 'en-GB', + timezoneId: 'UTC', + ...(width === 390 ? { isMobile: true, hasTouch: true } : {}), + }); + page.setDefaultTimeout(20_000); + 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:5227') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ path: url.pathname, search: url.search, method: route.request().method() }); + assert.equal(route.request().method(), 'GET'); + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community', label: 'Pulse Community runtime' }, + blocked_capabilities: [], + }, + }); + if (url.pathname === '/api/metrics-store/history') { + const id = url.searchParams.get('resourceId'); + assert.ok(['vm-one', 'vm-two', 'agent-three'].includes(id), `unexpected target ${id}`); + const end = Date.UTC(2026, 9, 1, 12); + return route.fulfill({ + json: { + resourceType: url.searchParams.get('resourceType'), + resourceId: id, + range: url.searchParams.get('range'), + start: end - 86400000, + end, + source: 'store', + metrics: { + cpu: [ + { timestamp: end - 600000, value: 20 }, + { timestamp: end - 300000, value: 30 }, + ], + memory: [{ timestamp: end - 300000, value: 40 }], + netin: [ + { timestamp: end - 600000, value: 1024 }, + { timestamp: end - 300000, value: 2048 }, + ], + }, + }, + }); + } + return route.fulfill({ json: { data: [], enabled: false } }); + }); + console.log(JSON.stringify({ stage: 'production-pbs', engine, theme, width, sourceHashes })); + await page.goto('http://127.0.0.1:5227/browser-tests/pbs-identity-boundary.html', { + waitUntil: 'domcontentloaded', + timeout: 120_000, + }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + await activate(page.getByRole('button', { name: 'Corroborate links', exact: true })); + for (const suffix of ['one', 'two', 'three']) { + const cell = page.locator(`td[title="backup-connection-${suffix} · tank"]`); + const disclosure = cell.getByRole('button'); + assert.equal(await disclosure.getAttribute('aria-expanded'), 'false'); + // No focus, force, dispatchEvent, synthetic click or added event listeners. + await activate(cell); + await page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`).waitFor(); + assert.equal(await disclosure.getAttribute('aria-expanded'), 'true'); + const detail = page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`); + observations.push({ suffix, firstActivationOpened: true }); + await noOverflow(); + if (suffix === 'one') await snapshot('pbs-first-activation'); + await activate(detail.getByRole('tab', { name: 'History', exact: true })); + await detail.getByTestId('guest-history-plot').first().locator('path').first().waitFor(); + assert.equal( + await disclosure.getAttribute('aria-expanded'), + 'true', + 'tab must not retoggle row', + ); + if (suffix === 'three') await snapshot('pbs-third-history'); + // Close by the same ordinary cell; nested History input cannot consume it. + await activate(cell); + assert.equal(await disclosure.getAttribute('aria-expanded'), 'false'); + assert.equal(await detail.count(), 0); + } + const keyboardCell = page.locator('td[title="backup-connection-one · tank"]'); + const keyboardDisclosure = keyboardCell.getByRole('button'); + await keyboardDisclosure.focus(); + await page.keyboard.press('Enter'); + assert.equal(await keyboardDisclosure.getAttribute('aria-expanded'), 'true'); + await page.keyboard.press('Space'); + assert.equal(await keyboardDisclosure.getAttribute('aria-expanded'), 'false'); + assert.ok(requests.some((request) => request.search.includes('resourceId=vm-one'))); + assert.ok(requests.some((request) => request.search.includes('resourceId=vm-two'))); + assert.ok(requests.some((request) => request.search.includes('resourceId=agent-three'))); + + console.log(JSON.stringify({ stage: 'shared-controls', engine, theme, width })); + await page.goto('http://127.0.0.1:5227/browser-tests/table-row-touch.html', { + waitUntil: 'domcontentloaded', + timeout: 120_000, + }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + const state = async () => + JSON.parse(await page.getByRole('status', { name: 'Action state' }).innerText()); + const name = page.getByText('Shared resource name', { exact: true }); + await name.waitFor(); + await activate(name); + assert.equal((await state()).rowActions, 1); + assert.equal((await state()).expanded, true); + await activate(page.getByRole('button', { name: 'Child action', exact: true })); + await activate(page.getByRole('link', { name: 'Native link' })); + assert.equal(new URL(page.url()).hash, '#native-link'); + await activate(page.getByRole('checkbox', { name: 'Child checkbox' })); + assert.equal(await page.getByRole('checkbox').isChecked(), true); + assert.equal((await state()).rowActions, 1, 'nested controls must not run row action'); + assert.equal((await state()).childActions, 2); + const toggle = page.getByTestId('control-row').locator('[data-row-action="true"]'); + await activate(toggle); + assert.equal((await state()).rowActions, 2, 'nested disclosure must toggle once'); + await toggle.focus(); + await page.keyboard.press('Enter'); + assert.equal((await state()).rowActions, 3); + await page.keyboard.press('Space'); + assert.equal((await state()).rowActions, 4); + await activate(page.getByRole('button', { name: 'Toggle row action' })); + await activate(name); + assert.equal((await state()).rowActions, 4); + await activate(page.getByRole('button', { name: 'Toggle row action' })); + await activate(name); + assert.equal((await state()).rowActions, 5); + await activate(page.getByText('Static row', { exact: true })); + assert.equal((await state()).rowActions, 5); + await activate(page.getByText('Grid resource', { exact: true })); + assert.equal((await state()).gridActions, 1); + await activate(page.getByRole('button', { name: 'Grid child action', exact: true })); + assert.equal((await state()).gridActions, 1); + assert.equal((await state()).childActions, 3); + await noOverflow(); + await snapshot('shared-controls'); + assert.deepEqual(errors, []); + const result = { + engine, + browserVersion: browser.version(), + playwrightVersion, + theme, + width, + sourceHashes, + observations, + requests, + controlState: await state(), + errors, + result: 'passed', + }; + fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify(result, null, 2) + '\n', { + mode: 0o644, + }); + console.log(JSON.stringify(result)); + } catch (error) { + if (page) await snapshot('failure').catch(() => {}); + fs.writeFileSync( + path.join(out, 'failure.json'), + JSON.stringify( + { error: String(error), errors, requests, observations, sourceHashes }, + null, + 2, + ) + '\n', + { mode: 0o644 }, + ); + throw error; + } finally { + await browser?.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/table-row-touch.html b/frontend-modern/browser-tests/table-row-touch.html new file mode 100644 index 0000000000..9fd7276bf5 --- /dev/null +++ b/frontend-modern/browser-tests/table-row-touch.html @@ -0,0 +1,12 @@ + + + + + + Shared table touch verification + + +
+ + + diff --git a/frontend-modern/browser-tests/table-row-touch.tsx b/frontend-modern/browser-tests/table-row-touch.tsx new file mode 100644 index 0000000000..ab8e033077 --- /dev/null +++ b/frontend-modern/browser-tests/table-row-touch.tsx @@ -0,0 +1,91 @@ +// Real shared table/grid actions with synthetic data, not an installed journey. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Table, TableBody, TableCell, TableRow } from '../src/components/shared/Table'; +import { SummaryRowActionButton } from '../src/components/shared/SummaryRowActionButton'; +import { PulseDataGrid } from '../src/components/shared/PulseDataGrid'; +import '../src/index.css'; + +function Fixture() { + const [expanded, setExpanded] = createSignal(false); + const [rowActions, setRowActions] = createSignal(0); + const [childActions, setChildActions] = createSignal(0); + const [enabled, setEnabled] = createSignal(true); + const [gridActions, setGridActions] = createSignal(0); + const toggle = () => { + setRowActions((count) => count + 1); + setExpanded((value) => !value); + }; + const child = (event: MouseEvent) => { + event.stopPropagation(); + setChildActions((count) => count + 1); + }; + return ( +
+

Shared table touch verification

+ + + {JSON.stringify({ + expanded: expanded(), + rowActions: rowActions(), + childActions: childActions(), + enabled: enabled(), + gridActions: gridActions(), + })} + + + + + + + Shared resource name + + + + + Native link + + event.stopPropagation()} + /> + + + + Static row + No action + + +
+ + data={[{ id: 'grid-one', name: 'Grid resource' }]} + keyExtractor={(row) => row.id} + onRowClick={() => setGridActions((count) => count + 1)} + columns={[ + { key: 'name', label: 'Name' }, + { + key: 'action', + label: 'Action', + render: () => ( + + ), + }, + ]} + /> +
+ ); +} +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 6fd5437339..7e82fe7313 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,795 +1,63 @@ { "version": 1, - "base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9", - "verified_at": "2026-10-01T01:42:43Z", + "base_sha": "6b23876ec8104965c7842e194e055f3206134d4f", + "verified_at": "2026-10-01T04:21:56.727078Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + "frontend-modern/src/api/nodes.ts", + "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx", + "frontend-modern/src/components/Settings/useNodeModalState.ts", + "frontend-modern/src/stores/tokenReveal.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99" + "frontend-modern/src/api/nodes.ts": "398f3b87ce192c7650a9e34f184ecdca0e8baf35b57d90ef5f8f697348675fec", + "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "e4168945744f8ec20b5439f5730b1ecd8b4811b29ec00da4e7e3798f868a1b41", + "frontend-modern/src/components/Settings/useNodeModalState.ts": "9372c3a4b346a0e31733a7042c0a65b5ebe3518b12595c6c6a68b1cadd667dcd", + "frontend-modern/src/stores/tokenReveal.ts": "c21ac970bde6229506f7d6e627430b6e91c9ea983f705a28e3d149e4415c39b7" }, "routes": [ - "/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer, shared History/query and CSS with synthetic VM/Agent APIs; not installed collection or complete #1723 acceptance)" + "/browser-tests/proxmox-private-input.html?type=pve", + "/browser-tests/proxmox-private-input.html?type=pbs" ], "viewports": [ { - "width": 1365, + "width": 1280, "height": 900 }, { "width": 390, - "height": 844 + "height": 900 } ], "states": [ - "light/dark short sample coverage stays at its true position in the fulfilled 24-hour window rather than stretching across each plot", - "all configured utilization/network/disk/thermal panels share dated endpoints, including lone and empty panels", - "three separately mapped synthetic PBS drawers read vm-one, vm-two and agent-three, not unrelated host identities", - "same-time observations occupy the same horizontal coordinate across groups with different sample coverage", - "failed same-source refresh retains samples and window with a sanitised warning; successful refresh advances the window", - "held 1-hour range replacement clears former endpoints, paths and inspection controls; matching data then uses the new window", - "empty window has labelled bounds without a synthetic trend; seven-day bounds remain dated; locked fourteen-day range makes no read" + "Actual settings state/setup section and existing token-reveal dialog with production CSS; synthetic server artifacts and no installed bootstrap execution", + "Separate command and credential copy for API Inventory and Host Telemetry Agent; raw secrets absent from copied shell text", + "PVE/PBS tokenless script download reuses live bootstrap metadata; credential cache clears on close/reopen", + "Held setup and agent issuance completed after modal close is discarded: no secret cache and no reveal dialog", + "Default TLS verification and monitoring-only agent request (enableCommands=false) unchanged", + "Desktop light and phone-emulated WebKit dark token reveal is readable; screenshots visually inspected" ], "interactions": [ - "desktop native keyboard disclosure and History tab; Chromium phone row touch; WebKit phone native keyboard disclosure (whole-row touch opening not established)", - "focus native observation input, use Home/ArrowRight/End and compare dated values and same-time markers between utilization and network", - "Chromium/WebKit phone-emulated touch taps on the at-least-44px native History input; inspection performs no extra API reads", - "fail a real query refresh; retry with advancing bounds; hold and fulfil a 1-hour replacement read; refresh empty, choose seven days and a licence-locked range" + "Copy command; copy token via Enter and pointer; Escape and Dismiss close token dialog", + "Close/reopen setup; download script; switch API/Agent paths", + "Hold setup/agent response, close modal, then release response and verify no late reveal/cache" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=chromium --theme=light; --engine=chromium --theme=dark; both themes with --engine=chromium --phone; both themes with --engine=firefox; both themes with --engine=webkit --phone. Each is a separate bounded command from the assigned workspace root. Vite 6.4.3 / Playwright 1.56.1; integration-lock parity checked.", + "command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs; pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs --phone (separate commands from assigned workspace root)", "browser_versions": { + "playwright": "1.56.1", "chromium": "141.0.7390.37", - "firefox": "142.0.1", "webkit": "26.0" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-dark-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-light-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-dark-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-light-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-dark-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-light-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-dark-native.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-light-native.log" - ], - "notes": "All 40 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow and exact final runtime hashes. The ten-case exact-base source-fbz6bjig input fails all ten assertions; an inspected full PBS drawer baseline measures five minutes occupying 265/318 SVG plot units under 24 hours, versus about 1.10 units after repair. Initial navigation timeout, unknown browser-execution capability-unavailable exit, synthetic edge-point fixture mismatch and unsuccessful WebKit row/hidden-button touch attempts remain in internal receipts. WebKit final opening uses native keyboard disclosure, not a claimed touch repair; History touch inspection passes. Six Chromium/Firefox cases ran before the runner added the disclosure-state assertion and WebKit keyboard-opening branch; their runtime source hashes are identical to the final two WebKit cases. This establishes presentation, not real PBS/VirtualBox collection, all reported #1723 drawer/metric outcomes, screen-reader speech, installed Tailscale Serve/SSE update or signed upgrade/rollback, protected integration or release availability.", - "prior_verifications": [ - { - "version": 1, - "base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881", - "verified_at": "2026-10-01T00:37:51Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], - "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a" - }, - "routes": [ - "/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp", - "a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data", - "zero-valued lone observations can be inspected without a synthetic trend", - "genuinely aligned series share cursor geometry and their own observed values", - "network directions preserve independently missing and zero rates", - "native keyboard/touch selection takes precedence over pointer movement", - "failed same-source refresh retains correctly attributed observations and a visible warning", - "a successful refresh updates the hovered timestamp values without choosing other latest metrics", - "held service-target replacement has no former-host observations, cursor or dated pointer description" - ], - "interactions": [ - "move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry", - "leave the plot to restore latest/current legends; verify pointer inspection makes no API reads", - "focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection", - "Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area", - "fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).", - "browser_versions": { - "chromium": "141.0.7390.37", - "firefox": "142.0.1", - "webkit": "26.0" - }, - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log" - ], - "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability." - }, - { - "version": 1, - "base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f", - "verified_at": "2026-09-30T23:28:26Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], - "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2" - }, - "routes": [ - "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "light/dark ordered and sparse stored series", - "native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface", - "missing series at a selected timestamp stay missing, not nearby or live values", - "same-source refresh adds samples without changing the selected timestamp or keyboard focus", - "failed refresh retains inspectable matching observations and honest warning", - "single stored observation has a dated description but no fabricated trend or pointless slider", - "empty store/current readings expose no inspection control", - "held replacement target has no former-host points or selection; new target starts at its own latest observation", - "licence-locked range has no reads or inspection action" - ], - "interactions": [ - "Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads", - "refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp", - "move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works", - "Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values", - "fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-inspection.cjs --engine=chromium; the same command with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).", - "browser_versions": { - "chromium": "141.0.7390.37", - "firefox": "142.0.1", - "webkit": "26.0" - }, - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log" - ], - "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten." - }, - { - "version": 1, - "base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c", - "verified_at": "2026-09-30T22:39:35Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/UpdateProgressModal.tsx", - "frontend-modern/src/components/updateReadinessModel.ts" - ], - "content_sha256": { - "frontend-modern/src/components/UpdateProgressModal.tsx": "d51df7a98a379e313f306b68529c8db0687ff91176af23869411c4b46feb2599", - "frontend-modern/src/components/updateReadinessModel.ts": "b25bf82441fc6572b95891af48a1ed2a0e8ea1f4e583c171ea9352a1e67d76ce" - }, - "routes": [ - "/browser-tests/update-progress-modal.html (production UpdateProgressModal with a scripted update stream and scripted /api/updates/status and /api/version)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "stream delivers one downloading 10% event then stays open and silent; fallback polling advances the modal through 35, 70, 80 and 90% (applying)", - "stream closed during download with a single unreachable status poll between two successful ones; download progress continues and the restart phase is never entered", - "restarting status from polling, then /api/version unreachable twice, then the new version reported; Pulse is restarting shown, then exactly one page reload", - "pre-update version unavailable (503), late polls failing and a new version plus idle answering without a restarting or completed status; modal stays on Pulse is restarting with no reload and no completion claim", - "stream delivers downloading, verifying, extracting, backing-up, applying and completed; old version answers once, becomes unreachable, then the new version; exactly one reload and no fallback polls" - ], - "interactions": [ - "emit scripted SSE statuses and hold the stream silent past the 6s silence window; assert the first fallback poll lands after it and the stream stays open", - "fail the stream and abort one status poll mid-download; assert no restart text ever rendered (MutationObserver) and the do-not-close warning remains", - "count document loads of the harness to assert a single reload after real restart evidence, and none without a baseline or backend confirmation", - "inspect screenshots at desktop and phone widths; assert no page errors, no horizontal overflow and the dialog inside the viewport" - ], - "command": "NODE_PATH= PULSE_BROWSER_ARTIFACTS= PULSE_CHROMIUM_PATH= node frontend-modern/browser-tests/update-progress-modal.cjs from the worktree root; Vite dev server on 127.0.0.1:5208, Playwright 1.56.1, Chromium headless shell 151.0.7922.34", - "artifacts": [ - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/harness-rebased-3.log", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-390.png" - ], - "notes": [ - "Mock-backed UI proof of the production modal; all five scenarios passed at 1365x900 and 390x844 and the screenshots were inspected. The same harness run against the pre-fix UpdateProgressModal and updateReadinessModel (fa1d9eb36) fails scenario (a), staying on 10% (pre-fix/run.log). This does not exercise the backend SSE endpoint, a real proxy, or an installed update. Re-run after rebasing onto 8d5969fb3 (lockfile-only brace-expansion/DOMPurify floors); both full runs passed all five scenarios at both viewports with unchanged component content hashes.", - "Re-run after rebasing onto f3c92a242; all five scenarios passed at both viewports with unchanged component content hashes." - ] - }, - { - "version": 1, - "base_sha": "21a35494aecb335755de773dd099e1364e65b3e0", - "verified_at": "2026-09-30T20:12:09Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], - "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67" - }, - "routes": [ - "/browser-tests/pbs-identity-boundary.html (production PBS table and real resource History renderer; synthetic API data)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "corroborated host with seven observed metric paths", - "uncached range replacement pending with no old paths", - "new range loaded before the old range response", - "withdrawn host link with replacement target pending", - "replacement target returns 503 with no former-host history", - "service-only response retains CPU/memory, not host disk/network/I/O", - "locked 14-day range issues no history read" - ], - "interactions": [ - "desktop keyboard Enter / phone row click to open PBS details", - "select 6h while its GET is held; select 1h, then fulfil the obsolete 6h request", - "withdraw third host link while service GET is held, then return 503", - "select 6h after failure and inspect the service-only response", - "select locked 14d; check all ten screenshots, retained History tab, zero horizontal overflow and no page errors" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-source-isolation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/receipts/history-browser-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-390.png" - ], - "notes": "Ten final screenshots inspected. The superseded GET is observed ERR_ABORTED at both widths; the unit regression also resolves mocks that ignore cancellation. Mock-backed proof establishes source/range isolation, not installed PBS collection, VirtualBox telemetry, all three #1723 drawers or release availability. Earlier unconfigured-CSS red/attempt-one results are retained separately." - }, - { - "version": 1, - "base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764", - "verified_at": "2026-09-30T01:40:13Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], - "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36" - }, - "routes": [ - "/browser-tests/pbs-identity-boundary.html (production PBS table, resource drawer and History with bounded synthetic resource props)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "three unlinked PBS services alongside six label/IP-colliding Agents", - "explicit backend links for two PVE guests and a standalone host, with same-Agent guest/host deduplication", - "unchanged links with host rows omitted and restored", - "withdrawn third link with real and colliding host rows still present", - "fresh exact PBS nodeName to Agent hostname match with mismatched display labels" - ], - "interactions": [ - "desktop keyboard disclosure and phone row activation for all three PBS drawers", - "inspect Metrics Target, reject wrong-host disks, select History and 6h/1h ranges", - "join, omit, restore and withdraw links while keeping the drawer open; verify only service History remains after withdrawal", - "24 target/layout observations; 76 History GETs, zero unrelated targets, page errors or horizontal overflow" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-identity-boundary.cjs; Vite 6.4.3, Playwright 1.56.1, Chromium 141.0.7390.37", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-identity-boundary.cjs", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-390.png" - ], - "notes": "All six screenshots inspected. Mock-backed UI proof, not installed topology, actual persisted host History or full #1723 relief. First browser failure retained separately; source snapshot isolation fixes that observed revocation failure." - }, - { - "version": 1, - "base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6", - "verified_at": "2026-09-29T23:04:22Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], - "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b" - }, - "routes": [ - "/pbs-correlation-proof.html (production PBS backup-server table and resource drawer with scripted, source-scoped resources)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "three PBS services with distinct corroborated Agent links, including two PVE guests and one standalone host", - "host rows omitted in a transient refresh while the backend link is unchanged", - "third backend link withdrawn while the Agent row remains omitted" - ], - "interactions": [ - "open all three PBS drawers and inspect their Metrics Target identity", - "omit host rows and confirm targets stay on the correlated hosts", - "withdraw the third link and confirm its drawer returns to agent:pbs-three, with no page errors or horizontal overflow" - ], - "command": "pulse-worker-browser pbs-browser-proof.cjs from assigned workspace root; Vite 6.4.3, Playwright 1.56.1, Chromium desktop/phone", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof.cjs", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-linked.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-withdrawn.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-linked.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-withdrawn.png" - ], - "notes": "Mock-backed proof of the real PBS table and drawer; all four screenshots inspected. It does not establish installed #1723 relief or observed Agent History series." - }, - { - "version": 1, - "base_sha": "91d8822d76c5a7214995c0e370be637b6e6336e3", - "verified_at": "2026-09-29T22:13:56Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/shared/ContainerUpdateBadge.tsx", - "frontend-modern/src/components/shared/containerUpdateBadgeModel.ts", - "frontend-modern/src/components/shared/useContainerUpdateButtonState.ts", - "frontend-modern/src/stores/containerUpdates.ts" - ], - "content_sha256": { - "frontend-modern/src/components/shared/ContainerUpdateBadge.tsx": "fd88f3e3e275926572126f43cce7296e7361b5e527868dd3c59c7eb951e9f5c9", - "frontend-modern/src/components/shared/containerUpdateBadgeModel.ts": "10bc488cb82ae84c6ae46d77ec672d34df2a1b3c28e138ae0f3b4214cccbe37e", - "frontend-modern/src/components/shared/useContainerUpdateButtonState.ts": "4b9dd4a42549b0433a1afa56f26183fe721c80626f823fb3c8489934721267c5", - "frontend-modern/src/stores/containerUpdates.ts": "b5d47132f7daf7f14bcc38462043288fc211344410af622ada7728694bcd514d" - }, - "routes": [ - "/browser-tests/update-action-receipt.html (production Docker update button with scripted action reads)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "governed update receipt pending after registry updateAvailable becomes false", - "existing action re-opened by GET with no second plan or execute", - "recorded completed action after a second GET changes the displayed outcome" - ], - "interactions": [ - "inspect pending button and absence of a Current or Completed claim", - "select Review action and inspect receipt-pending dialog", - "select Check for receipt and inspect recorded completion", - "assert both action requests are GET; no page errors or horizontal overflow" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/update-action-receipt.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-browser-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-390.png" - ], - "notes": "Mock-backed UI proof; six screenshots inspected, including pending and completed phone states. This does not establish installed behaviour or resolve #1891." - }, - { - "version": 1, - "base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484", - "verified_at": "2026-09-29T19:50:45Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/pages/Actions.tsx"], - "content_sha256": { - "frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce" - }, - "routes": [ - "/browser-tests/action-review-navigation.html (production Actions page with scripted action reads)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "detail for edge-a held while edge-b is selected and rendered", - "late edge-a response cannot replace edge-b review or URL", - "pending receipt re-read finishes after edge-b review closes" - ], - "interactions": [ - "select edge-a then edge-b before the first detail response arrives", - "release older response and inspect current review at desktop and phone widths", - "start same-action receipt GET, close review, release response; no dialog reopens", - "assert all scripted API requests are GET and no horizontal overflow or page errors" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/action-review-navigation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-browser.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-390.png" - ], - "notes": "Mock-backed navigation proof of production Actions page; screenshots inspected. No update was executed, no installed or reporter result established." - }, - { - "version": 1, - "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c", - "verified_at": "2026-09-29T19:09:52Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/features/actions/ActionReviewDialog.tsx"], - "content_sha256": { - "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81" - }, - "routes": [ - "/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "fresh executing/receipt_pending, including after a same-action re-read", - "failed status re-read with unknown outcome preserved", - "completed action after a recorded receipt" - ], - "interactions": [ - "desktop pointer and phone keyboard Check for receipt", - "GET same action under pending, read error and completion; no POST", - "desktop and phone layout and horizontal-overflow checks" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png" - ], - "notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest." - }, - { - "version": 1, - "base_sha": "498e2b12a22510520f9df7a510105d6775ea0522", - "verified_at": "2026-09-29T16:42:43Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", - "frontend-modern/src/types/resource.ts" - ], - "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "fc3c71f792c91abe62475be2a341fbedc729dd079b31e2245b5769f969fa6b12", - "frontend-modern/src/types/resource.ts": "4e3b64a3799aad0d357b9d8898dd2daf9a557e05c529df3650d2f6d03eb8ca56" - }, - "routes": [ - "/browser-tests/pbs-mixed-source-1723.html (mock-backed current ProxmoxBackupServersTable)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "PBS API + corroborated Agent with token-auth nodeName absent", - "same-host PVE API joined alongside a PVE-only name collision", - "correlated host row temporarily omitted", - "correlated host row restored" - ], - "interactions": [ - "desktop disclosure-button keyboard focus and Enter; phone row click", - "open History and select 24h, 6h, 1h and 12h across topology changes", - "join PVE API, omit host row, restore host row" - ], - "command": "pulse-worker-browser frontend-modern/node_modules/proof-pbs-1723.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/proof-pbs-1723.cjs", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-run.log", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/result.json", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-390.png" - ], - "notes": "Fresh offline browser run on supplied main 498e2b12a2 plus unchanged #1723 runtime repair. At desktop and phone widths, History stayed selected with eight plotted paths through initial PBS + Agent, same-host PVE join, transient host omission and restoration. All eight captured history requests targeted agent:agent-uuid, never the PBS service key; joined screenshots were visually inspected. Mock-backed proof is not installed, second-topology or reporter acceptance.", - "preserved_candidate_source": "25d6ab2e7a23adf3c74e0c071dfd40e799d2d83d" - }, - { - "version": 1, - "base_sha": "fcbb7e58ad514ac146f666f49075039d40d829e2", - "verified_at": "2026-09-29T12:15:52Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx", - "frontend-modern/src/components/Settings/settingsHeaderMeta.ts", - "frontend-modern/src/components/Settings/settingsNavCatalog.ts", - "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts", - "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx", - "frontend-modern/src/i18n/messages.de.ts", - "frontend-modern/src/i18n/messages.es.ts", - "frontend-modern/src/i18n/messages.ts", - "frontend-modern/src/utils/alertDestinationsPresentation.ts", - "frontend-modern/src/utils/licensePresentation.ts", - "frontend-modern/src/utils/relayPresentation.ts", - "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts", - "frontend-modern/src/utils/selfHostedPlans.ts" - ], - "content_sha256": { - "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx": "1def33d23464ed3e0a6127e75f16e7da814c9ca7d8fc6a680532fbef05e67755", - "frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "4847df256a489833c5836e0babcd651ea85d640a204288979f965bda4ff7432b", - "frontend-modern/src/components/Settings/settingsNavCatalog.ts": "d4832713b84d88033fa15211342b6ff8a243e20c56732c5431aed8e10b22e9c2", - "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts": "56525ac9bd0f3634adea1cccbc53c557caed8faad223f48d788c074ab2fa8a86", - "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx": "3a160748f1bd0f42ba412757ca919803a440ad8afb0a946d0d5807188c4bc5a0", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "1e07af69a4f4268601f8227500e79eb6a827c49f7b0d62b283129ff5fa2935f9", - "frontend-modern/src/i18n/messages.de.ts": "2daa6b5a6bb2a59bf7846fa2f93310a48065fe7bc3082777cbb51c2fd5f107e2", - "frontend-modern/src/i18n/messages.es.ts": "7052ac4596ef1d36b46935f1bf451f552aee87abe45e23840b3e9d570b215205", - "frontend-modern/src/i18n/messages.ts": "42c6798b32c5647c89a9f658b40e605773d85e5478a4b889bc0469ef3b2efe8e", - "frontend-modern/src/utils/alertDestinationsPresentation.ts": "82980cfc35fab1e91c5484b75e1bc3b84f6c2234e26ed5306b4583e89da3f880", - "frontend-modern/src/utils/licensePresentation.ts": "3573f465207e64e2984c5f18dc89f644b6a88de6e124779840714b01f572e3d6", - "frontend-modern/src/utils/relayPresentation.ts": "9a0d1000d91e307d31b7176be7d4529a150f7364fd913a4b379e2c96a3aa9e1c", - "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts": "64baf62ae41b9832381ac2a91f7c9724131e765f2493cb894efabeed2ae16d0e", - "frontend-modern/src/utils/selfHostedPlans.ts": "5efab41402fa3409f307398d5feb3b8ccdbf16ac01d6ce0cdacc3024c640b970" - }, - "routes": [ - "/settings/system-relay", - "/alerts/notifications", - "/settings/pulse-intelligence/billing/plan", - "/settings/system-general" - ], - "viewports": [ - { - "width": 1024, - "height": 768 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "Licensed instance (MSP test license from the offline issuer, relay capability present): Settings > Pulse Mobile shows 'Pair Pulse Mobile' with the 31 March 2027 retirement copy, the 'Allow Pulse Mobile connections' toggle, no Remote Access wording and no upgrade prompt", - "Licensed instance: Alerts > Notifications shows the Mobile push notifications panel with retirement copy, minimum-severity control and the 'Open Pulse Mobile settings' link", - "Licensed instance: Plans & Billing lists the relay feature as 'Pulse Relay (Mobile Connection)' with no Remote Access or remote web access wording", - "Community instance (no license): Alerts > Notifications has no Mobile push panel and no Relay upsell while webhook, email and Apprise destinations remain", - "Community instance: settings navigation has no Pulse Mobile or Remote Access item; the direct /settings/system-relay route shows the retirement message with no upgrade link", - "Community instance: Plans & Billing shows the Community plan with no Relay plan card or remote web access copy" - ], - "interactions": [ - "Activated the offline issuer test license through /api/license/activate on the licensed stack and confirmed relay in /api/license/entitlements", - "Loaded /settings/system-relay and /alerts/notifications on the licensed stack at 1024px and 390px, checked the toggle and settings link stay inside the viewport with no horizontal overflow", - "Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links" - ] - }, - { - "version": 1, - "base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3", - "verified_at": "2026-09-30T22:01:47Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts" - ], - "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79" - }, - "routes": [ - "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "light/dark current host history", - "real 30-second poll fails while matching points remain explicitly labelled", - "keyboard retry held with busy/activation guard and retained points", - "successful retry keeps keyboard focus and updates the same control", - "real background polling supersedes a slow foreground read and settles loading", - "late aborted response cannot replace current paths", - "uncached service-target failure has neither former-host points nor a collecting claim", - "service-target retry renders CPU/memory only", - "locked range exposes no read or refresh action" - ], - "interactions": [ - "Enter retry twice while its GET is held; exactly one retry read starts", - "fulfil retry; inspect warning removal, changed paths and retained focus", - "hold manual refresh across the real polling interval; inspect loading settlement and aborted read", - "switch to service target, fail its GET, retry the same key, select locked 14d" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-refresh.cjs --phone; then the same command without --phone, from assigned workspace root. Native polling timers, Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1 (integration-lock parity verified).", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-phone-result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-desktop-result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-390.png" - ], - "notes": "All 12 final screenshots inspected. These are direct production-renderer and real query proofs, not containing-build #1723 acceptance, screen-reader announcement quality, or a complete PBS drawer/browser update journey. Earlier broader fixture/copy/cache/timeout failures are retained in the outcome receipts; no broad pass is claimed. A diagnostic stage label was clarified after desktop launch; no tested runtime content or assertions changed." - } + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-agent.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-setup.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-agent.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-setup.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/result.json", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-agent.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-setup.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-agent.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-setup.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/result.json" ] } diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index 46d3d3cbec..d0658a037f 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -34,7 +34,7 @@ X-API-Token: Then make a read-only request (curl 7.76 or later): ```bash -curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \ +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ http://127.0.0.1:7655/api/state/summary ``` @@ -43,7 +43,8 @@ access, use your Pulse HTTPS URL and keep certificate verification enabled; do not use `--insecure`. On an authentication-enabled instance, the protected summary checks token access; `/api/health` is public and does not verify authentication. `--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and -403, while retaining the error response. +403, while retaining the error response. Keep `--disable` first: it ignores +local curl configuration that could otherwise enable credential-bearing trace output. Do not paste tokens into command lines, URLs or issue reports. Keep the header file private and outside shared repositories and diagnostics; do not use curl @@ -93,6 +94,8 @@ Some endpoints require admin privileges and/or scopes. Common scopes include: - `settings:write` - `agent:config:read` - `agent:manage` +- `actions:plan`, `actions:approve`, `actions:execute` +- `audit:read` Endpoints that require admin access are noted below. @@ -311,77 +314,129 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels. -CLI adapter: +Use the private header file prepared in [Authentication](#-authentication), +with `settings:read` on the token: + ```bash -PULSE_API_TOKEN=your-token pulse fleet connections \ - --api-url http://localhost:7655 +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/connections ``` ### Unified Action Planning -`POST /api/actions/plan` -Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`. -This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything. +The existing action API separates planning, approval and execution. Run these +steps separately, inspecting each response before moving on; do not paste the +whole sequence as an unattended recovery script. Use the private header file +from [Authentication](#-authentication), not a token in a command or environment +assignment. For remote access, replace the loopback origin with your Pulse HTTPS +URL and keep certificate verification enabled. -`POST /api/actions/{id}/decision` -Records an explicit `approved` or `rejected` decision for a persisted `pending_approval` action. It does not execute the action. +| Endpoint | Token scope and access | +| --- | --- | +| `GET /api/agent/resource-capabilities/{id}` | `monitoring:read`; lists the resource's advertised capabilities and parameter schemas. | +| `POST /api/actions/plan` | `actions:plan` and permission to plan actions; creates a plan but does not approve or execute it. | +| `POST /api/actions/{id}/decision` | `actions:approve` and permission to approve actions; records an explicit `approved` or `rejected` decision for a pending action, without executing it. | +| `POST /api/actions/{id}/execute` | `actions:execute` and permission to execute actions; dispatches only an approved action or an approval-free executable plan. Dry-run-only plans cannot execute. | +| `GET /api/audit/actions` | `audit:read`, audit-log read permission and licensed audit logging. | +| `GET /api/audit/actions/{id}/events` | The same audit access; returns the action's lifecycle evidence. | -`POST /api/actions/{id}/execute` -Starts execution only for an approved action or an approval-free executable plan, records `executing` before dispatch, and records the terminal result afterward. Dry-run-only plans are rejected and cannot be executed through this endpoint. +The legacy `ai:execute` scope also permits planning, approval and execution, +but use the narrower action scopes when possible. Approval and execution tokens +must be bound to an authorised user; a scope alone does not bypass approval +policy, separation of duties or step-up requirements. Audit reads through a +browser session additionally require admin access. A `monitoring:read` token is +not an action-control or audit token. + +1. **Inspect capabilities.** Choose the canonical resource `id` from + `GET /api/resources`, not a display name or a VM number. Replace `vm:42` + throughout these examples and URL-encode it in request paths (`vm%3A42` here). + An empty capabilities list means there is nothing to plan for that resource. -CLI adapter: ```bash -PULSE_API_TOKEN=your-token pulse actions capabilities \ - --api-url http://localhost:7655 \ - --resource-id vm:42 - -PULSE_API_TOKEN=your-token pulse actions plan \ - --api-url http://localhost:7655 \ - --request-id agent-run-123 \ - --resource-id vm:42 \ - --capability restart \ - --param mode=graceful \ - --reason "Recover after confirmed outage" \ - --requested-by agent:oncall-helper - -PULSE_API_TOKEN=your-token pulse actions decide \ - --api-url http://localhost:7655 \ - --action-id act_... \ - --outcome approved \ - --reason "Inside maintenance window" - -PULSE_API_TOKEN=your-token pulse actions execute \ - --api-url http://localhost:7655 \ - --action-id act_... \ - --reason "Execute approved recovery" - -PULSE_API_TOKEN=your-token pulse actions audit \ - --api-url http://localhost:7655 \ - --resource-id vm:42 \ - --limit 10 - -PULSE_API_TOKEN=your-token pulse actions events \ - --api-url http://localhost:7655 \ - --action-id act_... +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42 ``` -Request: -```json +2. **Plan only.** Use an advertised capability and its actual parameter schema; + `restart` and `mode=graceful` are examples, not universal capabilities. Choose + a request ID for this specific intent. Pulse derives the actor from the + authenticated credential, not a caller-supplied `requestedBy` value. + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/plan <<'JSON' { - "requestId": "agent-run-123", + "requestId": "manual-recovery-123", "resourceId": "vm:42", "capabilityName": "restart", "params": { "mode": "graceful" }, - "reason": "Recover after confirmed outage", - "requestedBy": "agent:oncall-helper" + "reason": "Recover after confirmed outage" } +JSON ``` +Inspect the returned approval policy, blast radius, expiry and preflight checks. +Keep its `actionId` and reviewed `planHash`; replace `act_...` and `sha256:...` +below with those returned values. Planning is not proof that execution is safe +or currently available. + +3. **Decide only if approval is required.** Use an authorised approver's private + header file. Approve only the reviewed plan; stop on a refusal or stale-plan + response rather than weakening the policy or creating another recovery action. + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON' +{ + "outcome": "approved", + "reason": "Inside maintenance window", + "planHash": "sha256:..." +} +JSON +``` + +4. **Execute separately.** Only after reviewing a successful decision, or an + approval-free executable plan, use the executor's authorised header file: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST --data-binary @- \ + http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON' +{ + "reason": "Execute approved recovery", + "planHash": "sha256:..." +} +JSON +``` + +If the response is lost or times out, inspect the existing action and target +before retrying: losing the connection does not prove that execution stopped. +An HTTP success is not a substitute for checking the terminal result and the +resource's observed state. + +5. **Read the audit and events**, using a private header file with audit access: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + 'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10' +``` + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/audit/actions/act_.../events +``` + +When audit logging is unavailable, use Pulse's action detail in the signed-in +UI to inspect the existing action; do not infer success or repeat execution +from an unavailable audit response. + Response: ```json { "actionId": "act_...", - "requestId": "agent-run-123", + "requestId": "manual-recovery-123", "allowed": true, "requiresApproval": true, "approvalPolicy": "admin", @@ -525,9 +580,11 @@ Request body: ### Setup Script (Public) `GET /api/setup-script` Returns the Proxmox/PBS setup script as a shell-script download. Accepts an -optional temporary setup token in the `setup_token` query for embedded -non-interactive bootstrap; otherwise the script prompts for the one-time setup -token at runtime. Canonical callers must send a supported `type` of `pve` or +optional legacy `setup_token` query for compatibility. Current downloads +contain no token: paste the separately revealed token only at the silent +terminal prompt, or supply `PULSE_SETUP_TOKEN_FILE` pointing to a mode-0600 +regular file in a mode-0700 directory owned by the script's user. Never put a +token in a copied command or URL. Canonical callers must send a supported `type` of `pve` or `pbs` plus non-empty `host` and `pulse_url`; the route no longer generates placeholder-host scripts for later repair or reconstructs Pulse identity from the request origin. The route now shares the same canonical type boundary as @@ -557,20 +614,23 @@ authorize the request itself. Pulse-managed Proxmox monitor-token names on the setup/bootstrap path derive from the canonical Pulse endpoint, not request-local host fallbacks, so setup-script and turnkey node-add flows stay on one deterministic `pulse-` identity per Pulse instance. -`setupToken` remains bootstrap transport data for `/api/setup-script` and -`/api/auto-register`, while `tokenHint` is the operator-facing display field -for quick-setup surfaces and must stay masked instead of exposing the full -one-time token in UI copy. Shared frontend consumers may validate -`setupToken`, but they should not retain or display it once the returned -bootstrap artifact and `tokenHint` are available; visible quick-setup previews -should use the non-secret `commandWithoutEnv` form while copy actions keep -using the token-bearing `commandWithEnv` artifact, and manual download flows -should use the token-bearing `downloadURL` artifact instead of rebuilding a -plain setup-script URL from non-secret preview state. Non-frontend bootstrap -consumers such as the runtime-side Unified Agent bootstrap flow and shell installer must fail closed on that -same full artifact contract too, rejecting missing or mismatched -`downloadURL`, `tokenHint`, or expired `expires` values instead of accepting a -reduced setup-token-only response shape. +The `command`, `commandWithEnv`, and `commandWithoutEnv` fields now contain +identical credential-free commands. They download the complete script before +running it and prompt silently in the root-or-sudo process. The token crosses +the installer boundary through a private file, not process arguments or an +exported secret. `downloadURL` equals the tokenless `url`, so a manual download +also needs the separately revealed token at runtime. `setupToken` is used for +`/api/auto-register`; `tokenHint` remains masked on the setup page. Settings +reveals the token in a separate dialog: run the command first, then copy and +paste the token only at its prompt. The artifact is reused only for the same +host and options while its five-minute expiry is live, and discarded when the +setup modal closes. +Non-frontend consumers must validate the complete artifact, including the +canonical host, type, URLs, filename, masked hint and live expiry. Current +Unified Agents and the shell installer also accept the coherent older-server +artifact during upgrades, but never execute its command text. For new +Proxmox agent enrolment against a newer server, use its current installer; +already enrolled agents keep reporting normally. ### Auto-Register (Public) `POST /api/auto-register` @@ -1123,7 +1183,7 @@ Returns organizations accessible to the authenticated user. ```json { "id": "acme-corp", "displayName": "Acme Corporation" } ``` -The creator becomes the owner and first member. Organization IDs must be lowercase alphanumeric with hyphens, 3-64 characters. +The creator becomes the owner and first member. Organization IDs use letters, digits, periods, underscores or hyphens, 1–64 characters, but cannot be `.` or `..`. Prefer a simple lowercase/hyphen ID such as the example. ### Get Organization `GET /api/orgs/{id}` (requires `settings:read`) @@ -1149,7 +1209,7 @@ Returns all members with their roles. User must be a member of the org. ```json { "userId": "jane", "role": "editor" } ``` -Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. Setting role to `owner` transfers ownership (only current owner can do this). Default org members cannot be managed. +Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. A new user receives a pending invitation (`202`) and must accept it in Pulse before gaining membership. Posting an existing member's `userId` updates their role; there is no member PATCH endpoint. Setting role to `owner` transfers ownership only to an existing member, by the current owner after fresh sign-in. Default org members cannot be managed. ### Remove Member `DELETE /api/orgs/{id}/members/{userId}` (requires `settings:write`, session auth only) @@ -1174,7 +1234,7 @@ Returns resources shared inbound to this organization from other organizations. "accessRole": "viewer" } ``` -Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg`. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. +Share a resource with another organization, using the resource type and ID returned by Pulse. Supported types include `vm`, `system-container`, `agent`, `node`, `docker-host`, `storage`, `pbs` and `pmg`; generic `host` and `container` types are not supported. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. The share is pending until a target-org admin or owner accepts it in Pulse; changing its access role requires acceptance again. ### Delete Share `DELETE /api/orgs/{id}/shares/{shareId}` (requires `settings:write`, session auth only) diff --git a/frontend-modern/public/docs/AUDIT_LOGGING.md b/frontend-modern/public/docs/AUDIT_LOGGING.md index b6d397f85f..403e918003 100644 --- a/frontend-modern/public/docs/AUDIT_LOGGING.md +++ b/frontend-modern/public/docs/AUDIT_LOGGING.md @@ -55,18 +55,31 @@ The audit log panel shows events in reverse chronological order with filtering b ### API +Use a token with `audit:read`, bound to a user permitted to read audit logs, +and the licensed `audit_logging` capability. Prepare the private header file +in [API authentication](API.md#-authentication); never paste the token or a +session cookie into a command. For a one-off read, you can instead open the +API path in your signed-in Pulse browser. + +The loopback URLs below apply on the Pulse host. For remote access, use your +Pulse HTTPS URL with certificate verification enabled. Use curl 7.76 or later, +keeping `--disable` first to ignore local trace/verbose defaults. +`--fail-with-body` returns a non-zero exit on HTTP failures, including 401, +402 and 403. Share only the relevant redacted error, not the header file or +whole audit response: events can contain usernames, client addresses and paths. + ```bash # List recent events -curl http://localhost:7655/api/audit?limit=50 \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + 'http://127.0.0.1:7655/api/audit?limit=50' # Filter by event type and date range -curl "http://localhost:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false" \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + 'http://127.0.0.1:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false' # Get audit summary -curl http://localhost:7655/api/audit/summary \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/audit/summary ``` ### Query Parameters @@ -87,12 +100,17 @@ curl http://localhost:7655/api/audit/summary \ Export the audit log for external analysis or compliance archival: ```bash -curl http://localhost:7655/api/audit/export \ - -H "Authorization: Bearer $TOKEN" \ - -o audit-export.json +umask 077 +export_dir="$(mktemp -d)" && +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --output "$export_dir/audit-export.json" \ + http://127.0.0.1:7655/api/audit/export && +printf 'Saved private export to %s\n' "$export_dir/audit-export.json" ``` -The export includes all events matching the current filter criteria. +This creates a new private directory and prints the file's location only on +success. It does not reuse filters selected in the UI. Treat the export as +sensitive data; keep it outside shared repositories and issue attachments. --- @@ -101,8 +119,8 @@ The export includes all events matching the current filter criteria. Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified: ```bash -curl http://localhost:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify ``` Response: diff --git a/frontend-modern/public/docs/MULTI_TENANT.md b/frontend-modern/public/docs/MULTI_TENANT.md index 6213f7c397..4e7e4d070d 100644 --- a/frontend-modern/public/docs/MULTI_TENANT.md +++ b/frontend-modern/public/docs/MULTI_TENANT.md @@ -51,9 +51,30 @@ Each member has a role within an organization: Organizations can share specific resources with other organizations: -- Share a VM, container, host, or storage resource with another org. +- Share a VM, container, machine agent, storage, PBS, or PMG resource with another org. - Assign an access role (`viewer`, `editor`, or `admin`) to the share. -- The receiving org sees shared resources alongside their own, with a share badge. +- The receiving org must accept the share before it sees the resource alongside its own. + +## Before Using the API + +Create organizations and manage members and shares in the signed-in Pulse UI. +These changes require **session-based user authentication** and the relevant +organization role; API tokens are rejected with `403 session_required`, even +when they have `settings:write`. The UI handles the session and CSRF protection. +Do not extract a session cookie or CSRF token into a shell command. + +The read-only curl examples use an org-bound token with `settings:read` and +the private header file from [API authentication](API.md#-authentication). +Keep the token out of command lines, URLs and reports. A token's organization +binding is an access boundary: changing a URL or `X-Pulse-Org-ID` header does +not grant it access to another organization. + +Use curl 7.76 or later. Keep `--disable` first to ignore local trace/verbose +defaults; `--fail-with-body` makes HTTP failures return a non-zero exit. The +loopback URLs apply on the Pulse host; remotely, use your Pulse HTTPS URL and +keep certificate verification enabled. Replace the example organization ID +`production-datacenter` with your own. Run requests separately, and share only +the relevant redacted error, not whole member or infrastructure responses. ## Managing Organizations @@ -61,14 +82,16 @@ Organizations can share specific resources with other organizations: **UI:** Settings → Organization → Create Organization -**API:** -```bash -curl -X POST http://localhost:7655/api/orgs \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name": "Production Datacenter", "description": "EU production infrastructure"}' +**API contract:** `POST /api/orgs`, session authentication only: +```json +{"id": "production-datacenter", "displayName": "Production Datacenter"} ``` +The creator becomes the owner. `id` is a stable ID of 1–64 characters using +letters, digits, periods, underscores or hyphens, but not `.` or `..`; use a +simple lowercase/hyphen ID such as the example. `displayName` is the name +shown in Pulse. `name` and `description` are not the creation fields. + ### Switching Organizations Use the **Org Switcher** dropdown in the header. When you switch: @@ -81,45 +104,58 @@ Use the **Org Switcher** dropdown in the header. When you switch: **UI:** Settings → Organization → Access -**API:** +**Read-only API:** ```bash -# List members -curl http://localhost:7655/api/orgs/{orgId}/members \ - -H "Authorization: Bearer $TOKEN" - -# Add a member -curl -X POST http://localhost:7655/api/orgs/{orgId}/members \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"userId": "user-id", "role": "editor"}' - -# Update role -curl -X PATCH http://localhost:7655/api/orgs/{orgId}/members/{userId} \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"role": "admin"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/orgs/production-datacenter/members ``` +**Invite or update a member:** use the Access panel as an owner or admin. +Its API contract is `POST /api/orgs/{id}/members`, session authentication only: +```json +{"userId": "user-id", "role": "editor"} +``` + +For a new member, the response is `202` with a pending invitation; the user +must accept it in Pulse before gaining access. An existing member's role is +updated by posting their `userId` and new role to the same endpoint, not by +PATCHing a member URL: +```json +{"userId": "user-id", "role": "admin"} +``` + +Only the current owner can transfer ownership, and only to an existing member +after fresh sign-in. The owner cannot be demoted or removed as an ordinary +member update. Default-organization members cannot be managed here. + ### Sharing Resources **UI:** Settings → Organization → Sharing -**API:** -```bash -# Create a share -curl -X POST http://localhost:7655/api/orgs/{orgId}/shares \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "targetOrgId": "other-org-id", - "resourceType": "host", - "resourceId": "resource-id", - "role": "viewer" - }' +Create the share as an owner or admin of the source organization. An owner or +admin of the target organization then accepts it in **Sharing → Incoming**. +Until acceptance, the share is pending and does not grant access. -# View incoming shares -curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \ - -H "Authorization: Bearer $TOKEN" +**API contract:** `POST /api/orgs/{id}/shares`, session authentication only: +```json +{ + "targetOrgId": "other-org-id", + "resourceType": "vm", + "resourceId": "vm:101", + "accessRole": "viewer" +} +``` + +Use `accessRole`, not `role`, and the resource type and ID returned by Pulse, +not a display name. Supported types include `vm`, `system-container`, `agent`, +`node`, `docker-host`, `storage`, `pbs` and `pmg`; the generic types `host` and +`container` are not supported. Changing a share's access role makes it pending +again, so the target must accept the new grant. + +**Read-only incoming shares:** +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/orgs/production-datacenter/shares/incoming ``` ## Monitoring Multiple Internal Estates @@ -158,15 +194,16 @@ When multi-tenant is enabled, **Settings → Organization** shows: | `GET` | `/api/orgs` | List organizations the current user can access | | `POST` | `/api/orgs` | Create a new organization | | `GET` | `/api/orgs/{id}` | Get organization details | -| `PATCH` | `/api/orgs/{id}` | Update organization | +| `PUT` | `/api/orgs/{id}` | Update organization (session only) | | `DELETE` | `/api/orgs/{id}` | Delete organization | | `GET` | `/api/orgs/{id}/members` | List members | -| `POST` | `/api/orgs/{id}/members` | Add a member | -| `PATCH` | `/api/orgs/{id}/members/{userId}` | Update member role | +| `POST` | `/api/orgs/{id}/members` | Invite a member or update an existing member's role (session only) | | `DELETE` | `/api/orgs/{id}/members/{userId}` | Remove a member | +| `POST` | `/api/org-invitations/{id}/accept` | Accept your pending invitation (session only) | | `GET` | `/api/orgs/{id}/shares` | List outgoing shares | | `GET` | `/api/orgs/{id}/shares/incoming` | List incoming shares | -| `POST` | `/api/orgs/{id}/shares` | Create a share | +| `POST` | `/api/orgs/{id}/shares` | Create or update a share (session only) | +| `POST` | `/api/orgs/{id}/shares/incoming/{shareId}/accept` | Accept an incoming share (session only) | | `DELETE` | `/api/orgs/{id}/shares/{shareId}` | Remove a share | ### Tenant Context @@ -177,6 +214,9 @@ All data-fetching endpoints respect the active organization context. The active 2. Session cookie (browser) 3. Falls back to the `default` organization +Neither the active context nor a token scope overrides organization membership +or a token's organization binding. + ## Storage - The **default** org uses the root data directory (backward compatible). @@ -202,7 +242,7 @@ Activate an Enterprise license with the `multi_tenant` capability in **Settings ### Shared resources not appearing -1. Verify the share exists: **Settings → Organization → Sharing → Incoming**. +1. Verify the share exists and has been accepted: **Settings → Organization → Sharing → Incoming**. 2. Confirm the share role grants sufficient access. 3. Check that the source org's resources are online. diff --git a/frontend-modern/public/docs/PBS.md b/frontend-modern/public/docs/PBS.md index 6093098e27..922ed75833 100644 --- a/frontend-modern/public/docs/PBS.md +++ b/frontend-modern/public/docs/PBS.md @@ -8,7 +8,8 @@ Pulse can monitor PBS backups in two ways: ### 1. Direct PBS Connection (Recommended) -Connect directly to your PBS server for full monitoring capabilities: +Connect to the PBS API with a dedicated read-only token. An agent is not +required for these API-backed readings: **Benefits:** - ✅ Deduplication factor and storage efficiency stats @@ -29,73 +30,100 @@ If your PVE cluster has PBS storage configured, Pulse automatically fetches back - ❌ Can be slow for encrypted PBS storage - ❌ Limited metadata per backup -**Recommendation:** If you see a banner in the Recovery page (formerly Backups) suggesting you add PBS directly, following this guide will significantly improve your monitoring experience. +**Recommendation:** Start with a direct API connection when you need PBS +datastore, job or server status beyond the PVE passthrough. Add a host agent +only for extra local telemetry such as SMART and temperatures; see +[Agent Security](AGENT_SECURITY.md#proxmox-deployment-choices). --- ## Setting Up Direct PBS Connection -### Method 1: Unified Agent Install (Recommended for Bare Metal) +### Method 1: API-Only Connection (Recommended) -Install the unified agent directly on your PBS server for automatic setup: +Use a dedicated PBS monitoring user and token, not a root password. On a new +setup, run these commands in an administrator shell **on the PBS server**: ```bash -# Run on your PBS server -curl -fsSL http://:7655/install.sh | \ - sudo bash -s -- --url http://:7655 --token --enable-proxmox --proxmox-type pbs -``` - -The agent will: -1. Detect it's running on a PBS server -2. Create a `pulse-monitor@pbs` user with read-only access -3. Generate an API token -4. Register the PBS node with Pulse automatically - -### Method 2: API-Only Setup Script (Best for PBS in Containers) ⭐ - -Use this when you can run a command on the PBS host but do not want to install the agent. - -From Pulse's Settings page: -1. Go to **Settings → Infrastructure**. -2. Click **Add infrastructure**. -3. Choose **Proxmox Backup Server**. -4. Use the API-only setup path and enter your PBS server's URL. -5. Click copy to get the setup command. -6. Run the command on your PBS server. - -Example (what the UI generates): -```bash -curl -fsSL "http://:7655/api/setup-script?type=pbs&host=https://:8007&pulse_url=http://:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; } -``` - -Pulse generates that full command for you from **Settings → Infrastructure**, including -the one-time setup token. The script creates a `pulse-monitor@pbs` user, -generates a scoped API token, and registers the server with Pulse. - -> **Note**: API-only mode does not include temperature monitoring or AI command execution. Use **Agent Install** for full functionality. - -> **Tip**: The installer now auto-detects Proxmox mode (`pve` or `pbs`) when possible, but keeping `--proxmox-type pbs` explicit is recommended for predictable PBS onboarding. - -### Method 3: Manual Token Creation - -If you prefer manual setup: - -```bash -# SSH into your PBS server - -# 1. Create a dedicated monitoring user proxmox-backup-manager user create pulse-monitor@pbs --comment "Pulse monitoring" - -# 2. Grant read-only access (Audit role) -proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs - -# 3. Generate an API token (save the output!) proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token +proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs +proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token' ``` -Copy the token value and enter it in Pulse: -- **Token ID:** `pulse-monitor@pbs!pulse-token` -- **Token Value:** The UUID shown after running the command +PBS tokens have their own permissions, limited by their owning user's +permissions. Grant `Audit` to **both the user and the token**. If either already +exists, inspect and reuse it instead of deleting it or rerunning token creation. +Do not disable privilege separation or grant `Admin` to work around a missing +permission. + +The token secret is shown once. Save it privately and enter it only in Pulse's +**Token Value** field; do not paste it into a shell command, URL, screenshot or +issue report. Keep any terminal recording containing that output private. + +In Pulse: +1. Open **Settings → Infrastructure → Add infrastructure** and choose + **Proxmox Backup Server**. +2. Enter the PBS HTTPS URL, normally `https://pbs.example.com:8007`. +3. Select **API Token** and **Manual Token Setup**. +4. Enter **Token ID** `pulse-monitor@pbs!pulse-token` and its secret in + **Token Value**. +5. Keep certificate verification enabled. For a self-signed certificate, use + an independently verified **SSL Fingerprint**; see the TLS guidance below. +6. Test and save the connection. Check that the expected datastores and backups + are visible, not just that the connection test succeeds. + +### Method 2: Optional Host Agent + +Use this only when you also need host-local telemetry. The Linux agent normally +runs as root; it is not required to fix API authentication or missing backup +permissions. Review [Agent Security](AGENT_SECURITY.md) first. + +Create a separate Pulse token in **API Access**, using the **Agent host** preset +for reporting, configuration reads and agent management. This is a **Pulse** +token, not the **PBS** token used above. Do not enable command execution just +for monitoring. + +On the PBS host, enter an administrator root shell before preparing the private +file below. In the editor, save only the Pulse agent token, with no header or +quotes; never put the secret in a command argument: + +```bash +umask 077 +mkdir -p "$HOME/.config/pulse" +chmod 700 "$HOME/.config/pulse" +touch "$HOME/.config/pulse/pbs-agent-token" +chmod 600 "$HOME/.config/pulse/pbs-agent-token" +vi "$HOME/.config/pulse/pbs-agent-token" +``` + +Download the agent installer from **your Pulse server's HTTPS address**, then +inspect the saved script before running it. Replace the example Pulse URL in +both commands. Stop if the download fails; run the next command only after a +successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs +the Pulse server, not the agent. + +```bash +curl --fail --silent --show-error \ + --output "$HOME/.config/pulse/pbs-agent-install.sh" \ + https://pulse.example.com/install.sh +``` + +```bash +bash "$HOME/.config/pulse/pbs-agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/pbs-agent-token" \ + --enable-proxmox --proxmox-type pbs --enable-docker=false +``` + +Do not bypass certificate checks to fetch or run the installer. Use a trusted +Pulse certificate or a CA file you verified separately. See +[Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles. +After installation, check a fresh agent report and the host's +`pulse-agent --version`; installation or hardware capacity alone does not prove +that every CPU, memory or History reading is available. Keep the token file +private and remove the bootstrap copy when no longer needed; do not remove the +installed agent's runtime credential. --- @@ -133,18 +161,65 @@ If you have multiple PBS servers, add each one separately in Settings. Pulse wil ### "Connection Failed" Error -1. **Check URL:** Ensure the PBS URL is correct (default port is 8007) - - Format: `https://pbs.example.com:8007` +Check from the Pulse host or container's network, if possible. A request from +another machine does not prove that Pulse can reach PBS. -2. **Verify token:** Test authentication: - ```bash - curl -sk -H "Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:YOUR_TOKEN" \ - https://your-pbs:8007/api2/json/version - ``` +- **Address/network:** use the PBS HTTPS URL and port `8007`; check DNS, routing + and the firewall before changing credentials. +- **TLS:** keep verification enabled. Use a certificate trusted by the Pulse + runtime, or set its **SSL Fingerprint** after verifying the SHA-256 value + through the PBS console or another already-trusted administrative channel. + Do not accept a fingerprint solely from the failed connection. For example, + on the PBS server you can inspect its public certificate (not its private key): -3. **Network access:** Ensure Pulse can reach PBS on port 8007 + ```bash + openssl x509 -in /etc/proxmox-backup/proxy.pem -noout -fingerprint -sha256 + ``` -4. **SSL verification:** If using self-signed certificates, disable SSL verification in the node settings +- **Authentication/permissions:** test a datastore request, not just `/version`. + Prepare a private header file on the machine running curl: + + ```bash + umask 077 + mkdir -p "$HOME/.config/pulse" + chmod 700 "$HOME/.config/pulse" + touch "$HOME/.config/pulse/pbs-header" + chmod 600 "$HOME/.config/pulse/pbs-header" + vi "$HOME/.config/pulse/pbs-header" + ``` + + In the editor, save this line, replacing `` with the secret + for the PBS token being tested: + + ```text + Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token: + ``` + + Then use curl 7.76 or later, with your PBS hostname: + + ```bash + curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \ + --header "@$HOME/.config/pulse/pbs-header" \ + https://pbs.example.com:8007/api2/json/admin/datastore + ``` + + For curl with a private CA or self-signed certificate, add + `--cacert "$HOME/.config/pulse/pbs-ca.pem"` using a public certificate obtained + and verified through a trusted channel. Its hostname must still match. Do not + use `--insecure` or `-k`. + + `401` means authentication was rejected; `403` means the requested access was + refused. Both return a non-zero curl exit while retaining the error body. + Inspect the existing token and both `Audit` grants before replacing anything. + A `200` response listing the expected datastore establishes access to that + list, not successful collection of every backup, job or History graph. An + empty list is not proof that all permissions are correct. + +Keep the header file outside shared repositories and diagnostics. Do not share +verbose/trace curl output, full infrastructure responses, token values or +private keys. If help is needed, provide only the HTTP status, relevant redacted +error and which expected reading is missing. Do not clear History or recreate a +working connection to make missing data look resolved. ### Slow Backup Loading @@ -155,10 +230,12 @@ If you notice slow loading for PBS storage accessed via PVE: ### Duplicate Backups -If you see the same backup twice: -- This shouldn't happen—Pulse deduplicates by VMID and timestamp -- If it does occur, the direct PBS version takes priority -- Check console for debug logs: `localStorage.setItem('debug-pmg', 'true')` +Check the source, datastore, namespace, guest type/ID and backup time on each +entry. Independent PVE installations can reuse a guest ID; matching VMIDs alone +are not proof of a duplicate. Keep both records while checking their origin. +If the same backup remains listed twice, report those redacted distinctions and +whether each entry came from direct PBS or PVE passthrough. Do not delete backups +or change retention to hide a display problem. --- @@ -169,7 +246,9 @@ In the Recovery view, PBS backups show a data source indicator: - **"PBS"** badge alone = Direct PBS connection (full data) - **"PBS via PVE"** = Passthrough via PVE storage (limited data) -Adding your PBS server directly will remove the "via PVE" indicator and unlock full monitoring capabilities. +When the same backup is reconciled across both sources, Pulse prefers the +direct PBS observation. Check the actual source and expected readings after +adding the connection; the presence of a badge alone is not collection proof. --- diff --git a/frontend-modern/public/docs/RBAC.md b/frontend-modern/public/docs/RBAC.md index 3162eaa43e..ca9b5d1ccd 100644 --- a/frontend-modern/public/docs/RBAC.md +++ b/frontend-modern/public/docs/RBAC.md @@ -46,42 +46,65 @@ user merely because no local administrator is configured. ## Managing Roles +### Before Using the API + +Prefer **Settings → Security → Access Control** for one-off administration. +The API examples require the licensed `rbac` capability and permission to +administer users. With RBAC enabled, role and user administration requires a +full-access (`*`) API token bound to an authorised administrator; a monitoring +token is not enough. Do not widen an agent's token for this job. + +If you already use an administration token, prepare its private header file +as described in [API authentication](API.md#-authentication). The examples read +`$HOME/.config/pulse/api-header`; never paste the token or a session cookie into +a command. Keep this file outside shared repositories and diagnostics. + +The loopback URLs work on the Pulse host. For remote use, substitute your +Pulse HTTPS URL and keep certificate verification enabled. Use curl 7.76 or +later; `--disable` must remain first to ignore local trace/verbose defaults, +and `--fail-with-body` makes HTTP failures return a non-zero exit. Run each +change separately and check its response before continuing. + ### Creating a Role **UI:** Settings → Security → Access Control → Create Role +Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`, +`auditor`) cannot be modified or deleted. These examples use `alert-manager`. + **API:** ```bash -curl -X POST http://localhost:7655/api/admin/roles \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "operator", - "name": "Operator", +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request POST \ + --data-binary @- http://127.0.0.1:7655/api/admin/roles <<'JSON' + { + "id": "alert-manager", + "name": "Alert Manager", "description": "Can view and manage alerts", "permissions": [ {"action": "read", "resource": "alerts"}, {"action": "write", "resource": "alerts"}, {"action": "read", "resource": "nodes"} ] - }' + } +JSON ``` ### Listing Roles ```bash -curl http://localhost:7655/api/admin/roles \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/admin/roles ``` ### Updating a Role ```bash -curl -X PUT http://localhost:7655/api/admin/roles/operator \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Operator", +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request PUT \ + --data-binary @- http://127.0.0.1:7655/api/admin/roles/alert-manager <<'JSON' + { + "name": "Alert Manager", "description": "Updated description", "permissions": [ {"action": "read", "resource": "alerts"}, @@ -89,14 +112,15 @@ curl -X PUT http://localhost:7655/api/admin/roles/operator \ {"action": "read", "resource": "nodes"}, {"action": "read", "resource": "ai"} ] - }' + } +JSON ``` ### Deleting a Role ```bash -curl -X DELETE http://localhost:7655/api/admin/roles/operator \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager ``` --- @@ -106,8 +130,8 @@ curl -X DELETE http://localhost:7655/api/admin/roles/operator \ ### Listing Users and Their Roles ```bash -curl http://localhost:7655/api/admin/users \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/admin/users ``` SSO users are displayed using the latest configured username claim and email @@ -118,20 +142,27 @@ principal used for authorization. Role assignments are set as a complete list — the user's roles are replaced with the provided set: +Use the stable `username` returned by the user list, not the display name or +email. URL-encode it as one path segment (for example, `:` becomes `%3A` for an +SSO principal). `jane` below is an example local username. Create the custom +role before assigning it; do not run the deletion example first. + ```bash -curl -X PUT http://localhost:7655/api/admin/users/jane/roles \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"roleIds": ["operator", "viewer"]}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request PUT \ + --data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON' +{"roleIds": ["alert-manager", "viewer"]} +JSON ``` -To remove all custom roles from a user, send an empty list: +To clear a user's entire role assignment, including built-in roles, send an empty list: ```bash -curl -X PUT http://localhost:7655/api/admin/users/jane/roles \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"roleIds": []}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' --request PUT \ + --data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON' +{"roleIds": []} +JSON ``` Note: Users cannot modify their own role assignments (self-escalation prevention). @@ -139,8 +170,8 @@ Note: Users cannot modify their own role assignments (self-escalation prevention ### Removing User Access ```bash -curl -X DELETE http://localhost:7655/api/admin/users/jane \ - -H "Authorization: Bearer $TOKEN" +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request DELETE http://127.0.0.1:7655/api/admin/users/jane ``` This removes the Pulse identity and all role assignments and revokes its active diff --git a/frontend-modern/public/docs/TEMPERATURE_MONITORING.md b/frontend-modern/public/docs/TEMPERATURE_MONITORING.md index 4f727e2e04..bc1063438a 100644 --- a/frontend-modern/public/docs/TEMPERATURE_MONITORING.md +++ b/frontend-modern/public/docs/TEMPERATURE_MONITORING.md @@ -9,13 +9,22 @@ If you are upgrading from older releases that used `pulse-sensor-proxy`, see the ## Recommended: Pulse Agent (Proxmox) -The unified agent runs on each Proxmox host and reports temperatures locally with no SSH keys needed. +The unified agent runs on each Proxmox host and reports temperatures locally +with no SSH keys needed. First complete the +[private-file preparation](UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas) +on that host: protect the Pulse agent token, download the installer from your +Pulse server over verified HTTPS, and inspect it. Then use the same Pulse +address and add the Proxmox collector flag: ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-proxmox +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-proxmox ``` +The Pulse agent token is not a Proxmox API token. Keep the secret out of +command arguments and leave command execution disabled for monitoring. + Notes: - Install `lm-sensors` on each host (`apt install lm-sensors && sensors-detect --auto`). - Temperatures appear automatically once the agent reports. @@ -104,11 +113,25 @@ ssh -i /path/to/key root@node "cat /sys/class/thermal/thermal_zone0/temp" ## Legacy Cleanup (If Upgrading) -If you still have the old sensor proxy installed from prior releases, remove it from each **Proxmox host** (not the Pulse container) with the supported cleanup helper: +If you still have the old sensor proxy installed from prior releases, remove it +from each **Proxmox host** (not the Pulse container) with the supported cleanup +helper. In that host's administrative shell, download it to a private directory: ```bash -curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge --local-only +umask 077 +mkdir -p "$HOME/.config/pulse" +chmod 700 "$HOME/.config/pulse" +curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \ + --output "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \ + https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh +``` + +Stop if the download fails and inspect the saved script before running it. +Do not pipe a web response into a privileged shell: + +```bash +bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \ + --uninstall --purge --local-only ``` `--local-only` avoids cluster SSH entirely; run the command once on every @@ -122,8 +145,8 @@ remote portion fail after local cleanup completes. If you also want to remove the old `pulse-monitor@pam` API user and tokens before re-adding the node, include `--remove-proxmox-access`: ```bash -curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge --remove-proxmox-access --local-only +bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \ + --uninstall --purge --remove-proxmox-access --local-only ``` Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log. diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index ff7435ca9d..4ef1f23ae1 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -27,18 +27,25 @@ preventing duplicate root-SSH sensor polling between cluster peers. ## Quick Start -Generate an installation command in the UI: +Choose the host profile and create a monitoring token in the UI: **Settings → Infrastructure → Install on a host** Choose a target profile in that screen when you want explicit install flags for Docker, Kubernetes, Proxmox VE, or Proxmox Backup Server. -The generated command is not tied to a single machine. For a Proxmox VE -cluster, one API connection already provides cluster-wide inventory; the agent -is per host, so run the same generated command on each cluster node where you -want agent-provided telemetry (temperatures, SMART, host identity). Each agent -registers itself and attaches to its own cluster member. +Keep the token out of shell commands, history, URLs and screenshots. Use the +private-file installation steps below; the command arguments contain only the +file's path. A Pulse agent token is not a Proxmox or PBS API token. Monitoring +does not require command execution: leave that option off unless you intend +to grant it. See [Agent Security](AGENT_SECURITY.md) before choosing a root +host agent instead of an API connection or the opt-in Linux safe profile. -The same generated command is also the supported v5-to-v6 agent upgrade path. +For a Proxmox VE cluster, one API connection already provides cluster-wide +inventory; the agent is per host. Repeat the private-file installation on each +cluster node where you want agent-provided telemetry (temperatures, SMART, host +identity), using the intended profile. Each agent registers itself and attaches +to its own cluster member. + +The same installer is also the supported v5-to-v6 agent upgrade path. Run it on the host that already has the v5 `pulse-agent` service to replace the binary and service configuration in place; do not uninstall the old service first unless you are intentionally removing that host from Pulse. @@ -51,24 +58,29 @@ agents initiate the connection. Prefer a stable DNS name for the primary URL so replacing the Pulse host does not require an agent migration. After importing the configuration on a Pulse server with a different address, -retarget each existing standard Linux agent from that agent machine: +retarget each existing standard Linux agent from that agent machine. Download +and inspect the installer from the **new** Pulse address using the HTTPS +preparation below; retargeting reuses the saved credential, so do not create +another token just for this operation: ```bash -curl -fsSL https://pulse.example.com:7655/install.sh | \ - sudo bash -s -- --retarget --url https://pulse.example.com:7655 +bash "$HOME/.config/pulse/agent-install.sh" \ + --retarget --url https://pulse.example.com ``` The retarget operation recovers the existing token, agent ID, enabled collectors, and other service options. It does not carry the old endpoint's TLS bypass, custom CA, or certificate fingerprint to the new address. Supply -`--cacert`, `--server-fingerprint`, or (only on a trusted network) -`--insecure` explicitly when the new endpoint requires it. The script must -come from the new server so it supports the retarget operation. A newly -generated full installation command from **Settings → Infrastructure → Install -on a host** remains the fallback. +`--cacert` or an independently verified `--server-fingerprint` explicitly +when the new agent endpoint requires it. The installer download itself must +use a trusted certificate or a separately verified CA file; an agent +fingerprint option does not verify that earlier download. The script must +come from the new server so it supports retargeting. If retargeting is not +supported on that host, use the private-file installation below with the +intended profile, rather than uninstalling the existing service first. -On Windows, run the full generated PowerShell installation command from the -new Pulse server as Administrator, including the desired collector options. +On Windows, use the PowerShell private-file installation below from the new +Pulse server as Administrator, including the desired collector options. Do not expect the configuration import itself to make agent-only machines appear at the new address. @@ -83,42 +95,96 @@ the agent has reported, and confirm the host-local version with This is the agent installer served by your Pulse server. It is separate from the top-level GitHub `install.sh`, which installs or updates the Pulse server itself. -### Linux (systemd) -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token -``` +### Private-file installation: Linux, macOS and NAS -### macOS -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token -``` +Use an administrative shell **on the host being monitored**, not the Pulse +server or an unrelated container. Linux/systemd, macOS, Synology and TrueNAS +use the same preparation. TrueNAS SCALE uses systemd; CORE uses rc.d. An API +connection is usually enough for TrueNAS inventory and usage; an agent is +optional for host-local data. + +1. In that shell, protect the file before opening the editor. Save only the + Pulse agent token, with no quotes or header. Use your own private directory + and regular files, not shared paths or symlinks: + + ```bash + umask 077 + mkdir -p "$HOME/.config/pulse" + chmod 700 "$HOME/.config/pulse" + touch "$HOME/.config/pulse/agent-token" + chmod 600 "$HOME/.config/pulse/agent-token" + vi "$HOME/.config/pulse/agent-token" + ``` + +2. Replace `https://pulse.example.com` with your Pulse server's HTTPS address + in both the download and installation commands. Download to a file: + + ```bash + curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \ + --output "$HOME/.config/pulse/agent-install.sh" \ + https://pulse.example.com/install.sh + ``` + + **Stop if the download fails.** Inspect the saved script before executing + it. Do not bypass certificate verification or pipe an unchecked response + into a privileged shell. For a private CA, add curl's `--cacert` with the + separately verified CA file, and supply the installer's `--cacert` option + for the agent connection as well. Do not substitute GitHub's top-level + `install.sh`: that installs the Pulse server, not the agent. + +3. Install with the private token file, adding a profile from + [Installation Options](#installation-options) when needed: + + ```bash + bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" + ``` + +Keep the bootstrap token file private and remove that copy when no longer +needed; do not delete the installed agent's runtime credential. Check a fresh +report in Pulse and `pulse-agent --version` on the host. A started service or +hardware capacity alone does not prove that every requested metric is present. ### Windows (PowerShell, run as Administrator) + +Use a **new** setup directory under your own Windows profile; if it already +exists, choose another name. The following preparation grants access only to +your account and SYSTEM. Stop if directory creation or the ACL command fails: + ```powershell -irm http://:7655/install.ps1 | iex +$setupDir = Join-Path $env:USERPROFILE 'PulseAgentSetup' +New-Item -ItemType Directory -Path $setupDir -ErrorAction Stop | Out-Null +$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +icacls.exe $setupDir /inheritance:r /grant:r "*${userSid}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' +if ($LASTEXITCODE -ne 0) { throw 'Could not protect the setup directory' } +$tokenFile = Join-Path $setupDir 'agent-token.txt' +New-Item -ItemType File -Path $tokenFile -ErrorAction Stop | Out-Null +Start-Process -FilePath notepad.exe -ArgumentList "`"$tokenFile`"" -Wait ``` -With environment variables: +In Notepad, save only the Pulse agent token, with no quotes or header. Never +paste it into a PowerShell command or assign a literal secret to an environment +variable. Replace the example HTTPS address in both commands below. Download +and inspect the script first; do not use `Invoke-Expression` on a web response: + ```powershell -$env:PULSE_URL="http://:7655" -$env:PULSE_TOKEN="" -irm http://:7655/install.ps1 | iex +$installerFile = Join-Path $setupDir 'install.ps1' +Invoke-WebRequest -Uri 'https://pulse.example.com/install.ps1' -OutFile $installerFile -ErrorAction Stop ``` -### Synology NAS -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token +Only after a successful download and inspection, run the saved script. Clear +an old `PULSE_TOKEN` environment value so it cannot override the file: + +```powershell +Remove-Item Env:PULSE_TOKEN -ErrorAction SilentlyContinue +& $installerFile -Url 'https://pulse.example.com' -TokenFile $tokenFile ``` -### TrueNAS SCALE/CORE -TrueNAS SCALE and TrueNAS CORE are both supported. The installer auto-detects the platform and configures the appropriate service manager (systemd for SCALE, rc.d for CORE). -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token -``` +Keep the setup directory private and remove the bootstrap copy when no longer +needed, not the installed service's token. Verify a fresh report and the +installed agent version; the remaining platform trust trade-offs are in +[Agent Security](AGENT_SECURITY.md). ## Features @@ -550,46 +616,58 @@ operators are comfortable with Proxmox-side guest probing. ## Installation Options +These commands use the protected token file and successfully downloaded, +inspected installer from [Private-file installation](#private-file-installation-linux-macos-and-nas). +Use the same HTTPS address in preparation and installation. The flags below +choose collectors; they do not grant command execution. + ### Simple Install (host + Docker auto-detect) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" ``` ### Proxmox VE Node (explicit profile) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-proxmox --proxmox-type pve +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pve ``` ### Proxmox Backup Server Node (explicit profile) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-proxmox --proxmox-type pbs +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pbs ``` ### Force Enable Docker (if auto-detection fails) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-docker +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-docker ``` ### Disable Docker (even if detected) ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-docker=false +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-docker=false ``` ### Host + Kubernetes Monitoring ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-kubernetes +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-kubernetes ``` ### Docker Monitoring Only ```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --enable-host=false --enable-docker +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --enable-host=false --enable-docker ``` ### Exclude Specific Disks from Monitoring @@ -693,9 +771,10 @@ installer path instead of relying on a plain-HTTP first hop. To disable auto-updates: ```bash -# During installation -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --disable-auto-update +# After private-file preparation and successful installer inspection +bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" --disable-auto-update # Or set environment variable PULSE_DISABLE_AUTO_UPDATE=true @@ -718,8 +797,11 @@ See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md) for supported keys ## Uninstall +Download and inspect the agent installer using the HTTPS preparation above +before running this on the agent host. Uninstallation needs no new token: + ```bash -curl -fsSL http://:7655/install.sh | bash -s -- --uninstall +bash "$HOME/.config/pulse/agent-install.sh" --uninstall ``` This removes: @@ -805,10 +887,13 @@ Unraid), `/tmp` and `/usr/local/bin` share that filesystem, so both the staged and installed copy must fit at once. If the check fails because `/tmp` is on a constrained root, point `TMPDIR` at a -directory on a data volume and re-run the installer: +directory on a data volume and re-run the already downloaded and inspected +agent installer from the private-file preparation above: ```bash -TMPDIR=/share/CACHEDEV1_DATA/tmp bash install.sh --url http://pulse --token +TMPDIR=/share/CACHEDEV1_DATA/tmp bash "$HOME/.config/pulse/agent-install.sh" \ + --url https://pulse.example.com \ + --token-file "$HOME/.config/pulse/agent-token" ``` (`mktemp` honours `TMPDIR`, so this moves the staging copy off the RAM root. diff --git a/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts b/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts index 7e9e3fda50..98b333ed27 100644 --- a/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts +++ b/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts @@ -93,7 +93,7 @@ describe('NodesAPI — branch coverage (normalizeProxmoxSetupCommandResponse val type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', @@ -149,10 +149,11 @@ describe('NodesAPI — branch coverage (downloadProxmoxSetupScript failure arms) }); const pveBootstrap = (): ProxmoxSetupCommandResponse => ({ + setupToken: 'synthetic-setup-token', type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', diff --git a/frontend-modern/src/api/__tests__/nodes.test.ts b/frontend-modern/src/api/__tests__/nodes.test.ts index e118544496..bd7eadcb10 100644 --- a/frontend-modern/src/api/__tests__/nodes.test.ts +++ b/frontend-modern/src/api/__tests__/nodes.test.ts @@ -313,8 +313,7 @@ describe('NodesAPI', () => { }), }), ); - expect(result).toEqual({ command: 'curl ...' }); - expect(result).not.toHaveProperty('token'); + expect(result).toEqual({ command: 'curl ...', token: 'secret-token' }); }); it('supports the PBS proxmox install command contract', async () => { @@ -332,6 +331,16 @@ describe('NodesAPI', () => { expect(result.command).toBe('curl pbs ...'); }); + it('rejects an agent credential embedded in the copied command', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + command: 'bash install.sh --token synthetic-secret', + token: 'synthetic-secret', + }); + await expect( + NodesAPI.getAgentInstallCommand({ type: 'pve', enableProxmox: true }), + ).rejects.toThrow('Install credentials must be entered separately from the command'); + }); + it('rejects blank install commands', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ command: ' ', token: 'secret-token' }); @@ -342,12 +351,12 @@ describe('NodesAPI', () => { }); describe('getProxmoxSetupCommand', () => { - it('uses the canonical setup-script-url contract for PVE while keeping raw setup tokens inside the shared client boundary', async () => { + it('uses the canonical PVE setup contract with a separate revealed setup token', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', setupToken: 'setup-token-123', @@ -374,21 +383,41 @@ describe('NodesAPI', () => { ); expect(result.type).toBe('pve'); expect(result.host).toBe('https://pve.example:8006'); - expect(result.downloadURL).toBe( - 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', - ); + expect(result.downloadURL).toBe('https://pulse.example/api/setup-script?type=pve'); expect(result.scriptFileName).toBe('pulse-setup-pve.sh'); expect(result.tokenHint).toBe('set…123'); expect(result.expires).toBe(1_900_000_000); - expect(result).not.toHaveProperty('setupToken'); + expect(result.setupToken).toMatch(/token-123$/); }); + it.each(['url', 'downloadURL', 'command', 'commandWithEnv', 'commandWithoutEnv'])( + 'rejects a setup credential embedded in %s without returning it in the error', + async (field) => { + const secret = 'synthetic-setup-secret'; + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + type: 'pve', + host: 'https://pve.example:8006', + url: 'https://pulse.example/api/setup-script?type=pve', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', + scriptFileName: 'pulse-setup-pve.sh', + command: 'safe command', + setupToken: secret, + tokenHint: 'syn…ret', + expires: 1_900_000_000, + [field]: `unsafe-${secret}`, + }); + await expect( + NodesAPI.getProxmoxSetupCommand({ type: 'pve', host: 'pve.example', backupPerms: false }), + ).rejects.toThrow('Setup credentials must be entered separately from commands and URLs'); + }, + ); + it('sends the typed connection name so the setup token can carry it into auto-registration', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', setupToken: 'setup-token-123', @@ -422,8 +451,7 @@ describe('NodesAPI', () => { type: ' pve ', host: ' https://pve.example:8006 ', url: ' https://pulse.example/api/setup-script?type=pve ', - downloadURL: - ' https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123 ', + downloadURL: ' https://pulse.example/api/setup-script?type=pve ', scriptFileName: ' pulse-setup-pve.sh ', command: ' curl pve ... ', commandWithEnv: ' curl env pve ... ', @@ -443,13 +471,14 @@ describe('NodesAPI', () => { type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', commandWithoutEnv: 'curl bare pve ...', expires: 1_900_000_000, tokenHint: 'set…123', + setupToken: 'setup-token-123', }); }); @@ -458,7 +487,7 @@ describe('NodesAPI', () => { type: 'pbs', host: 'https://pbs.example:8007', url: 'https://pulse.example/api/setup-script?type=pbs', - downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pbs', scriptFileName: 'pulse-setup-pbs.sh', command: 'curl pbs ...', setupToken: 'pbs-token-123', @@ -486,12 +515,10 @@ describe('NodesAPI', () => { expect(result.command).toBe('curl pbs ...'); expect(result.type).toBe('pbs'); expect(result.host).toBe('https://pbs.example:8007'); - expect(result.downloadURL).toBe( - 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123', - ); + expect(result.downloadURL).toBe('https://pulse.example/api/setup-script?type=pbs'); expect(result.scriptFileName).toBe('pulse-setup-pbs.sh'); expect(result.tokenHint).toBe('pbs…123'); - expect(result).not.toHaveProperty('setupToken'); + expect(result.setupToken).toMatch(/token-123$/); }); it('falls back to command for commandWithEnv when the backend omits it', async () => { @@ -499,7 +526,7 @@ describe('NodesAPI', () => { type: 'pbs', host: 'https://pbs.example:8007', url: 'https://pulse.example/api/setup-script?type=pbs', - downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pbs', scriptFileName: 'pulse-setup-pbs.sh', command: 'curl pbs ...', setupToken: 'pbs-token-123', @@ -521,7 +548,7 @@ describe('NodesAPI', () => { type: 'pbs', host: 'https://pbs.example:8007', url: '', - downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pbs', scriptFileName: 'pulse-setup-pbs.sh', command: 'curl pbs ...', setupToken: 'pbs-token-123', @@ -543,7 +570,7 @@ describe('NodesAPI', () => { type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', setupToken: 'setup-token-123', @@ -565,7 +592,7 @@ describe('NodesAPI', () => { type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', command: 'curl pve ...', setupToken: 'setup-token-123', tokenHint: 'set…123', @@ -586,7 +613,7 @@ describe('NodesAPI', () => { type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', setupToken: 'setup-token-123', @@ -616,11 +643,11 @@ describe('NodesAPI', () => { ); const result = await NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', @@ -629,9 +656,7 @@ describe('NodesAPI', () => { tokenHint: 'set…123', }); - expect(apiFetch).toHaveBeenCalledWith( - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', - ); + expect(apiFetch).toHaveBeenCalledWith('https://pulse.example/base/api/setup-script?type=pve'); expect(result).toEqual({ content: '#!/bin/bash\necho pve', contentType: 'text/x-shellscript; charset=utf-8', @@ -651,11 +676,11 @@ describe('NodesAPI', () => { ); const result = await NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pbs', host: 'pbs.example', url: 'https://pulse.example/base/api/setup-script?type=pbs', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pbs&setup_token=pbs-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pbs', scriptFileName: 'pulse-setup-pbs.sh', command: 'curl pbs ...', commandWithEnv: 'curl env pbs ...', @@ -664,9 +689,7 @@ describe('NodesAPI', () => { tokenHint: 'pbs…123', }); - expect(apiFetch).toHaveBeenCalledWith( - 'https://pulse.example/base/api/setup-script?type=pbs&setup_token=pbs-token-123', - ); + expect(apiFetch).toHaveBeenCalledWith('https://pulse.example/base/api/setup-script?type=pbs'); expect(result).toEqual({ content: '#!/bin/bash\necho pbs', contentType: 'text/x-shellscript; charset=utf-8', @@ -687,11 +710,11 @@ describe('NodesAPI', () => { await expect( NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', @@ -712,11 +735,11 @@ describe('NodesAPI', () => { await expect( NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', @@ -740,11 +763,11 @@ describe('NodesAPI', () => { await expect( NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', diff --git a/frontend-modern/src/api/nodes.ts b/frontend-modern/src/api/nodes.ts index ff19cd74bf..62e0b3734f 100644 --- a/frontend-modern/src/api/nodes.ts +++ b/frontend-modern/src/api/nodes.ts @@ -59,6 +59,7 @@ type RawAgentInstallCommandResponse = { export type AgentInstallCommandResponse = { command: string; + token?: string; }; type RawHostAgentInstallTokenResponse = { @@ -96,6 +97,7 @@ export type ProxmoxSetupCommandResponse = { commandWithoutEnv?: string; expires: number; tokenHint: string; + setupToken: string; }; export type DownloadedProxmoxSetupScript = { @@ -112,7 +114,11 @@ const normalizeAgentInstallCommandResponse = ( throw new Error('Invalid agent install command response'); } - return { command }; + const token = optionalTrimmedString(response.token); + if (token && command.includes(token)) { + throw new Error('Install credentials must be entered separately from the command'); + } + return { command, ...(token ? { token } : {}) }; }; const normalizeProxmoxSetupCommandResponse = ( @@ -156,6 +162,14 @@ const normalizeProxmoxSetupCommandResponse = ( if (!setupToken) { throw new Error('Invalid Proxmox setup response setup token'); } + if ( + [url, downloadURL, command, commandWithEnv, commandWithoutEnv ?? ''].some((value) => + value.includes(setupToken), + ) || + /[?&]setup_token=/.test(downloadURL) + ) { + throw new Error('Setup credentials must be entered separately from commands and URLs'); + } if (!tokenHint) { throw new Error('Invalid Proxmox setup response token hint'); } @@ -174,6 +188,7 @@ const normalizeProxmoxSetupCommandResponse = ( commandWithoutEnv, expires, tokenHint, + setupToken, }; }; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index dd722dc9db..a7c1fd727b 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -161,7 +161,7 @@ const baseResource = (overrides: Partial): Resource => ({ describe('ResourceDetailDrawer change history section', () => { it('keeps current readings separate from stored history samples', () => { expect(guestDrawerHistorySource).toContain('currentMetrics'); - expect(guestDrawerHistorySource).toContain('Collecting history'); + expect(guestDrawerHistorySource).toContain('No stored history in this range'); expect(guestDrawerHistorySource).not.toContain('buildFallbackHistoryPoints'); }); diff --git a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx index 1bdf60fd18..19ff8161d4 100644 --- a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx +++ b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx @@ -241,8 +241,8 @@ export const NodeModalSetupGuideSection: Component

- No token fields are needed here. The node appears in Pulse automatically after - the agent starts. + Run the command first, then paste the separately revealed token at its silent + prompt. The node appears in Pulse after the agent starts.

@@ -286,7 +286,7 @@ export const NodeModalSetupGuideSection: Component
-

Credentialed command ready

+

Credential-free command ready

Use Copy command to place the runnable command on your clipboard. The - one-time setup token is intentionally not shown on this page. + one-time setup token is shown separately. Run the command first, then + paste the token only at its silent prompt, never into a shell command.

@@ -424,7 +425,7 @@ export const NodeModalSetupGuideSection: Component

- Fully automatic: no manual token copying needed. + The monitoring API token is registered automatically.

@@ -728,8 +729,8 @@ export const NodeModalSetupGuideSection: Component

- No token fields are needed here. The server appears in Pulse automatically after - the agent connects. + Run the command first, then paste the separately revealed token at its silent + prompt. The server appears in Pulse after the agent connects.

@@ -757,7 +758,7 @@ export const NodeModalSetupGuideSection: Component
-

Credentialed command ready

+

Credential-free command ready

Use Copy command to place the runnable command on your clipboard. The - one-time setup token is intentionally not shown on this page. + one-time setup token is shown separately. Run the command first, then + paste the token only at its silent prompt, never into a shell command.

@@ -895,7 +897,7 @@ export const NodeModalSetupGuideSection: Component

- Fully automatic: no manual token copying needed. + The monitoring API token is registered automatically.

diff --git a/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx b/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx index 3f27e1aa90..5acd3c9780 100644 --- a/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx @@ -90,13 +90,11 @@ describe('NodeModalSetupGuideSection', () => { quickSetupTokenHint: 'set…123', }); - expect(screen.getByText('Credentialed command ready')).toBeInTheDocument(); + expect(screen.getByText('Credential-free command ready')).toBeInTheDocument(); expect( screen.getByText(/Use Copy command to place the runnable command on your clipboard/i), ).toBeInTheDocument(); - expect( - screen.getByText(/one-time setup token is intentionally not shown/i), - ).toBeInTheDocument(); + expect(screen.getByText(/one-time setup token is shown separately/i)).toBeInTheDocument(); expect(screen.queryByText(/curl -fsSL/i)).not.toBeInTheDocument(); expect(screen.getByText('set…123')).toBeInTheDocument(); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 6619ee7a5b..a6c84839ac 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1813,11 +1813,10 @@ describe('settings architecture guardrails', () => { ); expect(nodeModalSetupGuideSectionSource).toContain('setupCommandButtonTitle'); expect(nodeModalSetupGuideSectionSource).toContain('disabled={setupHandoffDisabled()}'); - expect(nodeModalSetupGuideSectionSource).toContain('Credentialed command ready'); - expect(nodeModalSetupGuideSectionSource).toContain( - 'one-time setup token is intentionally not shown on this page', - ); - expect(nodeModalStateSource).toContain('data.commandWithEnv'); + expect(nodeModalSetupGuideSectionSource).toContain('Credential-free command ready'); + expect(nodeModalSetupGuideSectionSource).toContain('one-time setup token is shown separately'); + expect(nodeModalStateSource).toContain('copyToClipboard(data.command)'); + expect(nodeModalStateSource).toContain('showSetupToken(data)'); expect(nodeModalStateSource).not.toContain('quickSetupPreviewCommand'); expect(nodeModalStatusFooterSource).toContain('guidedSetupOnlyMode'); expect(nodeModalStatusFooterSource).toContain('props.saveDisabled'); diff --git a/frontend-modern/src/components/Settings/useNodeModalState.ts b/frontend-modern/src/components/Settings/useNodeModalState.ts index e8b6249adf..b3f76c6635 100644 --- a/frontend-modern/src/components/Settings/useNodeModalState.ts +++ b/frontend-modern/src/components/Settings/useNodeModalState.ts @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal } from 'solid-js'; +import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import type { NodeConfig } from '@/types/nodes'; import { notificationStore } from '@/stores/notifications'; @@ -6,6 +6,7 @@ import { NodesAPI } from '@/api/nodes'; import type { ProxmoxSetupCommandResponse } from '@/api/nodes'; import { copyToClipboard } from '@/utils/clipboard'; import { logger } from '@/utils/logger'; +import { showTokenReveal } from '@/stores/tokenReveal'; import { buildNodeModalMonitoringPayload, getNodeModalDefaultFormData, @@ -71,6 +72,14 @@ export const useNodeModalState = (props: NodeModalProps) => { setQuickSetupExpiry(null); }; + let bootstrapGeneration = 0; + const bootstrapActive = (generation: number) => + generation === bootstrapGeneration && props.isOpen !== false; + onCleanup(() => { + bootstrapGeneration++; + clearQuickSetupState(); + }); + const copyCommand = async (command: string, successMessage = 'Command copied!') => { if (await copyToClipboard(command)) { notificationStore.success(successMessage); @@ -78,6 +87,7 @@ export const useNodeModalState = (props: NodeModalProps) => { }; const copyProxmoxAgentInstallCommand = async (type: 'pve' | 'pbs', successMessage: string) => { + const generation = bootstrapGeneration; try { setLoadingAgentCommand(true); setAgentCommandError(null); @@ -87,7 +97,15 @@ export const useNodeModalState = (props: NodeModalProps) => { enableCommands: false, insecure: agentInstallInsecure(), }); + if (!bootstrapActive(generation)) return; setAgentInstallCommand(data.command); + if (data.token) { + showTokenReveal({ + token: data.token, + source: type, + note: 'Run the copied install command first. Then copy this token and paste it only at the silent “Pulse agent token” prompt. It is not part of the command.', + }); + } const copied = await copyToClipboard(data.command); if (copied) { notificationStore.success(successMessage); @@ -98,12 +116,13 @@ export const useNodeModalState = (props: NodeModalProps) => { setAgentCommandError(copyFailureMessage); notificationStore.error(copyFailureMessage); } catch (error) { + if (!bootstrapActive(generation)) return; logger.error('[Host Telemetry Agent] Error:', error); const message = error instanceof Error ? error.message : 'Failed to generate install command'; setAgentCommandError(message); notificationStore.error(message); } finally { - setLoadingAgentCommand(false); + if (bootstrapActive(generation)) setLoadingAgentCommand(false); } }; @@ -113,7 +132,8 @@ export const useNodeModalState = (props: NodeModalProps) => { const loadQuickSetupBootstrap = async ( type: 'pve' | 'pbs', backupPerms: boolean, - ): Promise => { + ): Promise => { + const generation = bootstrapGeneration; const host = formData().host?.trim() ?? ''; if (!host) { notificationStore.error('Please enter the Endpoint URL first'); @@ -135,6 +155,8 @@ export const useNodeModalState = (props: NodeModalProps) => { backupPerms, name: formData().name?.trim() || undefined, }); + if (!bootstrapActive(generation) || quickSetupCacheKey(type, backupPerms) !== cacheKey) + return null; setQuickSetupBootstrap({ cacheKey, response }); setQuickSetupTokenHint(response.tokenHint); setQuickSetupExpiry(response.expires); @@ -146,18 +168,22 @@ export const useNodeModalState = (props: NodeModalProps) => { backupPerms: boolean, successMessage: string, ) => { + const generation = bootstrapGeneration; logger.debug('[Quick Setup] Copy button clicked'); try { logger.debug('[Quick Setup] Generating setup URL for host', { host: formData().host, }); const data = await loadQuickSetupBootstrap(type, backupPerms); - if (await copyToClipboard(data.commandWithEnv)) { + if (!data || !bootstrapActive(generation)) return; + showSetupToken(data); + if (await copyToClipboard(data.command)) { notificationStore.success(successMessage); return; } throw new Error('Failed to copy to clipboard'); } catch (error) { + if (!bootstrapActive(generation)) return; logger.error('[Quick Setup] Error:', error); clearQuickSetupState(); if (!(error instanceof Error && error.message === PROXMOX_SETUP_HOST_REQUIRED_MESSAGE)) { @@ -166,12 +192,24 @@ export const useNodeModalState = (props: NodeModalProps) => { } }; + const showSetupToken = (bootstrap: ProxmoxSetupCommandResponse) => { + showTokenReveal({ + token: bootstrap.setupToken, + source: bootstrap.type, + note: `Run the copied setup command (or downloaded script) first. Then copy this token and paste it only at the silent “Pulse setup token” prompt. It expires at ${new Date(bootstrap.expires * 1000).toLocaleTimeString()}. Never paste it into a shell command.`, + }); + }; + const setupScriptRunHint = (fileName: string) => `bash ${fileName}`; const downloadProxmoxSetupScript = async (type: 'pve' | 'pbs', backupPerms = false) => { + const generation = bootstrapGeneration; try { const bootstrap = await loadQuickSetupBootstrap(type, backupPerms); + if (!bootstrap || !bootstrapActive(generation)) return; const data = await NodesAPI.downloadProxmoxSetupScript(bootstrap); + if (!bootstrapActive(generation)) return; + showSetupToken(bootstrap); const blob = new Blob([data.content], { type: data.contentType, @@ -189,6 +227,7 @@ export const useNodeModalState = (props: NodeModalProps) => { `Script downloaded! Upload it to your server and run: ${setupScriptRunHint(data.fileName)}`, ); } catch (error) { + if (!bootstrapActive(generation)) return; logger.error('Failed to download script:', error); notificationStore.error('Failed to download script. Please check your connection.'); } @@ -215,6 +254,12 @@ export const useNodeModalState = (props: NodeModalProps) => { const editingNode = props.editingNode; const prefillNode = props.prefillNode; + if (isOpen === false || key !== previousResetKey || nodeType !== previousNodeType) { + bootstrapGeneration++; + clearQuickSetupState(); + setLoadingAgentCommand(false); + } + if (key !== undefined && key !== previousResetKey) { previousResetKey = key; setFormData(() => getNodeModalDefaultFormData(props.nodeType)); diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 8b44aefcb1..adb3254ccb 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -556,8 +556,8 @@ describe('GuestDrawer', () => { await waitFor(() => expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalled()); const utilizationChart = screen.getAllByTestId('guest-history-group-chart')[0]; - expect(utilizationChart).toHaveTextContent('CPU25.0%'); - expect(utilizationChart).toHaveTextContent('Collecting history'); + expect(utilizationChart).toHaveTextContent('CPU25.0%current'); + expect(utilizationChart).toHaveTextContent('No stored history in this range'); expect(utilizationChart.querySelector('path')).toBeNull(); }); diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 6cf02dac9d..427b7ec2f6 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -274,9 +274,13 @@ const GuestDrawerHistoryGroupChart: Component return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`; }); const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2)); + const singlePointSeries = createMemo(() => series().filter((item) => item.points.length === 1)); + const singleObservation = createMemo(() => + observationTimes().length === 1 ? { timestamp: observationTimes()[0] } : null, + ); const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit)); const bounds = () => props.timeBounds; - const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null); + const hasStoredData = createMemo(() => observationTimes().length > 0 && bounds() !== null); const hoveredSeries = createMemo(() => { const timestamp = activeTimestamp(); const rangeBounds = bounds(); @@ -309,6 +313,11 @@ const GuestDrawerHistoryGroupChart: Component const currentValue = props.currentMetrics?.[item.metric]; return { ...item, + isCurrent: + activeTimestamp() === null && + item.points.length === 0 && + typeof currentValue === 'number' && + Number.isFinite(currentValue), valueLabel: hovered ? getGuestDrawerHistoryValueLabel([hovered.point], item.unit) : activeTimestamp() !== null @@ -369,12 +378,18 @@ const GuestDrawerHistoryGroupChart: Component
{(item) => ( - + {item.label} {item.valueLabel} + + current + )} @@ -421,26 +436,48 @@ const GuestDrawerHistoryGroupChart: Component {(rangeBounds) => ( - - {(item) => ( - - )} - + <> + + {(item) => ( + + )} + + + {(item) => ( + + )} 0}> @@ -471,9 +508,9 @@ const GuestDrawerHistoryGroupChart: Component - +
- Collecting history + No stored history in this range
@@ -515,6 +552,19 @@ const GuestDrawerHistoryGroupChart: Component

{chartDescription()}

+ + {(observation) => ( +

+ Single observation. No trend yet. + +

+ )} +
1}>