Commit Graph

13803 Commits

Author SHA1 Message Date
pulse-triage[bot] 7956e32071 Align update-help assertions with integrated ownership safeguards
Correct the PI wording assertion after preserving newer canonical help in the merge. Require command-free LXC/systemd entries, linked ownership and installed-path checks, private Pro protection and retained chart/software guidance rather than restoring an older copied command.

Contract-Neutral: Integration-only documentation assertion alignment; update behaviour, routes and trust boundaries are unchanged.
Change-source: pulse-maintainer
2026-10-09 08:50:54 +01:00
pulse-triage[bot] a9ebc63292 Integrate chart-release guidance with current update safety
Retain exact reviewed PI commit af6cc9019f and its software-versus-chart explanation. Resolve AUTO_UPDATE overlap by retaining the newer canonical text byte-for-byte, including installed paths, private-edition and failed-helper safeguards; align the inherited wording assertion additively next.

Change-source: pulse-maintainer
2026-10-09 08:50:08 +01:00
pulse-triage[bot] d49412f201 Integrate server-only installer release selection
Accept exact reviewed Delivery commit 56fec40f2d with retained offline selector, signed-recipe and resilience controls. No third-party helper repair or installed update is claimed.

Change-source: pulse-maintainer
2026-10-09 08:49:41 +01:00
pulse-triage[bot] 56fec40f2d Select only published server releases in the installer
Use one complete-metadata selector for installer URLs and server archives. Exclude chart and draft releases, reject unusable metadata and transport bodies, and preserve explicit pins, signed assets and downgrade refusal. Exercise the actual selectors with bounded offline controls and retain native installation as separate acceptance.

Change-source: pulse-maintainer
Contract-Neutral: Restore server-only discovery within existing stable/preview channels; installer authority, signatures, architecture admission, downgrade refusal and state contracts are unchanged.
2026-10-09 08:29:32 +01:00
pulse-triage[bot] ee1d638575 Integrate ownership-checked manual update guidance
Accept the exact reviewed Web candidate d96ab64e01. Retain its focused documentation checks and inspected desktop/phone evidence; no updater repair or installed recovery is claimed.

Change-source: pulse-maintainer
2026-10-09 08:21:18 +01:00
pulse-triage[bot] fcfeba39e0 Integrate batch-start upstream main
Merge exact upstream 7f466fc183 without re-parenting its reviewed history. Retain both independent contract sections in the two documentation conflicts; no frontend resolution or runtime correction.

Change-source: pulse-maintainer
2026-10-09 08:04:32 +01:00
pulse-triage[bot] d96ab64e01 Keep manual update help from running an unknown helper
Use the ownership-checked existing-installation procedure for LXC and systemd update help instead of unconditional /bin/update commands. Explain chart selection without asserting a latest-pointer cause, and preserve exact signed downloads, private editions and existing state.

Contract-Neutral: Documentation-only update guidance and regression checks; no installer, updater, API or frontend runtime behaviour changes.
Change-source: pulse-maintainer
2026-10-09 08:00:25 +01:00
pulse-triage[bot] af6cc9019f Qualify LXC updater ownership and chart-only releases
Keep server-update help from treating a community helper as Pulse's signed updater or choosing a Helm chart as a server archive. Preserve current-state checks, full-state backup precautions and the existing signed route after a failed update.

