Commit Graph

12737 Commits

Author SHA1 Message Date
rcourtman f77c8b2f3d Register agent command channels under the identity Pulse resolves
A token binds the first agent ID its command channel presents and never
moves. The agent registered that channel before its first report, under
its configured --agent-id, or an ID Pulse had forked for a cloned
machine ID or kept from an earlier enrollment. Pulse meanwhile resolved
its reports to another host ID and acknowledged it, and the agent
persisted that ID. The config gate, which judges the resolved ID, then
turned the operator's commands off, and the next restart under the
persisted ID was refused for good.

Report acknowledgements now name commandAgentId: the resolved host ID,
only when the token's binding already holds it or a registration
presenting it would take the first bind, backfill, legacy migration,
hostname rebind or deploy repair it would get anyway. The credential
must also be one command admission takes, and the host must be held
under the reporting token. Naming grants nothing that registration
would not. An established binding to another ID is never moved from
report contents, because resolution can map a report onto another
host's continuity record by machine ID, so a forged report and a stale
one look the same. The config gate takes the same decision.

The agent keeps its channel staged until a report is acknowledged and
registers under the named identity, or under its own ID when none is
named, which is what an older Pulse or an already-bound token sees.
An unreadable ack keeps it waiting, and a 403 releases it under its own
ID because Pulse can never answer that token. A running client is never
rebuilt, since that would reopen its receipt store under in-flight
operations. A superseded client can no longer take the run slot.

