## 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)
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
## 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.
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.
Preserve the exact documentation and regression-test candidate; native browser follow-through and the missing UI remain owned separately.
Change-source: pulse-maintainer
Preserve the exact Web candidate and parent-bound browser proof; backend admission and native recovery remain separate work.
Change-source: pulse-maintainer
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.
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.
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
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.
## 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)
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.
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
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.
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.
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.
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
## 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.
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`.
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.
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
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
## Problem
An agent installed by the cluster deploy flow starts with `--enroll` and
a one-time bootstrap token. Only the host module exchanged that token
for the runtime token. Everything else `main.go` builds at startup kept
the bootstrap token for the whole first process lifetime:
- the remote config client (initial fetch and the one-minute refresh)
- the updater
- the Docker / Podman and Kubernetes modules and their primary report
targets
The bootstrap token carries only the enrollment scope and the server
removes it on exchange, so each of those failed with 401 until the
service restarted. On a freshly deployed Proxmox node this showed up as
a remote config 401 every minute. Those modules also kept the
pre-enrollment agent ID instead of the server-assigned one.
## Fix
`main.go` now enrolls once, before it builds any authenticated client:
- `hostagent.Enroll` is a standalone entry point over the existing
enrollment loop. Payload, headers, retry and backoff, status handling,
and `runtime.token` / `agent-id` persistence are unchanged.
- It runs after self-test and agent-ID preparation and before the first
remote config fetch. Every module is then built from the runtime token
and the canonical agent ID.
- The ID is persisted to the configured agent-ID file, and the host
module's own enrollment is switched off so there is no second exchange.
- A persisted runtime token is reused without a request or host
discovery. A `403` keeps the configured token as a non-bootstrap
credential. A permanent rejection stops startup before any module is
built.
- Windows is gated off and keeps its previous behaviour. Startup there
runs before the service control dispatcher, and an enrollment retry
could outlast its 30-second deadline. No Windows installer passes
`--enroll`.
- Enrollment and reporting now derive hostname, OS and architecture
through one `resolveHostIdentity`, so the runtime token is bound to the
hostname the agent reports under.
## Tests
- `TestRunEnrollsBeforeBuildingAuthenticatedModules`: enrollment happens
before any request to Pulse. Remote config, the updater, and the host,
Docker and Kubernetes configs and their targets all carry the runtime
token and canonical ID, and the agent-ID file is written. With the new
step disabled, this test fails.
- `TestRunStopsBeforeModulesWhenEnrollmentIsRejected`,
`TestRunWithoutEnrollDoesNotExchangeTokens` and
`TestRunLeavesEnrollmentToTheHostModuleWhenGatedOff`.
- `TestEnroll*` cases for `Enroll`: payload and identity, the
monitoring-only command ceiling, reuse of a persisted token (including
when host discovery fails), the 403 keep, the 409 rejection, and a
missing token.
- `TestEnrollPresentsTheIdentityNewReports` checks that enrollment
presents the hostname, OS and architecture that `New` reports, with
Proxmox detection and the architecture fallback.
- `go test ./cmd/pulse-agent ./internal/hostagent
./internal/remoteconfig` passes. `go vet` is clean for linux, windows
and freebsd.
## Not in this change
- The health listener still starts after enrollment, so `/readyz` is
unreachable while enrollment retries on network errors. The installer
only warns in that case. With the health listener enabled, its
registration check now always sees the runtime token. With it disabled,
the process-only check can still race enrollment.
- Distinguishing a deploy binding mismatch from a non-bootstrap token,
which both return 403.
- Two older durability gaps that this change doesn't make worse. If
writing a custom `--agent-id-file` fails, the next start has no fallback
to the canonical ID saved in the state directory. If persisting
`runtime.token` fails, enrollment still succeeds, and the next restart
is left with the spent bootstrap token.
The State column printed Docker's raw state, so a running container
whose health check failed read "running" and only its dot was red, and
the column hid entirely when every container was running, unhealthy or
not. v5 showed "running (healthy)" and an Uptime column; v6 had lost
both.
State now says what docker ps would flag, from the same verdict as the
row dot: Unhealthy and Starting for a running container's health check,
Exited (139) for a crash, Restarting, and muted Exited or Paused for a
container stopped on purpose, with the full sentence on hover. The
column appears whenever a row has one of those states. A health status
only counts for a running container: Docker keeps the last one on a
stopped container (#1724), which made a stopped container's dot read
unhealthy.
From a 900px table an Uptime column shows the current run, which marks
a container that just restarted; narrower rows keep it in the drawer.
A single-host table drops the Host column that repeated one name on
every row, and the Ports cell drops the all-interfaces address and the
duplicate IPv6 entry, so the widths for State and Uptime come from
columns that had room or truncated anyway.
## Problem
Image references that share a registry and namespace differ only at the
end, but table cells truncated them from the end:
- **Images (name column):** at 390 and 700px, three rows read
`ghcr.io/pulse-demo…`. From 390 to 900px the repository name was hidden
in 3–4 of 8 rows.
- **Swarm services (Image column):** from 390 to 700px, two of three
rows read `registry.…`.
- **Containers (Image column):** the repository name was hidden in up to
7 of 12 rows at 1024px, and in 3 rows even at 1440px.
So an update badge, a size or a service couldn't be traced to its image
without opening the row. Real fleets are worse: every
`lscr.io/linuxserver/*` image shares a 20-character prefix.
## Change
- **`splitDockerImageReference`** (in `dockerImageReference.ts`) splits
a reference just before its repository name. A digest never moves the
split, and a reference without a path stays whole.
- **`DockerImageReferenceText`** mirrors `KubernetesNameText`: the head
truncates, `name:tag` stays visible, and the full reference stays on the
title.
- **Where it's used:** the Images name cell (via `DockerResourceNameCell
imageReference`), the Containers Image column and the Services Image
column.
- **Copying:** head and tail are flex items, and a browser copy would
join them with a line break. So a selection inside one reference copies
without it; wider selections are left to the browser.
- **Unchanged:** sorting, search and the stored value.
This is a readability fix to existing columns, in the same class as
#2509, #2514 and #2516, so there is no demand-ledger entry.
## Verification
I ran a mock-mode private stack on main 800329cbd8, comparing before and
after at 12–18 widths from 390 to 1600px.
- **Visible text** (each character checked against its clipping box):
- Rows that read identically fall to 0 on every table at every width.
- Rows hiding the repository name fall to 0–1, and to 0 from 900px on
Images.
- **Clip sweep:**
- No column other than Image changes, and no metric sublabel is lost.
- Image cells with clipped text are never more numerous than on main at
any width, and fewer at most widths.
- Element counts rise only because a truncated reference now has two
clipping spans; the head truncates by design.
- **Clipboard:** a keyboard copy of one reference reads back
`ghcr.io/pulse-demo/backup-coordinator:2026.04` from
`navigator.clipboard`.
- **Interactions:** sort by Image, and row expand and collapse, work on
all three tables. No console errors.
- **Tests:**
- New tests: the split cases, a rendered Images-table test covering the
tail, the title and the copy scoping, and a source guardrail for the
three image cells.
- The new and updated tests fail against main's source.
- 544 test files pass across `src/features`, `src/hooks` and
`src/components/{shared,Workloads,Infrastructure}`.
The browser receipt is
`frontend-modern/browser-verification/a68fe9ea6a927e83.json`.
**Known residual:** when the head truncates completely there's no
leading ellipsis (as with `KubernetesNameText`). When it truncates
partly, the gap after its ellipsis can be up to one character wide.
The guests table's Backup badge does not truncate, but the column was
too narrow for it. Running is 73px with its shield and Unknown 79px.
The compact layout gave Backup a weight of 5 (a 33-58px content box
from 1000 to 1513px) and the wide layout 72px, so Running was cut
inside its 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
underway.
The compact weights now give Backup 10, taken from Type (5.4, still
room for the VM/LXC badge and the sorted header) and CPU (9.6, whose
bar shows only a percentage and core count). The wide column is 96px,
taken from CPU (116px). Name, ID, Uptime, Net I/O, Disk I/O and the
memory and disk bars keep their widths and used/total sublabels, and
views without a Backup column give them slightly more. Two GuestRow
width expectations for the pod and Docker views move with the CPU
weight.
KubernetesNameText renders a generated name as a truncating head and a
kept tail, and both are flex items. Flex items are blockified, so a
browser copy put a line break between them: copying a pod name gave
"cron-nightly-backfill\n-28918234", and pasting that into kubectl logs
broke. The same happened for the Pods table Node column and controller
names.
A copy whose selection sits inside one name now writes the name without
the break. Wider selections, such as a whole row, still copy as the
browser builds them.
Dragging across a pod or container name to copy it for kubectl or
docker 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.
The shared TableRow now owns a per-row selection guard: a click whose
pointer moved since the press while text inside the row is selected
skips the row action. A press released in place still runs it on the
first click, even over text that is already selected, because Chromium
clears that selection only after the click. The drawer.toggle tables,
the Proxmox rows built on the row interaction helper and PulseDataGrid
inherit the guard with no call-site changes, and the custom GuestRow
and StoragePoolRow shells wire the same guard. The disclosure chevron
and its keyboard activation handle their own events and are unchanged.
Adding the Pulse Agent to a Proxmox cluster member meant generating a
token in the UI and pasting it into an interactive prompt on the host,
even though Pulse already knows the cluster and has an agent on its
siblings. The server-side cluster deploy that avoids this was still
shipped, but its wizard was deleted as an orphan in May, and it could
not reach members discovered by hostname: targets were parsed from the
API URL, so a name like "delly2" hit the agent's literal-IP guard and
failed preflight.
Deploy targets now resolve to a literal IP in the member's API URL or
its cluster endpoint's effective IP, matched on the member's own
connection, and members with no known address come back undeployable
with a reason. The Settings > Infrastructure install actions open one
dialog that checks, installs, and confirms each node is reporting,
falling back to the scoped installer when no sibling agent is
connected or a node cannot be reached.
The deploy routes are keyed on the cluster name, so they now refuse a
name shared by two connections instead of merging two sites, and job
creation admits one install per cluster at a time under a lock, which
lets the dialog keep a run going after it is closed.
The agent-lifecycle, api-contracts, frontend-primitives, and
storage-recovery contracts record the new install path, its address
rule, and the persisted, time-bounded admission check.
Supersedes #2507, which split this change across two commits; the
per-commit canonical completion guard needs the contract updates in the
same commit.
KubernetesNameText renders a generated name as a truncating head and a
kept tail, and both are flex items. Flex items are blockified, so a
browser copy put a line break between them: copying a pod name gave
"cron-nightly-backfill\n-28918234", and pasting that into kubectl logs
broke. The same happened for the Pods table Node column and controller
names.
A copy whose selection sits inside one name now writes the name without
the break. Wider selections, such as a whole row, still copy as the
browser builds them.
A deployed agent's first run gave the remote config client, the updater, and
the Docker / Podman and Kubernetes modules the bootstrap token, which only the
host module exchanged. Pulse revokes that token on exchange, so those clients
failed with 401 until the service restarted. Enroll once at startup and build
every module from the runtime token and the server-assigned agent ID.
## Outcome
This batch makes guest identity persistence safer, keeps broadcast
resource rows and their History targets in one captured generation, and
makes completed-backup evidence accessible by keyboard and touch.
Existing help now explains container CPU scales and passive comparisons.
Native macOS guidance checks receive real GNU timeout without replacing
unrelated system utilities.
The work follows retained identity-write/shutdown failures, fleet CPU
and History consistency evidence, inaccessible backup disclosures and
confusion about container CPU readings. It improves those existing paths
without claiming field recovery or complete performance relief.
## Included reviewed candidates
The complete proposed range is
`800329cbd83c48fdb7402a18633189f2c8a2d6ff..9b057cb141221526a96fac05bdbdf8ed6e8625fc`:
- Guest metadata ordering/coalescing and safe retained shutdown:
`244d322dce` and `5e51069048`, integrated at `d7a305922a`.
- Native guidance prerequisites: `13f6d10798` and `443faa53b6`,
integrated at `f7fca0e164`.
- Accessible existing Workloads backup details: `02e6a90374`, integrated
at `8661c1fc65`.
- Paired complete resource/History capture and faithful full-content
controls: `54de7bdca7`, `32f09dc13d`, `8b12e0ee1b`, `d206b03990` and
`9cc8523881`, integrated at `2787b4eabd`.
- Container CPU documentation: `0c283d2dd5`, integrated at `15e8c5ccf6`.
- Router-owned limiter joins and sealed late worker admission:
`5b560521e2`.
Reviewed identities and authors are unchanged. Current upstream Docker
link editing and platform layout repairs are incorporated by an additive
full-SHA merge; earlier upstream governance and Kubernetes layout
history are retained. The later upstream Docker image badge sizing
repair at `800329cbd83c48fdb7402a18633189f2c8a2d6ff` is also retained
after one successful bounded refresh and additive merge.
## Behaviour and safeguards
Identity polls merge only monitor-owned fields, preserve operator
links/tags/notes and OCI classification, and do not spin-retry
filesystem failures. Incomplete tenant shutdown retains ownership and
prevents directory removal. Router shutdown joins its own limiters
without resetting budgets or stopping another Router.
Broadcast resources and target mappings are detached together. Health
comparison controls use one evaluation instant while production still
samples real UTC at the existing boundary. Full-content, overlay/clear,
identity and observation-time controls remain.
Backup disclosures preserve hover and add keyboard/touch access to the
same read-only evidence. CPU guidance distinguishes host-capacity
normalisation, VM CPU counts, version-qualified rounding, missing
readings and actual alert delivery; it asks for ordinary observations,
not forced workload or threshold changes.
## Validation and remaining limits
- Provenance, retained ancestry, clean additive merge and whitespace
checks pass.
- Every unpublished non-merge commit passes the canonical
completion/history guard. Final-range browser receipt coverage, public
registry audit and contract audit pass.
- Accepted candidate evidence includes focused writer/API races, startup
observations, vet and actual builds; 363 connected backup frontend tests
with four desktop/touch browser cases; CPU help's 132 connected frontend
tests, lint/types/build/budgets, focused Go checks and four Docs browser
cases. Browser receipts cover unchanged final backup bytes and are not
rerun merely because the batch contains several candidates.
- Gate frontend proof at `9daeed13`: 246 tests in four connected files,
type-check, all lint checks, actual embed build and bundle budgets pass;
complete output hash matches and guest stop completes.
- Exact refreshed head `9b057cb1` passes 49 Docker native-table and
platform-layout guardrail tests (`source-l8diazgn`); complete output and
guest stop are verified. This covers the later width-only upstream
change without repeating the earlier full frontend checks.
- Gate backend attempt `source-pz6qwk2t` fails in guest readiness after
capacity waiting, client exit 2, with no command verdict and confirmed
guest stop. It is not a test pass and is not replayed. All backend Go
source, manifests and native workflow bytes are unchanged from reviewed
`5b560521` and its focused races/vet/real-build evidence; that evidence
remains usable without inventing an aggregate pass.
- The retained broad API ten-minute timeout, earlier drain/startup
failures, adverse allocation/frame costs and failed stable RSS retest
are not erased by these focused results. Required exact-head CI remains
authoritative for landing.
- This is not native guest thaw/recovery, PBS badge agreement, CPU/RSS
relief, notification delivery or release inclusion. Independent thaw,
fresh writes on every covered filesystem, liveness and only-prior-active
restoration remain necessary before resuming uncertain monitoring.
Related to #2199
Related to #1723
Related to #2439
Related to #2447
[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
Retain exact Core candidate 3c07800709 and all five commits. Borrow converter-local inputs, skip discarded absent-facet JSON, and bound common alias scratch allocation without retaining a resource graph. Retained exact-source and identical-runtime proof is adequate; indicative adverse timings and original CPU/RSS/startup/API failures remain unresolved.
Change-source: pulse-maintainer
Image references that share a registry and namespace differ only at
the end, but table cells truncated them from the end. At 390 and 700px
three Images rows read "ghcr.io/pulse-demo…", Swarm services read
"registry.…" from 390 to 700px, and the Containers Image column hid
the repository name up to 1440px, so an update badge or a size could
not be traced to its image without opening the row. Real fleets are
worse: every lscr.io/linuxserver image shares a 20-character prefix.
splitDockerImageReference splits before the repository name (a digest
never moves the split), and DockerImageReferenceText renders the head
as the part that truncates and keeps name:tag visible, mirroring
KubernetesNameText, with the full reference on the title. The Images
name cell, the Containers Image column and the Services Image column
use it. The two halves are flex items, which a browser copy would
join with a line break, so a selection inside one reference copies
without it. Sorting, search and the stored value are unchanged, and a
reference without a path renders as before.
## Problem
Two platform tables cut a status badge at the cell edge at tablet
widths.
- **Docker containers, Updates column.** Columns are sized per layout
mode with weights that renormalize over whichever optional columns show
(State, Restarts, Host, Engine). Between a 34rem container and the md
breakpoint, where the phone wrap rule stops, Updates got 54–76px. So
"Update" (74px) and "Current" (69px) were cut from 573 to 800px, and
ungrouped up to 1100px.
- **Kubernetes Nodes, Status column.** The tablet band split equally
(51px of room at 573px), and md gave Status 8% (53px at 768px). So
"Degraded" (64px) was cut.
## Change
**Docker containers** (`dockerContainerTableModel.ts`): Updates takes
room only from the CPU bar, which never shows a sublabel, and the
collapsed actions menu. Every other column keeps its exact width in
every layout mode and optional-column set, so the memory bar keeps its
used/total sublabel.
- The 74px states (Update, Current, Pinned, Failed) fit at each layout's
narrowest table width in every column set.
- The 100px states (Check failed, Updating..., Completed) fit in most
layouts, but can still clip at a layout's narrow end when several
optional columns show.
- Wide rows also gain the 108px their expanded lifecycle controls need.
- A new model test checks every layout and column set.
**Kubernetes Nodes** (`KubernetesNodesTable.tsx`):
- The tablet band now sizes each column for its header, and Status for
Degraded, at a 34rem table.
- md moves 2.5 points from CPU (whose bar has no sublabel) to Status.
The visible total stays the same, so Node, Cluster, Roles, Memory and
Capacity keep their widths at every md width.
- Degraded, NotReady and Unknown fit from 573px. Unschedulable (89px)
fits from about 900px; main cut it at every width.
The guardrail test pins the new widths. Contract notes are in
`unified-resources.md` and `frontend-primitives.md`.
## Verification
Mock-mode private stack on main 800329cbd8, before against after, at
18–20 widths from 390 to 1600px:
| Table | Badge clipped (main) | Badge clipped (after) | Total clipped |
Columns worse | Sublabels lost |
|---|---|---|---|---|---|
| Docker containers, grouped | 573–900px | none | 89 → 53 | none | none
|
| Docker containers, ungrouped | 573–1100px | none | 195 → 132 | none |
none |
| Kubernetes Nodes | 573–800px | none | 85 → 78 | none | none |
- **Interactions:** sorting by Updates and by Status, and row expand and
collapse, work at 600, 768 and 1280px with no console errors.
- **Tests:** `src/features`, `src/hooks`, `src/components/shared` and
`src/components/Workloads` pass (489 files). The new and updated checks
fail against main's source.
**Known residuals:**
- "Review action" (112px, shown only while an update awaits review) can
still clip at tablet widths when the table is crowded.
- The Docker "RESTARTS" header was already clipped in the 480–768px
range on main and is unchanged.
The browser receipt is
`frontend-modern/browser-verification/6683727b493b9b45.json`.
The Docker container Updates column and the Kubernetes Nodes Status
column hold badges that cannot truncate, and both were cut at the
cell edge at tablet widths.
Docker containers size columns by layout mode with weights that
renormalize over whichever optional columns show (State, Restarts,
Host, Engine). Updates got 54-76px between a 34rem container and the
md breakpoint, where the phone wrap rule no longer applies, so Update
(74px) and Current (69px) were cut from 573 to 800px. The column now
takes its room only from the CPU bar, which never shows a sublabel,
and the collapsed actions menu, so every other column keeps its width
in every layout and column set and the memory bar keeps its used/total
sublabel. The 74px states fit in every layout and column set at the
layout's narrowest table. The 100px states (Check failed, Updating...,
Completed) fit in most layouts but can still clip at a layout's
narrow end when several optional columns show. Wide rows also gain the room their expanded
lifecycle controls need. A model test checks every layout and column
set.
Kubernetes Nodes Status shared the tablet band equally (51px of room
at 573px) and had 8 percent at md (53px at 768px), so Degraded (64px)
was cut. The band now sizes each column for its header and Degraded
at a 34rem table, and md moves 2.5 points from CPU to Status, keeping
the visible total, so Node, Cluster, Roles, Memory and Capacity keep
their widths everywhere. Degraded, NotReady and Unknown fit from
544px; Unschedulable (89px) fits from about 900px.
Incorporate the scoped-read upstream identity through the completed bounded refresh; preserve all reviewed commit identities and existing source evidence.
Change-source: pulse-maintainer
## Problem
On `/docker/images` the Update check badge was cut off at tablet widths.
Its longest labels ("Update available", "Managed by Pulse") need about
122px including cell padding.
- In the tablet band (a 34rem container below a 768px viewport) every
non-name column took an equal 17.5% share. That left Update check 100px
at 600px and 110px at 700px.
- The 16% md width left it 111px at 768px.
The badge was cut at the cell edge with a stray ellipsis dot, and the
"UPDATE CHECK" header clipped at 600px.
## Change
`DockerImagesTable.tsx` changes only column widths. The badge label is
unchanged.
| Column | Band (`bandWidth`) | md before → after |
|---|---|---|
| Image | 30 | 30 → 30 |
| Host | 17.5 | 18 → 17 |
| Used by | 17.5 | 24 → 23 |
| Size | 12 | 12 → 11 |
| Update check | 23 | 16 → 19 |
Host and Used by keep the band share they already had. Size gives up the
room in the band, because its widest values ("1023 MB", "9.99 GB",
49.6px) still fit its narrowest cell (53px). The md change takes one
point each from Host, Used by and Size.
`platformOverviewLayout.guardrails.test.ts` pins the new md and band
widths. Contract notes are in `unified-resources.md` (Docker images) and
`frontend-primitives.md` (sizing a badge column for its narrowest table
in each range).
## Verification
I ran a mock-mode private stack on main 8e68bab55a, which already
includes #2514's Used by span change.
- **Clip sweep:** 15 widths from 390 to 1440px, checking `scrollWidth >
clientWidth + 1` on every cell and its overflow-hidden descendants.
- The update badge clipped at 600, 640, 700 and 768px on main, and
nowhere after this change.
- Image, Host, Used by and Size have the same clip counts at every
width. Total clipped elements went from 65 to 61.
- **Label probe:** all six update labels cloned into the narrowest
Update check cell. On main, "Managed by Pulse" overflows from 600 to
800px and "Update available" at 600, 640, 700 and 768px. After this
change, every label fits at every width.
- **Headers:** no header clips at any width, including with a sort
indicator.
- **Interactions:** row expand and collapse works at 390, 600, 768 and
1440px. No console errors.
- **Tests:** `src/features/platformPage` and `src/features/docker` pass
(37 files, 653 tests). The updated guardrail test fails against the old
source.
The browser receipt is
`frontend-modern/browser-verification/7db5e194a586019d.json`.