Contract-Neutral: Clarifies existing update routes and release identities; no runtime, API, release policy or subsystem contract change.
Change-source: pulse-maintainer
2026-10-09 07:57:01 +01:00
rcourtman 7f466fc183 Fix Patrol investigation of host filesystem alerts (#2754)
Host filesystem alerts use a disk subject that is absent from Patrol's
resource inventory, so the Investigate action rejects a valid collected
alert before inference. Resolve that exact subject through current
canonical ownership, including agents linked into guests or nodes, and
retain the original mountpoint, device, value, threshold and message in
the investigation context. Missing owners, conflicting identities and
unrelated child subjects remain rejected.

Validation:
- Full `internal/unifiedresources` and `internal/ai` package suites.
- Production `CheckHost` emission through the Patrol handler for
snapshot and unified inventory, with standalone and linked hosts.
- Repeated admission/execution resolution, active and cleared alert
evidence, identity collisions and removed owners.
- `/alerts` browser interactions at 1440 and 390 pixels: pointer and
Enter submission, accepted/busy/unresolved responses, Escape and
outside-click dismissal, and reload/re-submit. The browser forwards
actual handler requests using a synthetic estate and session. Model
diagnosis is outside this proof.
2026-10-08 17:39:24 +01:00
rcourtman 19d016d7df fix(patrol): resolve disk alerts through canonical resource owners
Host filesystem alerts identify a disk child that Patrol does not collect as
a separate resource. Bind an exact collected alert to its current canonical
owner through the shared inventory contract, including linked agents, and
retain its filesystem, threshold and breach evidence in the scoped prompt.

Keep missing owners, ambiguous source identities and uncollected child
subjects fail-closed. Add evaluator-to-handler, repeated scope resolution
and browser admission coverage.
2026-10-08 17:22:29 +01:00
rcourtman 99f0aae77f Clarify unavailable Docker container updates (#2753)
When a Docker image update is available but the server refuses
execution, an Update button suggests Pulse can still apply it. Show a
readable amber Update available badge with a lock indicator. Its hover,
tap and keyboard-focus explanation preserves the server refusal and
directs the operator to their container manager on the host. Keep
available updates and audited action progress usable.

Validation: 15 update-control regression tests, frontend typecheck, full
commit governance checks and frontend lint. Browser verification at
1440, 900 and 390 pixels covers light/dark styling, hover, keyboard,
dismissal, passive status clicks and the available update review flow
using fixture responses without dispatching updates.
2026-10-08 12:20:16 +01:00
rcourtman 1a78919b75 Drop audit rules and allowlist entries for deleted recovery modules (#2752)
`canonical-platform-audit.mjs` still allowlisted and pointed authors at
eight recovery presenter modules that were deleted with the aggregate
Recovery page, so eight `canonical-recovery/*` rules told authors to
"Use @/utils/<module> instead" for files that do not exist. This removes
those rules and their allowlist entries.

Stacked on https://github.com/rcourtman/Pulse/pull/2750, which edits the
same file. The two commits from that PR show in this diff until it
merges; the change here is the last commit only (1 file, 63 deletions).

## What goes

Allowlist entries and rules for `recoveryAction`, `recoveryEmptyState`,
`recoveryRecord`, `recoveryFilterChip`, `recoveryIssue`,
`recoverySummary`, `recoveryStatus` and `recoveryTable` `Presentation`:

| Rule | Replacement it named |
| --- | --- |
| `no-local-filter-chip-presentation` | `recoveryFilterChipPresentation`
|
| `no-local-issue-rail-presentation-map` | `recoveryIssuePresentation` |
| `no-local-summary-presentation-maps` | `recoverySummaryPresentation` |
| `no-local-empty-state-copy` | `recoveryEmptyStatePresentation` |
| `no-local-table-presentation-helpers` | `recoveryTablePresentation` |
| `no-local-record-presentation-helpers` | `recoveryRecordPresentation`
|
| `no-local-status-pill-presentation` | `recoveryStatusPresentation` |
| `no-local-action-link-presentation` | `recoveryActionPresentation` |

All eight modules are gone (`git log --diff-filter=D`: `ae65c5e9a2`,
`c40455806f`, `c26665a63a` on 2026-05-26, `7d6b447c59` on 2026-05-14).
The identifiers and copy the rules matched (`protectedStaleOnly()`,
`ISSUE_RAIL_CLASS`, `RECOVERY_TIME_RANGE_LABELS`, `rollupSubjectLabel`,
`groupHeaderRowClass`, "No protected items yet") were last changed by
`66fb198cc6`, which deleted the Recovery page.

## Proof the rules matched nothing live

The audit skips test files and allowlisted paths, so a passing audit
alone does not show that. I ran a probe built from the audit's own rule
table instead:

- All eight regexes over every `.ts` and `.tsx` under `src`, tests and
allowlisted modules included (2,413 files): 0 matches.
- The same regexes over the 8,026 tracked text files in the repo: 4
matches, all the empty-state rule's own regex text in this script.
- Positive control: one planted forbidden string per rule, all 8
reported.

## Proof the dropped allowlist entries now scan

A planted `const niceAxisMax = (` at each of the eight dropped paths (a
string a retained rule forbids): the old script passes, the new script
reports all eight under
`canonical-recovery/no-local-date-presentation-helpers`. `node
scripts/canonical-platform-audit.mjs` passes on the clean tree before
and after.

## Kept

The date, timeline, timeline chart and outcome recovery rules and their
three allowlist entries stay: those modules still exist and the messages
point at them. Fifteen other allowlist entries and fourteen `@/` message
pointers name modules that are also gone (PMG, AI, deploy and a few
more). They belong to those surfaces' own cleanups and are not touched
here.

## Contracts and receipts

No contract describes these rules; `frontend-primitives.md` and
`patrol-intelligence.md` name this script only for the locale and Patrol
rules, and nothing in tests or tooling reads the rule ids. The commit
carries a `Contract-Neutral:` trailer naming the platform support
manifest projection proof residual, as `6d8dce2cae` did. The change is
script only, so there is no runtime output change and the browser
verification guard lists no changed paths.

Heavy runs were on pulse-dev because the local workload gate refused
node at the time; the file run there is byte-identical to the committed
blob.
2026-10-08 11:53:27 +01:00
rcourtman 71359ee58b Keep unavailable container update badges readable
Muted two-line statuses obscure available image updates and expand otherwise compact container rows. Use one amber badge with a lock indicator, keeping the server refusal and host guidance in its accessible explanation.
2026-10-08 11:53:14 +01:00
rcourtman 9f3726a05d Remove recovery exports and fallbacks the Recovery page left dead (#2750)
The aggregate Recovery page and its rollup, facet and series hooks went
on 2026-05-26, and #2728 removed the recovery query serializer. This
removes the recovery exports and fallbacks that nothing in the shipped
app reads.

## What goes

- `recoveryPlatformModel`: the rollup normalizers and
`getRecoveryRollupPlatforms`.
- `recoveryTimelinePresentation`: the tooltip-row, point-total,
day-filter and column aria-label builders. Their tooltip rows were the
only reader of `recoveryArtifactModePresentation.ts`, so that module,
its test and the audit rule that pointed page code at it go too.
- `recoveryTimelineChartPresentation`: the range-group class and bar
min-width helper.
- `recoveryOutcomePresentation`: `RECOVERY_OUTCOMES`, the bar class and
the text class.
- `recoveryDatePresentation`: the date-search resolver and normaliser,
and `formatRecoveryTimeOnly`.
- `types/recovery`: the rollup, series and facet types.
- TrueNAS Protection (`TrueNASProtectionTable.tsx`,
`truenasPageModel.ts`): the `display.subjectLabel`,
`display.subjectType` and `subjectRef` fallbacks for labels, sorting and
search.

## Why each is safe

- A TypeScript-compiler export graph from `src/index.tsx` finds no
reachable importer of any removed export; `git log -S` dates each one's
last reader (2026-03-29 to 2026-05-26).
- The TrueNAS fallbacks never fired: `useRecoveryPoints` is the tab's
only source and runs every `/api/recovery/points` response through
`normalizeRecoveryPointsResponse`, which folds `display.subjectLabel`,
`display.subjectType` and `subjectRef` onto `display.itemLabel`,
`display.itemType` and `itemRef` and drops the originals. The subject
names now live on `RecoveryPointTransport` and
`RecoveryPointDisplayTransport` only, so a future read of them off a
normalized point is a type error.
- The points handler sends the item label only as
`display.subjectLabel`, so that fold is what names TrueNAS rows.
`recoveryTransport.test.ts` now drives backend-shaped points through
`useRecoveryPoints` into the TrueNAS sort and search, and pins the type
split with `expectTypeOf`.

## Evidence

- Negative controls: breaking the label fold fails three
`recoveryTransport` tests, including the new one; re-adding `subjectRef`
to `RecoveryPoint` fails the type check; the new test file fails the
type check against the parent's types.
- Coverage of the surviving modules, matched by normalised source text,
loses no statement or branch that was covered before.
- Of 189 dist files, 93 are byte-identical and 93 differ only in chunk
hashes and SRI strings. The TrueNAS chunk loses the `datasetLabel`
subject terms, the `truenasPageModel` chunk loses the subject search and
sort terms, and the CSS loses four unused rules (`bg-gray-400` and three
`min-w` classes). The bundle-size check passes with no chunk renamed.
- Before and after builds driven by one browser script (pulse-dev mock
estate, 1440x900 and 390x844): 29 of 31 measurements identical, and the
other two differ only in the mock clock minute. TrueNAS Protection rows,
search, filters and detail panel and the Proxmox Backups chart, tooltip
and day selection match.
- Merged tree with the current `main`: type check, 8,634 tests across
the recovery, TrueNAS, Proxmox, vSphere, platform-page, shared-guardrail
and Workloads suites, and the canonical platform audit all pass.

## Governance

`registry.json`, `storage-recovery.md`, `frontend-primitives.md` and
`unified-resources.md` drop the deleted module and describe the decode
boundary as it now stands. Two sentences that still described rollup,
facet and series reads in the frontend are corrected. The canonical
platform audit loses the artifact-mode rule and its allowlist entry and
the retired alternatives of two recovery rules; planted strings still
trip the retained alternatives. The commit carries a `Contract-Neutral`
trailer naming the one residual guard demand, the
platform-support-manifest projection proof, as in 6d8dce2cae.
2026-10-08 11:34:34 +01:00
rcourtman 197f9bac5e Clarify unavailable Docker container updates
An Update control suggests Pulse can apply an image even when the host cannot execute commands. Show update availability and host guidance with the server refusal, while preserving audited reviews and progress for actions that can run.
2026-10-08 11:33:29 +01:00
rcourtman b63872c28b Drop audit rules and allowlist entries for deleted recovery modules
canonical-platform-audit.mjs still carried eight canonical-recovery/*
rules and eight allowlist entries for presenter modules that went away
with the aggregate Recovery page: recoveryActionPresentation,
recoveryEmptyStatePresentation, recoveryRecordPresentation,
recoveryFilterChipPresentation, recoveryIssuePresentation,
recoverySummaryPresentation, recoveryStatusPresentation and
recoveryTablePresentation (deleted in ae65c5e9a2, c40455806f and
c26665a63a on 2026-05-26 and 7d6b447c59 on 2026-05-14). Each rule told
authors "Use @/utils/<module> instead", naming a file that does not
exist, so a hit could not be fixed the way the message said.

The rules matched Recovery page code. protectedStaleOnly(),
ISSUE_RAIL_CLASS, RECOVERY_TIME_RANGE_LABELS, rollupSubjectLabel,
groupHeaderRowClass and the "No protected items yet" copy were each last
changed by 66fb198cc6, which deleted that page. Applying all eight
regexes to every .ts and .tsx file under src, tests and allowlisted
modules included (2,413 files), finds no match. Over the 8,026 tracked
text files in the repo the only matches are the empty-state rule's own
regex text in this script. A probe that plants one forbidden string per
rule reports all eight, so the empty result is not a blind scan.

With the eight allowlist entries gone the audit scans those paths like
any other. A planted "const niceAxisMax = (" at each of the eight paths
passes under the old script and is reported eight times by the new one.
The audit passes on the clean tree before and after.

The date, timeline, timeline chart and outcome rules stay, with their
three allowlist entries: those presenter modules still exist and the
messages point at them. Fifteen other allowlist entries name modules
that are also gone (PMG, AI, deploy and a few more); they belong to
those surfaces' own cleanups.

Nothing else reads these rule ids: no test pins them and no contract
describes them. frontend-primitives.md and patrol-intelligence.md name
this script only for the locale and Patrol rules. The change is script
only, so no runtime output changes and the browser verification guard
lists no changed paths.

Contract-Neutral: Only residual guard demand is the platform support manifest frontend projection proof that the canonical-platform-audit.mjs path policy names (resource.test.ts, sourcePlatformOptions.test.ts, sourcePlatforms.test.ts). This commit deletes eight dead canonical-recovery audit rules and eight allowlist entries for deleted modules and leaves the projection sync check untouched, so editing those tests would be fabricated proof. No subsystem contract describes the removed rules (frontend-primitives.md and patrol-intelligence.md name this script only for the locale and Patrol rules), so there is no contract text to change.
2026-10-08 11:28:53 +01:00
pulse-triage[bot] fe7f04f7e2 Integrate reviewed cross-installation intake guidance
Accept the exact documentation-only candidate with retained focused intake, mirror and public-document proof. Runtime identity recovery remains separate owning-lane work.

Change-source: pulse-maintainer
2026-10-08 11:21:42 +01:00
pulse-triage[bot] 1aac31d6ff Merge commit '8c9c628c28a40d2295cb193cfb6504991bc9b871'
Change-source: pulse-maintainer
2026-10-08 11:10:39 +01:00
rcourtman f0561c274b Merge remote-tracking branch 'origin/main' into refactor/recovery-orphan-exports 2026-10-08 11:08:32 +01:00
rcourtman 8c9c628c28 Remove WorkloadsSurface's unreachable built-in filter toolbar (#2749)
Supersedes #2738, which conflicted with main after #2718/#2720/#2734
rewrote the same contract paragraph, added an AST pin on
`suppressFilterToolbar`, and edited `WorkloadsFilter.tsx`. #2723, which
#2738 was stacked on, has merged, so this is one commit on main
d2ac0c5112.

`WorkloadsSurface` rendered its own `WorkloadsFilter` unless the caller
passed `suppressFilterToolbar`. Its only production mounts, the Proxmox
and vSphere overviews, have passed it since 58b2e10c1f hoisted one
page-owned toolbar above each surface, so the built-in branch never
rendered.

This removes:
- the toolbar branch and the `suppressFilterToolbar` option
- the props only that branch read: `filterAriaLabel`,
`filterSearchPlaceholder`, `filterSearchEmptyMessage`,
`filterStatusOptions`
- the `kioskMode` state member, which nothing else read
- `defaultSortKey`, which no caller set once the Docker page moved to
its own containers table (223b11185f). It goes from the surface props,
the controls hook and `WorkloadsFilter`, whose Clear filters already
reset to the default sort.

Tests: the Kubernetes surface tests read the host filter from the shared
state the way the pages feed their toolbar. Source pins that named the
surface's toolbar now pin the two page toolbars, and main's AST
guardrail now forbids `suppressFilterToolbar` instead of requiring it on
both mounts. Each page suite now also proves its toolbar renders once,
above the surface, only when the workload state is ready; the removed
websocket-test wait had only covered the dead toolbar. Contract prose
that described a surface-owned toolbar now says the page renders the
only one.

Verification (before = main d2ac0c5112, after = this commit, production
builds on pulse-dev):
- Browser pass on both overviews at 1440x900 and 390x844 plus kiosk
mode: all 104 measurements identical between the builds, with identical
toolbar chrome. Both show one page toolbar and none inside the surface,
search narrowing, the no-match empty state, Clear filters, drawer
pinning and Clear selection. This pass used a small mock estate, because
main d2ac0c5112 itself does not finish starting on the default 50-node
estate: the startup watchdog fires during the initial alert evaluation
(filed separately, not caused by this change).
- Dist: 94 files byte-identical, 92 differ only in hashes or SRI. After
normalising, the Proxmox and Vmware chunks lose only
`suppressFilterToolbar:!0`, and `WorkloadsSurface` loses only the
toolbar branch, the kiosk read and the `defaultSortKey` fallbacks. CSS
is unchanged.
- Bundle: `WorkloadsSurface` drops from 57.25 to 56.86 kB gzip (cap
70.23). No chunk was renamed, so `.bundlesize.json` is unchanged.
- Type check and lint pass; format check clean. Targeted vitest on the
affected files: 9 files, 310 tests pass. The earlier full run on the
first version of this change also passed, with the few timeouts under
load passing on a low-parallelism rerun. Negative controls: the
rewritten pins fail against the before sources.
- Sol reviewed three rounds; the last two found nothing.

Receipt: `frontend-modern/browser-verification/5333c576e9616b17.json`.
2026-10-08 11:05:57 +01:00
pulse-triage[bot] 91b9bb69e4 Distinguish agent identity from connection names in triage
Preserve the one-agent, repeated-short-guest-name case without conflating a full connection hostname or an ambiguous automatic link with stopped Docker reporting. Keep diagnostics observational and both shipped guide copies aligned.

Contract-Neutral: Intake guidance and its documentation assertions only; no runtime, API, identity policy or release contract changes.
Change-source: pulse-maintainer
2026-10-08 11:05:34 +01:00
rcourtman 18a9940020 Remove recovery exports and fallbacks the Recovery page left dead
The aggregate Recovery page and its rollup, facet and series hooks went on
2026-05-26, and #2728 removed the recovery query serializer. Several
recovery helpers kept exports that nothing in the shipped app reads. A
TypeScript-compiler export graph from src/index.tsx finds no reachable
importer of any of them, and git log -S dates when each lost its reader:

- recoveryPlatformModel: the rollup normalizers and getRecoveryRollupPlatforms
  (last reader useRecoveryRollups.ts, 2026-05-26).
- recoveryTimelinePresentation: the tooltip-row, point-total, day-filter and
  column aria-label builders (the page's timeline, 2026-05-26). The tooltip
  rows were the only reader of recoveryArtifactModePresentation.ts, so that
  module, its test and the audit rule that pointed page code at it go too.
- recoveryTimelineChartPresentation: the range-group class and bar
  min-width helper (unread since the timeline changes of 2026-03-29).
- recoveryOutcomePresentation: RECOVERY_OUTCOMES (dashboard retirement,
  2026-04-29) and the bar and text class helpers.
- recoveryDatePresentation: the date-search resolver and its normaliser,
  and formatRecoveryTimeOnly (2026-05-26).
- types/recovery: the rollup, series and facet types.

The TrueNAS Protection tab also fell back to display.subjectLabel,
display.subjectType and subjectRef for labels, sorting and search. Its
points come only from useRecoveryPoints, which runs every
/api/recovery/points response through normalizeRecoveryPointsResponse.
That decode folds those fields onto display.itemLabel, display.itemType
and itemRef and drops the originals, so the fallbacks never fired. The
subject names now live on RecoveryPointTransport and
RecoveryPointDisplayTransport only, which turns any future read of them
off a normalized point into a type error instead of a silent no-op.

The points handler sends the item label only as display.subjectLabel, so
that fold is what names TrueNAS rows. recoveryTransport.test.ts now drives
backend-shaped points through useRecoveryPoints into the TrueNAS sort and
search, and pins the type split with expectTypeOf. Breaking the fold fails
the runtime test; re-adding subjectRef to RecoveryPoint fails the type check.
Tests that pinned the subject fallbacks now pin the itemLabel and itemRef
arms they stood beside. Coverage of the surviving modules is unchanged:
matched by normalised source text, no statement or branch covered before
is uncovered after.

canonical-platform-audit.mjs loses the artifact-mode rule and allowlist
entry, and the formatTimeOnly, range-group and bar min-width alternatives
of two recovery rules, since the helpers they pointed at are gone. A planted
niceAxisMax, labelEvery or tick-label tint string still trips the retained
alternatives.

The shipped JavaScript changes only by the removed code. Of 189 dist files,
93 are byte-identical and 93 differ only in chunk-hash references and SRI
strings; the TrueNAS chunk loses the datasetLabel subject terms, the
truenasPageModel chunk loses the subject search and sort terms, and the CSS
loses four rules no source uses any more (bg-gray-400 and three min-w
classes). The bundle-size check passes with no chunk renamed. Before and
after builds driven by one browser script show the same TrueNAS Protection
rows, search, filters and detail panel and the same Proxmox Backups chart,
tooltip and day selection at desktop and phone width.

registry.json, storage-recovery.md, frontend-primitives.md and
unified-resources.md drop the deleted module and describe the decode
boundary as it now stands; two storage-recovery and unified-resources
sentences that still described rollup, facet and series reads in the
frontend are corrected.

Contract-Neutral: Only residual guard demand is the platform support manifest frontend projection proof that the canonical-platform-audit.mjs path policy names (resource.test.ts, sourcePlatformOptions.test.ts, sourcePlatforms.test.ts). This commit deletes one recovery audit rule, one allowlist entry and retired alternatives in two other recovery rules, and leaves the projection sync check untouched, so editing those tests would be fabricated proof. storage-recovery, frontend-primitives and unified-resources carry substantive notes, and recoveryTransport.test.ts and frontendResourceTypeBoundaries.test.ts are the staged recovery-product-surface proofs.
2026-10-08 11:05:31 +01:00
pulse-triage[bot] 39615aa987 Merge commit '7606b72a8b85e70696e8bfa52a0792fbb146a34d'
Change-source: pulse-maintainer
2026-10-08 11:03:49 +01:00
rcourtman 7606b72a8b Let an operator split override a Proxmox node's agent link (#2741)
## Problem

The resource drawer's **Split merged resource** (`POST
/api/resources/{id}/report-merge`), and `POST
/api/resources/{id}/unlink`, on a Proxmox node merged with its
pulse-agent recorded exclusions and answered "Resource split applied",
but the pair stayed merged on every rebuild, in the monitor and in the
resources API.

`ResourceRegistry.ingestRecord` joined the pair through the node↔agent
link in `resolveLinkedResource` (both sides declared, or a node-side
link the agent's hostname corroborates) before `findMatch`, and that
path never read `rr.exclusions`. The linked node had also already taken
the agent's identity in `resourceFromProxmoxNode`, so it minted the
agent's machine-derived canonical ID before the agent was ingested.

## Decision

The operator's split wins. The node↔agent link is Pulse's own inference
from endpoint addresses and hostnames, the evidence cloned machines and
reused short hostnames defeat, and report-merge exists precisely for
wrong merges. A later `POST /api/resources/{id}/link` between the split
rows joins them again.

## Change

- `nodeAgentSplit` (`proxmox_node_links.go`) decides the pair from the
operator's decisions read across the IDs each side can hold: the store
records them against the merged ID and the `SourceTargets` candidates,
while a split node holds its own ID. The latest decision across those
sets wins (registries now keep each exclusion's `CreatedAt`). An
exclusion naming only the agent's candidate against the agent's
machine-derived ID does not count, because report-merge records that
same pair when it splits another source (a Docker host) off the agent.
- Every path that joined the pair applies it: the snapshot ingest no
longer lends a split node the agent's identity (`splitProxmoxNodeLink`);
identity-pin completion refuses the agent's machine key from a
joined-era pin; `resolveLinkedResource` refuses the declared and the
one-sided link; `applyManualLinks` skips an older link between split
rows; and the presentation filter keeps split rows apart when no single
exclusion names their IDs.
- A split node names the agent only where the monitor's node record
does, so guest discovery, deploy and service discovery are not handed
the rejected agent.
- A relink of the split rows is recorded as a folded ID, so pin
succession keeps it instead of re-keying it onto the joined ID.

Builds on #2725 (now merged). This PR's own changes are 4a97d2225f, plus
two repairs after `main` moved: 5b5026fafd records a relinked row as a
`ManualLinkFold` (#2721 replaced `linkMergedIDs`), and e4519ec5bc passes
thresholds to `CoalesceForPresentation` (#2745). The rest of the branch
is merges of `main`.

## Verification

- `TestOperatorSplitOverridesProxmoxNodeAgentLink`
(`registry_merge_policy_test.go`): seven link shapes (declared both
ways, node-side only, agent-side only, unclustered and clustered, an
agent hostname in another form, an agent reporting no machine key) × six
requests (report-merge, unlink naming the node's candidate, unlink
naming the split rows, an exclusion between the two candidates, an
exclusion naming only the agent's candidate, an unrelated exclusion).
Each step runs three rebuilds of a SQLite-backed `MonitorAdapter`,
checked in the monitor listing, the broadcast coalesce, and the
resources API's registry seeded from the listing and from the snapshot.
The test also covers agent-first ingests with and without the host, a
relink (plus an agent-first rebuild that persists its pins), and the
same request again. The unrelated exclusion and the agent-only exclusion
under a machine-derived ID are the negative controls: those pairs stay
merged.
- `TestOperatorSplitFromOneAgentKeepsAnotherAgentsPin`: a node split
from a second agent still completes its own linked agent's boot-window
pin. `TestOperatorSplitHoldsWhenTheAgentStopsReportingItsMachineID`: a
split holds when the agent comes back without its machine ID.
- 15 mutation controls, one per rule. Each makes these tests fail.
- Full `./internal/unifiedresources/...`,
`./internal/api/resourceapi/...` and `./internal/monitoring/...` pass,
as does `./internal/api/` filtered to resource, link, unlink, merge and
broadcast tests. The targeted tests also pass on the pushed head, which
includes current `main` (#2719).

## Not covered (contract "What the split does not reach")

- The browser's realtime coalesce (`mergeCanonicalResourceSnapshot`)
receives no exclusions and still joins a Proxmox-only row and an
agent-only row that share a hostname. A split pair, from this change or
from an identity-match split, therefore still renders as one row in the
UI.
- The monitor's own node record keeps its link. The SMART fallback,
disk-exclude patterns, guest discovery, deploy and service discovery
still follow the agent.
- `resolveLinkedPhysicalDisk` still joins disks inside one host without
reading exclusions.
- The agents API's node link does not replace a resource split.
- Exclusions keyed by cluster- or hostname-derived IDs carry no source.
2026-10-08 10:59:13 +01:00
pulse-triage[bot] 55f80f7ada Merge commit 'd09099ad9e7e54f90a56a59a24b95d264c7b7375'
Change-source: pulse-maintainer
2026-10-08 10:50:55 +01:00
pulse-triage[bot] 8ae2a7e08e Merge commit 'bdc7ec2d3c3febc0e5fa1a6ccccebe30401c88b4'
Change-source: pulse-maintainer
2026-10-08 10:48:51 +01:00
rcourtman 55b6e5145e Let a clean full Patrol clear the stale coverage caveat (#2697)
Stacked on #2686 (which is stacked on #2682). It lands after them.

## Problem

Patrol > Activity > Finding options and history > Open work showed
**Check needed: Run Patrol to check everything and refresh open work.**
whenever `overall_health.factors` held any `coverage` factor. After a
clean full run, the backend keeps its `Recent Patrol errors` coverage
factor if an earlier run in its 24-hour window errored
(`summarizeRecentPatrolCoverage`). It drops the factor only when three
clean full runs lead that window or the errored runs age out. At the
default 6-hour interval that left about 12 hours of "Check needed" after
a clean check of every resource, while the header read `Last check: 4m
ago · checked 1377 resources`. One press of Check now did not clear it.
At impact 20 or 30 the factor lowers the grade to B or C, so the same
stale signal showed **Patrol needs review** instead.

## Change

- `getPatrolFindingsEmptyState` reads run history first, through the new
`getPatrolRunCoverage`:
- **complete**: the latest completed full run ended cleanly, checked
resources, finished within the last 24 hours (the backend's window), and
no run after it failed. A coverage factor no longer withholds the
all-clear, and a grade lowered only by coverage factors no longer counts
as degraded health.
- **incomplete**: the latest full run, or a run after it, ended with
errors, or only targeted or follow-up runs have completed. Shows "Check
needed". A failed targeted run after the last clean full run is new
here: it used to read green whenever the backend had dropped its factor.
- **unproven**: no completed run, or a clean full run that checked
nothing or finished more than 24 hours ago. The coverage factor decides,
as before.
- `getPatrolVerificationPresentation` was read only for its warning
tone. `getPatrolRunCoverage` replaces it. The activity-mix label had no
reader, so it goes, along with `getPatrolActivityBreakdown` /
`formatPatrolActivityBreakdown`, whose only caller it was.
- The backend factor and its three-run rule (documented in ai-runtime)
are unchanged.
- patrol-intelligence, api-contracts and frontend-primitives now
describe the reconciliation.

## Verification

- Vitest over `src/utils src/features src/components/AI
src/components/patrol src/components/shared src/pages src/hooks
src/__tests__`: all pass except `agentInstallCommand.test.ts`, which
fails on the Mac regardless of this change, and one `DockerHostsTable`
case that timed out under load and passes on its own. The new
empty-state cases fail against the parent tree.
- Browser: embedded builds of the parent (#2686) and this tree on
private DEMO_MODE stacks, with run history and overall health stubbed,
at desktop 1440x1000 and phone 390x844. Eleven scenarios per width,
including Check now while the factor is present. Results are in the
receipt.
2026-10-08 10:47:26 +01:00
rcourtman 46d3ca214f Point dev:verify at the Proxmox backups layout spec (#2747)
`npm run dev:verify` (and `dev:verify:lab`) still passed
`tests/17-recovery-layout.spec.ts` to the integration runner. That spec
was replaced by `tests/17-proxmox-backups-layout.spec.ts` in 1c38d79937,
and `scripts/tests/test-hot-dev-bg.sh` pinned the stale path.

The stale path never failed the pack. Playwright treats spec paths as
filters, so `test --list` with main's arguments reports 10 tests in 2
files and exits 0. The backups layout guard had silently dropped out of
the managed proof pack.

Changes:
- `scripts/hot-dev-bg.sh`: the default verify command names
`17-proxmox-backups-layout.spec.ts`.
- `tests/integration/tests/17-proxmox-backups-layout.spec.ts`: the
guards need the mock-mode estate. Under the managed hot-dev runtime
(`PULSE_E2E_USE_HOT_DEV`) they skip with an `npm run mock:on` hint when
mock mode is off. They do not switch modes in place, because
`Monitor.SetMockMode` clears the runtime's active alerts and in-memory
history. Other harnesses are unchanged; Core E2E runs mock mode.
- `scripts/tests/test-hot-dev-bg.sh`: pins the new spec and checks that
every spec the default verify command names exists under
`tests/integration`. Against main's `hot-dev-bg.sh` that check fails on
the retired path. The README pin is updated the same way.
- `tests/integration/README.md`, `deployment-installability.md`:
describe the real spec and what it checks.
- Removes two unused `RECOVERY_PRODUCT_SURFACE_EXACT_FILES` lists that
named deleted files.

Verification (private stacks on pulse-dev, not the shared dev stack):
- Fixed proof-pack arguments through `scripts/run-playwright.mjs`
against a mock backend: 7 passed, 5 skipped (spec 16 needs the managed
runtime), twice. An earlier run at load ~110 timed out on clicks in
specs 17 and 18; those passed on rerun.
- Gated spec 17 with `PULSE_E2E_USE_HOT_DEV=1`: 2 skipped against a
real-mode backend, 2 passed against a mock backend. Main's ungated spec
17 fails both tests against the same real-mode backend.
- `scripts/tests/test-hot-dev-bg.sh`: 288 passed. The touched functions
fail 4 assertions with main's `hot-dev-bg.sh` and README.
- `canonical_completion_guard_test` and `subsystem_lookup_test`: 304
tests OK.

The mock backend used a reduced estate (`PULSE_MOCK_NODES=5` and
similar). The default 50-node estate did not finish starting within 25
minutes on pulse-dev at load ~100.
2026-10-08 10:47:14 +01:00
rcourtman bdc7ec2d3c Honour operator splits in the linked physical disk join (#2748)
## Problem

`resolveLinkedPhysicalDisk` joins an agent SMART disk and the Proxmox
disk under one linked host by hardware identity or, for SAS and
identity-less disks, by device path. `ingestRecord` merges into that
match before `findMatch`, and nothing on that path read the operator's
exclusions. So `POST /api/resources/{id}/report-merge` on such a disk
(the drawer's **Split merged resource**) recorded its exclusions and
answered 200, while the disks merged again on every monitor rebuild and
in the resources API.

Reproduced on main before the change for a SAS disk (Proxmox serial =
transport address), matching NVMe serials and an identity-less USB disk:
after report-merge's exclusions, three rebuilds of a SQLite-backed
monitor adapter, the REST registry seeded from its listing and the REST
registry built from the snapshot all kept one merged disk.

## Change

- `resolveLinkedResource` refuses the linked-disk match when an
exclusion separates the observation's candidate ID from the disk it
matched. The match itself is computed as before, so a refused candidate
still counts toward the path join's ambiguity rule.
- `physicalDiskSplitLocked` decides that one way for the linked join,
`findMatch` and `physicalDiskIDForMachineLocked`: the exclusion may name
the existing disk's current ID, or the unscoped hardware-keyed or
machine-scoped ID either disk's identity mints. So a split also holds
across the re-key a same-serial disk on another machine causes, and
while the agent reports its disk without a serial (smartctl skips a disk
in standby). `findMatch` used to read only the current ID.
- Keying: the agent's disk keeps the merged ID (agent disks are ingested
first); the Proxmox disk takes the ID it holds alone, or its
source-specific ID when both share one hardware identity, as
`findMatch`'s excluded branch already did. An exclusion naming only the
agent's candidate splits nothing, since the agent's row holds the merged
ID.

## Not covered (documented in the unified-resources contract)

- Monitoring's `mergeHostAgentSMARTIntoDisks` still copies the linked
agent's SMART data into the Proxmox disk record before the registry sees
it, so a split Proxmox row keeps those values.
- Proxmox keys a disk by its slot, so a split disk that Proxmox reports
under another device path joins again until split again.
- Two split rows sharing a serial read one serial-keyed history, and a
PVE disk alert row matching both stays under its PVE reference (the
owner never breaks a tie by path).
- A split recorded while the agent's disk reported no serial, or against
an agent disk whose identity Proxmox does not carry, stops applying when
that changes, as every exclusion keyed by a canonical ID does.
- A report-merge filtered to the agent source leaves the disks merged;
the drawer sends every merged source.

## Verification

- `TestOperatorSplitSeparatesDisksJoinedInsideALinkedHost`: four disk
shapes × four requests (report-merge, Proxmox candidate only, agent
candidate only, unrelated), three rebuilds each across the monitor
listing and both REST builds, then relink, a second split, the agent's
disk in standby and waking, a same-serial disk appearing on another
machine, and a split recorded while it was present that holds after it
leaves.
- Negative controls, each failing the test: drop the linked-join check;
drop the `findMatch` arm; drop the unscoped-ID arm; drop the scoped-ID
arm; drop the incoming disk's IDs (standby step).
- Full `internal/unifiedresources` and `internal/api/resourceapi` suites
pass on the rebased tree.
2026-10-08 10:39:15 +01:00
rcourtman acb6bcfff6 Judge PBS and PMG freshness by the live polling interval (#2746)
Non-default organisations run tenant monitors against a detached config
copy (#1619). A saved PBS or PMG polling interval reaches them only as a
runtime override pushed by the system-settings save
(`SetPBSPollingInterval` / `SetPMGPollingInterval`). Their fixed-cadence
schedulers read that override, but `Monitor.resourceStaleThresholds`
still derived the PBS and PMG stale windows from the config copy.

Effect before this change: raising PBS from 1 to 5 minutes left the
stale window at 120s, so a healthy PBS or PMG instance in a non-default
organisation read stale (status `warning`) for part of every poll cycle
until the next restart. Lowering the interval left the window too long.

- `resource_stale_thresholds.go`: `resourceStaleThresholds` derives
PBS/PMG freshness from the same override-aware, clamped setting readers
the scheduler uses. An adaptive scheduler picks its own intervals and
ignores the overrides, so freshness there keeps its existing derivation.
`ResourceStaleThresholdsForConfig` stays config-only for adapter
construction, and the monitor overwrites those thresholds on its next
store update.
- `canonical_guardrails_test.go`:
`TestMonitorResourceStaleThresholdsFollowRuntimePollingOverrides` drives
a monitor with a `MonitorAdapter` through `updateResourceStore`. On
origin/main, a PBS sighting 3 minutes old reads `warning` under a
5-minute override, and the threshold stays 2m under a raised override
and 10m under a lowered one. All subtests pass with the fix. One subtest
also ties thresholds to `baseIntervalForInstanceType` for clamped
overrides (5s, 90s, 2h).
- `monitoring.md`: the derivation note now records the override rule.

Unchanged by design: the default organisation shares the server's config
pointer (the save writes it directly), and a PVE interval save reloads
every monitor from saved config.

Verification: the new test fails on origin/main's
`resource_stale_thresholds.go` and passes with the fix. The full
`internal/monitoring` package passes, and `go vet` is clean.
2026-10-08 10:37:54 +01:00
rcourtman 73ce61359c Remove WorkloadsSurface's unreachable built-in filter toolbar
WorkloadsSurface rendered its own WorkloadsFilter unless the caller
passed suppressFilterToolbar. Its only production mounts, the Proxmox
and vSphere overviews, have passed it since 58b2e10c1f hoisted one
page-owned toolbar above each surface, so the built-in branch never
rendered.

Drop the branch, the suppressFilterToolbar option, and the props only
it read (filterAriaLabel, filterSearchPlaceholder,
filterSearchEmptyMessage, filterStatusOptions), plus the kioskMode
state member nothing else read. defaultSortKey had no setter left at
all; the Docker page that passed it moved to its own containers table
in 223b11185f. It goes from the surface props, the controls hook and
WorkloadsFilter, whose Clear filters already reset to the default sort.

The Kubernetes surface tests now read the host filter from the state
the way the pages feed their toolbar, and source pins that named the
surface's toolbar now pin the two page toolbars; the platform-page AST
guardrail now forbids suppressFilterToolbar instead of requiring it on
both mounts. The websocket test lost its wait on the dead toolbar, so
each page suite now proves its own toolbar renders once, above the
surface, only when the workload state is ready. Contract prose that
described a surface-owned toolbar
(showFilterToolbar, tableOnly, the global Workloads page, Docker,
Kubernetes and TrueNAS surfaces) now says the page renders the only
toolbar.

No behaviour change. Before/after production builds on pulse-dev give
the same results on both overviews at desktop and phone width: one
page toolbar, search narrowing, the no-match empty state, Clear
filters, drawer pinning and Clear selection, kiosk mode. The dist
differs only by the removed code, CSS is unchanged, and the
WorkloadsSurface chunk drops from 57.25 to 56.86 kB gzip.
2026-10-08 10:36:47 +01:00
rcourtman 8464939716 Merge remote-tracking branch 'origin/main' into fix/declared-node-link-honours-split 2026-10-08 10:14:07 +01:00
pulse-triage[bot] 32f63980fb Preserve reviewed monitoring repairs with published mock-view manual links
Additively incorporate current upstream 0d79f1cdda without rewriting the reviewed fe246963e0 history.

Change-source: pulse-maintainer
2026-10-08 10:12:53 +01:00
pulse-triage[bot] d09099ad9e Retain notification guidance with current reviewed monitoring source
Change-source: pulse-maintainer
2026-10-08 10:06:07 +01:00
rcourtman 0d79f1cdda Apply operator links in the mock-mode unified view (#2732)
## Problem

In mock mode, operator manual links (`POST /api/resources/{id}/link`)
never reached the websocket broadcast or `/api/state` `resources`, even
after a restart, while `GET /api/resources/{id}` showed the pair merged.
`Monitor.currentUnifiedStateView` builds the mock view from the fixture
graph, and both the fixture build and the view's re-ingest used
`unifiedresources.NewRegistry(nil)`, so no links were loaded. The
resources API seeds its own store-backed registry from that same list,
which is why it showed the merge.

## Change

- `mock.UnifiedResourceSnapshotWithLinks(links)` builds the fixture
graph in `unifiedresources.NewRegistryWithManualLinks(links)`, a
registry with the links but no store. The links apply at each ingest
stage (snapshot, TrueNAS and vSphere records, then availability), as in
the live rebuild, so an availability check that names the folded
resource by source still projects onto the primary. Nothing the fixture
build ingests can write identity pins, successions or change rows.
Without links the shared memoized snapshot is used, as before.
- The monitor takes the links from its resource store's current
generation (`MonitorAdapter.ManualLinks`). It keys the cached mock view
on the fixture data version and the link list, so a link change rebuilds
the view without waiting for a fixture tick.
- The view's freshness is the key for the resources API's registry
cache, so it is now assigned in publication order. A view with the
published fixture freshness and links keeps the published value. Any
other view gets a value later than every view before it, so a slow build
that publishes late cannot reuse one.
- The real-mode fence view behind `currentModeReadState` still leaves
the links out. It only covers the short window after leaving mock mode,
before the registry rebuilds. Its comment now says why.

## Verification

- Private mock stack, websocket not blocked. Linked standalone agent
Apollo-114 into VM checkout-web-01.
- Before: REST showed the agent facet while `/api/state` and the
websocket `initialState` still listed the agent as its own row. Machines
still listed both linked agents.
- After: REST, `/api/state` and `initialState` agree, and they still
agree after a restart.
- On a websocket held open across the link, a `resourceDelta` upserted
the VM with sources `[proxmox, agent]` and removed the agent.
- A headless browser kept the Machines table open while the link was
posted from outside the page. The agent left the table in 3.6 s, with no
reload.
- New tests:
- `TestMockUnifiedViewAppliesOperatorManualLinks`
(monitor_host_agents_test.go)
- `TestFixtureGraphAppliesManualLinksAtEachIngestStage` and
`TestUnifiedResourceSnapshotWithLinksLeavesTheSharedSnapshotUnlinked`
(platform_fixtures_test.go)
- `TestRegistryWithManualLinksFoldsLikeTheStoreBackedRegistry`
(registry_test.go)
- Negative controls, run with `go test -overlay`. Each one fails at the
expected assertion:
  - main's monitor.go
  - a `NewRegistryWithManualLinks` that drops its links
  - monitor.go without the publication-order freshness rule
- `internal/unifiedresources/...`, `internal/monitoring/...` and
`internal/mock/...` pass on pulse-dev. In `internal/api/...` there are
two failures, and neither is from this change.
`TestServerInfoEndpointReportsDevelopment` fails because the synced tree
has no `.git`. `TestContainerBootstrapFailuresStopAndClean/hangup`
failed under load and passed 3 of 3 runs alone.

## Interplay with #2725 and #2739

Those landed while this was open. Unlink now deletes the pair's link, so
it reaches the mock view through `ManualLinks` at the adapter's next
rebuild, the same timing as live. The broadcast's presentation
coalescing now takes exclusions from the resource store adapter for
store-less views, and the mock view is one of those. The merge commit
updates the unified-resources notes for both changes.
2026-10-08 10:05:40 +01:00
pulse-triage[bot] e89797edcd Merge branch 'main' into fix/patrol-empty-state-coverage-reconcile 2026-10-08 10:03:25 +01:00
rcourtman 1289728ded Point dev:verify at the Proxmox backups layout spec
The managed proof pack behind `npm run dev:verify` still named
tests/17-recovery-layout.spec.ts, which 1c38d79937 replaced with
tests/17-proxmox-backups-layout.spec.ts in July. The registered proof for
scripts/hot-dev-bg.sh pinned the stale path, so nothing flagged it.

The stale path did not fail the pack. Playwright treats each spec path as
a filter, so `test --list` with main's arguments reports 10 tests in 2
files and exits 0: the Proxmox backups layout guard had silently dropped
out of dev:verify.

The replacement spec reads the mock-mode estate, while dev:verify attaches
to the managed runtime in whatever mode it was left in. Under the managed
runtime it now skips with a `npm run mock:on` hint when mock mode is off,
instead of flipping modes in place: Monitor.SetMockMode clears the
runtime's active alerts and in-memory history on every switch. The Core
E2E harness runs mock mode and is unaffected.

test-hot-dev-bg.sh now pins the new spec and checks that every spec the
default verify command names exists under tests/integration; against
main's hot-dev-bg.sh that check fails on the retired path. The integration
README and its pin, the deployment-installability contract clause, and two
unused RECOVERY_PRODUCT_SURFACE_EXACT_FILES lists naming deleted files are
brought in line.
2026-10-08 09:56:53 +01:00
rcourtman e4519ec5bc Pass thresholds to the broadcast coalesce in the split test
Main's #2745 gave MonitorAdapter.CoalesceForPresentation a thresholds
argument; the operator-split test still called the old signature, so
the merged branch did not compile. It passes nil, as the adapter's own
tests do, to keep the default freshness thresholds.
2026-10-08 09:56:37 +01:00
rcourtman e2e19dcc61 Honour operator splits in the linked physical disk join
resolveLinkedPhysicalDisk joins an agent SMART disk and the Proxmox disk
under one linked host by hardware identity or, for SAS and identity-less
disks, by device path, and ingestRecord merges into its match before
findMatch. Nothing on that path read the operator's exclusions, so
report-merge on such a disk (the drawer's Split merged resource) answered
200 while the disks merged again on every monitor rebuild and in the
resources API.

The join is now refused when an exclusion separates the observation's
candidate ID from the one disk it matched. physicalDiskSplitLocked decides
that for the linked join, findMatch and physicalDiskIDForMachineLocked
alike. It reads the existing disk's current ID and the unscoped
hardware-keyed and machine-scoped IDs either disk's identity mints, so a
split survives the re-key a same-serial disk on another machine causes,
and an agent disk reported without its serial (smartctl skips a disk in
standby) stays apart from a Proxmox observation still carrying it.
findMatch used to read only the current ID.

The agent's disk keeps the merged ID; the Proxmox disk takes the ID it
holds alone, or its source-specific ID when both share one hardware
identity, as findMatch's excluded branch already did. What the split
still does not reach (monitoring's SMART enrichment of the Proxmox disk,
slot renames, identity transitions) is written down in the contract.
2026-10-08 09:56:20 +01:00
rcourtman cdd7aa2d10 Merge remote-tracking branch 'origin/main' into fix/declared-node-link-honours-split 2026-10-08 09:53:05 +01:00
rcourtman 32a4f00c44 Judge PBS and PMG freshness by the live polling interval
Non-default organisations run tenant monitors against a detached config
copy (#1619), so a saved PBS or PMG polling interval reaches them only as
a runtime override pushed by the system-settings save. Their fixed-cadence
schedulers read that override, but resourceStaleThresholds still derived
the PBS and PMG stale windows from the config copy. Raising PBS to 5
minutes left the window at max(2 x old interval, 120s), so healthy PBS and
PMG rows in those organisations read stale and degraded between real
polls until the next restart; lowering the interval left it too long.

The monitor now derives those thresholds from the same override-aware,
clamped setting readers its scheduler uses. An adaptive scheduler picks
its own intervals and ignores the overrides, so freshness there keeps its
existing derivation. ResourceStaleThresholdsForConfig stays the
config-only derivation for adapter construction, which the monitor
overwrites on its next store update. The default organisation shares the
server's config pointer and PVE interval saves reload every monitor, so
neither needed the change.
2026-10-08 09:49:44 +01:00
rcourtman 5b5026fafd Record a relinked node/agent row as a manual-link fold
Main replaced the registry's linkMergedIDs with ManualLinkFold records
on the holder row (#2721), which canonical-ID succession, reference
resolution and report-merge all read. The relink of a split node and
its agent still wrote the removed map, so the branch no longer built
against main.

When a relink's absent side is one of a joined node+agent row's own
IDs, applyManualLinks now records it as a fold on that row, with the
side's own source. Pin succession then keeps the link instead of
re-keying it onto the joined ID, and a later report-merge of the joined
row names the relinked pair, which replaces the link.
2026-10-08 09:49:18 +01:00
rcourtman 25dc55335d Merge origin/main into fix/mock-view-manual-links
Resolve agent-lifecycle.md by keeping both sides (additive notes), and
bring the unified-resources notes in line with #2725 and #2739: unlink now
reaches the mock view through ManualLinks, and broadcast presentation
coalescing honours exclusions for store-less views.
2026-10-08 09:43:04 +01:00
pulse-triage[bot] fe246963e0 Merge reviewed full-hostname agent attribution repair
Preserve exact candidate 8b213d05d4 on Pulse main. Retained source-m1yh0d0b establishes focused normal/race/vet acceptance; source-y3abkhx2 has no test verdict. This is an attribution repair, not established reporter cause, native recovery or release availability.

Change-source: pulse-maintainer
2026-10-08 09:35:03 +01:00
pulse-triage[bot] e2967173d8 Preserve reviewed monitoring work with current upstream freshness thresholds
Additively incorporate the exact main repair that landed during this publication turn. Preserve every starting reviewed commit and the deferred browser qualification boundary.

Change-source: pulse-maintainer
2026-10-08 09:27:07 +01:00
rcourtman b0ba51db16 Judge resources API freshness by the monitor's thresholds (#2745)
Supersedes #2737, rebuilt on current main because its merge with main
conflicted in four files (`monitor.go`, `registry.go`,
`canonical_guardrails_test.go`, `code_standards_test.go`) and a
conflicted merge cannot pass the commit hooks.

The resources API rebuilt its registry from the monitor's unified seed
with the registry default stale thresholds, while the monitor judges
each source by its configured polling cadence (Proxmox/PBS/PMG: max(2x
polling interval, default)). Presentation coalescing used the defaults
too, in both the websocket broadcast and `/api/resources`.

Reproduced before the fix (Proxmox polled every two minutes, read 90
seconds after its poll, agent silent for 75 seconds):

| | websocket, before | `/api/resources`, before | both, after |
|---|---|---|---|
| Proxmox sighting | online | stale | online |
| node status | online | warning | online |
| CPU on a node whose agent keeps its own row (coalesced by hostname) |
70% (silent agent) | 70% (silent agent) | 20% (node's poll) |

## Change

- The seed carries the thresholds its registry generation was judged by.
`MonitorAdapter` records them per generation (`StaleThresholds`,
`GetAllWithMetricsTargetsAndStaleThresholds`). Thresholds set since the
last rebuild wait for the next one, and overlays and live supplemental
refreshes of a generation are judged by its thresholds. A view built
from mock fixtures or a bare snapshot reports none (the defaults it was
judged by).
- `Monitor.UnifiedResourceSnapshotWithStaleThresholds` and
`MultiTenantStateProvider.UnifiedResourceSnapshotWithStaleThresholdsForTenant`
hand seed, freshness and thresholds over from one view.
- `resourceapi` builds its registry with
`NewRegistryWithStaleThresholds` (ingests and stale passes that pass
none use them) and keys its cache on its own copy of the thresholds.
- The coalesce judges metric freshness with `metricSourceStale`, the
rule the stale pass and merge already share. The broadcast and
`ListForPresentation` pass their view's thresholds.
- Conflict resolution with main's exclusion-aware coalesce
(#2725/#2739): `ListForPresentation` passes both its exclusion filter
and its construction thresholds;
`MonitorAdapter.CoalesceForPresentation(resources, thresholds)` takes
the listing's thresholds next to the exclusions, and
`Monitor.coalesceResourcesForPresentation` passes the broadcast view's,
falling back to `CoalescePresentationHostResourcesWithStaleThresholds`.

Not changed here: Patrol's scoped runtime state still rebuilds
registries with the defaults, and PBS/PMG thresholds read the config
rather than runtime polling overrides. Both predate this change.

## Verification

- New: `TestContract_ResourceListJudgesFreshnessLikeTheMonitorBroadcast`
(default monitor, tenant monitor, monitor without a resource store, each
against the same monitor's broadcast),
`TestRegistrySeededFromMonitorJudgesFreshnessByItsThresholds`,
`TestMonitorAdapterReportsTheThresholdsItsGenerationWasJudgedBy`,
`TestUnifiedViewCarriesItsGenerationsStaleThresholds`,
`TestBroadcastCoalescedHostJudgesFreshnessByConfiguredThresholds`,
`TestResourceListRebuildsWhenSeedStaleThresholdsChange`, plus source
guardrails.
- Negative controls: each new test fails with its runtime piece reverted
(base `resources.go`, coalesce by defaults, providers dropping
thresholds, cache keeping the provider's map, adapter reporting pending
thresholds, overlay judged by pending thresholds).
- Full `internal/unifiedresources`, `internal/api/resourceapi` and
`internal/monitoring` suites pass. Re-run on the rebuilt tree: full
`internal/unifiedresources`, `internal/api/resourceapi` and
`internal/monitoring` suites pass, plus the stale-threshold,
resource-list and contract tests in `internal/api`. The full
`internal/api` suite passes except
`TestServerInfoEndpointReportsDevelopment`, which fails only because the
test tree had no `.git`.
2026-10-08 09:19:38 +01:00
rcourtman 415c09dafc Judge resources API freshness by the monitor's thresholds
The resources API rebuilds its registry from the monitor's unified
seed, but built it with NewRegistry and plain ingests, so its stale
pass used the registry defaults while the monitor judges each source
by its configured polling cadence. A Proxmox node polled every two
minutes read stale, with warning status, in /api/resources ninety
seconds after its poll while the websocket called it online.

Presentation coalescing had the same gap in both views. When it merges
an agent row the registry kept apart into its Proxmox node by hostname,
it picked each reading's source by the default thresholds, so the row
showed a silent agent's readings over a poll the registry calls
current.

The seed now carries the thresholds its registry generation was judged
by. The monitor adapter records them per generation: thresholds set
since the last rebuild wait for the rebuild that applies them, and the
read-state overlays and live supplemental refreshes of a generation are
judged by its thresholds, since re-judging a listing cannot clear a
stale verdict it already carries. The monitor lists a generation with
its thresholds from one registry pointer, and a view built from mock
fixtures or a bare snapshot reports none (the defaults it was judged
by). The monitor hands seed, freshness and thresholds over from one
view, per tenant too. The resources API builds
its registry with them (NewRegistryWithStaleThresholds: ingests and
stale passes that pass none use them) and keys its cache on its own
copy. The coalesce judges metric freshness with metricSourceStale, the
rule the stale pass and the merge already share, and both the
broadcast and ListForPresentation pass their view's thresholds. The
broadcast coalesce also honours the operator's merge exclusions, so the
adapter's CoalesceForPresentation takes the listing's thresholds next
to them.

Patrol's scoped runtime state still rebuilds registries with the
defaults.
2026-10-08 08:53:19 +01:00
rcourtman fe51ee2e67 Merge remote-tracking branch 'origin/main' into fix/declared-node-link-honours-split 2026-10-08 08:47:03 +01:00
pulse-triage[bot] b0dc796ffb Explain notification pause, cancellation and in-flight limits
Distinguish global cancellation from quiet-hours holds, queued policy skips from provider receipt, and settings changes from recall or credential revocation. Add connected sender and persistent-queue controls, and run the existing notification guidance suites in docs-only CI.

Contract-Neutral: Documentation, mirrors, tests and documentation-only CI describe existing notification behaviour; no runtime, API, permission, schema, dependency or user-visible feature contract changes.
Change-source: pulse-maintainer
2026-10-08 08:46:03 +01:00
pulse-triage[bot] 0a7d408ca7 Merge reviewed Windows service-removal containment
Retain exact Delivery commits, bounded SCM refusal before destructive continuation, and focused PowerShell 5.1 lifecycle controls. Linux proof does not establish native Windows acceptance.

Change-source: pulse-maintainer
2026-10-08 08:42:58 +01:00