Tokens already bound to a diverged ID stay as they are; re-running the
install command issues a fresh token that binds the resolved identity.
2026-10-06 00:36:41 +01:00
rcourtman d961e0b18f Let agents without a command channel reduce their authority (#2547)
## Problem

The safe-profile migration's authority reduction (`POST
/api/agents/collector/reduce-authority`) needs the agent's ID on its
token: `bound_agent_id` must be set and equal the ID the installer
sends. Until now only a command-channel registration wrote that ID.

Deploy enrollment (since rcourtman/Pulse#2542), Proxmox
auto-registration and pre-v6.1.1 installs all leave a token bound to the
node's hostname with no agent ID. The ID is filled in only at the
agent's first command registration. An agent whose token lacks
`agent:exec`, or that never connects the command channel, never gets an
ID on its token. The reduction then refuses it with "Collector
credential binding mismatch" for as long as the agent runs. Before
#2542, deploy tokens instead carried an invented `agent-<hostname>`,
which failed the same check.

## Fix

The report path now records the agent's identity on such a token, under
the same rules as command binding:

- **When it binds.** After `/api/agents/agent/report` resolves a report,
a token with a `bound_hostname` and no `bound_agent_id` records the
server-resolved host ID. This happens only when the report presented
that same ID. In the same run that is the ID the agent's command
registration presents. It is also the `agentId` the response
acknowledges, which the agent persists and the installer sends to the
reduction.
- **Same decision as the command channel.**
`agentbinding.EvaluateReportedIdentity` records only where `Evaluate`
would first-bind, legacy-migrate or backfill that ID for the reported
hostname. It also requires:
- a token bound to at most one organization, whose command-session
organization matches the report's;
  - a collector, legacy full-trust or empty runtime role;
  - a token that has not expired.
- **Only a host Pulse actually holds.** The resolved host must be one
the monitor holds under that token (`host.TokenID`). Mock mode's
discarded, unresolved acknowledgements therefore never qualify, however
mock mode is toggled.
- **Judged on the report's own hostname.** That is the hostname the
agent's command registration presents, and it matters because a report
rejected by the ordering watermark is answered with the stored host.
- **Nothing else changes.** The path writes `bound_agent_id`, `bound_at`
and the binding version. The binding version makes the ID immutable to
every later registration. Scopes, runtime role, command policy and
`bound_hostname` are never touched. A bound ID never moves and a deploy
placeholder is never repaired. A recorded identity grants no execution
authority: command admission still needs `agent:exec` and the same ID.
- **Same store and lock as command admission.** The write goes to the
router's canonical token store and persistence, under the token lock
command admission holds, against the live record. A failed save restores
the previous metadata and the next report retries.
- **Cheap screen first.** The handler checks the request's token
snapshot before taking the exclusive token lock, so already-bound or
ineligible tokens cost nothing per report.

Contracts updated: agent-lifecycle, security-privacy, api-contracts and
storage-recovery.

## Behaviour change: the first recorded identity is final

As with a command registration, the first identity recorded is final.
Take an agent that reported under its machine ID while it had no command
channel, for example with commands off. If it is later restarted under a
different `--agent-id`, its command registration under the new ID is
refused on that token. A report replayed from an earlier run's buffer
has the same effect. This is exactly what happens today when that first
run's command channel did connect.

`TestReportedIdentityIsFinalForALaterReconfiguredAgent` pins the
behaviour. Command-channel identity convergence lifts it: that follow-up
to #2542 has the agent register under the identity Pulse names in the
report ack, and it builds on this decision.

## Tests

-
**`TestContractDeployEnrolledCollectorReducesItsAuthorityAfterItsFirstReport`**
(`internal/api/contract_test.go`) replays enrollment without commands,
the first report and the reduction, through the real router. It checks
that the token's scopes and runtime role are unchanged and that command
admission is still refused. It fails without the fix: the reduction
returns 403.
- **Boundary tests in `internal/api/security_regression_test.go`:**
- `TestReportedIdentityMatchesCommandRegistrationAndNeverMoves`: the
recorded ID matches the agent's own command registration; neither a
second identity's registration nor another machine's reports moves it;
an ID the command channel bound first survives a report that resolves
elsewhere.
-
`TestReportedIdentityRecordsNothingWhenTheResolvedIdentityIsNotThePresentedOne`:
an agent with a configured `--agent-id` records nothing, and its command
registration is still admitted.
- `TestReportedIdentityIsFinalForALaterReconfiguredAgent`: pins the
trade-off above.
- `TestReportedIdentityJudgesAReplayedReportByItsOwnHostname`: a report
the ordering watermark rejects is answered with the stored host, and is
judged on its own hostname rather than the stored one.
- `TestReportedIdentityRestoresTheTokenWhenTheSaveFails`,
`TestReportedIdentityRequiresTheTokensOwnOrganization` and
`TestReportedIdentityIgnoresAnExpiredToken`.
- `TestReportHandlerOffersOnlyQualifyingLiveReportsToTheRecorder`: the
handler's snapshot screen and mock mode.
- **`TestApplyHostReportStampsTheTokenOnlyOnHostsItHolds`**
(`internal/monitoring/monitor_host_agents_test.go`) pins the monitor
properties the handler relies on:
  - an applied report carries its token's ID;
  - a watermark-rejected report returns the stored host and hostname;
  - mock mode's discard acknowledgement carries no token.
-
**`TestEvaluateReportedIdentityRecordsOnlyWhatCommandRegistrationWouldBind`**
(`internal/api/agentbinding/policy_test.go`) is the policy table: the
deploy, auto-registered, legacy and equivalent-hostname cases, plus
every refusal, including a versionless token already bound to an ID.
- **Mutation checks.** Each of these fails at least one named test:
  - dropping the wiring;
  - allowing presented ≠ resolved;
  - letting a bound ID move;
  - allowing multiple organizations;
  - allowing any runtime role;
  - skipping `Evaluate`;
  - not stamping the binding version;
  - not restoring on a failed save;
  - dropping the organization or expiry check;
  - dropping the held-host check;
  - judging by the stored host's hostname;
  - dropping the snapshot screen.

Reviewed adversarially in three passes:

1. The first pass found two issues: the overstated "same ID as command
registration" claim, now scoped to the same run and paired with the
final-identity trade-off; and a mock-mode toggle race, replaced by the
held-host check.
2. The second pass found that replayed reports were judged on the stored
hostname, now judged on the report's own.
3. The third pass found nothing further.

Governance:

- `canonical_completion_guard.py` passes in CI mode on this commit.
- `registry_audit.py --check`, `contract_audit.py --check` and
`status_audit.py --check` pass.
- All governance `*_test.py` suites from `canonical-governance.yml` and
`go test ./internal/repoctl` pass.

## Not in this change

- **Presented ≠ resolved.** When the agent presents an ID other than the
one Pulse resolves (a configured agent ID, a continuity fork), nothing
is recorded. Reconciling that disagreement belongs to the
command-channel identity convergence follow-up.
- **Tokens bound to no hostname.** Generic host install tokens without
`agent:exec` still get their binding only from command-channel first
use, so their reduction still has no ID to match.
- **Historical deploy placeholders.** Tokens still bound to
`agent-<hostname>` are repaired only by command registration, so a
placeholder token without `agent:exec` still cannot reduce.
- **Builds on #2542**, which has merged; this branch adds one commit on
top of it.
2026-10-06 00:35:23 +01:00
rcourtman 77bdc77b35 Move three forms onto shared controls and draw the select arrow (#2546)
This clears the shared template audit (`node
scripts/shared-template-audit.mjs`) for the forms that still built their
own controls. Three findings remain, in BackupTransferDialogs.tsx and
AlertDeadManDestinationSection.tsx. Those two files are part of an open
`bg-base` change and will follow after it lands.

**Moved onto shared controls:**
- **Alerts › Thresholds › CPU averaging:** the ten per-platform selects
now use `FormSelect`, with each profile name as a visible label.
- **Discovery availability proposal:** the Interval and Observation
location fields use `FormSelect`, and the check-name field next to them
uses the shared field classes, so the three match.
- **Availability check › Request body:** the field uses `FormTextarea`.
Its size note is now the field's accessible description. With
`sm:min-h-24` it also gets the 96px height its class asked for on
desktop; on main it rendered at 54px.

**Select arrow.** `FormSelect`'s default chrome set `appearance-none`
and drew no arrow of its own, so every default select in Settings read
as a text field. That includes the existing Preset, Target type and
Probe selects in the availability dialog. A new `form-select-arrow`
utility draws a caret in the padding the class already reserved, using
the muted text colour. In forced-colors mode it hands the native arrow
back. Filter-bar selects use their own class and are unchanged.

**Registry fixes:**
- `AlertEscalationSection` is removed from the `form-select-shell`
required consumers. It has used checkboxes since e0baecc418.
- The inline detail row rule now excludes test files. Its only finding
was `ProxmoxBackupsTable.test.tsx`.

Verification:
- Checked live on a mock stack, before and after, in light and dark mode
at 1440px and 390px.
- The Discovery card was rendered through route interception.
- Forced-colors mode was emulated.
- 11,393 tests pass, and the receipt is included.
2026-10-06 00:20:46 +01:00
rcourtman df41170313 Send built-in webhook template headers on real alerts (#2545)
Fixes #2540.

A Telegram webhook passed **Test** while every real alert was rejected
with `Bad Request: message text is empty`.

**Cause.** The two paths built their headers differently:
- The Test path always overlaid the service template's headers
(`Content-Type: application/json`).
- Real deliveries sent the stored header copy that the UI seeded when
the webhook was created. An edit can leave that copy blank, masked or
replaced. For example, the edit form shows the stored value as
`***REDACTED***`, and clearing it saves an empty `Content-Type`.

Telegram's HTTP reader parses body arguments only for JSON,
form-urlencoded or multipart content types, and `sendMessage` checks
`text` before `chat_id`. So a JSON body without a JSON Content-Type
produces exactly that error.

**Fix.** When a built-in service renders its own body (no custom
template), the template's static headers describe that body:
- `prepareWebhookDeliveryContext` applies them on every real firing and
recovery, replacing stored values under any spelling of the header name.
Unrelated stored headers stay.
- The Test path uses the same `withWebhookTemplateHeaders` helper, so a
passing Test predicts real delivery.
- Custom templates and generic webhooks keep their stored headers. ntfy
recoveries still build their own plain-text request.

**Tests** (`internal/notifications/webhook_template_headers_test.go`):
- A real queued Telegram alert, with blank, masked, replaced, lowercase
and duplicate stored Content-Type values, now sends JSON with `text`.
Before the fix it sent `""`, `***REDACTED***` or `text/plain`.
- For every built-in service, the Test, grouped and resolved requests
carry identical template headers and keep unrelated stored headers.
- A custom template keeps its stored Content-Type.

`go test ./internal/notifications` and `./internal/api/alerting` pass.
2026-10-06 00:19:35 +01:00
rcourtman d043c09341 Show a Docker container's health check in its drawer (#2534)
Follow-up to #2526. The containers table now says Unhealthy or Exited
(139), but the row's drawer still disagreed with it. A running container
whose health check was failing read **Observed state: Online**, and a
crashed one read **Offline**. The drawer never mentioned the health
check, and it could show uptime that a stopped container had left
behind.

For a Docker container, the drawer's Runtime context now uses the same
presentation as the table (`dockerContainerStatePresentation.ts`):

- **Observed state** is Docker's own state, such as Running, or Exited
(137) in red.
- **Health check** is a new row for a running container: Failing (red),
Starting (amber) or Passing.
- **Uptime** covers only the current run. A stopped container shows
neither a stale health check nor leftover uptime.

Other resources are unchanged.

Verification:
- I opened the drawers at 1440px and on a 390px phone, using edited API
data: one container with a failing health check, healthy containers, and
a crashed container carrying a stale unhealthy status and leftover
uptime. I compared each against main.
- Tests cover the helpers and both drawer cases, and the receipt is
included.
2026-10-06 00:17:11 +01:00
rcourtman b4ea3761e8 Keep paired PR benchmarks inside their time budget (#2544)
The non-required **Benchmarks** check has failed on every non-superseded
PR since about 15:00 UTC on 5 October. Examples are #2521, #2520, #2524,
#2525, #2527, #2530 and #2532, each ending with `Paired base and
candidate benchmarks (pull requests)` timed out after 25 minutes. The
paired step runs 10 base and 10 candidate samples, so each second added
per sample costs 20 seconds of step time.

### What made it slower

Paired step duration across PR runs, with per-package seconds per sample
taken from the `ok` lines in the job logs:

| Period | Paired step | `internal/unifiedresources` |
`internal/monitoring` |
|---|---|---|---|
| 24 Sep – 5 Oct 08:00 | 16–18 min | 7.9 s | 4.5 s |
| after #2486 (08:11) | 20.5–20.9 min | 16.2 s | 5.0 s |
| after #2518 (15:01) | killed at 25 min | 38.2 s | 9.9 s |

- **#2518** added `BenchmarkRegistryProjectionSnapshot`. Its `cold` mode
rebuilt the registry between `b.StopTimer` and `b.StartTimer` on every
iteration. Go picks `b.N` from timed work only, so the empty-registry
cases ran hundreds of thousands of untimed ingests. Per sample,
`/0/cold/point-targets` took 10.3 s and `/0/cold/bulk-targets` 5.9 s at
a 100 ms benchtime. That is the cause of the timeouts.
- **#2486** added 46 owner-index and metadata sub-benchmarks, about +8 s
per sample. That is legitimate coverage. #2518's
`BenchmarkBroadcastMetricsSnapshot` and #2527 added about +6 s more.
- `-count`, `-benchtime` and the package list did not change.

### Fix

1. **Root cause.** `cold` now invalidates the registry's derived state
(canonical metadata, typed views, source-target index) instead of
rebuilding the registry. The refresh never reads its own output, so this
repeats a fresh ingest's first read: at 1000 VMs, 40011 vs 40014
allocs/op and about 5.09 MB/op, the same as before. Locally the 24-case
family now runs in 4.9 s; before, its 6 cold cases alone took 33.8 s.
2. **Scope.** Paired PR runs now sample only packages whose in-module
dependency closure (`go list -deps -test`) holds a changed file. A
`_test.go` file selects only its own package. Changes to
`go.mod`/`go.sum`, dirty or non-Git trees, or an unreadable graph select
every package. Applied to today's failures: #2521, #2524 and #2525
sample nothing; #2530 samples `internal/api`; #2532 samples api and
monitoring; #2520 samples 5 packages; #2527 samples 7. The metadata
records `selection=` and `packages=`.
3. **Budget.** A module or shared-model change still samples nearly
everything, about 24 minutes including the ~2.7 min warm-up. The step
timeout is now a measured 30 minutes (job stays at 35).
`PULSE_BENCH_BUDGET_SECONDS=1680` makes the script project its total
after each round. Once the projection reaches 28 minutes it stops and
prints each package's mean seconds per sample, which replaces an
unexplained timeout. `TestPairedBenchmarksStopInsideTheirBudget` keeps
budget < step timeout < job timeout.

4. **Store-backed chart benchmarks** (second commit). Go calls a
benchmark function again for each `b.N` it tries.
`HandleWorkloadCharts_StoreBacked` and
`HandleWorkloadsSummaryCharts_StoreBacked` each seeded 84,000 points
into a fresh SQLite store (about 0.6 s) to time a request of 0.2 ms or
less, two or three times per sample. In CI that was 3.5 s and 3.6 s of
internal/api's 11.5 s per sample. The timed loop now runs in a
`range=4h` sub-benchmark, so the parent seeds once per run. Data,
request and bytes/allocs per op are unchanged, and locally the pair
falls from about 6 s to 1–1.5 s. The names gain `/range=4h`, so this
PR's comparison reports them unmatched.

Unpaired branch runs still sample every package. With the cold fix,
their 15-minute step (13–14.2 min today) regains about 3 minutes of
headroom.

### Verification

- `scripts/tests/test-ci-benchmarks.sh` covers no-op, own-test-only,
shared-dependency, unreadable-graph, dirty-tree, module-file,
budget-exceeded, in-budget and invalid-budget cases. Five mutations of
the selection and budget logic are each caught.
- On the real package graph, selection maps `internal/models` to 7
packages, `internal/monitoring` to api+monitoring, an `internal/api`
embed to api, and frontend or docs paths to none.
- End to end with real Go on two clean clones (`origin/main` vs this
change), only `./internal/unifiedresources/` was sampled. Its per-sample
time went from 53.9 s to 26.7 s on a loaded Mac.
- `go test ./scripts/installtests -run
'TestPairedBenchmarksStopInsideTheirBudget|TestBenchmarkQualificationRetainsProvenance'`,
plus the script-reference and doc-drift suites.

This PR's own Benchmarks run should sample only
`internal/unifiedresources` and `internal/api`, because both changes are
those packages' own test files.
2026-10-06 00:00:56 +01:00
rcourtman 8d77c0e277 Explain why the Container update risk switch is locked (#2541)
Patrol settings showed the Container update risk switch dimmed on
installs without ai_alerts and gave no reason, so a free install could
not tell a plan limit from a broken setting.

The card now renders the canonical Patrol availability presentation.
patrolAutonomyAvailability.ts keeps one precedence and the only action
labels, while Patrol mode and container update risk each supply their
lock state and copy. Links appear only when the session allows upgrade
prompts. The explanation is tied to the switch with aria-describedby,
because the shared Toggle does not render its locked/lockedMessage
props.

The second commit makes hidden commercial surfaces win over the runtime
lock, and the Patrol header and banners now pass the session's real
presentation policy instead of a constant. Demo and white-label sessions
on the community runtime previously saw "Pulse Pro runtime required" on
the card and "Install the Pulse Pro runtime" inside the header's mode
disclosure; they now get the neutral lock. Sessions that show commercial
surfaces keep the runtime prompt and download link unchanged.
2026-10-05 23:57:17 +01:00
pulse-triage[bot] e0647e860f Release v6.5.0 (#2536)
Release preparation for `v6.5.0`. After this merges, the release
workflow runs at this exact head
`38c981a397c7a37f469b619200b5035bfc60b328`.
2026-10-05 22:45:32 +00:00
rcourtman 4742159bb3 Let agents without a command channel reduce their authority
Deploy enrollment, Proxmox auto-registration and pre-v6.1.1 installs
leave an agent's token bound to the node's hostname and to no agent ID
until the agent registers a command channel. An agent whose token lacks
agent:exec, or that never connects that channel, never gets an ID on its
token, and the safe-profile migration's authority reduction, which
matches that ID, refuses it for as long as the agent runs.

A report the monitor resolved under such a token now records the
server-resolved host ID on it when the report presented that same ID,
which in the same run is the ID the agent's command registration
presents. The shared binding policy must first-bind, migrate or backfill
that ID for the reported hostname, the token must run command sessions
in the report's organization, and the binding version makes the ID
immutable. Scopes, runtime role and command policy are untouched, so a
recorded identity grants nothing; command registration still needs
agent:exec and the same ID. When the presented and resolved IDs differ,
nothing is recorded and the command channel decides as before. As with a
command registration, the first identity recorded is final, so an agent
later restarted under a different --agent-id is refused under that ID on
the same token.
2026-10-05 23:38:18 +01:00
rcourtman 25d299346d Keep deployed agents' command channel across restarts (#2542)
## Problem

Agents installed through the one-step cluster deploy lose their command
channel for good after their first restart.

`POST /api/agents/agent/enroll` bound the runtime token to an invented
`agent-<hostname>` and returned that as the agent's ID. The agent used
it until Pulse acknowledged its first report with the host's real,
machine-derived ID, which the agent persists. On the next start the
agent registered under that real ID. The token was by then a version-2
binding to `agent-<hostname>`, so admission refused it with "Agent token
mismatch: token is not bound to the registering agent", every few
minutes, indefinitely.

Seen live on a v6.5.0-rc.1 server with a deployed Proxmox node: reports
flowed, but the node could run no commands or actions.

## Fix

- **Enrollment leaves identity to the agent.** The runtime token is
bound to the node's hostname and the current binding version, marked
`deploy_identity_binding=agent`, and the response no longer carries
`agentId`. The first command registration backfills the agent's own ID
through the existing hostname-bound backfill path, and the config gate
takes the same decision.
- **One-time repair for tokens already issued.** A token enrollment
bound to `agent-<hostname>` (it has `deploy_job_id` and no identity
marker) may move once to the registering agent's ID:
- The hostname must match exactly (case-insensitive), not by short-name
equivalence.
- The move persists `deploy_identity_binding=repaired` in the same save,
and a failed save restores the previous metadata.
  - It applies whatever the token's binding version.
- Any other identity or hostname is refused rather than handed to the
legacy hostname migration.
- A marked token never moves again, including after a hostname rebind
recreates the `agent-<hostname>` shape.

## Tests

-
**`TestHandleEnroll_RuntimeTokenBindsTheAgentsOwnIdentityOnFirstCommandRegistration`**
replays the field sequence: the identity enrollment handed out, then the
acknowledged identity after a restart. It fails on the old code at the
"registration after restart" step.
- **`TestDeployPlaceholderTokenMovesToTheRegisteringAgentOnce`** covers:
  - the repair on the exact hostname only;
  - the persisted marker after a reload;
  - the repair, rebind to host b, repair-again attack;
  - config-gate parity.
- **`TestVersionlessDeployPlaceholderCannotBeMovedTwice`** covers the
versionless variant of that attack.
- **`TestCurrentDeployTokenNeverMovesOnceBound`** checks that a token
issued by current enrollment never moves once bound.
- **Policy unit tests:**
`TestEvaluateRepairsDeployPlaceholderBindingOnce`,
`TestEvaluateVersionlessDeployPlaceholderOnlyMovesThroughTheRepair` and
`TestEvaluateCurrentDeployTokenBackfillsTheAgentIdentity`.
- **`TestContractDeployEnrollResponseCarriesNoAgentIdentity`** pins the
enroll response keys.
- **Mutation checks:** removing the marker check, loosening the hostname
match, not persisting the marker, or letting placeholders fall through
to the legacy branch each fails at least one of these tests.

## Not in this change

Both of these exist for every install path and are not made worse here:

- The first command registration binds whatever ID the agent presents.
If that differs from the identity Pulse later resolves for its reports
(a configured agent ID, or a host continuity fork), the next restart
still mismatches.
- Tokens that never connect the command channel keep an empty
`bound_agent_id`, so collector-authority reduction cannot match them.
Before this change they had a non-empty but wrong `agent-<hostname>`,
which failed the same way.
2026-10-05 23:33:44 +01:00
rcourtman ca381121df Keep agent-reported disks out of cluster member state (#2543)
An agent on a Proxmox cluster node reports SMART data for the node's
disks, and the registry stamps each disk with its node's Proxmox
identity so it stays discoverable in the Proxmox workspace. The
`/api/connections` member projection accepted any resource carrying that
identity, so each disk merged into its node's member row. Its health
became the node's state and its model name became a host alias.

A disk in warning (60C or more, wear, errors) made an actively reporting
node show Stale; a disk with unknown health made it show Pending. Since
6.4.5 merges members by freshness, the row flips whenever the agent
report and the Proxmox poll swap places, several times a minute on a
customer's three-node 6.4.5 cluster. Standalone nodes are unaffected
because only cluster nodes carry the cluster-member flag.

Only node resources now compose cluster members. The disk keeps its
ownership facet, risk and storage evidence.

`TestContract_ConnectionSystemMembersIgnoreOwnedPhysicalDisks` runs a
cluster node, its linked agent and a 63C NVMe disk through the real
registry. On current main it reproduces the customer's payload exactly
(aliases include `wd_black sn7100 4tb`, state `stale`); with the fix the
member stays active.

Refs #2062, which reports the same Pending/Stale symptom on a 3-node
cluster and was expected to be fixed by 54c3cdc061 in 6.4.5.
2026-10-05 23:14:31 +01:00
rcourtman 9c0b0262df Keep Actions target names whole on narrow cards (#2539)
## Problem

Each Actions card's second line shows the target name, its type and the
time. The name and type were sibling flex items that shrank together. So
on a phone the line read "analytics-ba… · Virtual ma…" and "backup-coor…
· App cont…", which hid which resource the action touches. That's what
the operator needs to see before opening the review. From 360 to 430px
every target name was cut; from 480px up everything fit.

## Change

In `src/pages/Actions.tsx`, the name and the type now sit in their own
inline flex row:

- **Name:** never shrinks and is capped at the row's width (`shrink-0
max-w-full truncate`).
- **Type:** wrapped together with its separator, takes whatever room is
left (`min-w-0 flex-1 truncate`).

The time keeps its place. A first attempt with a large shrink factor
still gave the name about 0.08px of the shortfall, which was enough to
draw an ellipsis at every pixel density. The two-level row hands the
name no shortfall at all.

## Verification

Mock-mode private stack on main 1dcf9418aa, three open demo actions,
before against after at 360, 390, 430, 480, 640, 768, 1024 and 1440px:

| Viewport | `analytics-batch-01` (main → after) | `backup-coordinator`
(main → after) |
|---|---|---|
| 360 | 61 → 107 of 107px | 67 → 112 of 112px |
| 390 | 77 → 107 | 84 → 112 |
| 430 | 98 → 107 | 107 → 112 |

- **Exact fit:** the name's box equals its text width at 1×, 2× and 3×
pixel ratio. The type with its separator gets the rest ("Virtual
machine" 21, 51 and 91px at 360, 390 and 430px; whole from 480px).
Nothing changes from 480px up.
- **Long names:** a name longer than the line, injected in the browser,
truncates inside it at 360 and 390px with the time fully visible.
- **Interactions:** the review opens from a card and closes with Escape
at 390 and 1024px, and the History tab works. No console errors.
- **Tests:** the new
`features/actions/__tests__/Actions.metaLine.test.tsx` pins the
structure and fails against main's source. 25 test files across actions,
pages, routing and the resource-actions API pass.

The browser receipt is
`frontend-modern/browser-verification/2294fd5a84fdc31a.json`.
2026-10-05 23:12:54 +01:00
rcourtman f7e61446bd Move three forms onto shared controls and draw the select arrow
The shared template audit listed Settings, Alerts and Discovery forms
still building their own controls. The Thresholds CPU averaging selects
and the Discovery availability proposal now compose FormSelect (the
proposal's name field takes the shared field classes beside them), and
an availability check's request body composes FormTextarea, which links
its help text and, with sm:min-h-24, finally gets the height its class
asked for on desktop.

FormSelect's default chrome set appearance-none and drew no arrow, so
every default select in Settings read as a text field. A
form-select-arrow utility draws the caret in the padding the class
already reserved, and hands the native arrow back in forced-colors mode.

Two registry entries were wrong: AlertEscalationSection has used
checkboxes instead of a select since its destinations became
per-escalation, and the inline detail row rule matched a test file;
test files are now excluded from it.
2026-10-05 23:01:46 +01:00
rcourtman 9794660138 Keep a disabled toggle's track visible and true to its state (#2535)
A disabled toggle set its track to `bg-base`, which isn't a theme
colour. The `base` colour alias was removed long ago because it collided
with the `text-base` font-size utility, so the track rendered
transparent. As a result, every disabled toggle showed only a faint
knob:

- Plan-locked settings such as Settings › Patrol › **Container update
risk** were nearly invisible.
- In read-only sessions, a setting that was on looked the same as one
that was off.

A disabled toggle now keeps its on or off track colour and dims to 50%.
The knob relies on the track's opacity instead of dimming a second time.

Verification:
- Measured live on Settings › Patrol, in light and dark mode at 1440px
and at 390px. With the settings response set to what a free-tier backend
returns, the track background was transparent on main; it is now the off
colour (slate-200 in light mode, slate-700 in dark) at 50%.
- With an "on" value, the same disabled toggle reads as a dimmed on
switch.
- Added a toggle test and a shared-primitive guardrail. The receipt is
included.

`bg-base` is used about 80 more times elsewhere and is a no-op in each
of those places too. That is a separate sweep.
2026-10-05 22:55:00 +01:00
rcourtman 06cdaa577a Send built-in webhook template headers on real alerts
A Telegram webhook passed Test while every real alert was rejected with
"message text is empty" (#2540). The Test path always overlaid the
service template's headers, but real deliveries sent the stored header
copy that the UI seeded at creation. An edit can leave that copy blank,
masked or replaced. Telegram reads arguments from a body only when it is
labelled as JSON or form data, and checks text before chat_id, so a JSON
body without a JSON Content-Type produces exactly that error.

When a built-in service renders its own body (no custom template), the
template's static headers describe that body. Apply them on every real
firing and recovery through prepareWebhookDeliveryContext, replace stored
values under any spelling of the name, and route the Test path through
the same helper so Test predicts real delivery. Custom templates and
generic webhooks keep their stored headers.
2026-10-05 22:46:47 +01:00
rcourtman f94b10b4dd Seed store-backed chart benchmarks once per run
Go calls a benchmark function again for every b.N it tries. Each of the
two store-backed chart benchmarks seeded 84,000 points into a fresh
SQLite store, about 0.6 s, to time a request of 0.2 ms or less, and did
so two or three times per sample. In CI they took 3.5 s and 3.6 s of
internal/api's 11.5 s per sample, for about 0.1 s of timed work each.

The timed loop now runs in a range=4h sub-benchmark. A benchmark with
sub-benchmarks runs once, so the parent seeds its store once per run
while the data, request and sanity checks stay the same, and bytes and
allocations per op are unchanged. Locally the pair falls from about 6 s
to 1-1.5 s. The names gain /range=4h, so the first paired comparison
reports them unmatched.
2026-10-05 22:45:56 +01:00
rcourtman b027bb0e8e Keep paired PR benchmarks inside their time budget
Since 15:00 UTC on 5 October every paired benchmark run that was not
superseded has hit its 25-minute step timeout. Each step runs ten base
and ten candidate samples, so per-sample growth is multiplied by twenty.

BenchmarkRegistryProjectionSnapshot (#2518) rebuilt a registry behind
StopTimer on every cold iteration. Go sizes b.N from timed work only, so
the empty-registry cases ran hundreds of thousands of untimed ingests
and took 16 s of a 100 ms benchtime. With #2486's new owner-index and
metadata benchmarks, internal/unifiedresources went from 8 s to 38 s per
sample. Cold now invalidates the registry's derived state instead. The
refresh never reads its own output, so this repeats a fresh ingest's
first read with the same allocations, and the family drops from 22 s to
about 5 s per sample.

Paired runs also measured every package even when both trees compiled
identical code, as on frontend-only changes. They now sample only
packages whose in-module dependency closure the change reaches. Module
files, dirty or non-Git trees and an unreadable graph still select
everything.

A module or shared-model change still samples nearly every package,
about 24 minutes with warm-up. The step therefore gets a measured 30
minutes, and the script stops at a 28-minute projected budget with
per-package costs instead of meeting a bare timeout.
2026-10-05 22:39:51 +01:00
rcourtman 4ce78685e8 Keep Pro runtime wording out of hidden-commercial sessions
Demo and white-label sessions hide commercial surfaces, but a licensed
install on the community runtime still saw "Pulse Pro runtime required"
on the Container update risk card and, inside the Patrol header's mode
disclosure, "Install the Pulse Pro runtime" beside a disabled paid-mode
picker. The shared availability presentation checked the runtime lock
before the hidden-commercial policy, and the Patrol header passed a
constant true for that policy, so the order could not be fixed without
breaking the header's runtime prompt for everyone else.

Hidden commercial surfaces now win over the runtime lock, and the Patrol
header and banners pass the session's real policy. Sessions that show
commercial surfaces keep the runtime prompt and download link unchanged.
2026-10-05 22:38:49 +01:00
rcourtman 16c81784b9 Keep agent-reported disks out of cluster member state
An agent on a Proxmox cluster node reports SMART data for the node's
disks, and the registry stamps each disk with its node's Proxmox
identity so it stays discoverable in the Proxmox workspace. The
/api/connections member projection accepted any resource carrying that
identity, so each disk merged into its node's member row, adding its
model name to the aliases and its health to the node state.

A disk with a risk warning (for example 60C or more) therefore made an
actively reporting node show Stale. Since members are merged by
freshness, the row flipped every time the agent report and the Proxmox
poll swapped places, several times a minute on a customer's three-node
cluster running 6.4.5.

Only node resources now compose cluster members. The disk keeps its
ownership facet, risk and storage evidence unchanged.
2026-10-05 22:27:03 +01:00
rcourtman d01ad47524 Keep deployed agents' command channel across restarts
Deploy enrollment bound the runtime token to an invented agent-<hostname>
and handed that ID to the agent. Once Pulse acknowledged the agent's reports
with its machine-derived ID, the next restart registered under that ID and
the command channel refused it for good.

Enrollment now binds the token to the node's hostname only and lets the first
command registration bind the agent's own ID. Tokens already issued with the
placeholder move once, on the exact same hostname, to the registering agent's
ID, and record that they were repaired so they never move again.
2026-10-05 22:23:09 +01:00
rcourtman 357b1a505e Explain why the Container update risk switch is locked
Patrol settings showed the Container update risk switch dimmed on
installs without ai_alerts and gave no reason, so a free install could
not tell a plan limit from a broken setting.

The card now renders the canonical Patrol availability presentation.
patrolAutonomyAvailability.ts keeps one precedence (runtime lock, then
hidden commercial surfaces, then plan lock) and the only action labels,
while Patrol mode and container update risk each supply their lock
state and copy. Links appear only when the session allows upgrade
prompts, and installs that hide commercial surfaces get a neutral line
with no plan or Pro wording. The explanation is tied to the switch with
aria-describedby, because the shared Toggle does not render its
locked/lockedMessage props.
2026-10-05 22:16:21 +01:00
rcourtman 4819afb082 Keep Actions target names whole on narrow cards
Each Actions card's second line shows the target name, its type and
the time. Name and type were sibling flex items that shrank together,
so on a phone the line read "analytics-ba... · Virtual ma..." and hid
which resource the action touches, which is what the operator needs
before opening the review. From 360 to 430px every target name was
cut.

The name and the type now sit in their own row where the name keeps
its width (capped at the line) and the type, with its separator,
takes whatever is left. Names read in full at every width from 360px
(the box now equals the text width at 1x, 2x and 3x pixel ratio), a
name longer than the line still truncates with the time visible, and
nothing changes from 480px up.
2026-10-05 22:08:34 +01:00
pulse-triage[bot] 38c981a397 Prepare v6.5.0
Promote v6.5.0-rc.1 to stable after its 24-hour soak. Only release metadata changes.

Contract-Neutral: Release-only v6.5.0 version, release notes and distribution metadata; no public install or runtime contract changes.
Change-source: pulse-maintainer
v6.5.0 helm-chart-6.5.0
2026-10-05 21:53:41 +01:00
rcourtman 531a34f5cb Keep a disabled toggle's track visible and true to its state
A disabled toggle switched its track to bg-base, which names no theme
colour (the base alias was removed because it collided with text-base),
so the track rendered transparent. Every disabled toggle showed only a
faint knob: a plan-locked setting such as Patrol's Container update risk
was nearly invisible, and for a read-only session a setting that was on
looked the same as one that was off.

A disabled toggle now keeps its on or off track colour and dims, and the
knob relies on the track's opacity instead of dimming again.
2026-10-05 21:48:58 +01:00
rcourtman 17633ecfee Show a Docker container's health check in its drawer
The containers table now says Unhealthy or Exited (139), but the row's
drawer still read "Observed state: Online" for a running container whose
health check was failing, "Offline" for a crashed one, never mentioned
the health check, and could show uptime a stopped container had left
behind.

For a Docker container the drawer's Runtime context now uses the same
presentation as the table: Observed state is Docker's own state
(Running, Exited (137)), the health check of a running container gets a
row of its own (Failing, Starting, Passing), and Uptime is the current
run, so a stopped container shows neither a stale health check nor
leftover uptime. Other resources are unchanged.
2026-10-05 21:23:26 +01:00
pulse-triage[bot] 1dcf9418aa Keep VM Discovery safe during backups and document precise Patrol rule removal (#2533)
## Outcome and need

Keep the canonical VM drawer from offering a manual Discovery rescan
while its own guest-read evidence reports a backup or deferral. The
Workloads drawer already applied this precaution, but the canonical
Manage → Access → Analysis path did not. Typed and retained legacy PVE
evidence now independently pause both Discovery mounts; a healthy
alternate facet cannot cancel a reported deferral. Saved results stay
readable, the backup-safety guidance stays visible across tabs, and
clearance does not automatically dispatch a scan. Ordinary agent
Discovery remains usable.

Also document how an authorised signed-in administrator can remove
exactly one mistakenly created manual Patrol rule through the existing
API. Discussion 2523 exposed a missing rule-management UI: Reopen undoes
a finding dismissal, not a separately created permanent rule. The guide
selects and confirms an exact manual ID, preserves session/CSRF and
organisation scope, stops on errors or uncertainty, and verifies
readback without requesting pasted credentials or deleting unrelated
rules/history. This is a workaround, not the missing UI repair or proof
of a reporter's deletion.

Related to #2439

Related to #2528

Related to https://github.com/rcourtman/Pulse/discussions/2523

## Included source

- Web candidate `20261005T154017Z-web-product`: three commits
`ee618fb99366bb76c798f8ada028de85079d4dfa` through
`8abe8d4744c486bdc7b78e3bb13c08c5fd8c725f`, over `90a7d2e0`.
- Product-intelligence candidate
`20261005T154825Z-product-intelligence`: three commits
`310c7e6e5195271efc8200aebb3f32feef687c2c` through
`b27b6b0836273fbe8ad6803cdd012a964560bc8c`, over `d916f416`.
- Reviewed integrations `377aea8ad55804e9f469bea05f89e1aa616ce6db` and
`53ec5fae93d7d65c9815ee09d868dc9c7fb5cd19` retain both exact candidates.
Additive upstream composition `bd95e64bf2fc24f3cabf0468784979d8da5ad636`
includes current upstream without source conflicts or rewriting accepted
commits.
- Proposed range:
`690d0ef31ee47c6a56009a8fd827e87451517721..bd95e64bf2fc24f3cabf0468784979d8da5ad636`.

## Validation and remaining limits

- Recorded Web proof `source-8ah5ki7c`: final implementation types,
lint, build, bundle budgets and full frontend suite pass (22,761 tests;
three skipped). Parent-bound desktop Chromium and narrow dark WebKit
controls cover pause/clearance, retained saved evidence and explicit
synthetic actions. The two committed browser receipts hash-match all
five final production files, including the final type-only correction.
- Recorded guidance proof `source-ni_ulklt`: eleven exact-snippet tests,
real frontend types/build/embed and focused authenticated API/store
races pass, with complete output and completed guest stop. Help, API
implementation, primary dependency manifests and relevant regression
tests remain unchanged. The guide's separate final browser admission was
busy before execution; no installed browser deletion is claimed.
- Publisher final-tree reconciliation proof: `source-q5pr3mwf` on the
exact proposed head passes types, five connected guest-safety/helper
test files (153 tests), eleven exact-snippet tests, the real embed build
and bundle budgets. Guest/client exit 0, complete 35,220-byte raw output
independently hash/count-matched, and completed guest stop. Readiness
connect/synchronisation errors preceded successful readiness; the
line-bound flag remains true.
- Publication provenance, clean-tree, whitespace and retained/upstream
ancestry checks pass. Sufficient full-suite and browser evidence was
reused rather than rerun just because the candidates share a batch.
- The pre-existing docs-policy literal assertion failure remains; its
affected text/test are unchanged. Earlier setup, type and
browser-orchestration failures remain recorded alongside their
corrections, not counted as passes.

Exact-head independent review and required GitHub checks still precede
landing. The Discovery UI precaution does not establish
all-path/shared/in-flight/restart-safe backend admission, independent
thaw, fresh writes on every covered filesystem, liveness or
prior-active-only resumption. Those native recovery obligations and the
monitoring-outage precaution remain. Publication establishes neither
installed acceptance nor release readiness, and the Patrol UI repair
remains separate.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
2026-10-05 18:24:21 +00:00
pulse-triage[bot] bd95e64bf2 Reconcile batch-start upstream with reviewed local source
Preserve the exact reviewed Patrol guidance and VM Discovery safety commits while incorporating upstream commits captured before coordination. No source conflict resolution or product changes are added by this integration.

Change-source: pulse-maintainer
2026-10-05 18:46:19 +01:00
rcourtman 690d0ef31e Give organization deletion time for a busy tenant loop to stop (#2529)
## Problem

244d322dce, merged at 15:01:08 UTC today in #2518, made organization
deletion refuse with `503 tenant_shutdown_incomplete`, and keep the
data, when the tenant's monitoring loop hasn't exited. That guard is
right, but it reused `tenantMonitorShutdownTimeout`. That 2-second
budget dates from March, when it was only a best-effort wait before
logging a warning and carrying on.

A loop that is still starting up or finishing a tick can take longer
than that to see its cancelled context. The observed case is mock mode,
which CI uses: `Monitor.Start` runs `startMockMetricsSampler`
synchronously before its select loop, and `prepareMockMetricsHistory`
seeds seven days of fixture history without a context. The loop only
sees cancellation once seeding finishes; "Mock metrics history sampler
started" and "monitoring loop stopped" log in the same second. E2E run
[37329726585](https://github.com/rcourtman/Pulse/actions/runs/37329726585/job/111833069435),
shard 7, shows it:

| Time (UTC) | Event |
|---|---|
| 15:27:57 | Organization A's monitor initializes |
| 15:28:00 | Delete requested |
| 15:28:02 | `timed out waiting for tenant monitor loop to exit` → 503 |
| 15:28:10 | `monitoring loop stopped` |
| 15:28:20 → 15:28:29 | The retry, with a new organization, repeats the
same sequence |

So `13-api-token-scope` "org-bound token stays inside the issuing org"
failed its org cleanup on both attempts. Run 37331626618 failed the same
way: the first Core E2E failure came two minutes after #2518 merged. A
real delete shortly after a tenant was busy gets the same refusal, with
its alarming "data has been retained" message, until someone retries.

## Change

- **New budget:** `removeTenant` waits on
`tenantDeletionShutdownTimeout` (30s) when deleting. Process `Stop` and
`RemoveTenant` keep the 2-second `tenantMonitorShutdownTimeout`.
- **Still refused:** a loop that never exits, and a failed metadata
close, both still refuse and retain the data. The guard's semantics
don't change.
- **Tests:** both budgets are now variables so tests can shorten them.
`TestTenantDeletionRetainsUnfinishedMonitorLoop` uses a short deletion
budget instead of the real 30 seconds.
- **No timeout conflicts:** the server's `WriteTimeout` is disabled, the
frontend's org delete has no request timeout, and Playwright's request
timeout is 30s.

## Verification

- **New test:** `TestTenantDeletionWaitsForSlowMonitoringLoop` (in the
registered `monitor_additional_test.go`) uses a loop that exits after
300ms, with a 50ms plain budget and a 2s deletion budget. Plain removal
gives up and retains the monitor; deletion waits and completes. Giving
deletion the plain budget makes it fail, so it checks the behaviour
itself.
- **Existing tests:** the deletion safety tests in `internal/monitoring`
(`TestTenantDeletion*`, `TestMultiTenantMonitor*`) and the org-delete
handler tests in `internal/api` all pass, including
`TestOrgDeleteRetainsDataWhenGuestMetadataPersistenceFails`.

**Follow-up in a separate PR:** another session is making the mock
history seeding honour cancellation
(`internal/monitoring/mock_metrics_history.go`), so CI deletions finish
almost at once instead of waiting out the seeding. This PR keeps a
realistic deletion budget either way.
2026-10-05 18:33:36 +01:00
rcourtman 9f3874dccd Stop mock metrics seeding when its monitor is cancelled (#2532)
In mock mode, a tenant monitor's loop only sees its cancelled context
after `startMockMetricsSampler` returns. Seeding the 50-node demo estate
takes 4-11s on CI runners. Organization deletion cancels the loop and
waits for it, so every deletion of a just-used organization was held for
the whole seed. Under the 2s budget from #2518 it was refused with
`tenant_shutdown_incomplete`, which is the intermittent Core E2E failure
in `13-api-token-scope.spec.ts:129` (runs 37329726585 and 37331626618).
In the server logs, "monitoring loop stopped" lands in the same second
as "Mock metrics history sampler started", 5-10s after the deletion's
cancel.

- `seedMockMetricsHistory` checks the context before each resource and
returns `ctx.Err()` once cancelled. Series already sampled are recorded
whole.
- `prepareMockMetricsHistory` never stores a cancelled seed as the
shared template, and returns at once if its monitor was cancelled while
it waited on the template lock.
- `startMockMetricsSampler` skips the chart-cache warm-up and the
sampler goroutine when seeding was cancelled.

Measured on the full demo estate: cancel-to-return went from the whole
seed (about 2.7s on an M-series Mac) to 0.6ms.

#2529 separately gives deletion a longer bounded wait for any slow loop.
This PR removes the mock-mode cause of the wait. They touch different
files and land independently.

New tests in `mock_metrics_history_cancel_test.go` use a context that
cancels after a set number of checks, so they don't depend on timing.
All three fail with the cancellation checks removed.
2026-10-05 18:27:14 +01:00
pulse-triage[bot] 53ec5fae93 Integrate reviewed safe Patrol rule-removal guidance
Preserve the exact documentation and regression-test candidate; native browser follow-through and the missing UI remain owned separately.

Change-source: pulse-maintainer
2026-10-05 18:26:38 +01:00
pulse-triage[bot] 377aea8ad5 Integrate reviewed canonical VM Discovery safety repair
Preserve the exact Web candidate and parent-bound browser proof; backend admission and native recovery remain separate work.

Change-source: pulse-maintainer
2026-10-05 18:26:25 +01:00
rcourtman ac643e2bde Flag an outdated agent once on the Infrastructure page (#2531)
Each outdated Pulse Agent on Settings › Infrastructure was flagged
twice: an amber **Agent update** badge, plus a second-line **Version
behind** note. The band also counted every outdated agent as needing
attention. On a mock estate it read "11 systems needs attention", and
all 11 were version lag.

- **Version lag is maintenance.** Version behind and Update available
are now maintenance signals. The badge, which opens Agent Doctor,
carries them, so they never become the row's problem line and don't
count toward needs attention. The Manage drawer still lists every fleet
signal.
- **Real update problems still show.** A failed update and auto-update
switched off remain row problems, since each needs a different action.
- **Problems can't hide behind the cap.** A row's problem is now picked
from all its visible signals before the three-highlight cap, and
problems rank ahead of maintenance. A second attached agent's failed
update can no longer fall behind another agent's version lag.
- **The band** says how many agent updates are available, in a neutral
tone. "Systems need" and "hosts have" now agree in number.
- **Column widths.** The Actions column is wide only when a row shows an
install button beside Manage, and Health gets 27% of the table. The
Health cell now keeps the status, the badge and the last-seen time on
one line. On main, the last-seen time was cut to "18s a…".

Verification:
- At 1280, 1440 and 1920px the table has 16 rows and no clipped cells.
Main has 27 rows (including 11 problem lines), with 11 and 6 clipped
cells at 1280 and 1440px.
- The phone card layout shows no Version behind lines.
- Re-measured after rebasing onto the shared Table change in #2524. The
receipt is included.
2026-10-05 18:23:21 +01:00
rcourtman 67db9b6daa Give Patrol autopilot tests their own session store (#2530)
TestPatrolAutopilotStoreUnavailableDoesNotChangeModeOrFabricateEvidence
built its request through patrolAutopilotSessionRequest, which writes a
session into the process-global store, but never initialized one. Run
alone it panicked in GetSessionStore; in the package run it passed only
because an earlier test left a store behind.

The rest of the file got its store from newPatrolAutopilotTestHandler,
which called InitSessionStore on a t.TempDir() and never cleared it, so
these tests were also the ones leaving a store pointed at a deleted
directory for whatever ran next.

Both now go through initPatrolAutopilotSessionStore, which follows the
package's existing resetSessionStoreForTests/t.Cleanup pattern: start
from no store, initialize one on a temp dir, and clear it when the test
ends. Every test in the file passes when run alone.
2026-10-05 18:10:34 +01:00
pulse-triage[bot] 8abe8d4744 Record final guest-read safety browser proof
Fresh desktop and narrow-screen checks verify the corrected shared input against this commit parent. This proof-only commit changes no production frontend content.

Change-source: pulse-maintainer
2026-10-05 18:00:08 +01:00
rcourtman d086054b7e Stop mock metrics seeding when its monitor is cancelled
A tenant monitor's loop only sees its cancelled context after
startMockMetricsSampler returns, and seeding the 50-node demo estate
took 4-11s on CI runners (about 2.7s on an M-series Mac). Organization
deletion cancels the loop and waits for it, so in mock mode every
deletion of a just-used organization was held for the whole seed, and
under the 2s budget #2518 introduced it was refused with
tenant_shutdown_incomplete (Core E2E 13-api-token-scope since 15:03 UTC
on 2026-10-05).

Seeding now checks the context before each resource and returns
ctx.Err() once cancelled, so the loop exits within a millisecond of the
cancel. Series already sampled are recorded whole. A cancelled seed is
never stored as the shared template other tenants reuse, and a monitor
cancelled while it waited on the template lock returns without seeding.
startMockMetricsSampler then skips the chart-cache warm-up and the
sampler goroutine.

#2529 separately gives deletion a longer bounded wait for any slow
loop; this removes the mock-mode cause of the wait.

Contract-Neutral: Mock-mode seeding stops early on monitor cancellation; no route, field, schema, persistence format or provider behaviour changes.
2026-10-05 17:53:17 +01:00
pulse-triage[bot] f434e55be0 Reduce temporary memory churn in complete monitoring updates (#2527)
## What changes and why

Reduce temporary allocation churn while building complete monitoring
broadcasts, without keeping another fleet-sized cache. Read-only
conversion helpers borrow their own local resource, absent facets avoid
discarded `null` encoding while preserving custom marshalers, and common
canonical aliases use bounded scratch storage with a larger-vocabulary
fallback. Payload fields, identity/tenant ownership, freshness, alerts
and History coordinates remain unchanged.

Related to #2199. This is a bounded allocation improvement, not a claim
of resolved fleet CPU or RSS. The recorded indicative samples show fewer
allocations but mixed timing results, including clean-frame regressions.
Previous loaded-startup/API failures, warm-frame costs and the failed
stable RSS observation remain unresolved.

## Included source

- Complete unpublished range:
`d836208f16b60300eb0ab246cce66125efc1025d..921f82575f31102dd3bdda558625fdee49884101`.
- Reviewed Core candidate `3c07800709834b6c27299808ec053f8a0ec1c543`:
five additive commits beginning at
`eb26fbba050e17f0c5a2bef0b45306303343da98`, including
custom-text-marshaler coverage, alias allocation controls and preserved
shared-contract assertions.
- Accepted integration commits `e52cf4be50`, `90a7d2e0ad`, `d916f41631`
remain intact. Additive merge `921f82575f` incorporates the exact
published upstream enrollment and image-name repairs at `d836208f16`; no
reviewed commit was rewritten.

## Validation

Recorded candidate evidence is reused where the final affected source is
byte-identical: `source-_55vdw5v` for affected suites/races, actual
frontend type/build/embed, vet and server/agent builds;
`source-ggkm104v` for twelve same-generation parent/current
byte-identical complete frames. Earlier failed aggregates and
unequal-clock comparisons remain failed evidence, not performance proof.

Final combined-source proof and provenance results are recorded below.
No new frontend delta relative to current main requires another browser
run. Upstream #2521's completed E2E run `37329726585` failed on
tenant-monitor shutdown during organization cleanup; advisory benchmark
run `37329726359` hit its 25-minute paired-step deadline. These are
retained adverse observations, not diagnosed noise or passing checks.
Required exact-head CI and independent review remain necessary before
landing; publication is not release or installed-user acceptance.

Final `source-dx8sj4u6` at `921f82575f31102dd3bdda558625fdee49884101`
passes full agent/hostagent/remoteconfig tests, focused
broadcast/conversion/canonical-alias races, five-package vet and a real
agent build with Go 1.26.8. Receipt binds matching prepared
dependencies, complete 1,172-byte output/hash and completed guest stop.
Provenance and clean/additive ancestry checks pass.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
2026-10-05 16:50:39 +00:00
rcourtman 574b4dd350 Name mock history seeding as the observed slow tenant stop
The deletion-budget note and its test said only that a loop still
starting up can be slow to stop. The CI failures have a specific
cause: in mock mode Monitor.Start runs startMockMetricsSampler
synchronously before its select loop, and prepareMockMetricsHistory
seeds seven days of fixture history without a context, so the loop
saw its cancelled context only once seeding finished ("Mock metrics
history sampler started" and "monitoring loop stopped" log in the same
second in runs 37329726585 and 37331626618). Say so in the contract
and the test comment.
2026-10-05 17:48:05 +01:00
pulse-triage[bot] ce2111c089 Keep shared guest-read evidence optional across workload types
Correct the union input identified by source-9p3hq3rc without changing guest-read decisions. Keep required resource identity fields explicit in the new test fixtures.

Contract-Neutral: performance-and-scalability; type-only optional guest-read input correction and typed fixture fields, with unchanged runtime decisions, metrics and performance contract.
Change-source: pulse-maintainer
2026-10-05 17:41:55 +01:00
rcourtman 065cd2bf37 Show Docker container health and uptime in the containers table (#2526)
The Docker containers table now shows whether each container is healthy
and how long it has been running.

- **State names the problem.** It shows Unhealthy (red) or Starting
(amber) for a running container's health check, Exited (139) (red) for a
crash, Restarting, and a muted Exited or Paused for a container stopped
on purpose, with the full sentence on hover. Before, an unhealthy
container read `running` and only its dot was red. The column also
disappeared whenever every container was running, so a fleet with one
failing health check showed no State column at all. The column now
appears whenever any row has one of these states.
- **Stale health is ignored.** Docker keeps the last health status on a
stopped container (#1724), which made a stopped container's dot read
unhealthy. Health now counts only for a running container. A container
with no reported Docker state is treated as running when its status is
online.
- **Uptime column.** From a 900px table, an Uptime column shows the
current run, so `0m` or `5m` marks a container that just restarted. v5
had this column. Stopped containers show a dash, and narrower rows keep
uptime in the drawer.
- **Room for the new columns:**
- A single-host table, which is always flat, drops the Host column that
repeated one name on every row.
- The Ports cell drops the all-interfaces address and the duplicate IPv6
entry, so it reads `23210->8080/tcp`. A specific bind address such as
`127.0.0.1` stays, and the hover text, drawer and search keep the full
form.
  - Column weights moved from CPU, memory, ports and networks.
- New model tests hold the State words (86px) and the Uptime header
(57px) at each layout's narrowest table, alongside the existing
Update-control width test.

Verification:
- Measured every column at nine widths from 390px to 1920px against
main, which now includes #2521. Nothing new clips except one more
truncated container name at 1000px. Ports overflow per cell drops from
175px to 123px at 1440px. "Exited (139)" fits on a 390px phone.
- Checked unhealthy, starting and stale-health states, the single-host
view, dark mode and the phone drawer, using edited API data. The receipt
is included.
2026-10-05 17:37:27 +01:00
rcourtman 74a195daaf Flag an outdated agent once on the Infrastructure page
Every outdated Pulse Agent row showed an amber Agent update badge and,
on a second line, Version behind, and the band counted each of them as
a system that needs attention ("11 systems needs attention"), although
an agent waiting for an update is maintenance rather than an incident.

Version behind and Update available are now maintenance signals: the
badge, which opens Agent Doctor, carries them, so they never become the
row's problem line and do not count toward needs attention. The band
says how many agent updates are available in a neutral tone instead. A
failed update and auto-update switched off stay problems, since each
needs a different action, and a row's problem is picked before the
three-highlight cap so a second agent's failure cannot hide behind
another agent's version lag.

The band's counts now agree in number, and the Actions column is wide
only when a row shows an install button beside Manage, so the Health
cell keeps the badge and the last-seen time on one line.
2026-10-05 17:35:39 +01:00
rcourtman 98ed022428 Give Patrol autopilot tests their own session store
TestPatrolAutopilotStoreUnavailableDoesNotChangeModeOrFabricateEvidence
built its request through patrolAutopilotSessionRequest, which writes a
session into the process-global store, but never initialized one. Run
alone it panicked in GetSessionStore; in the package run it passed only
because an earlier test left a store behind.

The rest of the file got its store from newPatrolAutopilotTestHandler,
which called InitSessionStore on a t.TempDir() and never cleared it, so
these tests were also the ones leaving a store pointed at a deleted
directory for whatever ran next.

Both now go through initPatrolAutopilotSessionStore, which follows the
package's existing resetSessionStoreForTests/t.Cleanup pattern: start
from no store, initialize one on a temp dir, and clear it when the test
ends. Every test in the file passes when run alone.
2026-10-05 17:25:52 +01:00
pulse-triage[bot] b27b6b0836 Keep rule-help browser fixtures on their intended request paths
Serve a harmless 404 for browser favicon requests and bind synthetic session requests to their recorded user agent. Retain the earlier browser setup failure.

Contract-Neutral: Synthetic proof-fixture preparation only; no runtime or rule-removal instructions changed.
Change-source: pulse-maintainer
2026-10-05 17:24:27 +01:00
rcourtman de84b0e311 Merge remote-tracking branch 'origin/main' into fix/tenant-deletion-shutdown-wait 2026-10-05 17:21:05 +01:00
rcourtman 2218ab6c18 Keep the Proxmox guest Backup badge whole at laptop widths (#2525)
## Problem

The Proxmox guest table's Backup badge doesn't truncate, but its column
was too narrow for it.

- **Badge widths, with the shield:** Running 73px, Unknown 79px, None
58px, month ages ("12mo") 61px.
- **Column room:** the compact layout gave Backup a weight of 5, which
left a 33–58px content box from 1000 to 1513px. The wide layout gave it
72px.
- **Result:** Running and Unknown were cut inside their pill at every
width up to 1600px, and None and month ages up to about 1500px.

Running is the badge that says a backup is in progress.

## Change

In `guestRowModel.tsx`:

| Layout | Backup | Taken from | Why that column can give |
|---|---|---|---|
| Compact | weight 5 → 10 | Type 7 → 5.4 | It still fits the VM/LXC
badge and the sorted "TYPE▲" header (about 51px). |
| Compact | | CPU 13 → 9.6 | Its bar only shows a percentage and core
count. |
| Wide | 72px → 96px | CPU 140px → 116px | Same reason. |

Name, ID, Uptime, Net I/O, Disk I/O and the memory and disk bars keep
their widths, and their used/total figures. Views without a Backup
column (pods, Docker) give them slightly more, so two
`GuestRow.test.tsx` width expectations change. The peer whose claim
covers that file agreed to the edit; its own PR, #2524, edits a distant
hunk.

## Verification

Mock-mode private stack on main ebd6d09337, before against after, at 15
widths from 390 to 1920px:

- **Badge fit:** every Backup label fits at every width where the column
shows (1000–1920px). Main clipped Running and Unknown everywhere up to
1600px.
- **Clip sweep:** Proxmox goes from 464 to 452 clipped elements and
vSphere from 2 to 1. No column clips more at any width, and the memory
and disk used/total figures and the CPU core counts are unchanged in
every row.
- **Interactions:** clicking the Running badge opens the backup evidence
dialog at 1024, 1280 and 1600px, and Escape closes it. Row details open.
No console errors.
- **Tests:** a new check in
`WorkloadsSurface.performance.contract.test.tsx` covers the narrowest
compact table and the wide width, and fails against main's source. All
94 Workloads test files pass.

The browser receipt is
`frontend-modern/browser-verification/0c24a36bf1559a9d.json`.

**Known residual:** if a user enables an extra compact column, Backup's
share shrinks, and Unknown can clip again at the narrowest compact
width.
2026-10-05 17:19:31 +01:00
rcourtman 51dbf1485a Keep a text-selection drag from toggling table rows (#2524)
Dragging across a pod or container name to copy it ends with a click on
the row the drag stayed inside. Every platform table ran its row action
on that click, so the detail drawer opened or closed and shifted the
layout under the selection.

**Change**

- `TableRow` (`components/shared/Table.tsx`) owns a per-row
`createRowTextSelectionGuard()`. It records where the row was pressed. A
click whose pointer moved more than 3px since that press, while text
inside the row is selected, skips the row action.
- A press released in place still runs the action on the first click,
even over text that is already selected. Chromium clears that selection
only after the click, so a plain selection check alone would need a
second click.
- The 37 `drawer.toggle` tables, the Proxmox tables built on
`getPlatformResourceDetailRowInteractionProps`, and `PulseDataGrid`
inherit the guard with no call-site changes. The custom `GuestRow`
(Workloads) and `StoragePoolRow` shells wire the same guard.
- The disclosure chevron and its keyboard activation handle their own
events and are unchanged. A double-click is two stationary clicks, so it
behaves as before.

**Verification**

- New cases in `Table.test.tsx`, `PulseDataGrid.test.tsx`,
`GuestRow.test.tsx`, `Storage.test.tsx` and
`PlatformResourceDetailTableRow.test.tsx` cover drags, in-place clicks
over a selection, selections outside the row, bound handlers, and the
chevron plus Enter while text stays selected. All six fail with the
guard disabled.
- `src/features src/hooks src/__tests__
src/components/{shared,Workloads,Storage,Infrastructure}`: 580 files and
10,012 tests pass.
- Ran Playwright with real mouse drags on a private mock stack at
1440x900 and 768x1024 across Kubernetes pods, Docker containers, Proxmox
hosts, guests and storage pools, plus 390x844 touch taps.
- The drag reproduces the toggle on the parent commit and leaves state
unchanged on this branch.
  - A plain click on the selected name opens the row first time.
- Receipt: `frontend-modern/browser-verification/4f08442c2617c48d.json`.
2026-10-05 17:19:04 +01:00
rcourtman 94e10d65d8 Give organization deletion time for a busy tenant loop to stop
244d322dce made organization deletion refuse, and keep the data, when
the tenant's monitoring loop has not exited. That is the right guard,
but it reused tenantMonitorShutdownTimeout, a two-second budget that
had only ever been a best-effort wait before logging and carrying on.
A loop that is still starting up or finishing a tick can take longer
to see its cancelled context. In E2E run 37329726585 a just-used
organization's loop took 8-10 seconds, so both attempts of
13-api-token-scope's org cleanup got 503 tenant_shutdown_incomplete.
Any real delete shortly after a tenant was busy hits the same refusal
until a retry.

Deletion now waits on its own tenantDeletionShutdownTimeout (30s)
before it refuses. Process shutdown and RemoveTenant keep the
two-second budget, and a loop that never exits still refuses and
retains the data. Both budgets are variables so tests can shorten
them; the never-stopping-loop test no longer waits on the real budget.
2026-10-05 17:17:29 +01:00
pulse-triage[bot] ee618fb993 Guard canonical VM Discovery during guest-read deferrals
Use the existing guest safety signals in the canonical resource drawer, including typed and legacy PVE facets. Keep saved Discovery evidence readable and the backup precaution visible without admitting a manual rescan while paused.

Contract-Neutral: performance-and-scalability; the workload helper input is narrowed without changing its decisions, metrics, render path or performance contract.
Change-source: pulse-maintainer
2026-10-05 17:16:03 +01:00
pulse-triage[bot] becd63364a Bind rule-help browser controls before releasing the prompt
Await the organisation-cookie change before accepting the synthetic selection prompt and keep generated proof artefacts outside source admission.

Contract-Neutral: Proof orchestration only; no runtime or documented rule-removal behaviour changes.
Change-source: pulse-maintainer
2026-10-05 17:15:54 +01:00
pulse-triage[bot] 310c7e6e51 Document safe removal of mistakenly created Patrol rules
Explain the missing v6.4.5 undo control and the existing scoped API. Verify exact selection, confirmation, browser session and CSRF, failure handling and readback without copying credentials or deleting unrelated rules.

Contract-Neutral: Documentation and regression-proof coverage of existing Patrol suppression APIs only; no runtime, route, authority or subsystem contract changes.
Change-source: pulse-maintainer
2026-10-05 17:14:39 +01:00
pulse-triage[bot] 921f82575f Incorporate current upstream enrollment and image-name repairs
Preserve all reviewed allocation-maintenance commits and accepted upstream history additively.

Change-source: pulse-maintainer
2026-10-05 17:09:35 +01:00