mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
b60f441e22a0c261dc7b3f30fe0950ba470f6d88
6219 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b60f441e22 |
Resolve Patrol finding references before reading operator state
Patrol's finding operator-state provider read the store under the finding's exact reference first and resolved it only on a miss. Once an operator link folds an agent into a VM, a finding kept under the agent's ID still found the agent's own row, even a default-valued one, and missed the maintenance window or monitoring mode the operator then set on the merged VM. The resources API and alert intent resolve first, so Patrol disagreed with both. The provider now resolves every reference through the organization monitor's current read state (the published registry with saved hosts overlaid, or the mock estate), which is the listing the resources API seeds from, and reads only an unresolved reference as given. The old miss path built a resources-API registry, which clones the unified listing, for every finding without a row; the read state answers from a registry it keeps. One window still differs: a saved agent linked into a live VM that has not reported since a restart stays its own row in the read state, so its findings read the agent's own state there, as alert intent does. |
||
|
|
059428e2dc |
Merge fix/receipt-patrol-state-hook-merge into fix/link-fold-references
Main's tip fails the required governance check: the merged Patrol state hook has no browser-verification receipt. PR 2742 adds that receipt on main's tip. Carry it here, together with main's commits beneath it, so this branch's governance range is covered whichever PR lands first. |
||
|
|
a9f23501eb |
Remove test-only Patrol autonomy save helpers (#2740)
Stacked on #2695 (branch contains its head |
||
|
|
986cead30c |
Stop Workloads row focus staging a stale shell scroll restore (#2736)
Stacked on #2723 (its one commit is the parent here). Whichever lands
first, the other still merges.
## Problem
Selecting a Workloads row called `capturePendingAppShellRestoreTop()`,
which staged the `.app-scroll-shell` position for `App.tsx` to restore
on the next route change. That made sense when row focus wrote
`?resource=` into the URL.
|
||
|
|
7949036657 |
Merge origin/main into fix/link-fold-references
PR 2739 added Resource.linkFolds, a record of every link folded into a resource that rides in-memory clones, which is the same fold set this branch carried as Resource.linkFoldedIDs. Keep one record: drop linkFoldedIDs and index folded IDs from linkFolds when a link applies and when resource seeding copies the record in. Main's unlink test treated a lookup by the folded agent's ID as proof that the agent was still listed. That lookup now answers with the VM holding it, as lookups by source IDs and canonical aliases already do, so the test checks which row answered and pins the VM answer. |
||
|
|
31fcc4f4d6 |
Split operator-linked resources when a merge is reported (#2739)
Stacked on #2725 (Split a manually linked pair when the API unlinks it). Until that merges this PR carries its commit too; only the last commit is new here. ## Problem `POST /api/resources/{id}/report-merge` (the drawer's "Split merged resource" dialog) excluded only the candidate IDs that `ResourceRegistry.SourceTargets` derives with `sourceSpecificID(mergedResource.Type, source, sourceID)`. For a source an operator link folded in, that candidate names neither side of the link: - an agent linked into a VM gets a `vm-` candidate; - an agent linked with a node or a Docker host gets a source-specific candidate, while the folded side's canonical ID derives from its machine ID. No `resource_links` row matched, so even with #2725's one-decision-per-pair store rule the link survived, the pair stayed merged on REST, `/api/state` and the websocket, and the API answered 200 "Merge reported" while the dialog toasted "Resource split applied". Two linked agents share their only source, so the API refused them as "Resource is not merged". Identity merges were already handled: `findMatch` checks exactly the candidate `SourceTargets` reports, and an agent/Docker pair merged by machine identity stays apart through three monitor rebuilds after report-merge. That is now pinned by a test. ## Fix - `applyManualLinks` records each link it applies on the merged resource (`recordManualLinkFold`, unexported `Resource.linkFolds`): the link row's own pair plus the sources the folded side brought, carrying the folded side's earlier folds so each link along a chain keeps its pair. - The record rides in-memory clones. The resources API seeds its registry from the monitor's already-linked listing and never holds the folded row, so this is the only place it can learn the pair. - `HandleReportMerge` excludes the pair of every link whose folded side brought a reported source (`ResourceRegistry.ManualLinkFolds`), alongside the existing identity-match exclusions, deduplicated in either order. #2725's `AddExclusion` then deletes the link row, so the pair splits from the monitor's next rebuild, as with unlink. An applied link also counts as a merge, so two linked agents can be reported. - A pair is recorded once. Record ingest can recreate a folded side whose source mapping points at a holder of another type, then fold it again. - A link joins only the pair its row names. Canonical-ID succession re-keys link endpoints with `UPDATE OR IGNORE` but moves `primary_id` unconditionally. A row whose re-key collided with the successor's own row therefore kept the retired endpoint and took the successor as its primary, and `applyManualLinks` merged the successor through it. No unlink or report-merge of the merged pair removed that row. A primary naming neither side now falls back to the row's first side, which leaves such a row inert. The drawer sends every merged source. Source categories are not attributed to individual contributors, so naming one that a folded side carried undoes that link. A source that arrived along a chain undoes every link on its way to the merged resource. ## Verification - `TestResourceReportMergeSplitsOperatorLinkedPair`: link and report-merge through the API handlers against a `MonitorAdapter` on the same SQLite store, rebuilt three times per step. Cases cover VM/agent, node/agent and agent/Docker pairs linked from either side, plus two linked agents. With #2725's handler, 6 of the 7 cases fail: five with "report-merge left 1 monitor resources", and the agent pair with a 400. The exception is a node linked from its agent, because the node's source-specific candidate happens to be its own ID. - `TestResourceReportMergeSplitsPairBehindASuccessionShadowedRow`, `TestResourceReportMergeSourceFilterSelectsLinks`, `TestResourceReportMergeSplitsIdentityMergedPair`, `TestContract_ResourceReportMergeReplacesOperatorLink` (response shape plus link removal), `TestManualLinkFoldsNameEachLinkPairAlongAChain` (monitor registry and a registry seeded from its listing), `TestManualLinkFoldsRecordEachPairOnceAcrossRecordIngests`, `TestManualLinkFoldRepeatedPairKeepsEarlierSources`, `TestCloneResource_CarriesLinkFolds`, and the `TestResourceAPIReportMergeExcludesRegistryLinkFolds` guardrail. - Negative controls, each failing at the expected assertion: the handler from #2725, the old not-merged gate, the primary honoured outside its row, folds appended without dedupe, a repeated pair keeping only its later sources, `applyManualLinks` without `recordManualLinkFold`, and a clone that shares the fold slice. - Full `./internal/unifiedresources/...`, `./internal/api/resourceapi/...`, `./internal/truenas/...` and `./internal/monitoring/...` pass. |
||
|
|
217ebc3db5 |
Remove the unreachable Workloads chart visibility toggle (#2734)
The Workloads View disclosure rendered `ChartVisibilityToggleButton`
only inside `<Show when={props.onChartsToggle}>`, and no surface passes
`onChartsToggle` or `chartsCollapsed` since the workload summary chart
section was deleted with the summary components. The button had no other
production consumer.
This removes:
- the `Summary` block and import in `WorkloadsFilter.tsx`, and the
`chartsCollapsed` / `onChartsToggle` props in `workloadsFilterModel.ts`
- `ChartVisibilityToggleButton` and its icon import in
`shared/FilterToolbar.tsx`
- the `chart-visibility-toggle-button` rule and
`chart-visibility-local-toggle-labels` pattern guard in
`scripts/shared-template-registry.json`
- the unit tests for the button and the WorkloadsFilter charts describe
The guardrail case that required the primitive now keeps the exact
retired registry ids, export and props out. It deliberately does not
block unrelated future chart controls: a returning summary section is a
product decision that should register its own primitive.
Contract prose: `frontend-primitives.md` (View-disclosure list, the
FilterToolbar rule, the registry paragraph, the FilterBar view-options
list), `performance-and-scalability.md` (the workload chart-visibility
sentence) and `unified-resources.md` rule 12 now say no chart show/hide
control exists instead of describing the deleted one.
Verification:
- frontend `src/features`, `src/components/shared`,
`src/components/Workloads`, `src/utils` suites on pulse-dev: 783 files
passed, one load-induced 5s timeout in `AgentsMachinesTable.test.tsx`
that passes alone (32/32) and does not reference these symbols; tsc,
prettier and the shared-template audit pass
- browser: production builds of this change and of main's three runtime
files, served against the 50-node mock rig, Proxmox and vSphere
overviews at 1440x900 and 390x844 (phone via Filters > View). The View
preferences disclosure shows identical headings, controls and pressed
states before and after, with no Summary heading, no Charts button, no
horizontal overflow and no console errors. The after bundle no longer
carries the `Show charts` copy.
|
||
|
|
b9c98a4a16 |
Stop Patrol fetching the circuit-breaker status nobody reads (#2704)
The Patrol page fetched `GET /api/ai/circuit/status` on every load, every 60 s poll, every return to the tab, and again in the background after Retry. The store's breaker state had one production reader: the `circuitBreakerStatus` memo in `usePatrolIntelligenceState`, which nothing read and which #2695 removes. An open breaker already reaches the page through `/api/ai/patrol/status` as the `circuit_open` blocked cause, and the paused banner shows its reason. ## Change - `stores/aiIntelligence.ts`: remove the breaker signal, getter, `circuitBreakerStatusSignal`, `loadCircuitBreakerStatus()`, its org-switch reset, and its `loadDashboardData()` entry. - `features/patrol/usePatrolIntelligenceState.ts`: drop the read from the Retry path's background supporting reads. - `api/ai.ts`: remove `AIAPI.getCircuitBreakerStatus` and the `CircuitBreakerStatus` type; nothing else used them. The backend route stays. - Tests: the client's unit tests and the store and page test mocks go. The store bundle test still pins the exact AIAPI read set. The breaker route stubs are removed from seven E2E specs. - Contracts: patrol-intelligence, ai-runtime, api-contracts and frontend-primitives say the browser reads no breaker status and how an open breaker still reaches Patrol. Each note sits next to #2677's correlation note. ## Evidence - Normalized production-build compare: only the removed store loader, signal, getter and client differ in `index.js`, plus the removed supporting read in `AIIntelligence.js`. `check-bundle-size.mjs` passes. - Private demo-mode stacks for the parent and this tree, driven by the same Playwright script at 1440x900 and 390x844. Across Patrol load, tab hide/show and stale-data Retry, the breaker read drops from 1-2 requests per phase to none. Every other endpoint matches, the Open in Assistant handoff is unchanged, and neither build logs a console error. - A forced `circuit_open` Patrol status still shows "Patrol paused / circuit breaker is open" on both builds. - `tsc --noEmit`, eslint, and vitest for `src/stores`, `src/api`, `src/features/patrol`, `src/components/AI`, the Patrol page test and the boundaries test pass, as does the full pre-commit battery. Stacked on #2695 (which stacks on #2687) with #2677 merged in, so until those land this PR shows their commits too. The 2677 owner agreed this read belongs in a follow-up. `git merge-tree` is clean against main, #2677, #2690, #2691 and #2693. |
||
|
|
533027ee06 |
Drop Patrol state members no surface reads (#2695)
`usePatrolIntelligenceState()` returned 18 members that no file outside the hook reads. A TypeScript checker scan of src and tests found no readers and no spread, computed or `Object.*` escapes of the state object. Several were also dead inside the hook. ## Change - **Return object.** Drops activePatrolFindings, activityRefreshTrigger, acceptedManualRunId, assistantHandoffFindingId, circuitBreakerStatus, fullModeUnlocked, initialSurfaceReady, isRefreshing, loadAllData, openAssistantOperationsLoopForFinding, the four decision/outcome counts, both work-evidence counts, recordPatrolWorkflowStarterActivity and setFullModeUnlocked, plus handleAssistantFindingHandoff (see below). - **Dead locals.** Removes the write-only signals (initialSurfaceReady, isRefreshing, fullModeUnlocked, assistantHandoffFindingId), the 15 s background refresh timeout that only reset isRefreshing, the unread memos, and the never-called `openAssistantOperationsLoopForFinding`. That last one was a second copy of the handoff `FindingsPanel` already performs. Locals still used inside the hook (activePatrolFindings, loadAllData, activityRefreshTrigger, acceptedManualRunId) stay. - **Test-only exports.** `openPatrolAssistantWorkflowHandoff` and `recordPatrolWorkflowStarterActivity` were used only by that dead path and by their own tests, so they go. - **Workspace.** `handleAssistantFindingHandoff` only wrote the unread handoff signal, so `PatrolIntelligenceWorkspace` no longer passes `FindingsPanel` an `onAssistantHandoff` callback. The prop is optional. - **Tests.** Raw-source pins on removed code are dropped. The manual-sync timeout test now pins the Retry path's own generation guard; a mutation of its timeout fails it. The non-blocking starter test now exercises `recordPatrolControlStarterActivity`. - **Contracts.** - patrol-intelligence said a finding-to-Assistant handoff *must* record a `pulse_patrol` workflow starter. Only the dead path did that; the live `FindingsPanel` handoff never has, so `pulse_patrol` has no browser producer. The clause now says that and keeps the content-free rules as a condition. - The "local Patrol state must expose work evidence" sentence no longer requires counts nothing reads. - Three refresh clauses that described a header refresh action now name the stale-data Retry, the only operator refresh left. - api-contracts and frontend-primitives record that `FindingsPanel` owns the handoff. ## Not in this PR The store still fetches `GET /api/ai/circuit/status` for nothing. That removal is a follow-up after #2677, because it edits the same lines. ## Evidence - Production builds before and after compared with chunk hashes, SRI strings and short identifiers normalised. Only the removed hook code differs, the unused `pulse_patrol` constant drops out of `aiChat.js`, and `index.js` reorders the same preload list. `check-bundle-size.mjs` passes. - Private demo-mode stacks for both builds, driven by the same Playwright script at 1440x900 and 390x844: Patrol load, Activity > Finding options and history, Open in Assistant (drawer opens with the finding), tab hide/show, and the forced stale-data banner plus Retry. Each phase hit the same `/api` endpoints on both builds, and neither build had console errors. - `tsc --noEmit`, eslint, and vitest for `src/features/patrol`, `AIIntelligence.test.tsx`, `frontendResourceTypeBoundaries.test.ts` and `FindingsPanel.test.ts` pass, as does the full pre-commit battery. Stacked on #2687 (which carries #2682 and #2686). Until those merge, this PR shows their commits too. `git merge-tree` is clean against main, #2690, #2677 and #2691. |
||
|
|
91dbc94b5f |
Colour pulse-relaxed guests by the thresholds their alerts use (#2735)
The `pulse-relaxed` Proxmox tag lifts a guest's CPU, memory and disk alert triggers to a 95/92/95 floor (applied in `effectiveAlertPolicyNoLock` since #2705), but the browser coloured bars from the configured thresholds alone. A relaxed guest at 88% memory showed a red bar while the alert engine stayed silent until 92%, and the Alerts thresholds row kept showing 80/85/90 with no sign the tag raised them. That is the gap a user described on #863 (comment of 2026-01-16): the tags override the UI settings "but there's no visible indication in the UI". ## What changes - **Bars follow the tag.** `resolveMetricDisplayThresholds` takes the tags the engine reads for a guest and applies the backend's `applyRelaxedGuestThresholds` step before mapping to display thresholds: unset gets the floor, a lower trigger is raised and keeps its clear point (a missing clear is filled five below, as `ensureHysteresisThreshold` does) unless the raised trigger reaches it, and Off stays Off. A relaxed guest now turns red where its alert fires. - **Only Proxmox guests.** `getWorkloadAlertPolicyTags` and `useThresholdsGuestData` pass tags only for Proxmox VMs and LXCs, the only guests whose tags reach alert policy. vSphere and TrueNAS VMs (also `type: vm`) and containers keep their thresholds whatever they are tagged. - **The thresholds row says what is in effect.** A relaxed guest's Alerts → Thresholds row (table and card) adds one line, "Proxmox tag pulse-relaxed: alerts at CPU 95% · Memory 92% · Disk 95%", listing only the thresholds the tag actually raises. It follows unsaved edits. The cells keep the configured values the editor changes. ## Why the row and not the drawer or table Following the user-lens procedure: the job is "I tagged this noisy VM pulse-relaxed: is Pulse honouring it, and when will it alert?". Before this change the bar answered it wrongly at zero clicks, and the only surface that states thresholds (Alerts → Thresholds → Proxmox → VMs & Containers, then search) stated the wrong numbers. No user doc describes the tag, and GitHub comments give conflicting values (95/95/98 in #323, 95/92/95 in #863), so the row has to state the numbers. A badge in the guest drawer or table row would fail the actionability test: once the bar colour is right, the user has nothing to do there, and the place to act is the thresholds row (or the tag in Proxmox). ## Verification - Private mock stack from this branch with a dev-only overlay tagging two demo profiles `pulse-relaxed` (68 VMs). Before the change: relaxed `ci-runner-117` at 88% memory was red. After: every running relaxed row matches the relaxed band (`analytics-batch-70` at 87% reads warning), while untagged guests at 86–92% stay red, at desktop and 375px. The thresholds row line was checked in the card layout, in the table layout (full-width mode at 2560px), at 375px, and through an unsaved edit (Memory 96 drops it from the line). Receipt: `frontend-modern/browser-verification/1f9dcc1c17da6345.json`. - Proof files: `metricThresholds.test.ts` (relaxed resolution, Off, unset, clear filling at margins 5 and 10, scope gating), `workloadTopology.test.ts` (Proxmox-only tags, legacy tag strings), `ResourceTable.test.tsx` (desktop and mobile line, omitted metrics, edits, disabled and untagged rows). `GuestRow.test.tsx` gets the new export in its topology mock. - On pulse-dev: tsc, `npm run lint` and the Alerts, Workloads, utils, stores, platformPage, shared and `src/__tests__` suites. The broad run's only failures were missing repo-root files from a partial sync and 5s timeouts at load ~65; all 15 affected files pass when rerun with the full tree. ## Known limits (existing, not widened here) - The display resolver does not read filter-driven custom rules, which the engine folds in before the override, so a guest a custom rule matches can colour by thresholds the engine does not use. Custom rules have no UI today. The alerts contract now says so. - The Alerts page's guest list memo compares only id and name, so adding or removing the tag in Proxmox shows on that page after a reload. Workloads rows update live. - The thresholds page reads an unset global default as Off (`getTriggerValue`), so the line omits the floor the engine would give a genuinely unset default. A save from the Alerts page always writes these defaults. - `pulse-no-alerts` and `pulse-monitor-only` still have no indication on the row. They are tracked with the rest of the #863 comment in the demand ledger. |
||
|
|
1c6d2a0b2f |
Stop Storage scrolling back to open pools and reopening groups (#2733)
Follow-up to #2709. ## Problem On Proxmox > Storage (pools view): 1. With a pool open, scrolling the page back to the top pulled it back down to the open pool a second or two later, and kept doing so. 2. With `?group=node`, collapsing the open pool's group (Collapse button or header click) bounced it straight back open. ## Causes and fixes - **Bridge reveal re-ran on unrelated state.** `useSummaryTableFocusBridge` called the consumer's `revealActiveSeries` inside a tracked effect, so Storage's reads (the pool-to-group lookup rebuilt on every live update, the expanded group set) re-ran the reveal and its inline-detail scroll. The effect now depends only on a memo of the focused series id and the table root, and runs the callback and scroll untracked (`summaryTableFocus.ts`). Cleanup is registered before the first attempt, so a focus change or unmount cancels pending settle frames. - **Expansion state reopened collapsed groups.** `syncExpandedStorageGroups` re-added every group key missing from the expanded set whenever live data rebuilt the groups, so any collapsed group reopened within ~10 s. The bounce above had been hiding this. `useStorageExpansionState` now stores only the collapsed groups and derives the expanded set, so new groups still open by default. Group keys are bare labels, so a grouping change clears the collapsed set. That stops a label carrying collapse state into another grouping or hiding the headerless `All` group. ## Evidence 12-node mock stack, Chromium at 1440x900 and 390x844, same stack swapped between #2709's code and this change: | Check | Before | After | |---|---|---| | App-shell scrollTop after scrolling away from an open pool (every 0.5 s, 12 s) | 0, 0, 1008, then 1111 ×21 | 0 ×24 | | Collapse open pool's group via button, group state (0.5 s samples) | open ×12 | shut ×32 (16 s) | | Same via header click | open ×12 | shut ×32 (16 s) | | Disks view / Workloads table scroll-away | 0 ×24 | 0 ×24 | Unchanged: lower pool, disk and guest rows open their detail with the row header and detail top in view; Escape closes it; `?resource=ceph-rbd` opens in view; collapsing `shared` and then following an in-app `?group=node&resource=ceph-rbd` link reopens the group and shows the detail; a `?summaryGroup=` deep link scrolls to the pinned header; regrouping node → type → none → node shows every pool. Tests: new bridge tests (`summaryTableFocus.test.tsx`), Storage-level live-update and collapse tests (`Storage.test.tsx`), expansion hook tests, and a guardrail pin that the reveal runs untracked. All six new tests fail on #2709's code. The three expansion tests also fail with the new bridge but the old expansion state. ## Known limit Filter changes do not re-run the reveal. A focused pool whose row stays filtered out past the reveal's ~1.2 s retry window is not revealed when the filter clears. Storage filters live in the URL and a storage link without filter parameters resets them, so only a link carrying a filter that excludes its own pool reaches this. Likewise, with a pool already open and its group collapsed, an in-app link to that same pool leaves the group collapsed, because focus does not change. |
||
|
|
295edef4d5 |
Bind SAML logins to the browser that started them (#2729)
## Why
With `allowIdpInitiated` off, the default, every SAML login failed.
`SAMLService.ProcessResponse` passed crewjam/saml an empty list of
request IDs, and crewjam refuses any Response whose `InResponseTo` is
not in that list, so both SP-initiated and unsolicited Responses ended
in "Authentication failed". Only providers with IdP-initiated logins
enabled, which skips the check, could sign in.
## What changes
- `/api/saml/{id}/login` records each AuthnRequest ID, on the provider's
service, with the login's `returnTo` and the hash of a random token it
sets as the `HttpOnly`, `SameSite=Lax` cookie `pulse_saml_login`
(`__Host-pulse_saml_login` over HTTPS). A browser that already holds a
token keeps it, so logins started one after another in several tabs all
stay valid. Records last ten minutes, 1,024 per provider, oldest
evicted.
- The IdP posts its Response cross-site, and browsers withhold
`SameSite=Lax` cookies from cross-site POSTs. The ACS answers a
cookieless POST with a small page that re-posts the same fields once
from Pulse's own origin (no form action, so path prefixes survive; CSP
nonce on the script; a no-JS Continue button; a marker field stops a
second repost).
- `ProcessResponse` reads the answered request ID from the canonical
content goxmldsig verified for the signature covering the assertion
crewjam returns: the signed Response (or the Response in a signed
ArtifactResponse), otherwise that assertion's own
`SubjectConfirmationData`. It spends the record only when it is bound to
the presenting browser, and the login returns to the recorded `returnTo`
rather than the posted `RelayState`. A Response for another browser
leaves that browser's record intact.
- The signature verifier refuses signed content carrying a prefixed
attribute or namespace declaration named like an un-namespaced attribute
crewjam reads. encoding/xml fills such a field from any attribute with
that local name, and exclusive canonicalization leaves an unused
declaration out of the signed form. On the base commit, appending
`xmlns:IssueInstant` and `xmlns:NotOnOrAfter` to a two-hour-old signed
Response got it accepted, so with `allowIdpInitiated` on a captured
login could be replayed indefinitely.
- RelayState is now URL-escaped into the redirect, so a `returnTo` with
its own query string reaches the IdP whole.
- With `allowIdpInitiated` on, behaviour is otherwise unchanged: no
cookie or repost needed, posted `RelayState` honoured.
- The SLO LogoutRequest store from #2713 is generalised into a shared
record store; its `consume` semantics are unchanged.
Stacked on #2714 (on #2713, on #2707). Those three conflict with main in
`storage-recovery.md`, where their boundary notes and main's land at the
same anchor, so none can merge on its own. This branch carries a merge
of main that keeps both sets of notes; landing it lands the stack.
## Verification
- New tests in `internal/api/saml_handlers_more_test.go`,
`saml_service_test.go` and `audit_handlers_test.go`: default-settings
login through the repost, multiple tabs, refusal of another browser's,
unknown, unsolicited and replayed Responses (the refused browser's own
login still completes afterwards), the `xmlns:InResponseTo` spoof with
the Response signed and with only the Assertion signed, a
confirmation-less signed assertion spliced ahead of another browser's
assertion, assertion-only signed success, stale IdP-initiated Responses
with spoofed ages, repost escaping and CSP nonce, and the audit event
for a binding refusal.
`TestContract_SAMLManualIDPCertificateVerifiesIdPSignatures` now runs at
default settings instead of enabling IdP-initiated logins.
- Negative controls by `-overlay`: with the attribute refusal disabled
the stale-Response replay is accepted again; with the refusal disabled
and the binding read from crewjam's parsed fields the
`xmlns:InResponseTo` spoof logs in; the round-one binding code accepted
the spliced-assertion case as the other user.
- A real Chromium run against a test IdP on `127.0.0.1` and the SP
handlers on `localhost`, behind `SecurityHeadersWithConfig` in
production CSP mode: the IdP's POST arrived `Sec-Fetch-Site: cross-site`
without the login cookie, the repost arrived `same-origin` with it, and
the login landed signed in at
`/alerts?range=7d&saml=success&tab=history`.
## Known limits
- Records live in memory: a restart, a provider re-initialization, or a
login started on a host that does not share cookies with the configured
public URL leaves the Response unmatched, and the user signs in again.
- Two logins started at the same moment in a browser that holds no token
yet each mint one; the last cookie wins and the other login fails.
- As with OIDC login state, a sustained flood of unauthenticated login
starts can evict pending records.
- On plain HTTP, or HTTPS that Pulse does not recognise behind a proxy,
the cookie has no `__Host-` prefix, so a sibling subdomain could plant
one, the same exposure the session cookie has there.
|
||
|
|
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 |
||
|
|
860b5087d2 |
Merge origin/main into fix/link-fold-references
PR 2719 added per-side link pins beside the fold bookkeeping this branch rewrote; keep both, and replace the contract bullet that said folded IDs do not resolve. |
||
|
|
776c2774ba |
Remove test-only Patrol autonomy save helpers
resolvePatrolAutonomyLevelForSave and resolvePatrolAutonomySettingsForSave
had no production caller; only their own unit tests imported them. Patrol
mode saves go through handleAutonomyChange and
acknowledgeAndActivateAutopilot, whose payload type (PatrolAutonomyUpdate)
has no full_mode_unlocked field, and both reconcile from
GET /api/ai/patrol/autonomy afterwards. Rollup already dropped both
helpers, so the production build is byte-identical.
Several contract sentences described the helpers' model rather than the
live path: the browser clearing stale full-mode unlock state before a
monitor-only save, sending full_mode_unlocked:false when locked, and
sending full_mode_unlocked:true for Autopilot. The API does that work.
The monitor-only handler and the paid SaveAutonomySettings adapter clear
the unlock and the Autopilot activation on every save below Autopilot,
and the autonomy gate derives the compatibility flag from a recorded
acknowledgement. Rewrite those sentences in patrol-intelligence,
api-contracts, frontend-primitives and ai-runtime to say what the header,
the state owner and the API each do. Two neighbouring api-contracts
sentences were also wrong about the same save: a `full` request without
a current acknowledgement gets 409 acknowledgement_required from the gate
before any licence check, and the Patrol mode dialog they name was
removed in
|
||
|
|
6dea7101a7 |
Split operator-linked resources when a merge is reported
POST /api/resources/{id}/report-merge excluded only candidate IDs that
SourceTargets derives from the merged resource's type. For a source an
operator link folded in, that candidate names neither side of the link:
an agent linked into a VM got a vm- ID, and an agent linked with a node
or Docker host keyed by its machine ID got a source-specific one. No
link row matched, so the pair stayed merged while the API answered
"Merge reported" and the drawer toasted "Resource split applied". Two
linked agents share their only source and were refused outright as
"Resource is not merged".
applyManualLinks now records each link it applies on the merged
resource: the link row's own pair and the sources the folded side
brought, once per pair, carrying earlier folds so a chain keeps every
pair. The record rides in-memory clones, so the resources API's
registry, seeded from the monitor's already-linked listing, has it
although it never holds the folded row. Report-merge excludes those
pairs for the reported sources, and the store's one-decision-per-pair
rule deletes the link, as unlink does.
A link also joins only the pair its row names now. Canonical-ID
succession moves primary_id even when the endpoint re-key collides
with the successor's own row, and honouring that primary merged the
successor through a row that no unlink or report-merge of the merged
pair removes.
|
||
|
|
544588b117 | Merge remote-tracking branch 'origin/main' into fix/drop-unread-circuit-breaker-read | ||
|
|
c99028bc82 |
Stop Workloads row focus staging a stale shell scroll restore
Selecting a Workloads row still staged the app-shell scroll position for
App.tsx to restore. That is left over from when row focus wrote ?resource=
into the URL, which
|
||
|
|
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. |
||
|
|
1b01ab6e1b | Merge branch 'refactor/drop-unread-patrol-state-members' into fix/drop-unread-circuit-breaker-read | ||
|
|
aac5ca5fc2 |
Colour pulse-relaxed guests by the thresholds their alerts use
The pulse-relaxed Proxmox tag lifts a guest's CPU, memory and disk alert triggers to a 95/92/95 floor, but the browser coloured bars from the configured thresholds alone. A relaxed guest at 88% memory showed a red bar while the alert engine stayed silent until 92%, and its Alerts thresholds row kept showing 80/85/90 with no sign that the tag raised them (issue #863, comment of 2026-01-16). - resolveMetricDisplayThresholds takes the tags the engine reads for a guest and applies the backend's applyRelaxedGuestThresholds step: unset gets the floor, a lower trigger is raised and keeps its clear point unless the raised trigger reaches it, and Off stays Off. - getWorkloadAlertPolicyTags passes tags only for Proxmox VMs and LXCs, the only guests whose tags reach alert policy. vSphere and TrueNAS VMs and containers keep their thresholds whatever they are tagged. - A relaxed guest's Alerts thresholds row and phone card add one line naming the tag and the thresholds it raises. It follows unsaved edits, while the cells keep the configured values the editor changes. |
||
|
|
32fad38aa4 | Merge remote-tracking branch 'origin/main' into refactor/drop-unread-patrol-state-members | ||
|
|
199f45e211 | Merge origin/main into refactor/drop-chart-visibility-toggle | ||
|
|
04f61d431f |
Remove the unreachable Workloads chart visibility toggle
WorkloadsFilter rendered ChartVisibilityToggleButton only when a surface passed onChartsToggle, and none has since the workload summary chart section was deleted with the summary components. The button had no other production consumer, so the shared primitive, its two shared-template registry entries, the filter-model props and the tests that pinned them described a Show/Hide charts control that nobody could reach. Delete them. The guardrail that required the primitive now keeps the exact retired registry ids, export and props out: a returning summary section is a product decision that should register its own control. Contract prose in frontend-primitives, performance-and-scalability and unified-resources now says no chart show/hide control exists instead of describing the deleted one. No visible change: the Workloads View disclosure on the Proxmox and vSphere overviews shows the same sections and controls before and after, at desktop and phone widths. |
||
|
|
4e95ca45cb |
Stop Storage scrolling back to open pools and reopening groups
On the Storage pools view, an open pool pulled the page back to itself a second or two after the operator scrolled away, and collapsing the open pool's group bounced it straight back open. The summary focus bridge ran the consumer's revealActiveSeries callback inside a tracked effect, so everything Storage's callback reads (the pool-to-group lookup that every live update rebuilds, the expanded group set) re-ran the reveal and its inline-detail scroll. The reveal now subscribes only to the focused series id and the table root, and runs the callback and scroll untracked, so it runs once per deliberate focus change. Its cleanup is registered up front, so a focus change or unmount also cancels pending settle frames. Fixing that exposed a second cause of the bounce: the expansion state re-added every group key missing from its expanded set whenever a live update rebuilt the groups, so any collapsed group reopened within about ten seconds. It now stores only the groups the operator collapsed and derives the expanded set, so new groups still open by default. Group keys are bare labels, so a grouping change clears the collapsed set rather than carrying collapse state into another grouping or hiding the headerless "All" group. Measured on a 12-node mock stack: after scrolling away from an open pool the shell stays at scrollTop 0 for 12 s (was 0 -> 1111 within ~1.5 s), and a group collapsed by its button or header stays collapsed for 16 s. The new bridge, Storage and expansion tests fail on the previous code. Filter changes do not re-run the reveal either, so a focused pool whose row stays filtered out past the reveal's retry window is not revealed when the filter clears; storage links reset the URL-owned filters, so only a link that carries an excluding filter gets there. |
||
|
|
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. |
||
|
|
077c3a8959 |
Merge origin/main into fix/saml-acs-request-binding
Brings the branch up to the main it lands on; the merge is clean. Contract-Neutral: Merge of origin/main into the SAML stack: main's changes were governed when they landed, and the merge is clean |
||
|
|
d72d355ccf |
Merge origin/main into fix/saml-acs-request-binding
The SAML stack this branch builds on appends its boundary notes to storage-recovery.md at the anchor main used for the linked-agent stale flag note. Both sets of notes are kept, the SAML ones first. Contract-Neutral: Merge of origin/main into the SAML stack: main's changes were governed when they landed, and the only resolution keeps both sets of storage-recovery boundary notes |
||
|
|
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.
|
||
|
|
1a48d27aa0 | Merge remote-tracking branch 'origin/main' into refactor/drop-unread-patrol-state-members | ||
|
|
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. |