mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
8d6f489d3e0e6c616daffe2d20bc1df1ff558a2d
6183 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8d6f489d3e |
Wrap long identity chips inside phone drawers (#2731)
Below the lg breakpoint, the inline drawer's Identity section rendered alias and IP chips as hand-rolled nowrap spans inside a fixed-layout table cell. Generated aliases such as `proxmox-lxc-docker:Production West:pve1:113` or `k8s-production-1:deployment:redis-ffcd-b17c78f012` are wider than that value cell on a phone, so the cell's hidden overflow and the drawer's clipping content shell cut them off with no ellipsis. The part that distinguishes two aliases is at the end, and a phone has no hover to show the title. - `MetadataBadge` (the contract owner of read-only chip whitespace handling) gains an opt-in `wrap` mode: the chip is capped at its container and breaks inside a token only where no space or hyphen fits. Default stays nowrap; existing consumers get byte-identical classes. - The drawer's IP, alias and alias-count chips in `ResourceDetailSummary.tsx` compose `MetadataBadge`; identity values wrap in full. Desktop drawers are unchanged (one line per alias). - The shared template registry requires the consumer and forbids the old hand-rolled chip classes there; the guardrail and unit tests cover the wrap classes. - `04-mobile.spec.ts` opens a Docker container drawer at phone width and fails if any Identity-section element or text crosses the detail cell, table wrapper or drawer shell (values that end in their own ellipsis excepted). Measured on a private mock estate at 390px: 53 cut chips across 92 Docker/Kubernetes/Proxmox/Machines drawers before, 0 identity chips past the shell across 140 drawers at 390 and 768px after. Rebased on main after #2703 (wider phone drawers): the new spec passes on the Pixel 5 and iPhone 12 projects and fails with the two runtime files reverted (`proxmox-lxc-docker:...:113` still 71px past the cell). Correlation-summary and redaction-label chips were checked and left alone: redaction labels are a fixed five-word vocabulary, and the correlation chips sit outside the nowrap table and break at spaces and hyphens. |
||
|
|
826252d274 |
Stop sending the ignored autonomous_mode chat field (#2730)
## Why
Since
|
||
|
|
2d5ea5758b |
Name sensor setup notice nodes the way the nodes table does (#2727)
The outdated sensor setup notice on the Proxmox page named each node by
its Proxmox node name, while the nodes table directly below it, the
outdated agent notice beside it and Infrastructure settings label the
same node by its display name. With display names set, the notice read
"pve3 is using an older temperature monitoring setup..." above a table
with no row called pve3.
The notice now leads with the label the nodes table shows for the row
and adds the Proxmox node name in parentheses:
> West Production C (pve3) is using an older temperature monitoring
setup that cannot read SATA/SAS disk temperatures.
Both names are needed. The user has to match the notice to a table row,
and the nodes table switches to the Proxmox node name in its narrowest
layout (it showed `pve1`, `pve3` at a 375px viewport and display names
at 414px and up). They also have to find the host to re-run the setup
script on, which is the Proxmox node. Nodes without a display name read
as before ("pve3 is using ...").
The node name is left out only when the label already contains it as a
whole word, ignoring ASCII case, or as a domain's host part (`PVE1`,
`pve3.lan`, `Rack 4 (pve3)`). This is deliberately stricter than
`hasAlternateDisplayName`, which the guest table's group header uses.
That helper normalises punctuation, so it would show `PVE3` alone for
node `pve-3` and hide the host the user has to act on.
The notice's own test file is also registered as proof for the
frontend-primitives policy that covers the notice, matching the sibling
agent notice's test.
Verification:
- Unit tests cover the renamed case, labels that already contain the
node name, labels that only resemble it (`PVE3`/`pve-3`, `pve5-backup`,
`pve10`/`pve1`, a non-ASCII lookalike), blank names and
`nodeName`-before-`node` precedence. The platformPage, SharedPrimitives
guardrail, resource-boundary, nodes and Proxmox page contract suites
pass (396 tests), and eslint, prettier and tsc are clean.
- Browser pass on a private mock stack with three legacy-format nodes,
at 1440, 768, 414 and 375 wide on Overview and Storage. The notice reads
"Affected: Automation Lab Standalone (standalone2), West Production A
(pve1), West Production C (pve3)." with no overflow and no console
errors. Before control with main's two source files: "Affected: pve1,
pve3, standalone2."
|
||
|
|
19b963be71 |
Drop the dead recovery serializer and its stale contract prose (#2728)
The aggregate Recovery page (`frontend-modern/src/components/Recovery/`,
`features/recovery/useRecoverySurfaceState.ts`) was deleted on
2026-05-26 (
|
||
|
|
d1ef487e09 |
Stop hidden tooltips and breakpoints listening to resize (#2724)
On `/proxmox` with 140 workload rows, 859 window `resize` listeners were active after load (203 at phone width with 36 rows), so every window resize ran one callback per listener: - 699 from `useTooltipState.ts` (`useTooltipLayoutState`), which registered a listener for every tooltip instance, including the hidden `TooltipPortal` on each row's metric bars and sparklines. - 156 from `useBreakpoint.ts`, where every call added its own listener and width signal. ## Change - Tooltip layout state listens only while its tooltip is visible and re-reads the viewport when it opens. `useTooltipPortalState` uses the same visibility gate as the portal's render (`when && supportsHoverTooltips()`), so a portal suppressed on a touch device holds no listener either. - `useBreakpoint` keeps one module-level width signal and shared memos, fed by a single rAF-debounced listener while at least one caller is mounted. A new caller syncs to the live `innerWidth`, so a caller created after resizes nobody heard starts correct. ## Evidence Live pass on a private mock-mode stack (headless Chromium, add/removeEventListener wrapped to track live listeners by registering frame): | | before | after | |---|---|---| | 1440×900, 140 rows | 859 active, 859 callbacks per resize | 5 active, 5 callbacks | | 390×844, 36 rows | 203 active | 5 active | - An open row tooltip adds exactly one listener, stays open through window resizes (its viewBox follows 1440×700 and 1380×640), and its listener is removed on close. - Crossing the phone breakpoint in both directions still switches the workload table (`workload-table--desktop` ↔ `--mobile`) and each row's layout. - New tests: `useBreakpoint.test.ts` (one listener for 50 callers, back-to-back resizes leave one pending frame, every caller crosses a breakpoint, a new caller starts from the live width) and `TooltipPortal.test.tsx` (hidden and touch-suppressed portals add no listener, an open portal re-clamps on resize, a portal opened after an unheard resize uses the live viewport). The listener assertions fail against main. - 198 frontend test files / 3620 tests pass across `components/shared`, `components/Workloads`, the hooks, AppLayout, AIChat, Docker tables and the boundary tests; prettier, eslint and tsc are clean. Contract notes: `frontend-primitives.md` (tooltip owner) and `performance-and-scalability.md` (new current-state section). `useBreakpoint.ts` has no owning subsystem, so its rule lives in the performance contract. |
||
|
|
6d8dc0aa73 |
Correct stale platform-page embed contract prose (#2726)
Several v6 subsystem contracts described platform pages embedding
canonical surfaces through controls that no longer exist: a `tableOnly`
/ `embedded tableOnly` mode, a `showFilterToolbar` prop, a
`RecoverySurface`, `forcedPlatformFilter`, an embedded `StorageSurface`
at `/truenas/storage`, and a TrueNAS Protection tab built on a Recovery
surface. The aggregate Recovery surface was deleted on 2026-05-26
(
|
||
|
|
b7bdf1b4eb |
Split a manually linked pair when the API unlinks it (#2725)
## Problem
`POST /api/resources/{id}/unlink` answered 200 "Resources unlinked" but
only recorded a `ResourceExclusion`. The manual link row stayed in
`resource_links`, and `ResourceRegistry.applyManualLinks` never consults
exclusions, so the pair stayed merged in the monitor's registry
rebuilds, the resources API and the websocket broadcast. Repro: link a
standalone agent into a VM, unlink it, then `GET /api/resources/<vm>`:
the agent facet is still there and the agent row is still gone.
Separately, the websocket broadcast coalesced host views with
`CoalescePresentationHostResources`, which applies no exclusions, while
REST's `ListForPresentation` applies the registry's, so a host pair the
operator split could be one broadcast row and two REST rows.
No frontend code calls `/api/resources/{id}/link` or `/unlink`
(`/api/agents/agent/unlink` is a different endpoint).
## Semantics
A resource pair carries one operator decision, and the later one
replaces the earlier: a link joins the pair, an exclusion (unlink,
report-merge) keeps it apart.
- Store writes: `AddExclusion` deletes the pair's link rows and
`AddLink` deletes its exclusion rows, in one transaction, matching both
ID orders; each also deletes a row of its own kind that canonical-ID
succession stored in the other order. The in-memory store does the same
and stamps `CreatedAt` like SQLite.
- Store reads: `GetLinks` / `GetExclusions` keep only each pair's latest
decision (`effectiveManualPairDecisions`), so rows written before this
change or rewritten onto one pair by succession resolve by their later
write; a tie keeps the pair apart.
- Registry: `loadOverrides` resolves its two reads together, so an
unlink committed between them cannot load both decisions into one
generation.
- Identity pins: while a Proxmox node was linked to an agent, the merged
resource's pin could record the node's endpoint hostname with the
agent's machine key. After unlink the node completed that key, minted
the agent's ID and folded into it by ID, a path no exclusion check sees.
A pin no longer completes the identity of a record the operator split
from the pinned resource.
- Broadcast: coalesces through `MonitorAdapter.CoalesceForPresentation`
on the read state that listed the rows when its registry is store-backed
(the store's adapter or a host-continuity overlay), else on the resource
store's adapter (the mock view lists through a store-less registry),
sharing `presentationExclusionFilter` with `ListForPresentation`.
In production REST seeds from the monitor's already-linked listing, so
an unlink shows on REST, `/api/state` and the websocket from the
monitor's next registry rebuild, the same generation in which a new link
reaches the broadcast.
## Known limits (unchanged, documented in the contract)
- Report-merge derives candidate IDs from the merged resource's type, so
it does not name the folded side of a cross-type manual link and cannot
undo one; unlink does.
- An agent's own declared node link (`resolveLinkedResource`) still
ignores exclusions.
- Presentation coalescing compares the IDs of the rows being merged, so
a third same-host fragment that merges first can carry an excluded pair
into one row (REST and broadcast now agree on this).
## Verification
- `TestResourceUnlinkSplitsManuallyLinkedPair` (new
`resources_unlink_test.go`) drives link, unlink and relink through the
API handlers against a `MonitorAdapter` on the same SQLite store,
rebuilt twice per step. It failed on main at "after unlink" exactly as
reported.
- `TestUnlinkSplitsHostPairWhosePinTookTheOtherSidesKey`: node/agent
pairs (IP and FQDN endpoints) through three rebuilds per step; both FQDN
cases fail with the pin guard removed.
- `TestManualPairDecisionReplacesThePreviousOne`,
`TestManualPairDecisionReadsResolveStoredConflictsByRecency`,
`TestRegistryResolvesDecisionsAcrossItsTwoReads`,
`TestMonitorAdapterCoalesceForPresentationHonoursExclusions`,
`TestBroadcastPresentationCoalesceHonoursMergeExclusions`,
`TestBroadcastPresentationCoalescePrefersTheListingReadState`.
- Negative controls (each fails with its fix reverted): store write
rule, newest-link-per-pair, registry combined resolution, pin guard,
broadcast exclusion coalesce, broadcast read-state preference,
store-less adapter fallback.
- Full `./internal/unifiedresources/...`,
`./internal/api/resourceapi/...` and `./internal/monitoring/...` pass.
- Live replay on private mock stacks built from main and from this
branch (`POST .../link`, `.../unlink`, `.../link` on a mock VM and a
standalone agent, then `GET /api/resources/{id}` for both). Main: after
unlink the VM still carries the agent facet and the agent row is still
404, unchanged 25 s later. This branch: after unlink the VM has no agent
facet and the agent row is back at once; relink folds it again.
Report-merge and agent-declared node links are left for separate
follow-ups.
|
||
|
|
ce338b1496 |
Remove the unreachable Workloads group drawer and group pin (#2723)
## Why `useWorkloadsState` defaulted `groupNodeDrawerMode` to `'inline'`, and only that mode wired group-header hover preview, click-to-pin group scope and an inline `NodeDrawer` under the group row. The only page that used the default, the standalone `/workloads` page, was retired in May. Both production mounts have passed `'disabled'` since host drawers moved to each page's own hosts table: Proxmox in |
||
|
|
39b6007f5d |
Pin each manual-link side from its own identity (#2719)
Stacked on #2715 (its commit is this branch's parent; whichever lands first, the other still merges). ## Problem A manual link merges two resources for display, but `PersistIdentityPins` pinned the merged identity. A link primary was pinned with the folded resource's machine key, and the store's one-owner-per-key rule deleted the folded resource's own pin. For a Proxmox node with a qualified endpoint (`https://pve1.example.test:8006`) linked to a host agent, the node's pin records the endpoint, so the next rebuild completed the agent's machine key from it, minted the agent's machine-derived ID and merged the two by ID before the link applied. With the node as primary, succession then rewrote the link into a self-link on the agent's ID; in either direction, removing the link left the pair merged. Reproduced through `MonitorAdapter.PopulateSnapshotAndSupplemental` with a `MemoryStore` on #2715's head. ## Change - `applyManualLinks` captures each side's own pin before the merge (`recordLinkOwnPin`, first capture per rebuild wins), and `PersistIdentityPins` writes those instead of the merged projection, keeping a pinnable folded side pinned under its own canonical ID. - Link sides write through a new `ResourceStore.ReplaceResourceIdentityPins` (both stores). Like an upsert the row keeps fields the side's own pin leaves empty, but it drops a strong key another resource holds this rebuild, which is how a key an earlier release borrowed from the merged identity leaves the row. - A folded side drops a key a live resource or an earlier folded side already holds instead of trading it every rebuild, and writes no pin when that would cost it its machine evidence (a cluster-only row would be open to cluster-slot succession). - Proxmox auto-links (LinkedAgentID / identity matcher) merge before any manual link applies, so their boot-window pins are unchanged (`TestIssue1753*`, `TestMonitorAdapterRebuildPersistsIdentityPins` pass). Known limits, written into the contract: a node row an earlier release already filled with the agent's key still merges the pair by ID while the node can complete from it; two live resources sharing a strong key still alternate, as on main; evidence a later record pass of the same rebuild merges into an already-joined row reaches neither side's pin. ## Verification - New tests in `registry_merge_policy_test.go` (standalone and clustered node, both link directions, three rebuilds with no steady-state pin writes, then unlink separates the pair; shared-key, kept-field, hostname-guard, machine-loss and borrowed-key cases) and `store_test.go` (replace vs upsert on memory and SQLite). - Each new test fails against #2715's runtime files via `go test -overlay`; each guard rule fails its own test when removed. - `internal/unifiedresources` full package, `internal/monitoring` link/identity tests, `internal/api/resourceapi`, `go build ./...` and `go vet` pass. - Contract: `unified-resources.md` clause 25 and "Operator links reach record-ingested resources". |
||
|
|
bb35a5c006 |
Correct stale Storage embed and History range contract prose (#2718)
Three contract passages described toolbar controls that no longer exist. **storage-recovery.md (StorageSurface embed).** The contract said platform pages opt into the Storage toolbar through a `showFilterToolbar` prop on `StorageProps`, keep `tableOnly` to hide a storage summary section, and get a chart-collapse control. It also mentioned `RecoverySurface`, `forcedPlatformFilter`, and an embedded `StorageSurface` at `/truenas/storage`. On main: - `Storage.tsx` always renders `StoragePageControls`; only kiosk mode hides it. - There is no summary section and no chart-collapse control. - The Proxmox Storage tab is the only embed. It passes `forcedSourceFilter`, `resourceSource`, `suppressNodeFilter`, `filterAriaLabel` and search copy. - `/truenas/storage` renders `TrueNASStorageTopologyTable`. The rewrite describes what `Storage.tsx`, `StoragePageControls.tsx` and `useStoragePageFilters.ts` actually do: - The forced source is read as the route source and never written back to the URL. - The Source chip is auto-suppressed and does not surface the clear action. - `suppressNodeFilter` drops the Node chip and resets a selected node to all nodes. **performance-and-scalability.md and frontend-primitives.md (Workloads History range).** Both said the sparkline range shows only while Trends is active and carries a `Trend range` label. `WorkloadsFilter` actually shows it in Trends, and in Bars at desktop widths while Row hover is `History`. Its label is `History`. **Test.** A new rendered test in `Storage.test.tsx` pins the embed behaviour the rewrite claims. Five negative controls each fail it: - dropping the forced-source suppression - dropping the node reset - unlocking the route source - gating the toolbar off - keeping the Node filter The `History` label was already pinned by `WorkloadsFilter.test.tsx` and `WorkloadsSurface.performance.contract.test.tsx`. Another stale test title, "table-only embedding", is renamed. Sibling stale passages are left for a follow-up: `embedded tableOnly` in frontend-primitives.md and cloud-paid.md, the WorkloadsSurface `showFilterToolbar` text in performance-and-scalability.md, and the TrueNAS StorageSurface line in unified-resources.md. |
||
|
|
1eb4a84fe6 |
Drop the dead recovery serializer and its stale contract prose
The aggregate Recovery page and its route-state owner were deleted on 2026-05-26, but RECOVERY_QUERY_PARAMS, parseRecoveryLinkSearch and buildRecoveryRouteSearch stayed in resourceLinks.ts with no production caller, which kept recoveryItemTypePresentation.ts alive as their only reader. Both go. The frontend-primitives, storage-recovery, unified-resources, alerts and cloud-paid contracts still described the deleted page as live: its shell, event-first workspace, section owners, filter and column models, recovery route state, and the incident panel's link chips. Those passages now say what exists. Recovery rows render in the Proxmox Backups and TrueNAS Protection tabs, useRecoveryPoints decodes every recovery-points read, and rules that only governed the deleted page are gone or reduced to a reintroduction guard. New assertions pin the rewritten claims: resourceLinks exports nothing recovery-shaped, the recovery-points hook hands out normalized points and is the only reader, the retired hooks stay deleted, each tab keeps its own view and filter state, and the incident panel imports no route helpers. |
||
|
|
d95a316118 |
Stop sending the ignored autonomous_mode chat field
Since
|
||
|
|
59cc3ce10b |
Wrap long identity chips inside phone drawers
Below the lg breakpoint, the inline drawer's Identity section rendered alias and IP chips as hand-rolled nowrap spans inside a fixed-layout table cell. A generated alias such as proxmox-lxc-docker:Production West:pve1:113 or k8s-production-1:deployment:redis-ffcd-b17c78f012 is wider than that value cell on a phone, so the cell's hidden overflow and the drawer's clipping content shell cut it off with no ellipsis. On a 390px mock estate 53 chips across Docker and Kubernetes drawers were cut. The part that tells two aliases apart sits at the end, and a phone has no hover to show the title. MetadataBadge owns read-only chip whitespace handling, so it gains an opt-in wrap mode that caps the chip at its container and breaks inside a token only where no space or hyphen fits. The drawer's IP, alias and alias-count chips now compose MetadataBadge, and the identity values wrap in full. The default stays nowrap, so existing badge consumers are unchanged, and desktop drawers still show each alias on one line. The shared template registry now requires ResourceDetailSummary to compose MetadataBadge and forbids the old hand-rolled chip classes there. A mobile integration check opens a Docker container drawer and fails if any Identity-section element or text crosses the detail cell, table wrapper or drawer shell; it fails on the old code and passes with this change. |
||
|
|
4d4e2e0159 | Merge remote-tracking branch 'origin/main' into fix/sensor-setup-notice-names | ||
|
|
371435e1eb |
Name sensor setup notice nodes the way the nodes table does
The outdated sensor setup notice named each node by its Proxmox node
name ("pve3") while the nodes table below it, the outdated agent notice
beside it and Infrastructure settings label the same node by its
display name ("West Production C"). A user with display names set could
not tell which row the notice meant.
The notice now leads with the label the nodes table shows for the row
(the resource name, else the id) and adds the Proxmox node name in
parentheses: "West Production C (pve3)". Both names are needed. The
nodes table switches to the Proxmox node name in its narrowest layout,
and the setup script is re-run on that node, so the exact native name is
what the user needs to find the host.
The node name is left out only when the label already contains it as a
whole word, ignoring ASCII case, or as a domain's host part ("PVE1",
"pve3.lan"). This is deliberately stricter than hasAlternateDisplayName
in the guest table's group header, which normalises punctuation and would
show "PVE3" alone for node "pve-3", hiding the host to act on.
The notice's own test file was never registered as proof for the
frontend-primitives policy that covers the notice, unlike the sibling
agent notice's test, so it is added to that policy's exact_files.
|
||
|
|
4cf1c318fb |
Correct stale platform-page embed contract prose
Several v6 contracts still described platform pages embedding canonical surfaces through controls that no longer exist: a `tableOnly` / `embedded tableOnly` mode, a `showFilterToolbar` prop, a `RecoverySurface`, an embedded `StorageSurface` at /truenas/storage, and a TrueNAS Protection tab built on a Recovery surface. The aggregate Recovery surface was deleted on 2026-05-26 and neither WorkloadsSurface nor Storage has an embed mode. The platform-page rule in frontend-primitives.md (and its cloud-paid counterpart) now says what the pages do: the Proxmox and vSphere Overview tabs embed WorkloadsSurface with the page owning the workloads state and the one WorkloadsFilter toolbar, the Proxmox Storage tab embeds StorageSurface with forcedSourceFilter, and every other tab, including /truenas/storage, Proxmox Backups and TrueNAS Protection, renders a platform-owned table on the shared platform-page primitives. The old rule said pages only embed canonical surfaces and must not add per-family tables; that has not been true since the platform-first migration, so the rule now names the three embeds, keeps those tabs' rows on them, and keeps the bans on estate summary panels and forked table primitives. cloud-paid defers to that rule instead of carrying a second copy. The new guardrail case pins those embeds per page, the absence of an embed-mode prop on both surfaces, the platform lock on the Workloads embeds, and the recovery tabs' platform-owned tables, so the contract and the code cannot drift apart silently again. |
||
|
|
58ee64fc1c |
Split a manually linked pair when the API unlinks it
POST /api/resources/{id}/unlink recorded only an exclusion. The link row
stayed, and applyManualLinks never consults exclusions, so the monitor's
rebuilds, the resources API and the websocket kept the pair folded while
the API answered "Resources unlinked".
A pair now carries one operator decision. AddExclusion deletes the pair's
links and AddLink its exclusions, in one transaction and in either ID
order. Reads keep each pair's latest decision, so rows written before
this change, or rewritten onto one pair by canonical-ID succession,
resolve by their later write, and a registry resolves its two reads
together so an unlink landing between them cannot load both.
While a Proxmox node was linked to an agent, the merged resource's
identity pin could take the agent's machine key. After unlink the node
completed that key, minted the agent's ID and folded into it by ID, where
no exclusion check runs. A pin no longer completes a record the operator
split from the pinned resource.
The broadcast coalesced host views without exclusions while REST's
ListForPresentation honoured them, so a split host pair was one websocket
row and two REST rows. The broadcast now coalesces through the read state
that listed the rows, with the same exclusion filter.
|
||
|
|
9daa8154c8 | Merge remote-tracking branch 'origin/main' into perf/shared-resize-listeners | ||
|
|
03e4920460 |
Stop hidden tooltips and breakpoints listening to resize
On /proxmox with 140 workload rows, 859 window resize listeners were active after load (203 at phone width with 36 rows), so every window resize ran one callback per listener and each set signals. 699 came from tooltip layout state registering a listener for every instance, including the hidden TooltipPortal on each metric bar and sparkline, and 156 from every useBreakpoint() call adding its own listener. Tooltip layout state now listens only while its tooltip is visible (a portal suppressed on a touch device counts as hidden) and re-reads the viewport when it opens. useBreakpoint keeps one module-level width signal fed by a single rAF-debounced listener while any caller is mounted, and a new caller syncs to the live innerWidth. The page now holds 5 listeners at both widths. An open tooltip adds one and removes it on close, still follows window resizes, and crossing the phone breakpoint still switches the workload table and row layout. |
||
|
|
03767f1e95 |
Remove the unreachable Workloads group drawer and group pin
useWorkloadsState defaulted groupNodeDrawerMode to 'inline', but only the standalone /workloads page used that default, and it was retired in May. Both production mounts, the Proxmox and vSphere overviews, have passed 'disabled' since host drawers moved to each page's own hosts table ( |
||
|
|
1a094646d7 |
Let only POST narrate single-resource reports with Pulse Assistant (#2716)
## What
Single-resource report generation (`/api/admin/reports/generate`) now
takes `POST` with a JSON body as its canonical transport, and only
`POST` may narrate a PDF with Pulse Assistant.
- **POST**: the same fields as before (`resourceType`, `resourceId`,
`format`, `metricType`, `start`, `end`, `title`) in a JSON body, decoded
with the `generate-multi` rules (1MB cap, unknown fields and trailing
payload rejected). When Pulse Assistant is configured, a PDF's executive
summary is narrated by the AI provider as before.
- **GET**: still accepts the same fields as query parameters and returns
the same report, but always uses the deterministic summary. The PDF
shows a short note in place of the "Configure Pulse Assistant" tip,
which would be wrong for a tenant that already has the Assistant
configured.
- **GET never resolves the AI service at all.** It reads Patrol findings
only from a tenant AI service that is already running, through a new
`AISettingsHandler.ExistingAIService` lookup. Constructing a tenant
service through `GetAIService` can list provider models or start
background discovery, so a report GET must not trigger it. The router's
reporting wiring moves into `Router.wireReportingAIResolvers` so a test
can drive it.
- **Other methods**: `405` with `Allow: GET, POST`.
- The shared report body decoder now also returns `body_too_large`,
rather than `invalid_body`, when the cap is hit while reading past a
valid value.
- The reporting catalog advertises
`performanceReport.singleResourceMethod: "POST"`, and the settings UI
follows it. A catalog without the field, and the legacy fallback
catalog, keep the GET query transport that older backends accept. Fleet
reports were already `POST`.
- `docs/API.md` and `docs/MSP.md` (and their shipped copies) document
both methods.
## Why
Before this change, a GET for a PDF resolved the tenant's AI narrator.
That made a paid provider call and recorded a usage event in the AI cost
ledger. GET passes the demo-mode guard and the CSRF check, and a
SameSite=Lax session cookie is sent on cross-site top-level GET
navigations. So following a link could spend a signed-in admin's AI
budget.
GET stays available rather than returning `405` because the API
reference and the MSP guide documented it for scripted exports. Breaking
those scripts would add no protection: CSV is never narrated, and GET no
longer reaches the provider.
## Verification
- `TestContract_SingleReportAINarrationRequiresPOST` drives a real
report engine with a counting narrator. GET, HEAD, PUT and DELETE make
zero narrator calls and POST makes one. The GET narrative is heuristic
and carries the note. As a negative control, the same test fails against
the handler on `main` via `go test -overlay` ("GET report called the AI
narrator 1 times, want 0").
- `TestContract_SingleReportGETDoesNotConstructTenantAIService` drives
the router's real resolvers with an uncached tenant: GET constructs no
tenant AI service, while POST resolves one. A negative control that
swaps `ExistingAIService` back to `GetAIService` fails it ("GET report
constructed 1 tenant AI services, want 0").
`TestAISettingsHandler_ExistingAIServiceNeverConstructs` covers the
lookup itself.
- Handler tests cover the POST body rules (empty, unknown field,
trailing value, form-encoded body, fields only in the query, oversized
padding) and the `Allow` header. The catalog contract snapshot includes
the new field.
- Frontend tests cover POST bodies, the GET transport for catalogs that
name GET, parser defaults and validation, and the legacy fallback.
`docsLinks.test.ts` ties the docs to the handler.
- Browser pass on a private stack: real login, dev Pro licence, and
Pulse Assistant pointed at a local stub OpenAI-compatible provider that
logs every call. Checked at 1280×800 and 390×844:
- Generating a single-resource PDF from Settings → Data & Reports sends
one `POST` with a JSON body, downloads the PDF and calls the provider
once. The downloaded PDF carries the AI narrative.
- CSV makes no provider call. The fleet report still posts to
`generate-multi`.
- A top-level browser navigation to the GET URL with the session cookie
downloads the deterministic PDF, with the new note, and makes no
provider call.
- HEAD, PUT, DELETE and PATCH return `405`, and a session POST without
the CSRF header returns `403`.
- The AI usage summary lists `report_narrative` only for the UI POSTs.
- Full `./internal/api/...` and `./pkg/reporting` run on the dev VM:
everything passed apart from two tests that fail there for environment
reasons in code this change does not touch
(`TestServerInfoEndpointReportsDevelopment` needs `.git`; a `configapi`
shell-hangup bootstrap test).
The same class of issue on the Assistant session routes
(`/api/ai/sessions/{id}/summarize` and friends) is handled separately in
#2670.
|
||
|
|
fd36869223 |
Judge source merges by the ingest's stale thresholds (#2717)
Stacked on #2715 (its commit is the parent of this one); either can land first. ## Problem `mergeInto` passed nil thresholds to `mergeMetrics`, so every source merge into an existing row (snapshot and record ingest alike) judged metric freshness by the registry defaults, while the stale pass and manual links used the monitor's configured thresholds. Two visible results: - A Proxmox node polled every two minutes (four-minute threshold) merged with its auto-linked agent showed the silent agent's CPU and memory, while the stale pass called the node's poll current. - A supplemental refresh (`MonitorAdapter.PopulateSupplementalRecords`, and the read-state overlay) of a linked vSphere VM kept the in-guest agent's memory over vSphere's current reading, where a full rebuild from the same records shows vSphere's. ## Change - Snapshot and record ingest hold the caller's thresholds on the registry while they run (the same per-pass pattern as the guest loop's agent index), and `mergeInto` passes them to `mergeMetrics`. The snapshot helpers that reach `mergeInto` keep their signatures. - The stale pass and the metric merge share one threshold rule, `sourceStaleThreshold`, so the merge also honours a sighting's expected update interval (slow Proxmox disk inventory), as the stale pass already did. - Outside mock mode the monitor configures only Proxmox, PBS and PMG, so those are the production sources this changes; mock mode also configures TrueNAS, vSphere and availability. ## Proof - `TestMonitorAdapterSourceMergesUseConfiguredStaleThresholds`: rebuild, supplemental refresh and overlay of a linked vSphere VM, plus a node with an auto-linked agent, through `NewMonitorAdapterWithStaleThresholds`. - `TestSourceMergesJudgeFreshnessByTheIngestStaleThresholds`: snapshot node+agent merge with and without thresholds. - `TestMetricMergeFreshnessMatchesTheStalePass`: the merge and the stale pass agree for default, configured and expected-interval sightings. - Negative control with #2715's `registry.go`: the supplemental, overlay, node+agent, snapshot and expected-interval cases fail; the rebuild case and the defaults case pass on both. - `go test ./internal/unifiedresources/... ./internal/monitoring/... ./internal/api/resourceapi/... ./internal/mock/...` on pulse-dev. |
||
|
|
7f45148d15 |
Drop the unused Patrol readiness-failure object behind the toggle toast (#2687)
Stacked on #2686 (which stacks on #2682 and #2674); this branch carries
their commits until they land. Only the top two commits are new.
## What
`buildPatrolSettingsReadinessFailure` built a full
`PatrolConfigurationFailureInput` for the Patrol page's inline "needs
attention" state. That state lost its render in
|
||
|
|
796a70999b |
Stop tenant incident stores racing on shared ID counters (#2711)
Each tenant monitor owns its own `IncidentStore`, but incident and incident-event IDs came from package-level `int64` counters bumped with `++` under nothing but the calling store's mutex. Two tenants recording alerts at once raced on them. `go test -race` failed `TestLeavingMockModeClearsEveryRunningTenantMonitor` on #2698 ([run](https://github.com/rcourtman/Pulse/actions/runs/37652317098/job/112919080928)) at `incidents.go:1753`. The remediation record counter in `remediation.go` had the same shape, and `startPatrolForContext` creates one remediation log per org. **Fix** - All six `internal/ai/memory` ID generators (incident, incident event, remediation record, memory, incident memory, pattern memory) now mint through one atomic helper, `nextSequencedID` in `ids.go`. - The numeric suffix no longer wraps at 1000. Even with atomic counters, a generator that minted more than a thousand IDs in one wall-clock second repeated an ID. That matters because `RecordNote` finds an incident by ID and the incident query deduplicates merged events by ID. The `<prefix><YYYYMMDDhhmmss>-<n>` layout is unchanged. Only `n` can now exceed three digits. - `intToString` lost its last runtime callers and is removed. **Proof** - `internal/ai/memory/ids_test.go` mints 2,400 IDs from every generator on 16 goroutines and records alerts into two tenant stores concurrently. - Against the `origin/main` runtime files (via `-overlay`), `-race` reports DATA RACE at `incidents.go:1753/1760` and `remediation.go:355`, and every generator fails the uniqueness/shape check: four produce duplicate IDs, and `mem`/`pat-mem` produce a wrapped `-0` suffix. - With the fix, `go test -race ./internal/ai/memory/` passes, and `go test -race -count=5 -run TestLeavingMockModeClearsEveryRunningTenantMonitor ./internal/monitoring/` passes. **Residual:** across restarts, only the timestamp separates IDs. If the local clock repeats a second (DST fall-back or a backward step), a restarted sequence could still meet an earlier suffix. Persisted IDs and the stored format are unchanged. The `ai-runtime` contract records this. |
||
|
|
89a05ed735 |
Pin each manual-link side from its own identity
A manual link merges two resources for display, and the durable identity pin was written from that merged identity. A link primary was pinned with the folded resource's machine key, and the store's one-owner-per-key rule then deleted the folded resource's own pin. A Proxmox node's pin records its qualified endpoint as the hostname, so on the next rebuild the node completed the linked agent's machine key from that pin, minted the agent's machine-derived ID and merged with the agent by ID before the link applied. With the node as primary, succession then rewrote the link into a self-link on the agent's ID; in either direction, removing the link left the pair merged. The registry now captures each link side's own pin when the link first joins it in a rebuild, and PersistIdentityPins writes those instead of the merged projection, keeping a pinnable folded side under its own canonical ID. Proxmox auto-links merge by identity before any manual link applies, so their boot-window pins are unchanged. A link side rewrites its whole row through a new ReplaceResourceIdentityPins. Like an upsert it keeps stored fields its own pin leaves empty, but it drops a strong key another resource holds, since an earlier release borrowed that key from the merged identity and the store's one-owner rule would otherwise delete one row or the other. A folded side drops a strong key a live resource or an earlier folded side already holds, so the two never trade it on every rebuild, and writes no pin when that would leave it without its machine evidence. A node row an earlier release filled with the agent's machine key is handed back only when the node cannot complete from it. With a qualified endpoint or a cluster name, completion still supplies the borrowed key before the link applies, so such a pair stays merged. |
||
|
|
c5416b6800 |
Alert on the Unraid temperature an agent disk shows (#2701)
Stacked on #2698 (which sits on #2694, #2685 and #2679); this PR's own change is the top commit, "Alert on the Unraid temperature an agent disk shows", plus a merge of main for an alerts.md anchor both touched. ## Why A host agent's physical disk can show a temperature that only the host's Unraid inventory reports: - a SMART row whose smartctl probe returned no temperature takes its Unraid row's reading (`HostSMARTDiskTemperature`), and - a disk only the inventory lists, such as a `--disk-exclude` match, is built from its Unraid row alone (`HostUnraidDiskTemperature`). Physical Disks (Temp cell and the `Running Hot` verdict, "at or above its alert threshold") and Patrol judge that reading against the alert disk temperature policy, but `CheckHost` only read the SMART row's own field. A disk could read as at its alert threshold while no alert fired. Unraid's own warning/critical disk temperature notifications do not make this redundant: Pulse already alerts on the smartctl readings and array state of the same hosts, which Unraid also notifies on. ## What changes - `unifiedresources.HostDiskTemperatureReadings(host)` lists the reading each of a host's disks shows, built with the registry's own adapters: one per SMART row the registry ingests, then one per Unraid row whose disk key no SMART row carries (the same rule as the history writer in #2698). - `CheckHost` judges those readings: - SMART rows keep their `agent:<host>/disk_temp:<device>` resource IDs. An inventory-only disk uses its block device token, which is the ID a plain Linux SMART row for that device uses, so a later SMART reading continues the same alert. - Per-type thresholds and host overrides use the disk type the disk shows. - Only a reading collected now fires or resolves, so a spun-down disk holds its alert. - When conflicting serials put two disks on one device, its alert judges the reading that stands highest against its own type's threshold. - An inventory-only disk matching the agent's `--disk-exclude` raises nothing, and an alert it raised before the exclusion resolves on the next report. This matches the documented exclusion from monitoring and how linked Proxmox hosts already treat excluded disks. - Absence cleanup still runs only on a non-empty SMART list. ## Verification - New alert tests in `internal/alerts/host_unraid_lifecycle_test.go` cover: the fallback, an inventory-only disk, an empty SMART list, two disks on one device (also of different types), a controller member under another device label, standby, exclusion, disk-type and host-override thresholds, holds, handover to a SMART reading, departure, and the exclusion clear. - Each fails on the previous `host.go`. - Removing any guard (exclusion skip, exclusion clear, disk type, collected-now, threshold ranking) fails its own test. - `TestHostDiskTemperatureReadingsMatchTheDisksTheRegistryShows` compares every reading with the disk the live registry ingests, including conflicting serials, a virtual device and a MegaRAID member Unraid lists under its block device. - `go test ./internal/alerts/... ./internal/unifiedresources/...` passes on the merged tree, as do the Unraid/SMART/temperature tests in `internal/monitoring`. |
||
|
|
a14584d9f1 |
Drop the summary focus bridge members nothing reads (#2709)
The jump-to-row affordance lost its only consumer when the summary components were deleted, yet the bridge kept computing it on every Workloads and Storage page: a capture-phase window scroll listener and a resize listener re-measured the hovered or focused row on each frame and hover change, walking its ancestors with getComputedStyle. On the 12-node mock storage table that was 128 calls across eight row hovers and 320 across ten scroll steps, now none. Storage's reveal callback kept branches that switched between the pools and disks views for that jump. Focus only ever names a row in the active view, so drop them and the disk-id memo that served them. The chart-hover input went the same way. No summary chart renders, so setChartHoverSync was never called and the resolver's chart branch was always fed null. Remove the signal, its type and the resolver option, and say in the contract how a returning chart re-enters the shared scope instead. Focus reveal, pinned-group reveal, Escape and row emphasis behave as before at desktop and phone widths; the browser receipt records the before/after pass. |
||
|
|
e7cabc72f1 |
Hide pprof routes on demo-mode instances (#2708)
The `/debug/pprof` handlers are gated on `RequireAdmin` plus a `settings:read` scope that session logins skip. A `DEMO_MODE` instance that signs visitors in as the configured admin therefore served every visitor goroutine and heap dumps, the process command line, and CPU profiles and execution traces that load the whole process. Demo mode already hides diagnostics, logs, admin users and manual discovery through the admin-operations policy; pprof was missing from it. ## Change - `internal/api/demo_mode_operations.go`: through the admin-operations policy, the demo guard answers `/debug/pprof` and every path below it with `404` for every method, ahead of route authentication, so a write probe such as the `POST` symbol lookup gets the same `404` instead of the generic demo `403`. Instances outside demo mode keep the routes, and `PULSE_PPROF_DISABLED` still removes them everywhere. - Contract notes in api-contracts (owner), agent-lifecycle and storage-recovery. ## Proof - `TestContract_DemoModeHidesPprofFromDemoAdminSession` signs in as the demo admin through the full router. The same session gets `200` from `/debug/pprof/cmdline` below the demo guard, and `404` for eight pprof paths × `GET`/`HEAD`/`OPTIONS`/`POST` through the served handler. Against the previous policy (`go test -overlay`) `GET` and `HEAD` returned pprof output (`200`, or the root's `301` into the index), `OPTIONS` got the router's preflight `200` and `POST` the generic demo `403`. - `TestPublicDemoAdminOperationsPolicyHidesEveryDebugRoute` parses the router files and fails if a literal route under `/debug/`, with or without a method prefix, escapes the policy for any of seven methods, so a new debug handler there cannot ship reachable on a demo by default. A temporary `GET /debug/vars` registration made it fail as expected. - Path-handling probe (not committed): 210 raw requests (35 request targets × six methods), covering trailing and double slashes, dot segments, `%2F`/`%2e` encodings, absolute-form targets and `CONNECT`, run against a copy of the router's mux dispatch. None reached a pprof handler; paths the mux would clean onto pprof only receive a redirect, and the policy catches the follow-up request. |
||
|
|
6263c580f7 |
Judge a linked agent's guest disks by that agent everywhere (#2712)
Follow-up to #2669, which made the guest drawer's filesystem rows follow a linked Pulse agent's freshness. The workload table's disk cell and the drawer History still judged the same guest by the Proxmox read reason, so one guest disagreed with itself across the three. ## What changes - **One rule for three readers.** `getWorkloadGuestDiskRead` (`workloadGuestPresentation.ts`) now drives the table disk cell, the drawer's filesystem rows and History. When the linked agent's filesystems fill in for a VM that has no Proxmox guest filesystems, the reading is current while the agent reports, last known once it is stale, and unavailable once the guest is stopped. Proxmox's own guest filesystems keep their read reason. - **Where the aggregate disk metric comes from.** On a manually linked VM, Proxmox records no guest disk usage without guest filesystems, so the registry's link merge keeps the agent's disk metric. A registry test pins that. - **Poller carry-forward.** `previousVMFromView` built the previous VM from the merged metric, so the next poll carried the agent's disk forward as a Proxmox `prev-agent-not-running` read. Proxmox priority then froze it over the agent's live value. Only Proxmox's own disk reading is carried now (`VMView.DiskFromLinkedAgent`). - **`agent.stale` is always on the wire.** The browser merges agent facets field by field, so an omitted false kept a resumed agent "stopped reporting" until a reload. This affected Machines rows and node drawers too. - **Withdrawn filesystems.** The websocket merge kept filesystems that a native Proxmox VM facet or a native agent facet (which always carries `stale`) no longer reported, including in the `platformData.agent` mirror that host threshold rows read. ## Verification - New tests, each failing on main: `GuestRow.test.tsx`, `GuestDrawer.test.tsx`, `workloadGuestPresentation.test.ts`, `resourceStateAdapters.test.ts`, `TestPreviousVMFromViewKeepsLinkedAgentDiskOutOfProxmoxCarry`, `TestAgentDataAlwaysSendsStale`, `TestVMViewDiskFromLinkedAgentFollowsSelectedMetricSource`, and `TestResourceRegistry_ManualGuestAgentLinkDiskMetricFollowsProxmoxGuestFilesystems`. The registry test is a characterization test; it fails when the agent outranks Proxmox on guest disk. - Private mock stack at 1440 and 390, with agents linked into VMs through `POST /api/resources/{vm}/link`: - Live agent, no QEMU guest agent: before, "- Unavailable · Guest agent not running", with History deferred; after, the agent's bars, nothing deferred. - Silent agent: before, "- Unavailable"; after, bars plus "Last known" (phone: "Prior"), with History deferred on the agent message. - Stopped guest: Unavailable. - A guest with Proxmox filesystems: unchanged. - Machines row for a standalone agent released from a silent hold, watched without a reload: on main it kept "(stale)"; now it clears. - Bundle: WorkloadsSurface 61.93 kB gzip against a 70.23 kB cap. ## Known residuals - Mock mode's websocket state does not apply manual links to resources ingested as records (`ingestRecords` never runs `applyManualLinks`), so the browser pass ran the Proxmox page on REST data. In production this likely affects links to vSphere or TrueNAS resources; it is tracked separately. - A stale Proxmox source can lose the aggregate to a live agent while its own filesystem rows and `prev-` reason remain. That guest stays on the Proxmox rule and reads its live aggregate as last known, which is the same as main and errs toward caution. A guest with no platform read reason can likewise keep a retained aggregate after the agent withdraws its filesystems, also as on main. Both need aggregate provenance on the wire. |
||
|
|
15322e1573 |
Drop contract prose for the deleted problem-resource surfaces (#2706)
Contract prose still described surfaces that no longer exist: - `frontend-primitives.md` told new route-level copy to extend `problemResourcePresentation` (deleted in |
||
|
|
23bf99e1ba |
Keep alert settings a config save leaves out (#2689)
## Problem
Saving anything on Alerts > Thresholds turned flapping detection and
alert TTL cleanup off. The page's `PUT /api/alerts/config` body never
carries `flappingEnabled`, `maxAlertAgeDays`, `maxAcknowledgedAgeDays`,
`autoAcknowledgeAfterHours` or `customRules` (the page has no control
for any of them). `UpdateAlertConfig` decoded the body into a zero
`AlertConfig`, so every save wrote `false`/`0` for those fields, reset
the flapping tuning to defaults, dropped custom rules, and persisted
that. The `flapping`, `aggregation` and `ioNormalization` objects the
page sends are not `AlertConfig` fields and never enabled anything.
## Fix
- `PUT /api/alerts/config` now replaces only the top-level keys the body
carries. A key left out keeps its stored value. A sent key replaces its
setting whole, so `"overrides": {}` still clears overrides, and the
usual normalization still runs. A body that is not a JSON object
(including `null`) is a 400 and changes nothing.
- `alertconfig.ApplyAlertConfigUpdate` decodes the body on its own, so
sent keys get exact `encoding/json` semantics, including duplicates and
case-folded keys. It copies every unsent top-level field from a JSON
clone of the stored config.
- `Manager.ApplyConfigUpdate` reads, merges and applies under the
manager lock, so concurrent partial saves cannot revert each other. It
returns an owned snapshot, which the handler persists, so
`SaveAlertConfig`'s in-place normalization no longer writes into the
live config's maps. The handler runs one save at a time, so successful
saves reach `alerts.json` in the order they were applied.
- Stored configs are not rewritten; a value an earlier save zeroed stays
as it is.
## Verification
- Unit: `internal/alerts/config/update_test.go`; manager save path and
concurrent partial updates in
`internal/alerts/config_validation_test.go`; handler against a real
manager in `internal/api/alerting/alerts_test.go`. Each new regression
fails against main's code (overlay negative controls). The concurrency
test fails at round 0 against a non-atomic variant.
- `go test ./internal/alerts/... ./internal/api/alerting/` and the
`internal/api` alert endpoint, tenant and scope tests pass; the alert
tests pass under `-race`.
- Private stack (mock mode, Vite on main's frontend):
- On main, a node CPU change saved from the Thresholds page took
flapping and all three TTLs to false/0, in memory and in `alerts.json`.
- With this change, the same UI save kept them (true/7/1/24) and stored
the new threshold.
- The API repro (GET, drop the four keys, PUT) keeps them; explicit
`false`/`0` still applies; `null` and `[]` return 400.
- Five rounds of concurrent partial PUTs kept both changes in memory and
on disk.
## Follow-ups
- The thresholds page still sends the dead
`flapping`/`aggregation`/`ioNormalization` objects and hard-coded
`minimumDelta`/`suppressionWindow`/`hysteresisMargin`. A frontend
cleanup will follow once the open PRs touching
`alertsConfigurationModel.ts` land.
- `SaveAlertConfig` still normalizes caller-owned maps in place for
other callers (`ActivateAlerts`, startup paths), and the GET handler
encodes a shallow copy. That pre-existing race is tracked separately.
|
||
|
|
36d3f8c557 |
Judge pulse-relaxed guests by one threshold on poll and save (#2705)
Stacked on #2700 (which carries #2696 and #2692). Merge order is enforced by the stack: this change must not reach main before #2700's per-filesystem reevaluation (see "Why stacked" below). ## Problem `pulse-relaxed` lifts a guest's CPU, memory and disk triggers to a 95/92/95 floor. Only `CheckGuest` applied it. Config-save reevaluation rebuilt the guest from alert metadata without its tags (`guestSnapshotFromAlert`), so a save judged a relaxed guest's alert by its unrelaxed thresholds: - A relaxed guest with an unset memory threshold alerts at 92 while polling. On save that reads as no threshold, so the save resolved the alert and sent a recovery notification, and the next poll raised it again. - The floor also lifted an Off threshold (trigger 0, or -1 from the editors) like any low trigger, so tagging a guest relaxed turned an Off alert on, with no recovery band (`{95, 0}`). A save then re-judged it Off and resolved it, and the next poll raised it again. ## Change - The relaxed step moves into `effectiveAlertPolicyNoLock`'s guest branch, so `CheckGuest`, config-save reevaluation (including #2700's `resolveGuestAlertThresholdsNoLock`) and posture checks share one resolver. - `guestSnapshotFromAlert` reads the recorded `tags` back, as `[]string` live or `[]interface{}` after a restart, through a new `metadataStringsValue` reader. `storagePolicyAliases` now uses the same reader. - An Off threshold (trigger <= 0) stays Off under `pulse-relaxed`; an unset one still gets the floor, as the existing tests pin. The tag is documented (issue #863) as relaxing thresholds, so it must never make a guest more sensitive than its config. - `metricOptionsWithTags` drops the `tags` key from an open alert once its resource has no tags. Reevaluation now reads that key, so a removed `pulse-relaxed` left behind would judge the alert by the relaxed trigger while polling uses the guest's own, and the alert would flap. Notification tag routing reads the same key, so it also stops routing on removed tags. ## Why stacked A filesystem's own override replaces the relaxed guest disk threshold in `CheckGuest`. On main, reevaluation misses filesystem overrides (fixed by #2700). With this change alone, a save would judge a relaxed guest's filesystem alert by the 95 floor instead of, say, an 85 override, and flap it. `TestConfigSaveKeepsRelaxedGuestFilesystemOverride` fails on main plus this change and passes on this stack. ## Proof New tests in `internal/alerts/alerts_test.go` and `internal/alerts/unified_eval_test.go`. With `go test -overlay` mapping the six runtime files back to #2700's head, these fail: - `TestConfigSaveJudgesRelaxedGuestAlertByRelaxedThresholds` (the save resolved the alert) - `TestPulseRelaxedKeepsOffGuestThresholdOff` (a disk alert was raised with disk Off) - `TestApplyRelaxedGuestThresholds/off_thresholds_stay_off` (Off became `{95, 0}` / `{92, 0}`) - `TestCheckGuestDropsRemovedTagsFromOpenAlerts` (stale tags; with only the metadata assertion relaxed, the save resolves the alert) `go test ./internal/alerts/... ./internal/notifications/` and the monitoring alert tests pass. |
||
|
|
b7049ff778 |
Raise Proxmox disk temperature alerts under the per-type policy (#2680)
**Stacked on #2675** (TrueNAS disk temperature alerts by disk type). Merge that first; this branch contains its commit. ## Problem A Proxmox physical disk that only the node's own sensors report never raised a temperature alert: - `CheckDiskHealth` judges health and wearout only; - unified evaluation types TrueNAS physical disks, not PVE ones. So Physical Disks could read a disk **Running Hot** under the per-type thresholds while alerts stayed silent. Whether a disk alerted depended on whether a Pulse agent ran on the node: agent-covered disks alert through `CheckHost` at the same thresholds. #1347 shows users look for disk temperature thresholds in the Proxmox tabs. ## Change - **`CheckProxmoxDiskTemperature`** (`internal/alerts/proxmox_disk_temperature.go`) raises a `diskTemperature` metric alert under the disk's existing PVE alert reference. It is judged by the disk temperature policy: the disk type's `DiskTempByType` entry under the agent Disk Temp default, which switches it off for every type. The alert carries the disk's serial, WWN and `diskType`, like the health and wearout alerts. - **One alert per disk.** `mergeHostAgentSMARTIntoDisks` marks a disk that a still-reporting linked agent lists in its SMART report (`AgentSMARTReported`, internal, never serialized). That disk is the agent's: `CheckHost` alerts on it, and an open PVE alert closes with `moved_to_agent`. A lapsed agent owns nothing, so the node's own current readings keep alerting while its agent is silent. - **Holds and clears.** Only a reading collected this poll is judged (`diskinventory.TemperatureCollected`), so a retained value holds the alert. An excluded device, or a switched-off policy, closes it. - **Stability.** A new `proxmox-disk` alert delay defaults to 5s, so warnings wait the same 300s noisy-gauge window as agent and TrueNAS disk temperatures. Critical readings bypass it. - **Config save.** `reevaluateActiveAlertsLocked` re-judges the alert per disk type. Without this it would fall through to guest thresholds, which have no disk temperature, and resolve on every save. An alert without a recorded type uses the lowest per-type trigger. - **History.** `ProxmoxPhysicalDiskAlertIdentifiers` lists the new `<ref>::metric-threshold:diskTemperature` identifier, so resource history owns its rows by recorded hardware identity, as #2646 does for health and wearout. - **Copy.** The Agents "Disk temperature by type" card says it covers agent, TrueNAS and Proxmox disks. ## Evidence Private mock stack (8 PVE nodes) with a local-only overlay pinning PVE disk readings: | Disk | Physical Disks | Alert | |---|---|---| | pve1 NVMe 63°C | Healthy | none | | pve2 SATA 56°C | Running Hot | warning, trigger 55, after the 300s window | | pve3 NVMe 72°C | Running Hot | warning, trigger 70, after the 300s window | | pve3 SAS 63°C | Healthy | none | | pve3 SATA 66°C | Running Hot | critical at once | The alert card reads: "Samsung 870 EVO 1TB (/dev/nvme2) · Temperature · Temperature 66°C, above the 55°C alert level · Clears once it drops to 50°C or lower and stays there for 5 minutes." ## Tests - `internal/alerts/alerts_test.go`: - the policy (per type, raised trigger); - ownership with `moved_to_agent`, the hold without a reading, exclusion, the off switch; - the stability window; - re-judging on save, including the lowest-trigger hold; - history identifiers. - `internal/monitoring/physical_disk_roundtrip_test.go`: the marker for reporting agents only (not a lapsed one), the SMART fallback path, and that `checkPhysicalDiskAlerts` passes collected readings, ownership and exclusion. - `internal/unifiedresources/store_test.go`: a temperature alert row is owned by its disk and still found by a read by its reference. - `internal/models/deepcopy_test.go`: the marker survives cloning and stays out of JSON. - `ThresholdsTable.test.tsx`: the card copy. - Ten wiring points were mutation-checked (marker, lease condition, collected reading, ownership, exclusion, re-judge branch, lowest trigger, resolution, history identifier); each removal fails a test. ## Second opinion - Fixed: an offline agent's retained SMART rows kept ownership, so neither side alerted while the agent was silent. Ownership now needs a reporting lease. - Kept, documented in `alerts.md`: on the first poll after an agent is newly linked to a disk with an open PVE alert, both alerts can be open until the next disk poll. This is the same window as the node-to-agent usage alert handover. ## Not in this PR - Like the health and wearout alerts, a disk that leaves the inventory keeps its temperature alert until the 24-hour stale sweep. - Per-host Disk Temp overrides do not reach PVE-only disks, which have no agent host. |
||
|
|
b814ccdd81 |
Count down to future Patrol times instead of reading just now (#2702)
`formatRelativeTime` is past-only: a time ahead of now reads "just now"
(compact) or "0s ago". Three Patrol lines handed it future times, so
each read wrong for its whole duration:
- Attention detail, suppressed issue: "Returns to active attention just
now." (also for an issue marked reviewed, which expiry hands back to
Reviewed, not active attention)
- Findings, will-fix-later: "Reminding just now"
- Findings, snoozed: "snoozed until just now"
Supersedes #2613, rebuilt on main now that #2598 has landed (#2613 was
stacked on two early #2598 commits whose contract wording main has since
replaced).
## Change
- `formatTimeUntil` in `utils/format.ts`, beside `formatRelativeTime`:
counts down ("in 3h", "in 1d"), rounds to the nearest unit (a 24-hour
suppression set a moment ago reads "in 1d", not "in 23h"), and returns
`dueText` (default "now") once the time arrives. `formatRelativeTime`
stays past-only and its doc points future times at the new helper.
- The three lines use it with the shared relative-time clock
(`useRelativeTimeNow`), since none of those times change while the panel
stays open, and keep the exact time on hover.
- Suppression line: "Returns to active attention in 1d." for an open
issue, "Suppression ends in 1d. It stays reviewed." for one marked
reviewed, "Suppression has ended." once due. It takes its words from
`getAttentionSuppressionEndPresentation` (added on main by
|
||
|
|
0b6473affc |
Fill the table's visible width with phone drawers (#2703)
Phone and tablet table drawers capped their sticky content at
`calc(100vw - 3.5rem)`, a guess at the surrounding page chrome. On pages
with less chrome than that, every drawer stopped short of the table and
left an empty strip on the right: 27px at 390px, measured on the
Availability checks, Proxmox node and guest, Docker host and container,
Kubernetes node and Machines drawers. At tablet widths a table wider
than its shell got a drawer wider than the visible area (712px in
695px).
The table scroll shell already declares `container-type: inline-size`
(the responsive column priorities use it), so `InlineDetailTableRow` now
caps the default content shell at `100cqi`, the shell's own visible
width. Measured live: content equals the visible table width at 320,
390, 768 and 1000px, with no right gap; desktop keeps the static
uncapped layout. The availability drawer's hostname now fits instead of
truncating. Callers passing their own `contentClass` are unaffected.
Not changed here: `.table-fixed td { overflow: hidden }` makes the
sticky offset inert, so a sideways-scrolled table still moves its drawer
(same before and after). Long unwrapped identity chips that were already
clipped are clipped less now; wrapping them is a separate follow-up.
Proof: `InlineDetailTableRow.test.tsx` and the shared-primitive
guardrail pin the new cap and reject the old one; the guardrail already
pins the scroll shell's `container-type`. Browser receipt covers five
routes at 320 to 1440px, before and after.
|
||
|
|
3fb267333d |
Require POST for Assistant session mutation routes (#2670)
The session router dispatches `/api/ai/sessions/{id}/abort`,
`summarize`, `fork`, `undo` and `redo` by path alone, and none of those
handlers checked the HTTP method, so a GET or HEAD cancelled a running
turn, compacted or rewound a transcript, or persisted a fork.
- **Demo mode:** `DemoModeMiddleware` admits every GET/HEAD as a read,
so a public-demo session could mutate Assistant sessions despite the
read-only guard.
- **CSRF:** `CheckCSRF` skips GET/HEAD, and session cookies default to
SameSite=Lax, which browsers send on cross-site top-level GET
navigations. A link carrying a session ID could therefore trigger these
mutations for a signed-in user.
## Change
- `requireAssistantSessionMutationMethod` in
`internal/api/ai_handler.go`: abort, summarize, fork, undo, redo and
steer admit only POST and answer anything else with `405` + `Allow:
POST` before resolving the Assistant service. Steer already refused
non-POST but sent no `Allow` header; it now uses the same guard.
- The guard sits in the handlers, matching how chat, steer and the
approval approve/deny handlers already own their method contracts.
- `messages` (GET) and the unsupported `diff`/`revert`/`unrevert` routes
are unchanged. Session rename/delete and the collection route already
dispatch on the method.
- Callers: `frontend-modern/src/api/aiChat.ts` uses POST for all six;
pulse-mobile's abort call and its OTA compatibility probe use POST.
## Proof
- `TestAssistantSessionMutationsRequirePost`: GET/HEAD/PUT/DELETE get
405 + `Allow: POST` for every mutation, with no mutation call and no
`IsRunning` check reaching the service. POST still dispatches.
- `TestAssistantSessionReadSubresourcesKeepTheirMethods`: GET `messages`
returns 200 and GET `diff` returns 501, with no mutation either way.
- `TestDemoModeRouterRejectsAssistantSessionMutationsOverSafeMethods`:
drives `NewRouter(...).Handler()` with `DemoMode` on. GET/HEAD to every
mutation pass the demo guard and get 405, POST stays 403, nothing
mutates, and GET `messages` still reads.
- Negative controls via `go test -overlay`: on main's `ai_handler.go`,
every non-POST case fails and the demo test reports `GET abort status =
200`. With abort's guard moved after the `IsRunning` check, the ordering
assertion fails.
|
||
|
|
3a482c610c |
Judge source merges by the ingest's stale thresholds
mergeInto always passed nil thresholds to mergeMetrics, so every source merge into an existing row judged metric freshness by the registry defaults while the stale pass, the manual links and the monitor's configuration used the caller's thresholds. A Proxmox node polled every two minutes (four-minute threshold) merged with its auto-linked agent showed the silent agent's readings while the stale pass called the node's poll current, and a supplemental refresh of a linked vSphere VM kept the in-guest agent's memory over vSphere's current reading. Snapshot and record ingest now hold the caller's thresholds on the registry while they run, the way the guest loop holds its agent index, and mergeInto reads them. The ~50 snapshot helpers that reach mergeInto through ingest() keep their signatures. The stale pass and the metric merge now share one threshold rule, sourceStaleThreshold, so the merge also honours a sighting's expected update interval (slow Proxmox disk inventory) as the stale pass already did, and a source without a default falls back to 120s in both (the merge used 60s). Every defined source has a default, so that fallback reaches none. Outside mock mode the monitor configures only Proxmox, PBS and PMG, so those are the production sources this changes; mock mode also configures TrueNAS, vSphere and availability. |
||
|
|
ccc25d269b |
Correct stale Storage embed and History range contract prose
The storage-recovery contract described an embedded StorageSurface that opts into its toolbar through a showFilterToolbar prop, keeps tableOnly to hide a storage summary section, and offers a chart-collapse control, alongside a RecoverySurface and a forcedPlatformFilter. None of that exists. Storage.tsx always renders StoragePageControls (only kiosk mode hides it), the Proxmox Storage tab is the only embed, and /truenas/storage renders TrueNASStorageTopologyTable rather than StorageSurface. The Workloads contracts said the sparkline range appears only while Trends is active and carries a "Trend range" label. WorkloadsFilter shows it in Trends and, in Bars, at desktop widths while Row hover is History, and labels it "History". A rendered Storage test now pins the embed behaviour the rewrite describes. The toolbar renders with no opt-in prop. The forced source outranks a URL source and is never written back. A URL node resets to all nodes. Neither lock surfaces as a chip, an Add filter option or a clear action. The History label was already pinned by WorkloadsFilter.test.tsx and the Workloads performance contract test. |
||
|
|
1ac600ec2b |
Judge filesystem alerts by their own threshold on config save (#2700)
Stacked on #2696, which is stacked on #2692. The unique commit is the
last one,
|
||
|
|
9bd1b6e582 |
Let only POST narrate single-resource reports with Pulse Assistant
GET /api/admin/reports/generate resolved the tenant's AI narrator, so a PDF requested with GET made a paid provider call and appended a usage event to the AI cost ledger. GET passes the demo-mode guard and the CSRF check, and a SameSite=Lax session cookie rides a cross-site top-level GET navigation, so a link could spend a signed-in admin's AI budget. POST with a JSON body, decoded with the generate-multi rules, is now the canonical single-resource transport and the only one that may use the AI narrator. The settings UI sends it, following a new performanceReport.singleResourceMethod catalog field; a catalog without the field, and the legacy fallback catalog, keep the GET transport that older backends accept. GET stays available with the same query parameters but narrates deterministically, printing a note in place of the "Configure Pulse Assistant" tip, and other methods get 405 with Allow: GET, POST. GET also stops resolving the AI service at all: it reads Patrol findings through a new ExistingAIService lookup, because GetAIService constructs a tenant service on first use and that can list provider models or start background discovery. The router's reporting wiring moves into wireReportingAIResolvers so a test can drive it. Keeping GET rather than answering 405 avoids breaking documented API scripts (MSP.md pointed providers at it) for no security gain: CSV is never narrated and GET no longer reaches the provider. |
||
|
|
b0590c0ccf |
Join operator links on resources that arrive as records
The monitor rebuilds its registry from the state snapshot and then ingests supplemental records (vSphere, TrueNAS). Snapshot and resource ingest applied the operator's manual links, but record ingest did not, so a link whose side exists only as a record (an agent inside a vSphere or TrueNAS VM, an agent on a TrueNAS host) never joined in the registry behind the websocket broadcast and /api/state. The resources API seeds through resource ingest and showed the same pair merged, and the published inventory also flipped to merged whenever continuity hydration cloned the registry for an unrelated absent machine. Record ingest now applies the links, with the adapter's stale thresholds on the rebuild, live supplemental and overlay paths. Continuity records stay out of it: a saved enrollment only fills an absent machine and must not lend its linked guest an offline verdict. A link merge keeps the TrueNAS and vSphere payloads the primary lacks. Joining links in the rebuild exposed how identity treats a folded resource. Its ID was no longer live, so once an agent-type primary was pinned with the folded agent's machine key, pin succession re-keyed the link onto the primary itself and the next rebuild split the pair. That already broke snapshot links with a Proxmox node as primary. The registry now remembers each folded resource, and pin-driven and record-declared successions treat it as observed. Mock mode still builds its view from fixtures without the link store, so the mock broadcast is unchanged here. |
||
|
|
77d2119792 |
Remove unread Patrol summaryStats and its audit rule (#2690)
Stacked on #2686, which sits on #2682 and #2674; its own change is the top commit, |
||
|
|
1231392e8d |
Judge a linked agent's guest disks by that agent everywhere
PR 2669 made the guest drawer's filesystem rows follow a linked Pulse agent's freshness when its filesystems fill in for a VM that has no Proxmox guest filesystems. The workload table's disk cell and the drawer History still judged the same reading only by the Proxmox read reason. So one guest read as current in the drawer and as "Unavailable: Guest agent not running" in the table and History, and a silent agent's reading could not show as last known there. getWorkloadGuestDiskRead now owns one rule for the table cell, the drawer rows and History: current while the agent reports, last known once it is stale, unavailable once the guest is stopped. Proxmox's own guest filesystems keep their read reason. The rule also covers the aggregate disk metric, because Proxmox records no guest disk usage without guest filesystems, so the manual link merge keeps the agent's metric. Three paths broke that: - The poller built its previous VM from the merged metric, so the next poll carried the agent's disk forward as a Proxmox prev- read, and Proxmox priority then froze it over the agent's live value. Only Proxmox's own disk reading is carried now. - agent.stale was omitempty, and the browser merges agent facets field by field, so a resumed agent stayed "stopped reporting" until a reload. It is always sent now. - The same merge kept filesystems a native Proxmox VM facet or a native agent facet had withdrawn, so the rows did not switch source. |
||
|
|
239cd06933 | Merge origin/main into fix/unraid-disk-temperature-alerts | ||
|
|
88d44099aa |
Stop tenant incident stores racing on shared ID counters
Each tenant monitor owns its own IncidentStore, but incident and incident-event IDs came from package-level int64 counters bumped with ++ under nothing but the calling store's mutex. Two tenants recording alerts at once raced on them: go test -race failed TestLeavingMockModeClearsEveryRunningTenantMonitor on PR 2698 at incidents.go:1753. The remediation record counter had the same shape, and remediation logs are created per org. All six internal/ai/memory ID generators now mint through one atomic helper. The numeric suffix also stops wrapping at 1000: even with atomic counters, one generator minting more than a thousand IDs in a wall-clock second repeated an ID, and incident notes find their incident by ID. The prefix and timestamp layout is unchanged, and intToString lost its last callers. ids_test.go mints 2,400 IDs from every generator on 16 goroutines and records alerts into two tenant stores concurrently. Against the old files it reports DATA RACE at incidents.go:1753/1760 and remediation.go:355, and every generator mints duplicate or wrapped IDs. |
||
|
|
aafa4793c3 |
Drop the summary focus bridge members nothing reads
The jump-to-row affordance lost its only consumer when the summary components were deleted, yet the bridge kept computing it on every Workloads and Storage page: a capture-phase window scroll listener and a resize listener re-measured the hovered or focused row on each frame and hover change, walking its ancestors with getComputedStyle. On the 12-node mock storage table that was 128 calls across eight row hovers and 320 across ten scroll steps, now none. Storage's reveal callback kept branches that switched between the pools and disks views for that jump. Focus only ever names a row in the active view, so drop them and the disk-id memo that served them. The chart-hover input went the same way. No summary chart renders, so setChartHoverSync was never called and the resolver's chart branch was always fed null. Remove the signal, its type and the resolver option, and say in the contract how a returning chart re-enters the shared scope instead. Focus reveal, pinned-group reveal, Escape and row emphasis behave as before at desktop and phone widths; the browser receipt records the before/after pass. |
||
|
|
0180b33431 |
Hide pprof routes on demo-mode instances
The /debug/pprof handlers are gated on RequireAdmin plus a settings:read scope that session logins skip. The public demo signs every visitor in as the configured admin, so any visitor could fetch goroutine and heap dumps and the process command line, or start CPU profiles and execution traces that load the whole process. DEMO_MODE already hides diagnostics, logs, admin users and manual discovery through the admin-operations policy; pprof was missing from it. The policy now has the demo guard answer /debug/pprof and every path below it with 404 for every method, ahead of route authentication. Instances outside demo mode keep the routes, and PULSE_PPROF_DISABLED still removes them everywhere. TestContract_DemoModeHidesPprofFromDemoAdminSession signs in as the demo admin through the full router, shows the same session still reads /debug/pprof/cmdline below the demo guard, and gets 404 for eight pprof paths through the served handler. On the previous policy GET and HEAD returned pprof output (200, or the root's 301 into the index) and POST got the generic demo 403. A policy test also fails if a literal route the router files register under /debug/, with or without a method prefix, escapes the policy for any common method. |
||
|
|
48c2d6cdce |
Stop Patrol fetching the correlation list nobody reads (#2677)
The Patrol page fetched `GET /api/ai/intelligence/correlations` on every mount, every 60 s poll, every return to the tab and on Retry, and the store's dashboard bundle fetched it again for FindingsPanel's refresh. Nothing has read the list since the Patrol supporting-context block was removed in 27ba85ddc6; the resource drawer reads per-resource correlations from the resource-intelligence payload. ## Change - `stores/aiIntelligence.ts`: remove the correlation signal, `correlations` getter, `correlationsSignal`, `loadCorrelations()`, its org-switch reset, and its entry in `loadDashboardData()`. - `features/patrol/usePatrolIntelligenceState.ts`: drop the read from the Retry path's background supporting reads. - `api/ai.ts`: remove `AIAPI.getCorrelations` (no other caller). The backend route stays as an API surface behind `ai:execute`. - Tests: the store bundle test now pins every AIAPI read the bundle makes; the manual-run test stalls the post-start refresh through a remaining read so it still proves "Starting" clears without waiting (negative controls: awaiting the refresh, or removing it, both fail the test). Dead correlation route stubs removed from six E2E specs. - Contract prose in api-contracts, patrol-intelligence, unified-resources, ai-runtime and frontend-primitives no longer says the Patrol page or store loads or renders correlations or recent changes. The remaining stale Patrol correlation paragraphs (patrol-intelligence ~L2135/~L2197, ai-runtime ~L6854) are rewritten by #2674; the two PRs split hunks by agreement and merge cleanly together. `CorrelationsResponse` stays for now: `patrolInvestigationContextModel.ts` still imports it until #2674 lands. It goes in a follow-up once both are on main. ## Evidence - Demo-mode stack, headless Chromium at 1440x900 and 390x844: correlation requests go from 1 to 0 per Patrol load, browser reload, 60 s poll and tab hide/show. Every other `/api/ai/*` read per phase is identical; no console errors. Re-run on the final base. - Production build: the route string is gone from the bundle; `check-bundle-size.mjs` passes with no chunk renamed (JS gzip -0.18 kB on the same base). - `tsc --noEmit` passes; vitest for src/stores, src/api, src/features/patrol, src/components/AI and the Patrol page test passes; contract, registry and status audits pass. |
||
|
|
71a723abb1 |
Read controller-member disk history at its written key (#2694)
## Problem A physical disk behind a RAID controller (target such as `megaraid,3`) with no usable serial or WWN had its SMART history written under one key and read under another, so its temperature and SMART charts stayed empty. - Host agent: writes under `HostSMARTDiskMetricID`, e.g. `host-pve:sdd@ctrl0/megaraid,3`. - Proxmox: writes under `PhysicalDiskMetricID`, which returns `ProxmoxPhysicalDiskSourceID`, e.g. `pve-node1--dev-sdx:sdx@/megaraid,0`. - Reader: `BuildMetricsTarget` passed those source IDs, which already name the member, to `PhysicalDiskMetaMetricID`. That function appended the topology again: `host-pve:sdd@ctrl0/megaraid,3:sdd@ctrl0/megaraid,3`. The same happened in a registry rehydrated from persisted resources. ## Fix `PhysicalDiskMetaMetricID` now follows the Proxmox writer. A fallback that already ends in the member's topology is returned unchanged. The topology is appended only to a fallback without it: - a Proxmox source ID from before members were scoped, which `PhysicalDiskMetricID` scopes the same way; - the canonical resource ID that `PhysicalDiskView.MetricResourceID` falls back to. TrueNAS disks carry no controller target and are unaffected. ## Existing history - **SMART:** no SMART writer used the doubled key, so samples already stored become readable without a migration. - **Disk I/O:** a linked node's agent with no SMART reading for a member, such as a standby row, files the device's I/O under the Proxmox disk's metrics target. That target was the doubled key. The I/O now lands beside the member's SMART history. Samples already stored under the doubled key are left to age out: nothing reads them, and they are the block path's counter filed under its one merged member. - Mock seeding keys these disks by `disk.ID`, the single-scoped key, so it already matched. ## Proof - `TestIdentitylessControllerMembersReadTheirWritersHistory` (`internal/monitoring/issue1595_collection_trust_test.go`) drives the real writers: `writeHostSMARTMetrics`, `writeHostPhysicalDiskIOMetrics` and the Proxmox `writeSMARTMetrics`. It then queries the store at `MetricsTarget` for five members in a live registry and in one rehydrated through a JSON round trip. The members are two agent members sharing a path, a lone agent member with I/O, a Proxmox member, and a standby member merged into its linked node's Proxmox row. Before the fix all ten targets read the doubled key. - `TestPhysicalDiskMetaMetricIDScopesAControllerMemberOnce` (`internal/unifiedresources/canonical_ids_types_test.go`) pins each fallback shape against its writer: agent, smartctl-labelled agent, Proxmox, legacy Proxmox, canonical resource ID, and a disk that is no controller member. Before the fix, three cases failed with the doubled key. ## Stacking This branch sits on #2685, which sits on #2679, because both edit `physical_disk_ids.go` and the proof file. It supersedes #2661, which carried the same reader fix but conflicted with this stack and with main. |
||
|
|
843daf7ae3 |
Remove dead Patrol assessment and save-failure handoffs (#2682)
Stacked on #2674 (branch `refactor/drop-patrol-context-summary`); this branch contains that commit. ## What Removes the two Patrol-to-Assistant handoff builders in `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` that no production code calls: - `buildPatrolAssessmentAssistantHandoff`: its caller, the summary card's Discuss handler, went in |