mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
Merge branch 'main' into refactor/drop-summary-chart-routes
This commit is contained in:
+7
-4
@@ -836,9 +836,12 @@ delivery for all resources.
|
||||
- `GET /api/admin/reports/catalog` (admin, `settings:read`)
|
||||
- Returns the canonical reporting catalog for the settings surface, including locked-state teaser copy, enabled-surface guidance copy, performance-report options, canonical single-report filename subject, canonical fallback filename date style, and the nested VM inventory export definition.
|
||||
- Metadata route: readable without the `advanced_reporting` feature so locked admin surfaces can render the same reporting definition before upsell.
|
||||
- `GET /api/admin/reports/generate` (admin, `settings:read`)
|
||||
- Query params: `format` (pdf/csv, default `pdf`), `resourceType`, `resourceId`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
|
||||
- `POST /api/admin/reports/generate` (admin, `settings:read`)
|
||||
- Body fields: `format` (pdf/csv, default `pdf`), `resourceType`, `resourceId`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
|
||||
- If `title` is omitted, the backend applies the canonical default title for that resource report.
|
||||
- When Pulse Assistant is configured, the configured AI provider narrates a PDF's executive summary; each narration counts against the AI budget and appears in AI usage. If the call fails, times out or the budget is spent, the PDF falls back to the deterministic summary. CSV output is never narrated. The settings UI uses this method.
|
||||
- `GET /api/admin/reports/generate` (admin, `settings:read`)
|
||||
- Takes the same fields as query params and returns the same report, except that it never calls an AI provider: a PDF carries the deterministic summary and a note saying AI narration needs `POST`. Other methods return `405` with `Allow: GET, POST`.
|
||||
- `POST /api/admin/reports/generate-multi` (admin, `settings:read`)
|
||||
- Body fields: `resources` (1-50 entries of `{resourceType,resourceId}`), `format`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
|
||||
- If `title` is omitted, the backend applies the canonical default fleet report title.
|
||||
@@ -851,8 +854,8 @@ Validation and limits:
|
||||
- Malformed `start`/`end` values, `end` not strictly after `start`, or report windows over 366 days return `400 invalid_time_range`.
|
||||
- `metricType` must match `[a-zA-Z0-9._:-]+` and be <= 64 chars, otherwise `400 invalid_metric_type`.
|
||||
- `title` must be <= 256 chars, otherwise `400 invalid_title`.
|
||||
- Multi-report body max size is 1MB; oversized payloads return `400 body_too_large`.
|
||||
- Multi-report bodies reject trailing payload and unknown JSON fields with `400 invalid_body`.
|
||||
- Report body max size is 1MB; oversized payloads return `400 body_too_large`.
|
||||
- Report bodies reject trailing payload and unknown JSON fields with `400 invalid_body`.
|
||||
- VM inventory export only accepts `csv`.
|
||||
|
||||
Common reporting error codes:
|
||||
|
||||
+4
-2
@@ -260,10 +260,12 @@ Each client runtime (or organization) generates its own reports, scoped to
|
||||
that client's resources:
|
||||
|
||||
- **UI**: Settings → Data & Reports.
|
||||
- **API**: `GET /api/admin/reports/generate` (single resource) and
|
||||
- **API**: `POST /api/admin/reports/generate` (single resource) and
|
||||
`POST /api/admin/reports/generate-multi` (up to 50 resources per report),
|
||||
returning PDF or CSV. In shared-process mode, scope with `X-Pulse-Org-ID`
|
||||
or an org-bound token.
|
||||
or an org-bound token. `GET /api/admin/reports/generate` with query
|
||||
parameters still returns a single-resource report, but never adds the
|
||||
Pulse Assistant narrative.
|
||||
- **Schedules**: `GET`/`POST /api/admin/reports/schedules`,
|
||||
`PUT`/`DELETE /api/admin/reports/schedules/{id}`, and
|
||||
`POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit
|
||||
|
||||
@@ -2480,12 +2480,13 @@ agent inventory, registration state, or command-channel readiness.
|
||||
or fleet-command evidence. Lifecycle flows must not
|
||||
recreate the retired Patrol quickstart bootstrap path, mint server-issued
|
||||
hosted-model tokens, or derive AI provider state from installation identity.
|
||||
Per-request `/api/ai/chat` execution-mode overrides follow that same
|
||||
boundary: lifecycle-adjacent consumers may rely on Assistant approval
|
||||
semantics, but scoped `autonomous_mode:false` chat requests must not be
|
||||
reinterpreted as agent registration, assignment, installer, or connection
|
||||
lifecycle state. Patrol finding handoffs that force approval-required mode
|
||||
from a non-empty `finding_id` are likewise AI/runtime governance, not an
|
||||
The server-set `/api/ai/chat` execution mode follows that same boundary:
|
||||
lifecycle-adjacent consumers may rely on Assistant approval semantics, but
|
||||
the approval-required mode `HandleChat` installs on every chat request
|
||||
(`ChatRequest` decodes no `autonomous_mode` field) must not be reinterpreted as agent
|
||||
registration, assignment, installer, or connection lifecycle state. Patrol
|
||||
finding handoffs running in that mode from a non-empty `finding_id` are
|
||||
likewise AI/runtime governance, not an
|
||||
agent command grant or lifecycle authorization primitive.
|
||||
Patrol queued-fix approvals may now seed the shared action-audit store with
|
||||
planned and pending lifecycle evidence, but lifecycle surfaces must treat
|
||||
@@ -4344,6 +4345,22 @@ findings handed to the orchestrator, so investigation reasoning sees
|
||||
the same lock-against-remediation flag that the action broker
|
||||
enforces downstream — no possible drift between "what Patrol
|
||||
proposes" and "what the broker accepts."
|
||||
That builder resolves a finding's resource reference through the
|
||||
organization monitor's current read state before it reads operator
|
||||
state. A finding still keyed by an agent's canonical ID after an
|
||||
operator linked the agent into its guest therefore reads the merged
|
||||
row's state while the read state folds the agent, as the resources API
|
||||
does. A finding naming a saved host that has not reported since a
|
||||
restart by its agent reference reads the row stored under that host's
|
||||
canonical ID, because the read state overlays host continuity as the
|
||||
resources API listing does. A saved host joined to its guest by a link
|
||||
stays its own row there, without lending the guest its saved payload,
|
||||
but the registry holds it under the guest, so its findings read the
|
||||
guest's state, as they did before the restart. The
|
||||
resolution only reads identity. It does not treat the folded agent as
|
||||
removed, offline or unenrolled, does not turn a continuity-only host
|
||||
into a live sighting, and grants no heartbeat, command, install or
|
||||
repair authority.
|
||||
|
||||
The same router wiring owns the alert-bridge patrol-trigger callback. It now
|
||||
receives the full alert payload as a struct and consults the operator's
|
||||
@@ -4926,8 +4943,8 @@ withheld) is an AI-runtime privacy concern; it is not agent enrollment config,
|
||||
installer readiness, command reachability, or any fleet-control capability signal.
|
||||
Patrol finding chat handoffs follow the same ownership split: when
|
||||
`/api/ai/chat` resolves a `finding_id` into model-only Patrol briefing,
|
||||
resource, or action context, the backend-enforced `autonomous_mode:false`
|
||||
clamp is Assistant action-governance, not agent readiness, fleet command
|
||||
resource, or action context, the backend-enforced approval-required clamp
|
||||
is Assistant action-governance, not agent readiness, fleet command
|
||||
reachability, or enrollment health.
|
||||
If the same request also carries recognized Patrol product handoff context,
|
||||
resources, or action references, the API handler may merge only same-finding
|
||||
@@ -7662,6 +7679,16 @@ bounded and classified temperature nodes, and leaves the host report intact
|
||||
when the helper is absent or incompatible. It does not treat Windows ACPI
|
||||
thermal zones as hardware sensor evidence, accept remote provider locations,
|
||||
or grant Pulse hardware-control authority.
|
||||
### Single-resource report AI narration requires POST
|
||||
|
||||
`internal/api/metrics_reporting_handlers.go`, `internal/api/router.go` and
|
||||
`internal/api/ai_handlers.go` changed only how `/api/admin/reports/generate`
|
||||
reads its fields (a `POST` JSON body or `GET` query parameters), which of those
|
||||
may use the tenant's AI narrator (`POST` only), and how a `GET` finds Patrol
|
||||
findings (`ExistingAIService`, which never constructs a tenant AI service). No
|
||||
agent registration, enrolment, install, update, removal or report ingest path
|
||||
changed.
|
||||
|
||||
### Agent profile adoption is reported as a count only
|
||||
|
||||
The usage telemetry snapshot reads `LoadAgentProfiles` and reports
|
||||
@@ -8969,6 +8996,45 @@ their own heartbeat cutoff (`fleethealth.AgentStaleThreshold`, which is
|
||||
separate from the monitoring reporting lease). Agent registration, enrolment,
|
||||
install, update, removal and report identity are unchanged.
|
||||
|
||||
### Safe methods refused on discovery settings and SAML logout
|
||||
|
||||
`internal/api/method_guard.go`, `internal/api/discovery_handlers.go` and
|
||||
`internal/api/saml_handlers.go` changed only so `/api/discovery/settings` and
|
||||
`/api/saml/{id}/logout` answer `405` to every method outside their allowed set,
|
||||
`GET` and `HEAD` included. The settings write still takes `PUT` or `POST` and
|
||||
logout still takes `POST`. No agent registration, enrolment, install, update or
|
||||
removal path changed.
|
||||
|
||||
### SAML SLO responses bound to the logout that requested them
|
||||
|
||||
`internal/api/saml_service.go` and `internal/api/saml_handlers.go` changed only
|
||||
so `/api/saml/{id}/slo` accepts an IdP LogoutResponse when its signed
|
||||
`InResponseTo` names an unexpired LogoutRequest that `/api/saml/{id}/logout`
|
||||
issued, once, in a browser carrying no session or the session that logout
|
||||
ended. Other responses are refused, the SLO handler writes no session or
|
||||
cookie for any response (the logout request already cleared both), and
|
||||
`/api/saml/{id}/logout` refuses a request that carries no session. No agent
|
||||
registration, enrolment, install, update or removal path changed.
|
||||
|
||||
### Manual SAML IdP certificate published as base64 DER
|
||||
|
||||
`internal/api/saml_service.go` changed only so a SAML provider configured with
|
||||
`idpSsoUrl` and a PEM `idpCertificate` or `idpCertFile` puts that certificate
|
||||
into its in-memory IdP descriptor as base64 DER, which crewjam/saml can read
|
||||
when it verifies the IdP's ACS Responses and LogoutResponses. No agent
|
||||
registration, enrolment, install, update or removal path changed.
|
||||
|
||||
### SAML logins bound to the browser that started them
|
||||
|
||||
`internal/api/saml_service.go` and `internal/api/saml_handlers.go` changed only
|
||||
so `/api/saml/{id}/login` records each SAML AuthnRequest against a short-lived
|
||||
login cookie and `/api/saml/{id}/acs` accepts the IdP's signed answer to it
|
||||
once, in that browser, at default settings, reposting the IdP's cross-site
|
||||
delivery once from Pulse's origin so the cookie arrives. Signed content
|
||||
carrying a prefixed attribute or namespace declaration named like a SAML
|
||||
attribute is refused. No agent
|
||||
registration, enrolment, install, update or removal path changed.
|
||||
|
||||
### Demo write guard ignores websocket upgrade headers
|
||||
|
||||
`internal/api/demo_middleware.go` changed only so a `POST`, `PUT`, `PATCH` or
|
||||
@@ -8985,3 +9051,24 @@ No agent registration, enrolment, install, update or removal path changed.
|
||||
for every method. No agent route lives under `/debug/`, so agent registration,
|
||||
enrolment, install, update, removal, report ingest and `/api/agent/ws`
|
||||
handshakes are unchanged on a demo instance.
|
||||
|
||||
### Broadcast host coalescing honours operator splits
|
||||
|
||||
`internal/monitoring/monitor.go` changed only so the websocket broadcast
|
||||
coalesces host views with the operator's merge exclusions
|
||||
(`MonitorAdapter.CoalesceForPresentation`), as the resources API's
|
||||
`ListForPresentation` already did, so an agent host the operator split from
|
||||
a same-named platform view by unlink or report-merge is coalesced on the
|
||||
websocket as REST coalesces it (the filter's limits are recorded under
|
||||
"Unlink replaces the pair's operator link" in the unified-resources
|
||||
contract). Agent registration, enrolment, install, update, removal, report
|
||||
identity and continuity are unchanged.
|
||||
|
||||
### Report-merge splits a linked agent
|
||||
|
||||
`internal/api/resourceapi/resources.go` changed only so report-merge on a
|
||||
resource an operator linked an agent into (a VM, a node or a Docker host)
|
||||
replaces that link, splitting the agent back out as unlink does. Agent
|
||||
registration, enrolment, install, update, removal, report identity and
|
||||
continuity are unchanged, and an agent's own declared node link still
|
||||
ignores exclusions.
|
||||
|
||||
@@ -128,6 +128,25 @@ on component mount. Closing clears the registered input so later keyboard
|
||||
commands cannot target a detached composer. A handoff must leave Escape and
|
||||
keyboard input in Assistant, not the underlying alert search.
|
||||
|
||||
### Process-wide memory ID sequences
|
||||
|
||||
`internal/ai/memory` mints incident, incident-event, remediation-record and
|
||||
context-memory IDs from package-level sequences that every store in the process
|
||||
shares. Multi-tenant monitoring gives each tenant monitor its own
|
||||
`IncidentStore`, and their alert checks call `RecordAlertFired` from separate
|
||||
goroutines, while a store's mutex covers only its own state. Each sequence is
|
||||
therefore an `atomic.Int64` minted through `nextSequencedID` in
|
||||
`internal/ai/memory/ids.go`, never a plain package counter. The ID shape stays
|
||||
`<prefix><local second>-<n>`, but `n` no longer wraps at 1000. The old
|
||||
`counter%1000` suffix repeated an ID whenever one generator minted more than a
|
||||
thousand in a wall-clock second, and incident notes resolve an incident by ID
|
||||
while timeline merges deduplicate events by ID. Within one process IDs are now
|
||||
unique by construction. Across restarts only the timestamp separates them, so a
|
||||
repeated local second (DST fall-back or a backward clock step) can still meet a
|
||||
restarted sequence's suffix. Persisted IDs and the stored format are unchanged.
|
||||
`internal/ai/memory/ids_test.go` mints from every generator concurrently and
|
||||
records alerts into two tenant stores concurrently; run it under `-race`.
|
||||
|
||||
### Canonical incident-history queries
|
||||
|
||||
Incident context reads `IncidentStore.QueryIncidents`, whose page contains
|
||||
@@ -2544,8 +2563,9 @@ deriving an older display status from `workflowStatusHistory`.
|
||||
popup/dialog only; it must not also close the Assistant drawer.
|
||||
Failed-turn retry is part of that same local chat-runtime boundary: a
|
||||
retryable in-memory assistant error may replay the original user turn's
|
||||
structured mentions, finding id, approval override, handoff resources,
|
||||
handoff actions, and handoff metadata, but must not reconstruct scoped
|
||||
structured mentions, finding id, model route, handoff context, handoff
|
||||
resources, handoff actions, and handoff metadata (execution mode is not
|
||||
part of that request; `/api/ai/chat` sets it), but must not reconstruct scoped
|
||||
context from prompt history or saved transcript prose. Provider/model route
|
||||
recovery is explicit user-visible recovery, not hidden chat execution. A
|
||||
failed turn may expose route-and-retry actions through the existing drawer
|
||||
@@ -3537,7 +3557,7 @@ query...`, and `Reading storage...` before streamed tool arguments are
|
||||
browser-safe session summary, clear redo state when a new message or trim
|
||||
changes the transcript, and keep forked sessions independent from the source
|
||||
redo stack. The drawer must restore the removed prompt, structured mentions,
|
||||
finding id, handoff metadata, approval/autonomous override, and selected
|
||||
finding id, handoff context and metadata, and selected
|
||||
model route when available so the operator can edit and resend without
|
||||
reconstructing hidden context from transcript prose.
|
||||
Retry and regenerate are turn re-runs over the same undo boundary, not a
|
||||
@@ -3792,8 +3812,10 @@ query...`, and `Reading storage...` before streamed tool arguments are
|
||||
dispatches the shared evidence-first explanation request through
|
||||
`frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts`.
|
||||
It preserves drafts and current work, captures the selected context before
|
||||
asynchronous initialization, and uses normal send, queue, and retry handling
|
||||
with request-local `autonomousMode:false`. It must not prescribe a diagnosis
|
||||
asynchronous initialization, and uses normal send, queue, and retry handling.
|
||||
The store marks that context `autonomousMode:false` so the drawer discloses
|
||||
approval-required posture; the request itself carries no execution-mode
|
||||
field, because `/api/ai/chat` sets that mode server-side. It must not prescribe a diagnosis
|
||||
or a tool sequence or grant action authority. The drawer
|
||||
presentation must stay compact: source, status, one primary subject, and an
|
||||
optional safe route link. It must not render Patrol-authored remediation
|
||||
@@ -4176,10 +4198,14 @@ resolve canonical/source IDs and unique aliases before collection, reject
|
||||
human-facing labels in the settings payload must frame the operator boundary
|
||||
as Patrol mode rather than Patrol configuration.
|
||||
Monitor-only Patrol autonomy saves are part of the same runtime gate:
|
||||
when the safe-remediation extension or entitlement is unavailable, both the
|
||||
browser state owner and `internal/api/ai_handlers.go` must clear stale
|
||||
full-mode unlock state while clamping autonomy back to `monitor`, so paid
|
||||
remediation permission cannot survive through a free runtime save.
|
||||
when the safe-remediation extension or entitlement is unavailable, the
|
||||
API must clear stale full-mode unlock and Autopilot activation state while
|
||||
clamping autonomy back to `monitor` (the monitor-only handler in
|
||||
`internal/api/ai_handlers.go` without the extension, the
|
||||
`SaveAutonomySettings` adapter in `internal/api/router_routes_ai_relay.go`
|
||||
behind the paid handler), and the browser state owner must refuse a paid
|
||||
level when a save starts, so paid remediation permission cannot survive
|
||||
through a free runtime save.
|
||||
Event-triggered Patrol runtime policy is also part of this gate: when local
|
||||
development background automation or another runtime policy pauses automatic
|
||||
alert/anomaly-triggered checks, `internal/ai` must expose an explicit
|
||||
@@ -4607,10 +4633,11 @@ resolve canonical/source IDs and unique aliases before collection, reject
|
||||
completed, failed, expired, or otherwise historical action references must
|
||||
remain action context without being relabeled as requiring approval.
|
||||
When the Assistant drawer restores any session from that `handoff_summary`,
|
||||
it must restore the scoped request-local approval boundary as well as the
|
||||
safe visible briefing: the next chat turn must carry
|
||||
`autonomous_mode:false` even when the summary is context-only and has no
|
||||
queued action, while the visible badge/action copy must still reflect the
|
||||
it must restore the scoped approval-required disclosure (the drawer-only
|
||||
`autonomousMode:false` context flag) as well as the safe visible briefing,
|
||||
even when the summary is context-only and has no queued action. The next
|
||||
chat turn carries no execution-mode field; `/api/ai/chat` runs it
|
||||
approval-required server-side. The visible badge/action copy must still reflect the
|
||||
actual last-known action state or Patrol assessment context instead of
|
||||
inventing a pending approval or restoring a Patrol recommendation. That
|
||||
restoration is success-bound: if the underlying session message load fails,
|
||||
@@ -4644,8 +4671,8 @@ resolve canonical/source IDs and unique aliases before collection, reject
|
||||
model-context text parsing must not resurrect them as a legacy fallback.
|
||||
After that send succeeds, the drawer
|
||||
must clear those request payloads while preserving the safe visible
|
||||
briefing and request-local
|
||||
approval-required posture; later turns must rely on backend-owned session
|
||||
briefing and the drawer's
|
||||
approval-required disclosure; later turns must rely on backend-owned session
|
||||
model-context hydration and current canonical stores instead of resending
|
||||
stale browser handoff payloads. Patrol approval-row Assistant entries are
|
||||
still Patrol finding handoffs, not local prompt-only shortcuts: live
|
||||
@@ -4657,34 +4684,38 @@ resolve canonical/source IDs and unique aliases before collection, reject
|
||||
Proposed-fix command text must stay out of both the persisted chat message
|
||||
and the model-only handoff context, and command payloads remain
|
||||
approval-context data, not conversational copy.
|
||||
`/api/ai/chat` must also clamp Patrol finding handoffs to
|
||||
approval-required mode when a request carries a non-empty `finding_id` or
|
||||
resolves to model-only briefing, resource, or action context, by forcing the
|
||||
request-local autonomous-mode override to false, even when a caller supplied
|
||||
`autonomous_mode:true`. That clamp belongs to the
|
||||
`/api/ai/chat` clamps every exchange to approval-required mode, Patrol
|
||||
finding handoffs included: `ChatRequest` has no `autonomous_mode` field, so
|
||||
caller JSON (a stale client's `autonomous_mode:true` included) is dropped at
|
||||
decode, and `HandleChat` passes `AutonomousMode:false` into the chat service
|
||||
whether or not the request carries a `finding_id` or model-only briefing,
|
||||
resource, or action context. The browser sends no execution-mode field.
|
||||
That clamp belongs to the
|
||||
backend/API execution boundary, does not mutate the user's persistent AI
|
||||
control setting, and prevents product-originated Patrol action context from
|
||||
becoming silent command authority.
|
||||
The chat runtime must apply any request-local autonomous-mode override to
|
||||
The chat runtime must apply that server-set request-local mode
|
||||
(`chat.ExecuteRequest.AutonomousMode`) to
|
||||
both the per-request `AgenticLoop` and the cloned `PulseToolExecutor`;
|
||||
persistent autonomous settings must not leak into scoped approval-required
|
||||
handoffs through executor state. When such an override forces approval mode
|
||||
persistent autonomous settings must not leak into approval-required
|
||||
requests through executor state. When the request forces approval mode
|
||||
and the saved control level is autonomous, the executor clone must clamp its
|
||||
effective control level to controlled for that request only, so even
|
||||
policy-allowed diagnostic commands require operator approval in scoped
|
||||
handoffs without mutating the user's saved setting.
|
||||
policy-allowed diagnostic commands require operator approval in public chat
|
||||
without mutating the user's saved setting.
|
||||
The Assistant drawer may also render an attached context briefing for that
|
||||
handoff, but the briefing is runtime context visibility only: it must not
|
||||
mutate chat control settings, execute tools, or reveal raw command payloads.
|
||||
Resource-drawer Assistant entries use that same briefing path with
|
||||
`handoff_metadata.kind=resource_context`, a structured `handoff_resources`
|
||||
reference, and `autonomous_mode:false`; they must not prefill or submit a
|
||||
reference, and the drawer-only `autonomousMode:false` approval disclosure;
|
||||
they must not prefill or submit a
|
||||
browser-authored prompt, and any rich resource facts must be hydrated by the
|
||||
backend context-pack path rather than reconstructed in the browser.
|
||||
Safe route-owned briefing actions may render as app links when the handoff
|
||||
includes an `actionHref`, but those links are navigation guidance only and
|
||||
do not grant tool execution or approval authority.
|
||||
Request-local approval-required scoped handoffs must present that boundary
|
||||
Scoped handoffs must present the approval-required boundary
|
||||
through compact source-named drawer state and the effective control label,
|
||||
so Patrol approval/finding handoffs and alert-investigation handoffs are
|
||||
named by their source rather than as generic dashboard briefs.
|
||||
@@ -6734,6 +6765,11 @@ The browser does not read the global learned-correlation list:
|
||||
`/api/ai/intelligence/correlations`, and neither the shared AI intelligence
|
||||
store nor the Patrol page loads it, because no page renders it. The resource
|
||||
drawer reads per-resource correlations from the resource-intelligence payload.
|
||||
The browser does not read `GET /api/ai/circuit/status` either:
|
||||
`frontend-modern/src/api/ai.ts` carries no client for it and the shared AI
|
||||
intelligence store holds no breaker state, because an open breaker already
|
||||
reaches Patrol as the `circuit_open` runtime block cause. The route stays a
|
||||
backend API surface.
|
||||
AI-facing policy metadata must also be cloned through the shared unified-
|
||||
resource policy helper so chat and tools consumers do not maintain their own
|
||||
policy copy logic. Chat mention prefetch now calls that shared helper directly
|
||||
@@ -6950,19 +6986,22 @@ operator paragraph. Future overview or brief surfaces need a governed product
|
||||
owner first, must pass fact-bound structured context from owning Infrastructure,
|
||||
Workloads, Patrol, storage, recovery, and alert summaries, and must not let an
|
||||
unbounded prompt become a route's source of truth.
|
||||
Future route-to-Assistant handoffs must also keep their execution mode scoped
|
||||
to the request. When an overview brief opens Assistant, the drawer may prefill
|
||||
only governed prompt/context data, but the submitted chat request must set
|
||||
`autonomous_mode:false`, preserve the operator's persistent Assistant
|
||||
control-level setting, and disclose the temporary approval-required mode in
|
||||
the drawer instead of showing the generic Autonomous warning.
|
||||
Future route-to-Assistant handoffs inherit the server-set execution mode
|
||||
instead of carrying one. When an overview brief opens Assistant, the drawer may
|
||||
prefill only governed prompt/context data; the submitted chat request carries
|
||||
no execution-mode field (`/api/ai/chat` runs every request approval-required),
|
||||
the operator's persistent Assistant control-level setting stays untouched, and
|
||||
the drawer discloses the approval-required mode through the
|
||||
`autonomousMode:false` context flag instead of showing the generic Autonomous
|
||||
warning.
|
||||
Scoped Assistant handoffs that originate in owned product surfaces may also
|
||||
send bounded `handoff_context` text, structured `handoff_resources`, and safe
|
||||
structured `handoff_actions` through `frontend-modern/src/api/aiChat.ts` and
|
||||
`/api/ai/chat`. That context is model-only session metadata, not saved
|
||||
user-authored message text, and the backend must clamp the exchange to
|
||||
approval-required mode whenever such scoped handoff context, resources, or
|
||||
action references are present. Patrol finding IDs remain stricter: when
|
||||
action references are present (`HandleChat` currently clamps every exchange).
|
||||
Patrol finding IDs remain stricter: when
|
||||
`finding_id` resolves, backend-refreshed durable Patrol context remains the
|
||||
canonical authority; the handler may merge only a recognized same-finding
|
||||
Patrol product handoff section as secondary model-only briefing, and it must
|
||||
@@ -7516,6 +7555,18 @@ prompt budget. Reporting is therefore an additive consumer of AI
|
||||
runtime, not a new ownership boundary, and the narrator/findings
|
||||
surfaces inherit the same governance the rest of the canonical AI
|
||||
runtime already enforces.
|
||||
Report narration is caller-requested spend, so on the single-resource HTTP
|
||||
route only `POST /api/admin/reports/generate` reaches it (fleet reports, from
|
||||
`POST .../generate-multi` or a report schedule, use the fleet narrator). The
|
||||
reporting narrator resolver goes through `AISettingsHandler.GetAIService`,
|
||||
which constructs a tenant service on first use and can then list provider
|
||||
models or start background discovery. A GET single-resource report never calls
|
||||
that resolver:
|
||||
it narrates deterministically and reads Patrol findings through
|
||||
`AISettingsHandler.ExistingAIService`, which returns only a service that is
|
||||
already running and never constructs one
|
||||
(`TestAISettingsHandler_ExistingAIServiceNeverConstructs`, and through the
|
||||
router's real wiring `TestContract_SingleReportGETDoesNotConstructTenantAIService`).
|
||||
|
||||
The same canonical AI runtime now also owns the fleet-level report
|
||||
narrative through `report_fleet_narrator.go`. `Service` implements
|
||||
|
||||
@@ -1609,6 +1609,34 @@ resolve overrides through the shared
|
||||
candidate set `buildProjectedOverrides` indexes platform alert rows under).
|
||||
Static metric-color defaults are only fallback presentation behavior for
|
||||
callers that do not have alert configuration in scope.
|
||||
Guest bars follow `pulse-relaxed` the same way. `resolveMetricDisplayThresholds`
|
||||
takes the tags the engine reads for a guest-scope resource and, when they
|
||||
include `pulse-relaxed`, applies the `applyRelaxedGuestThresholds` step to
|
||||
CPU, memory and disk before mapping to display thresholds: an unset threshold
|
||||
gets the 95/92/95 floor with a clear five below it, a lower trigger is raised
|
||||
to the floor and keeps its own clear point (a missing one filled five below,
|
||||
as `ensureHysteresisThreshold` does) unless the raised trigger reaches it, and
|
||||
an Off threshold stays Off. A relaxed guest's bar therefore turns critical
|
||||
where its alert fires; 88% memory reads warning, not critical, under the
|
||||
relaxed 92% trigger. 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 still colour by thresholds the engine does not use, relaxed
|
||||
or not. Only Proxmox VMs and LXCs carry their tags into alert
|
||||
policy, so `getWorkloadAlertPolicyTags` in
|
||||
`frontend-modern/src/components/Workloads/workloadTopology.ts` passes tags for
|
||||
those guests alone, and a vSphere VM or a container keeps its thresholds
|
||||
whatever it is tagged. The Alerts thresholds row for a relaxed Proxmox guest
|
||||
keeps showing the configured values its editor changes and adds one line
|
||||
naming the tag and the thresholds it raises
|
||||
(`getAlertResourceRelaxedTriggerSummary` in
|
||||
`frontend-modern/src/components/Alerts/alertResourceTableModel.ts`, fed by
|
||||
`pulseRelaxed` from `useThresholdsGuestData`); before it, the tag overrode
|
||||
those settings with no visible sign in the UI (issue #863). Proof: the
|
||||
pulse-relaxed cases in `frontend-modern/src/utils/__tests__/metricThresholds.test.ts`,
|
||||
the alert policy tag cases in
|
||||
`frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts`,
|
||||
and the pulse-relaxed guest cases in
|
||||
`frontend-modern/src/components/Alerts/ResourceTable.test.tsx`.
|
||||
|
||||
Docker container image-update alerts are lifecycle-governed by the alerts
|
||||
runtime. Disabling Docker update alerts globally, disabling alerts for a
|
||||
@@ -2642,7 +2670,7 @@ table subgroup-row contract. `AlertHistoryTableGroupRow` and grouped rows in
|
||||
`AlertResourceTableDesktop` must route their date/resource group bands through
|
||||
`frontend-modern/src/components/shared/groupedTableRowPresentation.ts` instead
|
||||
of local `bg-surface-alt` fills, so alert subgroup hierarchy stays visually
|
||||
consistent with Infrastructure, Workloads, Storage, and Recovery tables.
|
||||
consistent with Infrastructure, Workloads, and Storage tables.
|
||||
Alert history table shells must also rely on the shared `TableCard` frame and
|
||||
the shared `Table` primitive for horizontal overflow rather than adding
|
||||
alert-local bordered or `overflow-x-auto` wrappers inside the history section.
|
||||
@@ -3136,21 +3164,22 @@ issue sequential route writes. That reset removes all three query parameters
|
||||
in one navigation and clears any transient chart-bucket selection, so a
|
||||
search-only result set remains visibly resettable and an older URL write
|
||||
cannot resurrect another filter.
|
||||
That same history surface now also owns the canonical resource-incident
|
||||
handoff. `frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`
|
||||
must treat the selected incident resource as a unified-resource consumer,
|
||||
linking back into canonical infrastructure/resource detail first and then into
|
||||
shared workloads, storage, and recovery surfaces through
|
||||
`frontend-modern/src/routing/resourceLinks.ts` rather than leaving the panel
|
||||
as a dead-end investigation card or rebuilding provider-local route strings for
|
||||
platforms such as TrueNAS.
|
||||
That same alert handoff must now stay on the shared resolved-resource link
|
||||
builder. `AlertResourceIncidentsPanel.tsx` must resolve its chip set through
|
||||
`buildResolvedResourceSurfaceLinks(...)`, which owns exact unified-resource
|
||||
handoffs plus the infrastructure fallback when alert history still references a
|
||||
resource ID before the backing unified record has hydrated. Future incident-link
|
||||
work must not reintroduce local infrastructure-link assembly, local dedupe, or
|
||||
provider-local route strings inside the alert feature shell.
|
||||
That same history surface also owns the resource-incident panel, and the panel
|
||||
links nowhere. `frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`
|
||||
names the selected resource through the unified-resource lookup it is given
|
||||
(`getResource`, falling back to the history state's lookup) and
|
||||
`getPreferredInfrastructureDisplayName`, and shows the resource name the
|
||||
selected history row carried while no unified record matches. It imports nothing from
|
||||
`frontend-modern/src/routing/resourceLinks.ts`, and it offers no resource
|
||||
cross-jump links: investigation continues in place through the incident
|
||||
timeline cards or hands off to Pulse Assistant through
|
||||
`IncidentAssistantHandoffButton`, as the retired cross-jump chip note above
|
||||
describes. Future incident-link work
|
||||
must not reintroduce a chip set, local infrastructure-link assembly, or
|
||||
provider-local route strings inside the alert feature shell;
|
||||
`frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts` pins the panel
|
||||
free of `buildResolvedResourceSurfaceLinks`, `buildResourceSurfaceLinksForResource`,
|
||||
and `buildInfrastructureResourceLink`.
|
||||
|
||||
Alert configuration load/save state, notification config reloads, and threshold
|
||||
override normalization now route through
|
||||
|
||||
@@ -2706,9 +2706,10 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
remediation-plan handoffs must use the same boundary for model-only
|
||||
context: plan status, risk, step labels, and command counts are allowed,
|
||||
while raw command and rollback command payloads remain in governed action
|
||||
surfaces. Frontend Patrol finding-discussion handoffs must force a
|
||||
request-local approval-required Assistant mode instead of inheriting the
|
||||
user's persistent autonomous control setting; live approval, action artifact,
|
||||
surfaces. Frontend Patrol finding-discussion handoffs run in the
|
||||
approval-required Assistant mode that `/api/ai/chat` sets server-side for
|
||||
every request, never the user's persistent autonomous control setting, and
|
||||
send no execution-mode field of their own; live approval, action artifact,
|
||||
fix-outcome, and remediation-plan references only add structured action
|
||||
metadata, they are not the trigger for the boundary. Frontend-visible Patrol
|
||||
briefing payloads must stay compact and must not include suggested prompt
|
||||
@@ -2716,7 +2717,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
recommendation metadata. Frontend queued-fix recovery handoffs
|
||||
where the live approval or action artifact payload is unavailable must still
|
||||
carry that Patrol-owned finding briefing, current `fix_queued` posture,
|
||||
request-local approval-required mode, and model-only evidence context; they must
|
||||
approval-required disclosure, and model-only evidence context; they must
|
||||
not degrade into generic Assistant investigation chat or imply that
|
||||
execution can proceed from missing command payloads. Expired-approval
|
||||
recovery handoffs may use a still-available structured action artifact payload
|
||||
@@ -3329,6 +3330,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
and any frontend presentation of the AI summary's `policy_posture` counts, which must render that canonical snapshot through one shared component instead of page-local posture loops; no frontend surface renders those counts today, and the resource drawer stays on per-resource policy lines
|
||||
and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so if Patrol recent-change, learned-correlation, or policy-coverage summary text returns, it is derived from the canonical AI payload in that one place instead of as hook-local count and pluralization logic; Patrol renders no such summary today and the model carries no such derivation
|
||||
and the Watch-only forward-path handoffs owned by `frontend-modern/src/features/patrol/patrolControlPresentation.ts` together with the Actions inbox empty-state read of `GET /api/ai/patrol/autonomy`, so finding-level and inbox-level mode guidance consumes the canonical Patrol autonomy read/save contract through the Patrol state hook (`handleAutonomyChange`) instead of introducing a second mode mutation path or a page-local autonomy dialect
|
||||
and the Patrol finding-to-Assistant handoff owned by `frontend-modern/src/components/AI/FindingsPanel.tsx`, so the pending-approval, investigation, and proposed-fix reads behind that handoff happen once in the panel instead of in a second, unwired copy inside the Patrol state hook
|
||||
and that same Patrol investigation-context owner, so visible Assistant
|
||||
drawer handoffs may include live pending-approval metadata only as safe
|
||||
operator context: approval ID, status, risk, requested/expiry timestamps,
|
||||
@@ -3350,8 +3352,8 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
and the Patrol runtime-remediation destination shared with the AI settings endpoint, so run-history runtime-failure actions and runtime-finding actions may reuse the governed provider-settings route while still presenting that destination in Patrol as provider configuration instead of generic `AI Settings` copy
|
||||
and the Patrol route-shell destination itself, so the thin page shell at `frontend-modern/src/pages/AIIntelligence.tsx` may continue to bridge the shared AI-runtime payload boundary while exposing `/patrol` as the canonical product route and keeping retired `/ai` browser entry points unregistered
|
||||
and the Patrol route-shell accessibility boundary, so brand icons in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` stay decorative when the same heading already exposes visible Patrol text, preventing duplicate accessible names such as `Pulse Patrol Patrol`
|
||||
and the Patrol mode selector boundary, so the default header and the
|
||||
Patrol mode dialog compose the shared
|
||||
and the Patrol mode selector boundary, so the header's `Mode and automation`
|
||||
disclosure composes the shared
|
||||
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` primitive for
|
||||
the visible `Watch only` / `Ask first` / `Safe auto-fix` /
|
||||
`Autopilot` presentation while the API contract remains the sole owner of
|
||||
@@ -3915,6 +3917,13 @@ counters exist to measure.
|
||||
onboarding telemetry. Customer diagnostics may expose runtime health,
|
||||
supportability, and sanitized troubleshooting state; admin analytics must
|
||||
stay behind admin-owned metrics routes.
|
||||
25. Handlers that perform a caller-requested write (settings, sessions,
|
||||
credentials, workflow or resource state) while answering `GET` or `HEAD`.
|
||||
A route registered without a mux method pattern, or a sub-resource
|
||||
dispatched by path, must refuse safe methods in the mutating handler
|
||||
through `requireRequestMethod` in `internal/api/method_guard.go` (or an
|
||||
equivalent explicit check) instead of relying on its callers, the
|
||||
demo-mode read-only guard, or the CSRF check.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
@@ -4031,24 +4040,34 @@ the authoritative analysis outcome.
|
||||
self-hosted v6 GA UI
|
||||
and the Patrol autonomy save contract, so Community/free runtime payloads
|
||||
may persist only `monitor` autonomy settings through
|
||||
`/api/ai/patrol/autonomy`, while `approval`, `assisted`, and `full` return
|
||||
the canonical license-required response instead of a generic save failure,
|
||||
and Patrol frontend state owners must clamp stale paid autonomy to `monitor`
|
||||
and send `full_mode_unlocked:false` before submitting that endpoint when the
|
||||
safe-remediation entitlement is not effective. The monitor-only backend path
|
||||
must likewise clear stale full-mode unlock state in its response and
|
||||
persistence layer instead of preserving paid remediation state through a free
|
||||
configuration save
|
||||
`/api/ai/patrol/autonomy`, while `approval`, `assisted`, and an
|
||||
acknowledged `full` return the canonical license-required response instead
|
||||
of a generic save failure (the shared acknowledgement gate refuses a `full`
|
||||
request without a current Autopilot acknowledgement first, for example with
|
||||
`409 acknowledgement_required` when it carries none), and the Patrol
|
||||
frontend state owner must
|
||||
refuse any level other than `monitor` when a save starts while the
|
||||
safe-remediation entitlement is not effective; its payload carries only
|
||||
`autonomy_level`, the investigation budget and timeout, and (for Autopilot)
|
||||
`acknowledgement_id`, never `full_mode_unlocked`. The monitor-only backend
|
||||
path owns the unlock clamp:
|
||||
it must clear stale full-mode unlock and Autopilot activation state in its
|
||||
response and persistence layer instead of preserving paid remediation state
|
||||
through a free configuration save
|
||||
and the Patrol settings-save readiness contract, so
|
||||
`/api/settings/ai/update` may save a selected Patrol provider/model even
|
||||
when that model is not ready for tool-backed Patrol execution, but it must
|
||||
echo `patrol_readiness` with stable `cause` metadata and execution routes
|
||||
must continue to fail closed before model calls. Frontend Patrol settings
|
||||
must continue to fail closed before model calls. Frontend settings
|
||||
consumers must surface that saved-but-not-ready response as a saved
|
||||
configuration issue with the echoed provider, model, cause, and summary
|
||||
instead of reporting the successful save as a failed save or hiding the
|
||||
readiness blocker behind a generic notification; and the structured
|
||||
investigation-record contract, so unified findings may
|
||||
configuration issue rather than a failed save, and never as inline failure
|
||||
state: every Pulse Intelligence settings page (Provider & Models, Patrol,
|
||||
Assistant, Service context) raises a warning notification naming the echoed
|
||||
summary, provider, and model, while the Patrol page's on/off save raises a
|
||||
fixed not-ready warning and leaves the diagnosis to the page's own readiness
|
||||
surfaces, such as the readiness banner that names the summary, provider,
|
||||
and model while the header offers `Fix setup` when Patrol is active; and
|
||||
the structured investigation-record contract, so unified findings may
|
||||
expose `investigation_record` only through the shared
|
||||
`aicontracts.InvestigationRecord` payload shape, with frontend API types
|
||||
and backend contract tests updated in the same slice as any field change.
|
||||
@@ -4297,7 +4316,7 @@ the authoritative analysis outcome.
|
||||
run-history consumers may pass a bounded `[Patrol Run Context]` block,
|
||||
scoped resource references, run outcome/coverage facts, sanitized analysis,
|
||||
and structured runtime failure summary/detail as model-only chat context
|
||||
while forcing request-local approval-required mode and leaving retries,
|
||||
under the server-set approval-required mode, leaving retries,
|
||||
configuration changes, and remediation authority outside the chat payload
|
||||
and the main Patrol page composition boundary, so once that governed
|
||||
secondary area exists inside the Current issues and history workspace the same payloads must not
|
||||
@@ -5161,6 +5180,134 @@ pass the bounded non-symlink regular-file boundary in
|
||||
`internal/api/sso_outbound.go`. Cloud handoff redirects consume the shared
|
||||
host-local redirect validator rather than carrying route-local prefix checks.
|
||||
|
||||
A handler that performs a caller-requested write behind a route whose mux
|
||||
pattern carries no method must refuse `GET` and `HEAD` itself.
|
||||
`requireRequestMethod` in `internal/api/method_guard.go` is the shared
|
||||
handler-level guard: it admits only the listed methods and answers any other
|
||||
method that reaches the handler with `405` and an `Allow` header naming them.
|
||||
The demo-mode read-only guard and the CSRF check both admit `GET` and `HEAD` as
|
||||
reads, and the `SameSite=Lax` session cookie rides cross-site top-level `GET`
|
||||
navigations, so the method contract belongs to the mutating handler rather than
|
||||
to its callers. `/api/discovery/settings` accepts `PUT`, its documented method,
|
||||
and `POST`, which it took before the guard existed. SP-initiated SAML logout at
|
||||
`/api/saml/{id}/logout` accepts only `POST`, matching the `/api/logout`
|
||||
fallback it delegates to. The IdP's LogoutResponse keeps returning to
|
||||
`/api/saml/{id}/slo`, which must accept the HTTP-Redirect binding's `GET` and
|
||||
validates the signed response before it touches the session.
|
||||
`TestDemoModeSafeMethodsCannotReachRouteMutations` in
|
||||
`internal/api/ai_handlers_more_test.go` drives both routes through the full
|
||||
`DEMO_MODE` router and proves the threshold and the session survive.
|
||||
The rule does not cover `GET` flows the protocol or the browser handoff
|
||||
requires (OIDC login and callback, SAML login initiation and SLO, magic-link
|
||||
verify, cloud handoff, the checkout start and activation bridges, websocket
|
||||
handshakes), or reads that run one-time lazy initialization or idempotent
|
||||
reconciliation, such as the license and onboarding-overflow bootstrap behind
|
||||
the entitlement reads and the action-expiry sweep behind the action reads.
|
||||
Single-resource report generation at `GET /api/admin/reports/generate` still
|
||||
runs the configured AI narrator, a paid provider call recorded in the cost
|
||||
ledger; that is a known open exception, not part of this contract.
|
||||
|
||||
The SAML SLO callback at `/api/saml/{id}/slo` accepts a LogoutResponse only as
|
||||
the answer to a logout this SP started. crewjam/saml verifies the response's
|
||||
signature, `Destination`, `Issuer` and `Status` and rejects one issued more
|
||||
than 90 seconds ago, but never checks its `InResponseTo`, and the IdP signs its
|
||||
answer to every user's logout, so a valid signature alone let one user replay
|
||||
the answer to their own logout through a cross-site `GET` and clear another
|
||||
user's session. `SAMLService.MakeLogoutRequest` in
|
||||
`internal/api/saml_service.go` therefore records each LogoutRequest ID, on the
|
||||
provider's own service, against the session-store hash of the session
|
||||
`handleSAMLLogout` is ending. A record is honored for ten minutes unless the
|
||||
1,024-record cap evicts it first, oldest first. `SAMLService.ValidateLogoutResponse`
|
||||
reads `InResponseTo` from the same payload crewjam verified, taking only the
|
||||
root element's attribute written without a prefix, the one the signature
|
||||
covers (a namespace declaration such as `xmlns:InResponseTo` is ignored,
|
||||
because exclusive canonicalization can leave an unused one out of the signed
|
||||
form), and
|
||||
spends the matching record. The response is accepted only when that record
|
||||
exists and is unexpired, and the browser carries either no session cookie (the
|
||||
normal case, because `handleSAMLLogout` clears the cookie before redirecting to
|
||||
the IdP) or the session that request ended. Unsolicited, expired, evicted,
|
||||
replayed and other-session responses are refused with `403` and a failed
|
||||
`saml_slo_callback` audit event, and the handler leaves sessions and cookies
|
||||
alone. A misdelivered response still spends its record, so each LogoutRequest
|
||||
is answered at most once. The handler writes no session state or cookie for an
|
||||
accepted response either: `handleSAMLLogout` already invalidated the session
|
||||
and expired its cookies, and a cross-site HTTP-POST delivery withholds the
|
||||
`SameSite=Lax` cookie, so a deletion cookie written there could log out a
|
||||
browser whose session the request never presented. For the same reason
|
||||
`handleSAMLLogout` answers `401` to a request that carries no session cookie,
|
||||
API-token-only callers included, instead of falling back to a cookie-clearing
|
||||
logout: the SAML routes are public and skip CSRF, so that fallback was a
|
||||
cross-site forced logout of a kind a deployment with authentication configured
|
||||
refuses on `/api/logout` as unauthenticated. The records live in memory,
|
||||
so a restart or provider re-initialization during the IdP round trip leaves the
|
||||
returning response unmatched and refused; the local session was already
|
||||
cleared when logout began. `TestSAMLSLOAuditsLogoutResponseNotBoundToSession`
|
||||
in `internal/api/audit_handlers_test.go` replays one user's signed response
|
||||
into another user's browser and proves the victim's session survives and the
|
||||
refusal is audited.
|
||||
|
||||
The SAML ACS at `/api/saml/{id}/acs` accepts a Response, at default settings,
|
||||
only as the IdP's answer to a login the presenting browser started. crewjam/saml
|
||||
refuses a Response unless its `InResponseTo`, and that of each bearer
|
||||
`SubjectConfirmationData`, is one of the request IDs its caller supplies, and
|
||||
Pulse used to supply none, so with `allowIdpInitiated` off, the default, every
|
||||
SAML login failed with "Authentication failed". `SAMLService.MakeAuthRequest`
|
||||
in `internal/api/saml_service.go` therefore records each AuthnRequest ID, on
|
||||
the provider's own service, with the sanitized `returnTo` and the hash of a
|
||||
random token that `handleSAMLLogin` sets as the `HttpOnly`, `SameSite=Lax`
|
||||
cookie `pulse_saml_login` (`__Host-pulse_saml_login` over HTTPS) for ten
|
||||
minutes. A browser that already holds a token keeps it, so logins started one
|
||||
after another in several tabs all stay valid. A record is honored for ten
|
||||
minutes, judged when the Response spends it, unless the 1,024-record cap
|
||||
evicts it first, oldest first. The IdP delivers its Response by a cross-site
|
||||
HTTP-POST, which browsers send without `SameSite=Lax` cookies, so
|
||||
`handleSAMLACS` answers a POST that carries a `SAMLResponse` or
|
||||
`SAMLart` but no login cookie with a page that posts the same fields once more
|
||||
from Pulse's own origin. The form has no `action`, so it returns to the URL
|
||||
the IdP posted to, path prefix included, its script carries the request's CSP
|
||||
nonce, and a marker field prevents a second repost. `SAMLService.ProcessResponse`
|
||||
then has crewjam check the Response against every outstanding request ID and
|
||||
reads the request it answers from the canonical form goxmldsig verified for
|
||||
the signature covering the assertion crewjam returned, never from crewjam's
|
||||
parsed fields, taking only an `InResponseTo` written without a prefix: the
|
||||
signed `Response`'s, or that of the `Response` inside a signed
|
||||
`ArtifactResponse`, when the IdP signed the message, and otherwise that of
|
||||
every `SubjectConfirmationData` in the returned assertion's own `Subject`,
|
||||
which must agree and of which there must be at least one, and no other signed
|
||||
assertion may carry its ID. Other assertions in the Response, and assertions
|
||||
in `Advice`, do not take part. That record is
|
||||
spent only when it is bound to the presenting browser's token, and the login
|
||||
returns to the recorded `returnTo`, not to the posted `RelayState`, which
|
||||
`MakeAuthRequest` now escapes into the redirect query so a `returnTo` with its
|
||||
own query string is not cut short at the IdP. A Response answering another browser's login, an unknown,
|
||||
expired or already spent request, or none is refused with the
|
||||
`saml_validation_failed` redirect and a failed `saml_login` audit event, and
|
||||
refusing another browser's Response leaves that browser's record for it. With
|
||||
`allowIdpInitiated` on, crewjam skips `InResponseTo`, so a Response answering
|
||||
any request or none is accepted in any browser without the cookie or the
|
||||
repost and returns to its posted `RelayState`, as before. In both modes the
|
||||
signature verifier refuses signed content carrying a prefixed attribute or a
|
||||
namespace declaration named like an un-namespaced attribute crewjam reads from
|
||||
a Response or Assertion, such as `xmlns:IssueInstant`, `xmlns:NotOnOrAfter` or
|
||||
`ext:ID`: encoding/xml fills an un-namespaced attribute field from any
|
||||
attribute with that local name, and exclusive canonicalization leaves an
|
||||
unused declaration out of the signed form, so appending one let a stale signed
|
||||
Response pass crewjam's age checks, and an IdP-initiated login could be
|
||||
replayed indefinitely. The records live in memory, so a
|
||||
restart or a saved provider change (which replaces the provider's service;
|
||||
a metadata refresh keeps the records) during the IdP round trip, or a login
|
||||
started on a host that does not share cookies with the configured public
|
||||
URL's, leaves the Response unmatched and the user starts the login again. Two logins started at
|
||||
the same moment in a browser that holds no token yet each mint one and only
|
||||
the last cookie set survives, so the other fails the same way, and, as with
|
||||
OIDC login state, a flood of unauthenticated login starts can evict pending
|
||||
records.
|
||||
`TestSAMLACSAuditsResponseNotAnsweringThisBrowsersLogin` in
|
||||
`internal/api/audit_handlers_test.go` delivers one browser's signed Response to
|
||||
another browser with a login in progress and proves it is refused and audited
|
||||
while its own browser completes the login.
|
||||
|
||||
Alert delivery diagnosis is a read-only monitoring API contract.
|
||||
`GET /api/alerts/delivery-diagnosis?alertIdentifier=<id>` returns the alert
|
||||
manager's current delivery-policy projection for one active alert, including
|
||||
@@ -5421,16 +5568,51 @@ the projection to the Finding before calling the orchestrator. This
|
||||
is the one in-process write path that decides what the orchestrator
|
||||
sees about operator commitments; all consumers (in-process Patrol,
|
||||
external Claude Code, and MCP-speaking clients) read the same enriched shape.
|
||||
The provider closure must tolerate the findings runtime's mixed
|
||||
resource key spaces: unified-derived findings reference the hashed
|
||||
canonical resource ID, but Patrol guest inventory rows reference the
|
||||
node-scoped Proxmox source ID (`instance:node:vmid`). Operator state
|
||||
is keyed by canonical ID only, so a reference that misses the store
|
||||
directly resolves through the registry (`GetByReference`, which also
|
||||
covers retired canonical-ID eras and node-scoped guest references
|
||||
after #1669) before the provider reports no state — without that
|
||||
hop, maintenance windows and intentionally-offline intent silently
|
||||
never reach guest findings. Pinned by
|
||||
The provider (`Router.patrolResourceOperatorStateProvider`) must
|
||||
tolerate the findings runtime's mixed resource key spaces:
|
||||
unified-derived findings reference the hashed canonical resource ID,
|
||||
Patrol guest inventory rows reference the node-scoped Proxmox source
|
||||
ID (`instance:node:vmid`), and a finding raised before an operator
|
||||
link folded its resource into another keeps the folded canonical ID,
|
||||
which Patrol re-projects as stored. Operator state is keyed by
|
||||
canonical ID only, so every reference resolves before the store read,
|
||||
through the identity-only `ResolveCanonicalResourceID` of the
|
||||
organization monitor's current read state
|
||||
(`GetUnifiedReadStateOrSnapshot`: the published registry with saved
|
||||
hosts that have not reported since a restart overlaid, or the mock
|
||||
estate). That read state is the listing the resources API seeds its
|
||||
registry from, and resolution covers retired canonical-ID eras,
|
||||
node-scoped guest references after #1669 and link-folded IDs. Only a
|
||||
reference it cannot place is read as given. Resolving first is what
|
||||
keeps Patrol on the row the resources API writes for the same
|
||||
reference: an exact read first would let a row left under a folded
|
||||
ID, even a default-valued one, hide the maintenance window the
|
||||
operator then set on the merged resource. A saved host that has not
|
||||
reported since a restart but is linked into a live resource resolves
|
||||
alike everywhere: the read state keeps it as its own offline row, so its
|
||||
saved telemetry never reaches the resource, but holds it under the
|
||||
link's primary, and the resources API's registry, seeded from that
|
||||
listing, holds it the same way instead of folding it. A finding, an
|
||||
alert and an operator-state write naming the saved agent by its
|
||||
canonical ID or `agent:<host ID>` therefore all reach the resource the
|
||||
agent was folded into before the restart. Only when the saved agent is
|
||||
itself the link's primary and the resource is not its guest does each
|
||||
keep its own identity until the agent reports. Two cases still resolve
|
||||
differently in the resources API, whose registry re-ingests that
|
||||
listing: references to records it replays from supplemental providers
|
||||
on top of the listing, and manual links in mock mode, whose view
|
||||
applies none. The read
|
||||
state answers from a registry it keeps (a continuity overlay is shared
|
||||
and reused for up to two seconds while the published registry and the
|
||||
saved hosts are unchanged), so findings do not each clone the unified
|
||||
listing the way a resources-API registry build does; only a monitor
|
||||
left without a resource store, which the router never does, builds a
|
||||
snapshot registry per call. Pinned by
|
||||
`TestPatrolFindingOperatorStateFollowsLinkFoldedReference` and
|
||||
`TestPatrolFindingOperatorStateResolvesContinuityBackedHost` in
|
||||
`internal/api/ai_handlers_more_test.go`,
|
||||
`TestResourcesAPIAndPatrolAgreeOnContinuityAgentLinkedIntoGuest` in
|
||||
`internal/api/resources_link_continuity_test.go` and
|
||||
`TestContract_FindingsResourceOperatorStateProviderIsWired` in
|
||||
`internal/api/contract_test.go`.
|
||||
|
||||
@@ -6446,6 +6628,25 @@ That same SSO boundary also owns manual SAML endpoint validation payloads.
|
||||
through the same validated absolute HTTP(S) helpers instead of letting the
|
||||
manual logout URL drift out of the request model or bypass the governed URL
|
||||
normalization path.
|
||||
That same manual SAML configuration must verify signatures with the IdP
|
||||
certificate the administrator supplied. With no IdP metadata,
|
||||
`SAMLService.buildManualMetadata` in `internal/api/saml_service.go` builds the
|
||||
IdP descriptor from `idpSsoUrl`, optional `idpSloUrl` and the PEM signing
|
||||
certificate in `idpCertificate` or `idpCertFile`, and must put that certificate
|
||||
in the signing `KeyDescriptor` as base64 DER, the `<X509Certificate>` content
|
||||
IdP metadata carries. crewjam/saml base64-decodes that field to verify every
|
||||
ACS Response and LogoutResponse, so the PEM block it once held failed each
|
||||
check with "illegal base64 data" before any key was compared, and no manually
|
||||
configured provider could accept a login or complete SLO. The stored
|
||||
`idpCertificate` stays the PEM the administrator supplied; only the descriptor
|
||||
rebuilt in memory on each load changed, so no saved configuration needs
|
||||
migrating. `TestContract_SAMLManualIDPCertificateVerifiesIdPSignatures` in
|
||||
`internal/api/contract_test.go` signs an ACS Response and a LogoutResponse with
|
||||
one test IdP key and proves an inline-certificate provider, a certificate-file
|
||||
provider and a metadata provider accept both alike, while a Response signed by
|
||||
another key is refused. The proof runs at default settings, with each
|
||||
Response answering an AuthnRequest that `SAMLService.MakeAuthRequest`
|
||||
recorded for the test browser, as the SAML ACS binding above requires.
|
||||
That same SSO provider-detail boundary must return the non-secret nested
|
||||
provider configuration used by the settings edit form. `GET
|
||||
/api/security/sso/providers/{id}` may keep the flat list/card fields for
|
||||
@@ -7143,8 +7344,8 @@ unified-resource mentions as well; VMware network inventory does not create a
|
||||
provider-local mention family.
|
||||
That same `/api/ai/chat` payload boundary owns request-local execution mode,
|
||||
and the server sets it. `ChatRequest` in `internal/api/ai_handler.go` has no
|
||||
`autonomous_mode` field, so the `autonomous_mode:false` that scoped handoffs
|
||||
such as an explain-this-issue request still send is dropped at decode, and the
|
||||
`autonomous_mode` field, so a stale client's `autonomous_mode` is dropped at
|
||||
decode (the browser and the `internal/ai/eval` runner send none), and the
|
||||
handler passes approval-required mode into the chat service for every
|
||||
exchange; an Autonomous control level runs as Controlled for that request.
|
||||
That clamp is request-local and must not mutate the user's persistent AI
|
||||
@@ -7184,8 +7385,9 @@ status, and a runtime-failure flag; the backend rebuilds their context), but the
|
||||
must not turn their evidence or proposed remedies into user-authored message
|
||||
text. Ordinary attachment is context only. A labelled Explain action may
|
||||
submit the shared user-selected explanation request over the existing chat
|
||||
transport, with the evidence kept in model-only handoff fields and
|
||||
`autonomous_mode:false`. This starts explanation/review and grants no new
|
||||
transport, with the evidence kept in model-only handoff fields and no
|
||||
execution-mode field, since the handler already runs it approval-required.
|
||||
This starts explanation/review and grants no new
|
||||
approval or execution authority. When a
|
||||
Patrol `finding_id` resolves,
|
||||
backend-refreshed durable finding context remains canonical; the handler may
|
||||
@@ -7205,10 +7407,10 @@ recommendation fields from legacy handoffs.
|
||||
Patrol finding handoffs keep that clamp on their own path: when a request
|
||||
carries a non-empty `finding_id` or resolves to model-only Patrol briefing,
|
||||
resource, or action context, `internal/api/ai_handler.go` must clamp the
|
||||
request-local autonomous mode to false even if the caller supplied
|
||||
request-local autonomous mode to false even if a caller's JSON carries
|
||||
`autonomous_mode:true`. That server-side clamp is part of the public API
|
||||
contract because the frontend handoff setting is only advisory unless the
|
||||
backend preserves the approval-required boundary.
|
||||
contract because the drawer's `autonomousMode:false` flag is a disclosure only
|
||||
and never reaches the request.
|
||||
That same backend API boundary now also owns the negative space around
|
||||
assistant control. Wiring native TrueNAS app actions into
|
||||
`internal/api/router.go`, `internal/api/ai_handler.go`, or adjacent backend
|
||||
@@ -7329,6 +7531,12 @@ no client for it, and neither the dashboard load bundle in
|
||||
fetches it. The resource drawer's correlation evidence comes from the
|
||||
resource-intelligence payload above. The route stays an API surface behind the
|
||||
`ai:execute` scope.
|
||||
The frontend does not load `GET /api/ai/circuit/status` either. No surface
|
||||
renders provider breaker state, so `frontend-modern/src/api/ai.ts` carries no
|
||||
client or `CircuitBreakerStatus` type for it, and neither that dashboard bundle
|
||||
nor the Patrol refresh path fetches it. An open breaker reaches the Patrol page
|
||||
through `/api/ai/patrol/status` as the `circuit_open` blocked cause. The route
|
||||
stays a backend API surface.
|
||||
That correlations route now reads through the canonical AI intelligence
|
||||
facade first, so the handler and its payload keep the detector behind one
|
||||
shared access layer instead of routing directly to Patrol-local correlation
|
||||
@@ -8729,7 +8937,7 @@ briefings may reuse that same safe metadata for factual action labels and safety
|
||||
while approval command text remains inside the governed approval/remediation
|
||||
surface.
|
||||
Patrol approval-row Assistant handoffs must use the same safe metadata boundary
|
||||
and set `autonomousMode:false` for the request-local chat handoff; they must not
|
||||
and set the drawer's `autonomousMode:false` approval disclosure; they must not
|
||||
paste raw approval or action command text into a chat prompt.
|
||||
Patrol remediation-plan or action-artifact Assistant handoffs must pass only safe
|
||||
status, risk, description, and command-count posture as non-authoritative context
|
||||
@@ -9635,19 +9843,22 @@ configuration from paid remediation autonomy. `GET /api/ai/patrol/autonomy`
|
||||
continues to clamp effective Community autonomy to `monitor`, and
|
||||
`PUT /api/ai/patrol/autonomy` in the open-source/free adapter must accept and
|
||||
persist only `monitor` settings while returning the canonical license-required
|
||||
payload for investigation or remediation autonomy levels. Frontend Patrol state
|
||||
owners must not rely on that 402 as normal control flow for stale local state:
|
||||
when the current entitlement locks safe remediation, they submit `monitor` even
|
||||
if older persisted settings or a previous entitlement left `approval`,
|
||||
`assisted`, or `full` in memory.
|
||||
payload for investigation or remediation autonomy levels (a `full` request
|
||||
reaches that adapter only with a current Autopilot acknowledgement; otherwise
|
||||
the shared acknowledgement gate refuses it first, for example with
|
||||
`409 acknowledgement_required` when it carries none). Frontend Patrol
|
||||
state owners must not rely on that 402 as normal control flow for stale local
|
||||
state: when the current entitlement locks safe remediation, the Patrol header
|
||||
presents `monitor` as the effective mode and the state owner starts no save but
|
||||
an explicit `monitor` choice, even if older persisted settings or a previous
|
||||
entitlement left `approval`, `assisted`, or `full` in memory.
|
||||
After a successful persisted update whose effective mode actually crosses from
|
||||
`monitor` to `approval`, `assisted`, or eligible `full`, the shared API mutation
|
||||
boundary invokes the bounded Patrol actionable-finding reconciliation pass.
|
||||
The pass runs only after `ApplyPatrolAutonomyConfig` has published the new
|
||||
runtime authority; failed writes, Community clamps, repeated paid-mode saves,
|
||||
budget-only edits, and demotions must not start backlog work.
|
||||
The Patrol header and configuration-dialog presentation for that API boundary
|
||||
must compose the shared
|
||||
The Patrol header presentation for that API boundary must compose the shared
|
||||
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` selector: the
|
||||
endpoint contract still owns the accepted autonomy values and license-required
|
||||
response shape, while the frontend owns only the option mapping,
|
||||
@@ -9686,6 +9897,32 @@ narrator must fail closed: nil provider, parse failure, timeout, or
|
||||
empty response causes the engine to fall back to the heuristic
|
||||
narrative without surfacing the AI failure to the caller, so reporting
|
||||
is never blocked by AI availability.
|
||||
Only `POST /api/admin/reports/generate` may hand the tenant's AI narrator to
|
||||
the engine, because a narrated PDF makes a paid provider call and appends a
|
||||
`cost.UsageEvent` to the cost ledger. The single-resource handler accepts
|
||||
`POST` with a JSON body (the `generate-multi` decoding rules: 1MB cap, unknown
|
||||
fields and trailing payload rejected, through `decodeReportingRequestBody`) and
|
||||
`GET` with the same fields as query parameters, and answers any other method
|
||||
with `405` and `Allow: GET, POST`. GET never calls `resolveNarrator`, whose
|
||||
`GetAIService` can construct a tenant AI service (and with it list provider
|
||||
models or start background discovery): it narrates through `getReportNarrator`,
|
||||
the heuristic summary whose `Disclaimer` replaces the "Configure Pulse
|
||||
Assistant" tip with a note that AI narration needs POST, and reads Patrol
|
||||
findings through `SetExistingFindingsResolver`, which `router.go` backs with
|
||||
`AISettingsHandler.ExistingAIService` so only an already running service is
|
||||
used. 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 must not
|
||||
be able to spend AI budget. The reporting catalog advertises the transport as
|
||||
`performanceReport.singleResourceMethod` (`POST`), and the settings UI follows
|
||||
it. `generate-multi` and the manual schedule run endpoint
|
||||
(`POST /api/admin/reports/schedules/{id}/run`) were already POST-only; due
|
||||
schedules run from the server's scheduler, not an HTTP request.
|
||||
`TestContract_SingleReportAINarrationRequiresPOST` pins the rule on a real
|
||||
engine: GET, HEAD, PUT and DELETE make no narrator call and no AI service
|
||||
resolution, while POST makes one of each.
|
||||
`TestContract_SingleReportGETDoesNotConstructTenantAIService` drives
|
||||
`Router.wireReportingAIResolvers` with an uncached tenant: GET constructs no
|
||||
tenant AI service and POST resolves one.
|
||||
Report alerts keep the alert engine's resolution. The node, VM and
|
||||
container enrichers in `internal/api/metrics_reporting_handlers.go` build
|
||||
every row through `reportActiveAlertInfo` and `reportResolvedAlertInfo`,
|
||||
@@ -11462,3 +11699,17 @@ Router's or the package-global limiters. Limits, windows, tenant selection,
|
||||
revocation and response payloads are unchanged. The connected lifetime and
|
||||
ownership controls are in `router_limiter_lifecycle_test.go`; the integration
|
||||
fixture's cleanup also runs on a failed listener/request assertion.
|
||||
|
||||
### Report-merge undoes operator links
|
||||
|
||||
`POST /api/resources/{id}/report-merge` keeps its request, status codes and
|
||||
response shape. For a reported source that an operator link brought in, it
|
||||
now records the exclusion against the link's own pair instead of a candidate
|
||||
ID derived from the merged resource's type, so the store replaces the link
|
||||
and the pair splits as after unlink (contract under "Report-merge splits
|
||||
operator-linked resources" in unified-resources). `exclusions` counts those
|
||||
pairs with the identity-match exclusions. `400 Resource is not merged` and
|
||||
`400 No source targets found` now also need a resource with no applied link,
|
||||
so two linked agents, which share their only source, can be reported.
|
||||
`TestContract_ResourceReportMergeReplacesOperatorLink` pins the response
|
||||
shape and the link's removal for a VM and the agent linked into it.
|
||||
|
||||
@@ -1161,18 +1161,21 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`.
|
||||
absent platform families must not render as disabled placeholders in the
|
||||
paid or hosted app shell. Infrastructure is not an equal primary tab in
|
||||
that list. Workloads, Storage, and Recovery are not standalone aggregate
|
||||
workspace tabs; their component surfaces remain reusable inside platform
|
||||
pages via embedded `tableOnly` surfaces. Platform primary-tab settings
|
||||
workspace tabs. Workload and storage rows render inside platform pages,
|
||||
through the canonical `WorkloadsSurface` or `StorageSurface` where a page
|
||||
embeds one and through platform-owned tables elsewhere; recovery rows
|
||||
render only in platform-owned tabs such as Proxmox Backups and TrueNAS
|
||||
Protection, because no Recovery surface exists. Platform primary-tab settings
|
||||
handoffs in `AppLayout.tsx` must target the canonical
|
||||
`/settings/infrastructure` workspace and must not retain retired settings
|
||||
aliases such as `/settings/workloads/docker` or nested
|
||||
`/settings/infrastructure/platforms/*` paths. Each platform page must
|
||||
remain chrome-only: routing plus sub-tab navigation that embeds the
|
||||
canonical `WorkloadsSurface`, `StorageSurface`, or `RecoverySurface` in
|
||||
`embedded tableOnly` mode with a forced platform/source filter. The shell must not introduce dashboard cards,
|
||||
bespoke per-family tables, synthetic placeholder data, or reintroduce
|
||||
Infrastructure as a primary navigation entry without a governed contract
|
||||
decision recorded by the frontend-primitives owner.
|
||||
`/settings/infrastructure/platforms/*` paths. Which platform tabs embed a
|
||||
canonical surface and which render platform-owned tables is the
|
||||
frontend-primitives platform-page rule; cloud-paid must not carry a
|
||||
second copy of it. The paid or hosted shell must not introduce dashboard
|
||||
cards, synthetic placeholder data, or forked per-family table primitives,
|
||||
or reintroduce Infrastructure as a primary navigation entry, without a
|
||||
governed contract decision recorded by the frontend-primitives owner.
|
||||
The presence of the user-facing `Machines` tab, backed by the internal
|
||||
`standalone` route/id, is not a new commercial usage unit: hosted and paid
|
||||
surfaces continue to meter the governed monitored-system grouping result
|
||||
@@ -1583,9 +1586,10 @@ follow the same canonical home. The hosted bootstrap / activation / recovery
|
||||
routes (`/pricing`, `/preview/setup-complete`, `/login`, settings panels
|
||||
under `/settings/...`) are unchanged. Pricing-handoff and hosted-signup
|
||||
upgrade paths must use platform routes as their canonical "back to product"
|
||||
destination instead of the retired top-level Infrastructure route; aggregate
|
||||
Workloads / Storage / Recovery pages are product workspaces, not hosted
|
||||
acquisition destinations. The Docker / Podman runtime route remains `/docker`
|
||||
destination instead of the retired top-level Infrastructure route. Platform
|
||||
pages are product workspaces, not hosted acquisition destinations, and the
|
||||
retired aggregate Workloads / Storage / Recovery pages are not destinations at
|
||||
all. The Docker / Podman runtime route remains `/docker`
|
||||
for search familiarity, but the shell may label the
|
||||
destination as Docker so it reads as the recognizable container runtime lens
|
||||
rather than an owning infrastructure platform.
|
||||
|
||||
@@ -6101,6 +6101,14 @@ summaries whose scope, format, and attachments the server fixes. The delivery
|
||||
surface (`/api/admin/reports/schedules`) and tenant-local storage are
|
||||
unchanged.
|
||||
|
||||
### Provider docs name POST for single-resource reports
|
||||
|
||||
`docs/MSP.md` and its shipped copy now give `POST /api/admin/reports/generate`
|
||||
for per-client single-resource reports and say a `GET` with query parameters
|
||||
still returns the report without the Pulse Assistant narrative, matching the
|
||||
server, which lets only `POST` narrate. Install, upgrade and provider bundle
|
||||
delivery are unchanged.
|
||||
|
||||
### Convergence log-reader compatibility (5 September 2026)
|
||||
|
||||
Scheduled reconciliation run 33939926237 failed when `gh api` refused terminal
|
||||
|
||||
@@ -730,11 +730,14 @@ history, and must not discard the saved note.
|
||||
Platform-owned workload controls extend the shared `WorkloadsFilter` view
|
||||
options rather than creating page-local toolbar shells. Persistent presentation
|
||||
choices compose the shared `ViewOptionsDisclosure` instead of occupying the
|
||||
primary filter rail: layout, metric style, chart visibility, memory basis, and
|
||||
columns remain discoverable behind one `View` trigger. The history range stays
|
||||
primary filter rail: layout, metric style, row hover, memory basis, inventory
|
||||
totals, and columns remain discoverable behind one `View` trigger. The history range stays
|
||||
inline in both metric modes because bars now expose an intent-driven row
|
||||
history lens and Trends keeps the same charts persistent. That inline range
|
||||
must carry a visible contextual label.
|
||||
history lens and Trends keeps the same charts persistent. In Bars the range
|
||||
renders only at desktop widths while Row hover is set to `History`, the mode
|
||||
that drives that lens; `Details` hover and the phone (`isMobile`) layout hide
|
||||
it. The inline range carries the visible `History` label that
|
||||
`WorkloadsFilter` passes to `MetricHistoryRangeSegmentedControl`.
|
||||
Controls inside the View disclosure must expand in place rather than opening
|
||||
nested absolute panels that can clip or create competing overlay stacks. The
|
||||
Proxmox page owns and persists the `Guest` / `Host` memory basis;
|
||||
@@ -2352,6 +2355,20 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
Proxmox backup source/state chips follow the same boundary: storage/recovery
|
||||
owns backup-source labels and state semantics, while the visible chip shell
|
||||
and tone vocabulary route through `MetadataBadge`.
|
||||
Resource drawer identity chips (the IP address and alias chips and the
|
||||
alias overflow count in `ResourceDetailSummary.tsx`) compose `MetadataBadge`
|
||||
too. Badges stay on one line by default; an identity value that can outgrow
|
||||
a narrow drawer cell opts into the badge's `wrap` mode, which caps the chip
|
||||
at its container and breaks inside a token only where no space or hyphen
|
||||
fits. These values differ at their ends (`...:pve1:113`, a container hash)
|
||||
and a phone has no hover to show a title, so they wrap rather than
|
||||
truncate. A one-line alias used to overflow its fixed-width value cell at
|
||||
phone widths, where the cell's hidden overflow and the inline drawer's
|
||||
clipping content shell cut it off without an ellipsis.
|
||||
`tests/integration/tests/04-mobile.spec.ts` opens a Docker container drawer
|
||||
at phone width and checks that no element or text in its Identity section
|
||||
crosses either edge of those clipping boxes, apart from values that end in
|
||||
their own ellipsis.
|
||||
Workload backup freshness follows a separate shared presentation boundary:
|
||||
`frontend-modern/src/utils/workloadGuestPresentation.ts` owns the canonical
|
||||
tone and icon mapping consumed by workload rows and drawers. A recorded
|
||||
@@ -2635,15 +2652,20 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
drifting off-screen from page-local flex behavior. The shared action rail
|
||||
must align to the trailing edge at wrapped desktop widths and remain
|
||||
separate from the filter-control wrap zone instead of waiting for a wide
|
||||
breakpoint, so Recovery events, Workloads, Storage, Infrastructure, and
|
||||
breakpoint, so Workloads, Storage, Infrastructure, and
|
||||
future dense toolbars do not strand Filter/Columns/Reset actions as an
|
||||
isolated second-row fragment. Shared `FilterToolbarPanel` owns
|
||||
default filter-popover geometry, and `FilterToolbar` owns the shared chart
|
||||
visibility display action: Workloads, Storage, Infrastructure, and future
|
||||
summary-bearing pages must use `ChartVisibilityToggleButton` so the
|
||||
affordance exposes one `Show charts` / `Hide charts` pressed-state contract
|
||||
instead of rebuilding a one-option segmented control or an in-summary
|
||||
collapse chevron page by page. Feature state hooks under
|
||||
default filter-popover geometry. The workload summary chart section that the
|
||||
shared chart show/hide action toggled is deleted and no other page exposes
|
||||
one, so the shared toolbar carries no chart show/hide action: the
|
||||
`ChartVisibilityToggleButton` primitive, its
|
||||
`chart-visibility-toggle-button` rule, and its
|
||||
`chart-visibility-local-toggle-labels` pattern guard are retired, and
|
||||
`SharedPrimitives.guardrails.test.ts` keeps them out. A summary section that
|
||||
returns through a governed product decision must register one shared
|
||||
show/hide primitive with an explicit pressed state rather than a page-local
|
||||
one-option segmented control or in-summary collapse chevron.
|
||||
Feature state hooks under
|
||||
`frontend-modern/src/features/` own route-backed query state, selected item
|
||||
state, and data-window selection for their product surfaces; shared
|
||||
primitives and reusable presentation helpers may own viewport-safe chrome,
|
||||
@@ -3031,9 +3053,9 @@ Agent`), with the plain-language source phrase available through accessible
|
||||
than receiving an unfiltered copy of the estate. Opaque unified-resource ids
|
||||
are not part of that visible search vocabulary and must not retain a node
|
||||
when the normalized guest table has no corresponding match.
|
||||
8. Keep summary interaction identity on one shared helper. Pages that expose row-hover, group-hover, or route-focus-driven summary emphasis, today the Workloads and Storage tables through `useSummaryPageInteractionState` in `frontend-modern/src/components/shared/summaryTableFocus.ts`, must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` rather than letting each surface read `hovered || focused` from its own page-local ID source. Hovering or pinning a storage pool-group header, or a workload group header on a surface that enables inline group drawers (the Proxmox and VMware surfaces pass `groupNodeDrawerMode="disabled"` today), must scope the matching rows through that same shared contract instead of forking a page-local summary filter path. No summary card or chart strip renders today, and the bridge carries no chart-hover input; one that returns must consume that same resolved scope, and the chart under the pointer must enter its hovered series into `resolveSummaryScopeState` as a preview input that outranks row hover instead of keeping a chart-local hover island. Recovery is explicitly outside this interaction dialect: its retired posture-card strip must not return with row/group/chart hover behavior without a separate governed product decision.
|
||||
8. Keep summary interaction identity on one shared helper. Pages that expose row-hover, group-hover, or route-focus-driven summary emphasis, today the Workloads and Storage tables through `useSummaryPageInteractionState` in `frontend-modern/src/components/shared/summaryTableFocus.ts`, must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` rather than letting each surface read `hovered || focused` from its own page-local ID source. Hovering or pinning a storage pool-group header must scope the matching rows through that same shared contract instead of forking a page-local summary filter path. Workloads grouped host rows are static identity dividers and pass no group scope to the bridge; a grouped table that wants group hover or pin again needs a demand-ledger entry and must use this shared scope rather than reviving a Workloads-local path. No summary card or chart strip renders today, and the bridge carries no chart-hover input; one that returns must consume that same resolved scope, and the chart under the pointer must enter its hovered series into `resolveSummaryScopeState` as a preview input that outranks row hover instead of keeping a chart-local hover island. Recovery is explicitly outside this interaction dialect: its retired posture-card strip must not return with row/group/chart hover behavior without a separate governed product decision.
|
||||
9. Keep any page summary page-scoped when table rows enter contextual focus. No infrastructure, workloads, or storage summary cards render today. If one returns, route-backed row selection may add a focused label and shared series emphasis, but the summary must continue to render the page-level series set instead of collapsing down to the selected row or replacing the global trend view with row-local empty states.
|
||||
10. Keep contextual row focus on the shared primitives. Same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for local scroll preservation, off-screen reveal, inline-detail lookup, and deliberate inline-detail reveal, and must resolve the active series through `frontend-modern/src/components/shared/summaryCardInteraction.ts` behind `frontend-modern/src/components/shared/summaryTableFocus.ts`, instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface. `frontend-modern/src/components/shared/summaryTableFocus.ts` measures and scrolls rows only for deliberate focus, a focused row's inline detail or a pinned group header, so it must not keep window-level scroll or resize listeners or re-measure the hovered row on hover changes and scroll frames; an off-screen affordance for the active row needs a rendered consumer before that cost returns.
|
||||
10. Keep contextual row focus on the shared primitives. Same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for local scroll preservation, off-screen reveal, inline-detail lookup, and deliberate inline-detail reveal, and must resolve the active series through `frontend-modern/src/components/shared/summaryCardInteraction.ts` behind `frontend-modern/src/components/shared/summaryTableFocus.ts`, instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface. `frontend-modern/src/components/shared/summaryTableFocus.ts` measures and scrolls rows only for deliberate focus, a focused row's inline detail or a pinned group header, and reveals a focused row once per focus change: its reveal effect subscribes to the focused series id and table root alone (a remounted table root starts a new reveal for the same focus) and runs the consumer's `revealActiveSeries` callback and the inline-detail scroll untracked, so page state the callback reads (live group lookups, expanded groups) cannot start another reveal, scroll the page back to the row, or reopen a group the operator collapsed. A reveal ends when its row and detail settle in view or its bounded retry window lapses, and filter changes do not re-run the consumer callback, so a row that stays filtered out past that window is not revealed when it returns. It must not keep window-level scroll or resize listeners or re-measure the hovered row on hover changes and scroll frames; an off-screen affordance for the active row needs a rendered consumer before that cost returns.
|
||||
11. Keep summary-linked table row emphasis on the shared primitive contract. Workloads, infrastructure, and storage rows that mirror the active summary entity must expose that state through `data-summary-row-active` and let the shared presentation in `frontend-modern/src/index.css` render the row emphasis, rather than carrying page-local sky or blue fill classes inside each row renderer. Group-scoped preview and pin must use that same shared presentation boundary: child rows that belong to a hovered or pinned summary group should expose `data-summary-group-member-active="preview|pinned"` so the block-level emphasis stays subtle, consistent, and reversible instead of each table inventing its own outline, badge, or full-strength fill treatment. Static grouped row headers on workloads, infrastructure, storage, recovery, and future grouped tables must use `frontend-modern/src/components/shared/groupedTableRowPresentation.ts` plus the `.grouped-table-row` CSS contract in `frontend-modern/src/index.css`, rather than rebuilding local `bg-surface-alt` variants with subtly different light/dark behavior or page-local left-accent markers. That shared grouped-table primitive owns the subgroup cell padding, typography, small metadata, and badge treatment as well as the row background token, so a future adjustment to the subgroup visual language changes every grouped product table from one owner. Inline table detail rows on platform, workload, and infrastructure tables must compose `frontend-modern/src/components/shared/InlineDetailTableRow.tsx` for the full-width row, surface-alt cell, detail padding, and row-click containment instead of rebuilding page-local `TableRow` / `TableCell` / `div` shells around each drawer. Storage-backed reusable row presenters under `frontend-modern/src/features/storageBackups/` must also keep row height and alert accents on class/data-attribute presentation instead of runtime inline style maps, so the shared table contract stays CSP-safe on both steady-state and alert-highlighted routes.
|
||||
12. Keep retained-value data loading honest at the ownership boundary. Helpers
|
||||
that prevent a feature surface from falling through the app-level Suspense
|
||||
@@ -3164,33 +3186,34 @@ Agent`), with the plain-language source phrase available through accessible
|
||||
operator action or selected Patrol context, uses distinct monitor labels,
|
||||
and does not become a nested card, generic dashboard strip, trust summary,
|
||||
or duplicate Patrol empty-work list. The
|
||||
Patrol schedule and model drawer is part of that shared
|
||||
feature-presentation boundary: it must stay viewport-bounded, expose an
|
||||
accessible dialog label, keep the four-level control policy on the default
|
||||
Patrol header, and keep provider model, schedule, trigger tuning, and
|
||||
readiness validation inside the secondary disclosure. Backend save rejection reasons must pass
|
||||
through as inline dialog state instead of being replaced with generic toast
|
||||
copy, and that advanced disclosure must open when the inline state exists.
|
||||
When the failure includes
|
||||
Patrol readiness context, the inline state must expose the provider, model,
|
||||
and readiness summary next to a direct provider-settings action instead of
|
||||
hiding that diagnosis behind Assistant alone. The provider-model selector in
|
||||
that popover must stay bound to the shared runtime settings/model catalog
|
||||
even when the popover mounts after async catalog loading, but the full
|
||||
catalog must stay behind an explicit change action so the default advanced
|
||||
drawer leads with the current effective model summary rather than a raw
|
||||
provider route list. A saved direct-provider Patrol model still renders as
|
||||
that model instead of visually falling back to the default selection.
|
||||
Successful provider-model saves that return a not-ready Patrol
|
||||
readiness snapshot must use that same inline surface with `needs attention`
|
||||
wording, and any Assistant handoff from that state describes a saved
|
||||
configuration issue rather than a failed save. When governed fixes are locked, the same Patrol state
|
||||
owner must clear stale full-mode unlock state before persisting the
|
||||
monitor-only autonomy payload, so disabled paid controls cannot leak stale
|
||||
permission into a save. If that inline state opens Assistant, the Patrol
|
||||
feature must hand off
|
||||
a source-named, model-only briefing and close the popover so the shared
|
||||
Assistant drawer is not visually hidden behind feature chrome. When a
|
||||
Patrol page keeps the four-level control policy on its header and has no
|
||||
schedule and model drawer: schedule, trigger tuning, and the Patrol model
|
||||
override live on Settings > Pulse Intelligence > Patrol, and providers and
|
||||
the shared default model on Settings > Provider & Models. Those saves
|
||||
report through the shared notification toasts, not inline state: a
|
||||
rejected save raises an error toast carrying the backend's concrete reason
|
||||
rather than generic fallback copy, and a saved response whose Patrol
|
||||
readiness is not ready or degraded raises a warning toast naming the
|
||||
readiness summary, provider, and model instead of a success toast. On the
|
||||
Patrol page, an on/off save whose response reports Patrol not ready raises
|
||||
a fixed warning toast, and a failed Patrol mode save raises an error toast
|
||||
and restores the previous mode; neither renders inline save-failure state
|
||||
or offers an Assistant handoff. If a Patrol settings popover or inline
|
||||
save-failure state returns, it must stay viewport-bounded, expose an
|
||||
accessible dialog label, bind any model selector to the shared runtime
|
||||
settings and model catalog, show readiness context (provider, model,
|
||||
summary) next to a direct provider-settings action rather than behind
|
||||
Assistant alone, and describe a saved but not ready response as a saved
|
||||
configuration issue rather than a failed save. An Assistant handoff from it
|
||||
must send a source-named, model-only briefing and close the popover so the
|
||||
shared Assistant drawer is not visually hidden behind feature chrome. When
|
||||
governed fixes are locked, the Patrol header presents `Watch only` as the
|
||||
effective mode with no enabled paid choice, and the Patrol state owner
|
||||
refuses any other level when a save starts. Its autonomy payload
|
||||
never carries `full_mode_unlocked`: `PUT /api/ai/patrol/autonomy` itself
|
||||
clears stale full-mode unlock and Autopilot activation state on every save
|
||||
below Autopilot, so disabled paid controls cannot leak stale permission
|
||||
into a save. When a
|
||||
restored Patrol assessment session is attached, the shared Assistant drawer
|
||||
empty state must
|
||||
stay aligned with that source-named briefing and must not render generic
|
||||
@@ -3379,15 +3402,30 @@ Agent`), with the plain-language source phrase available through accessible
|
||||
default` instead of fusing provider and badge text such as
|
||||
`OpenRouterdefault`.
|
||||
Platform-first top-level pages registered through
|
||||
`frontend-modern/src/App.tsx` must stay chrome-only and route through the
|
||||
canonical app shell: each per-platform surface owns navigation and sub-tab
|
||||
chrome, then embeds the canonical `WorkloadsSurface`, `StorageSurface`, or
|
||||
`RecoverySurface` in `embedded tableOnly` mode with a forced platform or
|
||||
source filter. Per-platform features must not
|
||||
fork their own table primitives, header layouts, or summary cards when a
|
||||
shared canonical surface already exists; new shared platform-page
|
||||
primitives live under `frontend-modern/src/features/platformPage/` so the
|
||||
chrome stays reusable across families.
|
||||
`frontend-modern/src/App.tsx` route through the canonical app shell: each
|
||||
per-platform page surface owns its route, `PlatformSectionTabs` sub-tab
|
||||
navigation, and shared loading, empty, and error states, and its tabs are
|
||||
built around tables rather than estate overview or attention summary
|
||||
panels. Three tabs embed a cross-platform canonical surface with the
|
||||
platform scope locked: the Proxmox and vSphere Overview tabs embed
|
||||
`WorkloadsSurface` with `forcedPlatform` (the page owns the workloads
|
||||
state and the one `WorkloadsFilter` toolbar), and the Proxmox Storage tab
|
||||
embeds `StorageSurface` with `forcedSourceFilter`. Those tabs must keep
|
||||
their guests, VMs, and storage rows on those embeds rather than a
|
||||
platform-local copy. Neither surface has an `embedded` or `tableOnly`
|
||||
mode, and no `RecoverySurface` exists: the aggregate Recovery surface was
|
||||
deleted on 2026-05-26. Every other tab, including Docker containers,
|
||||
Kubernetes pods, Machines, `/truenas/storage`
|
||||
(`TrueNASStorageTopologyTable`), the Proxmox Backups tab, and the TrueNAS
|
||||
Protection tab, renders a platform-owned table built from the shared
|
||||
platform-page primitives in
|
||||
`frontend-modern/src/features/platformPage/sharedPlatformPage.tsx`, even
|
||||
where a canonical surface could list the same resource types.
|
||||
Per-platform features must not fork their own table primitives, header
|
||||
layouts, or summary cards when a shared canonical surface already exists;
|
||||
new shared platform-page primitives live under
|
||||
`frontend-modern/src/features/platformPage/` so the chrome stays reusable
|
||||
across families.
|
||||
Source-specific platform product surfaces under
|
||||
`frontend-modern/src/features/`, such as the Proxmox Backups tab, may own
|
||||
domain IA and row models in their product subsystem while consuming shared
|
||||
@@ -3715,9 +3753,12 @@ production table, router and styles; it does not qualify full-app scrolling.
|
||||
IDs into setup payloads. The shared settings shell should let the backend
|
||||
resolve the effective BYOK model and then render that returned state rather
|
||||
than guessing a model in the modal.
|
||||
Scoped Assistant handoffs must keep request-local execution overrides in
|
||||
drawer context. Dashboard and other route-owned entry points may open the
|
||||
Assistant drawer with source context and `autonomousMode:false`, but they
|
||||
Scoped Assistant handoffs keep their approval-required disclosure in
|
||||
drawer context: `autonomousMode:false` on `AIChatContext` is a drawer-only
|
||||
flag the chat transport never sends, because `/api/ai/chat` sets
|
||||
approval-required mode server-side for every request. Dashboard and other
|
||||
route-owned entry points may open the
|
||||
Assistant drawer with source context and that flag, but they
|
||||
must not infer a user task from an ordinary context-only open. Explicit
|
||||
labelled explanation actions use `aiChatStore.explain` and the shared
|
||||
explanation dispatcher, which captures the request context, waits for open
|
||||
@@ -3735,11 +3776,11 @@ production table, router and styles; it does not qualify full-app scrolling.
|
||||
assessment readout, but shared drawer/chrome primitives stay free of
|
||||
the `FindingsTrustSummary` shape so adding new trust signals goes
|
||||
through the contract first rather than per-shell branching.
|
||||
Patrol header refresh controls stay on that same feature-owned shell
|
||||
Patrol refresh controls stay on that same feature-owned shell
|
||||
boundary: `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`
|
||||
must make the refresh affordance generation-aware and timeout-bounded, so
|
||||
a slow supporting intelligence read cannot permanently disable the shared
|
||||
Patrol header control while Patrol findings and status remain visible.
|
||||
a slow supporting intelligence read cannot permanently disable the
|
||||
stale-data Retry control while Patrol findings and status remain visible.
|
||||
That feature-owned
|
||||
presentation helper is the single emitter for investigation-record
|
||||
`impact` and `rollback` fields: when an investigation record exists but those fields
|
||||
@@ -3783,7 +3824,7 @@ production table, router and styles; it does not qualify full-app scrolling.
|
||||
chips in the drawer and must not become another primitive path for raw
|
||||
approval, command, or rollback command payload text. Missing-detail
|
||||
queued-fix recovery actions must still provide the feature-owned Patrol
|
||||
briefing and request-local approval-required posture rather than opening the
|
||||
briefing and approval-required disclosure rather than opening the
|
||||
shared drawer as context-free generic Assistant chat. If a feature-owned
|
||||
expired-approval recovery action still has structured action artifact metadata,
|
||||
the shared drawer may receive only safe summary fields and command counts;
|
||||
@@ -3810,16 +3851,16 @@ production table, router and styles; it does not qualify full-app scrolling.
|
||||
feature-owned finding handoff helper rather than hand-written prompt-only
|
||||
drawer opens: safe approval metadata, action artifact summaries, resource
|
||||
references, and bounded `handoff_actions` may enter the prompt and context,
|
||||
but raw command text stays out and the scoped request must pass
|
||||
`autonomousMode:false` instead of changing the user's persistent Assistant
|
||||
control level. Patrol remediation-plan drawer handoffs must use the same
|
||||
but raw command text stays out and the handoff must set the drawer's
|
||||
`autonomousMode:false` disclosure instead of changing the user's persistent
|
||||
Assistant control level. Patrol remediation-plan drawer handoffs must use the same
|
||||
primitive boundary: plan title/status/risk, step labels, and command counts
|
||||
may enter Assistant context; raw command and rollback command payloads must
|
||||
stay in the governed remediation/action panel. All Patrol finding
|
||||
discussion handoffs, including context-only findings without a live approval
|
||||
or proposed fix, must pass `autonomousMode:false` as a request-local
|
||||
override so the drawer shows approval-required posture without mutating the
|
||||
persistent Assistant control setting.
|
||||
or proposed fix, must set `autonomousMode:false` as that drawer-only flag
|
||||
so the drawer shows the approval-required posture the server enforces,
|
||||
without mutating the persistent Assistant control setting.
|
||||
14. Keep shared filter primitives coherent with source-owned option hydration.
|
||||
Active platform/runtime pages and Settings infrastructure surfaces must keep
|
||||
canonical options visible in shared filter controls even when current
|
||||
@@ -3883,6 +3924,10 @@ production table, router and styles; it does not qualify full-app scrolling.
|
||||
finding-presentation destinations such as the Patrol provider-settings
|
||||
route, while `patrol_autonomy` and legacy Pro activation URLs remain parser
|
||||
aliases only and verified review links use the plain Patrol history anchor.
|
||||
The Patrol finding-to-Assistant handoff has one owner,
|
||||
`frontend-modern/src/components/AI/FindingsPanel.tsx`; the Patrol state
|
||||
hook keeps no second copy of it, and the Patrol workspace passes no
|
||||
handoff callback because nothing on the page reacts to a handoff.
|
||||
UI surfaces must not duplicate the `patrolControlStarter` query string or
|
||||
write Patrol control or legacy entry-point starter telemetry from local
|
||||
click handlers.
|
||||
@@ -4423,12 +4468,13 @@ labels) bounded — individual subject names and full PolicyRule contents stay
|
||||
outside the rendered surface, mirroring the agent and unified-resource
|
||||
contracts.
|
||||
|
||||
Embedded Recovery workspace controls now use the shared filter-toolbar
|
||||
primitive boundary. Platform pages may choose a default Recovery workspace,
|
||||
such as TrueNAS opening on protection coverage, but the compact
|
||||
protection/events selector must use `FilterSegmentedControl` and the
|
||||
recovery-owned `useRecoverySurfaceState` owner rather than page-local tabs,
|
||||
nested cards, or independent protection/event state in the embedding surface.
|
||||
No platform page embeds a Recovery workspace. The aggregate Recovery surface,
|
||||
its protection/events selector, and `useRecoverySurfaceState` were deleted on
|
||||
2026-05-26, and TrueNAS opens on its Overview tab. Recovery rows render in
|
||||
platform-owned tabs whose controls stay on the shared filter-toolbar
|
||||
primitives: the Proxmox Backups tab filters through the shared `FilterBar`,
|
||||
and `TrueNASProtectionTable.tsx` through `PlatformTableToolbar`, rather than
|
||||
page-local tab strips or nested cards.
|
||||
|
||||
Cross-jump chip strips on alert and Patrol surfaces were retired on
|
||||
2026-05-16 alongside the platform-first migration. The
|
||||
@@ -4472,10 +4518,13 @@ and `getActiveTabForPath` matcher must not recognize aggregate workspace URLs
|
||||
as owned shell destinations. New palette commands and shortcut chords must
|
||||
flow through the same shell owners; do not reintroduce hidden platform
|
||||
families or retired top-level aggregate routes by reanimating legacy paths.
|
||||
The shared route-state helpers follow the same boundary: workload, storage,
|
||||
and recovery helpers in `frontend-modern/src/routing/resourceLinks.ts` may
|
||||
build query strings for an already-owned platform/runtime route, but must not
|
||||
export pathname builders for `/workloads`, `/storage`, or `/recovery`.
|
||||
The shared route-state helpers follow the same boundary: the workload and
|
||||
storage helpers in `frontend-modern/src/routing/resourceLinks.ts` may build
|
||||
query strings for an already-owned platform/runtime route, but must not export
|
||||
pathname builders for `/workloads`, `/storage`, or `/recovery`. The module
|
||||
exports no recovery query serializer: the Proxmox Backups tab owns its query
|
||||
through `PROXMOX_BACKUPS_QUERY_PARAMS`, and the TrueNAS Protection tab keeps
|
||||
its search, status filter, and sort out of the URL.
|
||||
|
||||
The shared table chrome now allows `TableCardHeader` to expose a right-aligned
|
||||
action slot, currently used by the Workloads/Proxmox metric display control.
|
||||
@@ -4848,6 +4897,13 @@ performance-report transport (`/api/reporting` and `/api/reporting/generate-mult
|
||||
so the reporting panel does not go dead on mixed-version installs, but that
|
||||
compatibility path is intentionally report-only and must not invent the newer
|
||||
catalog-owned VM inventory export surface.
|
||||
`reportingPanelModel.ts` picks the single-resource transport from the catalog's
|
||||
`performanceReport.singleResourceMethod`: `POST` sends the request fields as a
|
||||
JSON body, which is the only transport the server lets narrate a PDF with Pulse
|
||||
Assistant, and `GET` sends the same fields as query parameters.
|
||||
`reportingCatalogModel.ts` reads a missing field as `GET`, the only method a
|
||||
backend that predates the field accepts, and the legacy compatibility catalog
|
||||
names `GET` for `/api/reporting`. Fleet reports stay `POST` on every backend.
|
||||
`ReportingPanel.tsx` must therefore treat `vmInventoryExport` as optional when
|
||||
it renders a governed reporting catalog. A legacy compatibility catalog with no
|
||||
inventory export still owns a valid enabled reporting surface and must continue
|
||||
@@ -5676,6 +5732,13 @@ identity candidates) and pass them into the shared metric bar primitives
|
||||
(`ResponsiveMetricCell`, `StackedMemoryBar`, `StackedDiskBar`); the static
|
||||
`METRIC_THRESHOLDS` display constants remain fallback-only presentation for
|
||||
callers without alert configuration in scope.
|
||||
Workload guest bars pass the guest's alert policy tags
|
||||
(`getWorkloadAlertPolicyTags`) through that same `getMetricThresholds` call,
|
||||
so a Proxmox guest tagged `pulse-relaxed` colours CPU, memory and disk by the
|
||||
relaxed floor the engine applies to its configured thresholds; bar
|
||||
primitives never read tags themselves. The Alerts thresholds table's shared row and mobile card render
|
||||
the relaxed-trigger line from `getAlertResourceRelaxedTriggerSummary`, not
|
||||
from per-section copy.
|
||||
The vSphere ESXi host table keeps power state distinct from aggregate resource
|
||||
health without spending a phone column on the repeated normal case. At phone
|
||||
widths the Power column is hidden and a shared `MetadataBadge` appears beside
|
||||
@@ -5767,11 +5830,6 @@ compose that primitive instead of copying rounded-full active selector styling.
|
||||
`ResourcePicker` report-domain filters are part of that boundary: the picker
|
||||
owns the reportable resource categories and labels, but the type selector shell
|
||||
must come from `FilterButtonGroup`.
|
||||
Chart visibility display actions are registry-backed too.
|
||||
`ChartVisibilityToggleButton` owns the `Show charts` / `Hide charts` label,
|
||||
pressed-state, title, icon, and toolbar action styling for summary-bearing
|
||||
filter surfaces. Pages must compose that primitive instead of recreating local
|
||||
chart visibility buttons or one-option segmented controls.
|
||||
Column visibility controls are registry-backed too. `ColumnPicker` owns the
|
||||
column chooser trigger, panel title, reset action, empty-state copy, hidden
|
||||
count badge, dropdown width, and outside-click lifecycle through the shared
|
||||
@@ -5954,14 +6012,14 @@ pages that need workspace tabs or count chips next to search should route that
|
||||
through the shared `searchLeading` slot instead of recreating a second local
|
||||
header strip above the control bar.
|
||||
|
||||
Pages that filter a list-of-resources surface (Infrastructure, Workloads,
|
||||
Storage, Recovery Protection coverage, Recovery events) compose the chip-based
|
||||
Pages that filter a list-of-resources surface (Workloads, Storage, and the
|
||||
Proxmox Backups tab) compose the chip-based
|
||||
`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx` shell instead
|
||||
of `PageControls`. Each page declares a `FilterDef[]` catalog (label, options,
|
||||
value, defaultValue, group); `FilterBar` renders chips for active filters and
|
||||
exposes the rest behind a "+ Filter" menu, with type-ahead at both the menu
|
||||
and chip popovers (`AddFilterMenu` and `FilterChip`). Low-frequency view options
|
||||
(grouping segmented control, charts toggle, columns picker, sort key) compose
|
||||
(grouping segmented control, columns picker, sort key) compose
|
||||
the shared `ViewOptionsDisclosure` through `FilterBar`'s `viewOptions` prop
|
||||
instead of remaining as permanent toolbar controls. `FilterBar` owns the View
|
||||
trigger and inline disclosure; feature consumers pass only panel content and
|
||||
@@ -5971,9 +6029,9 @@ than passing an empty conditional wrapper that leaves a dead View trigger.
|
||||
Contextual frequent actions may use `leadingControls`, while table counters,
|
||||
active trend ranges, and other persistent orientation readouts may use
|
||||
`trailingControls`. Platform tables inherit the same ownership through
|
||||
`PlatformTableToolbar`. Recovery is event-first and does not use equal
|
||||
workspace subtabs for protected rollups versus event history; Storage subtabs
|
||||
(Pools / Physical Disks) sit above the bar as navigation, not filters.
|
||||
`PlatformTableToolbar`. View switches sit above the bar as navigation, not
|
||||
filters: the Proxmox Backups tab's `By date` / `Coverage` `PlatformSectionTabs` and
|
||||
the Storage subtabs (Pools / Physical Disks).
|
||||
`FilterBar` owns committed infrastructure search terms as removable pills,
|
||||
separate from its consumer-owned structured `FilterDef` chips. An exact
|
||||
infrastructure completion or a recognized abbreviated query clears the draft
|
||||
@@ -6213,6 +6271,13 @@ When a shared portal tooltip is already visible, that same owner must
|
||||
reschedule positioning on live coordinate and viewport changes so chart hover
|
||||
tooltips keep following the active pointer instead of sticking to their first
|
||||
anchor.
|
||||
Only a visible tooltip holds a window resize listener. Hidden instances (one
|
||||
per metric bar or sparkline on every table row) and portals suppressed on a
|
||||
touch device register none and re-read the viewport when they open, so the
|
||||
listener count no longer grows with rows. `TooltipPortal.test.tsx` proves that
|
||||
hidden and touch-suppressed portals add no listener, that an open one re-clamps
|
||||
on a window resize, and that one opened after an unheard resize starts from the
|
||||
live viewport.
|
||||
Floating hover tooltips are a fine-pointer interaction only. The shared
|
||||
tooltip hook, portal, and singleton API must suppress them when the primary
|
||||
device reports no hover capability or a coarse pointer, because touch browsers
|
||||
@@ -6266,7 +6331,7 @@ an absent list as an empty audit history.
|
||||
That shared filter-option primitive is also the canonical owner for default
|
||||
`All <scope>` option wording wherever a product surface exposes filter selects
|
||||
or segmented filter choices. Workloads filters, storage source
|
||||
filters, recovery history and platform/type filters, Kubernetes namespace
|
||||
filters, Kubernetes namespace
|
||||
drawers, resource-change timeline filters, and alert configuration options must
|
||||
call `frontend-modern/src/components/shared/filterOptionPresentation.ts` through
|
||||
their nearest presentation/model owner instead of hard-coding page-local `All
|
||||
@@ -6361,18 +6426,13 @@ landing fallback. Shared desktop and mobile navigation must tolerate a missing
|
||||
active tab for those paths while still highlighting canonical active routes
|
||||
such as Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines, Alerts,
|
||||
Patrol, and Settings.
|
||||
The recovery feature shell now also depends on the shared
|
||||
`frontend-modern/src/components/shared/Subtabs.tsx` primitive for its primary
|
||||
protected-items versus recovery-events workspace switch. The recovery lane may
|
||||
own the active view and route-state semantics, but the top-level tab framing
|
||||
must stay on the canonical shared subtabs control instead of reviving a
|
||||
recovery-local switcher pattern. When recovery embeds that switcher inside the
|
||||
page shell, it should follow the same ordering already used by storage: shared
|
||||
subtabs row first, shared controls card second, and data card after that. The
|
||||
contained styling should come from the same canonical subtabs shell, list, and
|
||||
button class treatment already used by established Pulse surfaces rather than
|
||||
from a recovery-only variant boundary, adjacent chip row, or recovery-local
|
||||
filter-row embedding.
|
||||
Recovery has no workspace switch of its own: the protected-items versus
|
||||
recovery-events `Subtabs` switch left with the aggregate Recovery page on
|
||||
2026-05-26. The Proxmox Backups tab switches between `/proxmox/backups/date`
|
||||
and `/proxmox/backups/coverage` through the shared `PlatformSectionTabs`
|
||||
row above its `FilterBar`, and the TrueNAS Protection tab is a single table.
|
||||
A future recovery view switch belongs on those platform-page primitives, not
|
||||
on a recovery-local switcher, chip row, or filter-row embedding.
|
||||
The shared table primitives now also need to preserve caller-owned separator
|
||||
styling. `TableHeader` and `TableBody` may provide canonical default borders
|
||||
and dividers, but when a caller supplies explicit border or divide classes the
|
||||
@@ -6393,63 +6453,38 @@ That same shared table boundary now owns CSP-safe fill rendering for metric
|
||||
bars: `frontend-modern/src/components/shared/ProgressBar.tsx` must render fill
|
||||
width through DOM attributes rather than inline width styles that break the
|
||||
public demo CSP.
|
||||
That same recovery shell boundary now also owns one canonical top-level filter
|
||||
controller in
|
||||
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`. Route-backed
|
||||
recovery filters such as the provider-neutral `itemType` selector must be
|
||||
derived, normalized, and fanned out to inventory, history, activity, facets,
|
||||
and series consumers from that shared state owner rather than being recreated
|
||||
as page-local toolbar state inside individual recovery sections.
|
||||
That same shared recovery filter boundary also owns canonical recovery
|
||||
item-type derivation through
|
||||
`frontend-modern/src/utils/recoveryItemTypePresentation.ts`. Recovery shell
|
||||
state, tables, summaries, and point-detail surfaces must resolve rollup and
|
||||
point item types through the shared presenter helpers instead of repeating
|
||||
`display.itemType` / `subjectType` / `subjectRef.type` fallback chains in
|
||||
page-local consumers.
|
||||
That same shared recovery decode boundary also owns canonical recovery display
|
||||
shape. `frontend-modern/src/utils/recoveryPlatformModel.ts`,
|
||||
`frontend-modern/src/hooks/useRecoveryPoints.ts`, and
|
||||
`frontend-modern/src/hooks/useRecoveryRollups.ts` must normalize legacy
|
||||
transport display aliases like `subjectLabel` and `subjectType` into canonical
|
||||
runtime `itemLabel` and `itemType` fields before recovery presenters consume
|
||||
the model.
|
||||
The same shared recovery-column boundary must keep legacy `subject` and
|
||||
`source` column ids at migration-only scope once
|
||||
`frontend-modern/src/hooks/useColumnVisibility.ts` owns alias rewrites.
|
||||
Recovery table runtime helpers and render switches should operate on canonical
|
||||
`item` and `platform` ids rather than carrying the deleted ids as live cases.
|
||||
That same shared recovery state owner now also keeps `platform` as the
|
||||
canonical route and transport filter name for operator-facing recovery links,
|
||||
while any accepted legacy `provider` aliases remain parser compatibility only.
|
||||
Caller-facing shared recovery route builders must therefore stay
|
||||
platform-first as well: compatibility `provider` aliases may be accepted while
|
||||
parsing legacy links, but they should not remain a first-class input on new
|
||||
recovery link construction helpers.
|
||||
Recovery frontend decode and derived option builders must treat payload
|
||||
`platform` / `platforms` as the canonical response fields and only fall back
|
||||
to legacy `provider` / `providers` aliases for compatibility, so route,
|
||||
filter, and table state do not keep backend-era vocabulary alive as the
|
||||
default client model.
|
||||
That normalization belongs at the shared recovery transport boundary in
|
||||
`frontend-modern/src/hooks/useRecoveryPoints.ts` and
|
||||
`frontend-modern/src/hooks/useRecoveryRollups.ts`, not in individual tables,
|
||||
drawers, or summary cards. Recovery components should receive canonical
|
||||
platform-first runtime models rather than re-deriving legacy alias fallback
|
||||
locally.
|
||||
Recovery section owners under `frontend-modern/src/components/Recovery/` must
|
||||
consume that shared `platform` filter surface directly. They must not keep
|
||||
recovery-local `provider` route/query vocabulary alive behind renamed labels,
|
||||
or the UI will drift back to backend-shaped navigation even when the copy says
|
||||
`Platform`.
|
||||
That same shared recovery filter owner must also preserve route-owned platform
|
||||
visibility while transport-backed options are still hydrating. If
|
||||
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` restores a
|
||||
canonical `platform` selection such as `truenas` from the route before the
|
||||
rollups, points, or facets payloads arrive, it must keep that selected
|
||||
platform present in the option set so the shared `LabeledFilterSelect` shows
|
||||
the owned value immediately instead of flashing back to `All Platforms` until
|
||||
recovery data warms.
|
||||
Recovery has no shell, filter controller, route serializer, or column model
|
||||
left for frontend primitives to govern. The aggregate Recovery page, its
|
||||
section owners under `frontend-modern/src/components/Recovery/`, and its state
|
||||
owner `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` were
|
||||
deleted on 2026-05-26 together with the rollup, facet, and series hooks
|
||||
(`useRecoveryRollups.ts`, `useRecoveryPointsFacets.ts`,
|
||||
`useRecoveryPointsSeries.ts`), and the recovery query serializer and
|
||||
`recoveryItemTypePresentation.ts` that only it read have been deleted since.
|
||||
Recovery rows render in two platform-owned tabs built from the shared
|
||||
platform-page primitives: the Proxmox Backups tab and the TrueNAS Protection
|
||||
tab.
|
||||
The Proxmox Backups tab parses `/api/backups/pve` and `/api/backups/pbs`
|
||||
inside `ProxmoxBackupsTable.tsx` and reads posture through
|
||||
`frontend-modern/src/hooks/useProtectionPostures.ts`. The TrueNAS Protection
|
||||
tab reads recovery points, and what binds that path is the decode boundary.
|
||||
`frontend-modern/src/utils/recoveryPlatformModel.ts` maps legacy transport
|
||||
aliases onto the canonical runtime model: `provider` to `platform`,
|
||||
`subjectResourceId` to `itemResourceId`, `subjectRef` to `itemRef`, and
|
||||
`display.subjectLabel` / `display.subjectType` to `display.itemLabel` /
|
||||
`display.itemType`. `frontend-modern/src/hooks/useRecoveryPoints.ts`, the
|
||||
only recovery-points reader, applies `normalizeRecoveryPointsResponse` to
|
||||
every `/api/recovery/points` response, so the TrueNAS Protection tab receives
|
||||
platform-first, item-first points. The `display.subjectLabel`,
|
||||
`display.subjectType`, and `subjectRef` fallbacks that
|
||||
`TrueNASProtectionTable.tsx` and `truenasPageModel.ts` still read are dead on
|
||||
that path, because normalized points never carry those fields; new recovery
|
||||
code reads the canonical fields only. New recovery filters, links, or view
|
||||
switches belong to the platform tab that owns the rows, on the platform-page primitives, rather than in a revived
|
||||
cross-platform recovery shell or a shared recovery serializer in
|
||||
`frontend-modern/src/routing/resourceLinks.ts`. The rollup normalizer
|
||||
(`normalizeRecoveryRollupsResponse`) has had no production reader since
|
||||
`useRecoveryRollups.ts` was deleted.
|
||||
The retired dashboard action queue must not be reintroduced as a compact
|
||||
Patrol or infrastructure issue panel. Patrol-owned runtime findings remain
|
||||
governed by `frontend-modern/src/utils/aiFindingPresentation.ts` and their
|
||||
@@ -6664,6 +6699,16 @@ notice renders on. `collectOutdatedSensorSetupNodes` lists the PVE nodes that
|
||||
carry the registry's `proxmox.sensorSetupOutdated` verdict and takes no disk
|
||||
rows, because only the Proxmox Storage tab loads them; a predicate over them
|
||||
left the notice silent on Overview.
|
||||
A notice names each affected resource by the label its page's table shows for
|
||||
the row. The sensor-setup notice leads with the node's trimmed resource name
|
||||
(the operator-set display name when one exists, else the Proxmox node name),
|
||||
falling back to the id as the nodes table does, and adds the Proxmox node name
|
||||
in parentheses unless the label already contains it as a whole word, ignoring
|
||||
ASCII case, or as the host part of a domain name, as in
|
||||
`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 Proxmox node, so the rule is stricter than `hasAlternateDisplayName` in
|
||||
the guest table's node group header and keeps `pve-3` beside a `PVE3` label.
|
||||
|
||||
Alert incident-event filter containers, labels, and chips must now route
|
||||
through the shared presentation helpers in
|
||||
@@ -6819,11 +6864,12 @@ That same Patrol shell ownership includes refresh affordance state:
|
||||
`frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` must keep
|
||||
operator refresh controls generation-aware, timeout-bounded, and separate from
|
||||
background polling state, so a slow supporting intelligence read cannot make the
|
||||
shared Patrol header Refresh Patrol action spin indefinitely or stay disabled
|
||||
Patrol stale-data Retry action spin indefinitely or stay disabled
|
||||
while Patrol findings and status remain visible.
|
||||
Neither the Patrol load and poll path (the store's `loadDashboardData` bundle)
|
||||
nor the Retry path's background supporting reads fetch the global
|
||||
learned-correlation list, which no Patrol surface shows.
|
||||
learned-correlation list or the provider circuit-breaker status, neither of
|
||||
which any Patrol surface shows.
|
||||
That same Patrol shell should make scoped trigger policy legible without
|
||||
another navigation step. `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`
|
||||
should keep actionable scoped-trigger state legible without promoting
|
||||
@@ -7417,20 +7463,19 @@ customer-facing action copy, while
|
||||
provider labels, empty states, and action/status messaging. Future settings
|
||||
copy changes in those areas should extend these helpers instead of inlining
|
||||
panel-local strings inside the shell or reactive state owners.
|
||||
Shared infrastructure action-link framing now also owns recovery entry wording
|
||||
for service resources. `frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts`
|
||||
must keep platform-service recovery links on canonical recovery-events
|
||||
framing and route state, so upstream service surfaces do not drift back to
|
||||
PBS-backup wording or inherit the page-default inventory workspace when they
|
||||
are actually deep-linking into recovery activity.
|
||||
That same shared primitive boundary also owns resource handoff chip framing for
|
||||
cross-surface investigation UI. Alerts, Patrol, and similar feature shells may
|
||||
choose which governed surfaces to show, but they must build those links through
|
||||
the shared resolved-resource route helpers in
|
||||
`frontend-modern/src/routing/resourceLinks.ts` instead of freezing raw route
|
||||
strings, local link dedupe, or provider-specific link chips inside feature
|
||||
panels. Shared chip styling belongs in the feature shell; canonical href and
|
||||
label truth belongs in the shared route helper.
|
||||
Shared infrastructure action-link framing owns service-resource action links.
|
||||
`frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts` returns
|
||||
only the PMG thresholds link today: the PBS recovery-events link it used to
|
||||
build was retired with the platform-first route migration, and PBS backups are
|
||||
reached through the Proxmox Backups tab. A future service recovery link must
|
||||
target the platform tab that owns those rows rather than a recovery-events
|
||||
workspace.
|
||||
Alerts, Patrol, and similar feature shells render no resource handoff chips
|
||||
since the cross-jump strips were retired on 2026-05-16 (see the retired-helper
|
||||
note above). A future cross-surface link must take its href and label from a
|
||||
route helper in `frontend-modern/src/routing/resourceLinks.ts` instead of
|
||||
freezing raw route strings, local link dedupe, or provider-specific link chips
|
||||
inside a feature panel; chip styling belongs in the feature shell.
|
||||
That same shared primitive boundary now also owns persisted column-identity
|
||||
migration for governed surfaces. When a v6 surface canonicalizes saved column
|
||||
IDs, `frontend-modern/src/hooks/useColumnVisibility.ts` must accept explicit
|
||||
|
||||
@@ -2699,7 +2699,18 @@ truthfulness, not native thaw, containing-release or workload acceptance.
|
||||
ingestion so canonical host IDs stay stable across restarts (see the
|
||||
unified-resources contract's durable identity-pin obligation). Rebuild
|
||||
paths added to the adapter must keep that persistence step; ephemeral
|
||||
snapshot-bridge adapters stay read-only.
|
||||
snapshot-bridge adapters stay read-only. The rebuild, the live
|
||||
supplemental refresh and the read-state overlay ingest records with the
|
||||
adapter's configured stale thresholds, because record ingest joins
|
||||
operator links and the freshness gate of every metric merge, a link's or
|
||||
a source's into an existing row, reads them (unified-resources contract,
|
||||
"Operator links reach record-ingested resources"). Regression
|
||||
coverage:
|
||||
`TestMonitorAdapterJoinsLinkedRecordsWithConfiguredStaleThresholds` and
|
||||
`TestMonitorAdapterSourceMergesUseConfiguredStaleThresholds` in
|
||||
`internal/unifiedresources/monitor_adapter_read_state_test.go` and
|
||||
`TestManualLinkToSupplementalGuestHoldsWithAndWithoutContinuity` in
|
||||
`internal/monitoring/issue1913_host_continuity_test.go`.
|
||||
|
||||
11. The TrueNAS provider projects pools with `Storage.Topology` fixed to
|
||||
`pool` and the ZFS data vdev layout in `Storage.VDevLayout`. The
|
||||
@@ -5374,6 +5385,42 @@ canonical aliases retain their precedence and ambiguous aliases remain
|
||||
unresolved. This avoids cloning a full resource for every policy lookup while
|
||||
keeping alert intent tied to the same identity rules as resource reads.
|
||||
|
||||
Alert intent resolves references and ancestors through the read state Patrol
|
||||
resolves through and the resources API seeds from: the published registry with
|
||||
saved hosts that have not reported since a restart overlaid
|
||||
(`operatorIntentIdentity`). A saved host exists only there, so against the
|
||||
published registry alone its alerts (`agent:<host ID>`) read their literal
|
||||
reference after a restart and missed intent set on the host, and, when an
|
||||
operator link joins it to a live resource, intent set on the link primary that
|
||||
the registry now resolves the saved host to. Alerts resolve under the alert
|
||||
manager's lock, once per metric check, so the overlay is kept for the published
|
||||
registry generation it was built from. Publication builds it before alert
|
||||
evaluation, outside that lock; a lookup that races ahead of publication, or the
|
||||
first one after leaving mock mode, builds it under the lock. A generation is
|
||||
the registry the adapter publishes and when it last changed
|
||||
(`MonitorAdapter.Generation`), since two publications can share a
|
||||
`LastRebuiltAt`. A finished build is stored only if that generation is still
|
||||
the published one and the mock-mode epoch it started in is current, checked and
|
||||
stored inside the mock-mode fence, so a build that overlapped a mode switch is
|
||||
discarded. A lookup made while mock mode is on drops the stored overlay; a
|
||||
switch to mock and back with no lookup in between keeps it, since it still
|
||||
describes the published generation. A saved-host change that arrives without a
|
||||
new generation shows on the next one. Mock mode carries no saved hosts: lookups
|
||||
then resolve against the published registry as before.
|
||||
`monitor_alert_intent_test.go`
|
||||
(`TestOperatorIntentIdentityKeepsOneOverlayPerGeneration`,
|
||||
`TestOperatorIntentReachesSavedHostAlertsAfterRestart`) pins the reuse, a
|
||||
generation sharing or preceding the last one's timestamp, the mock switch, a
|
||||
rebuild made from inside an admitted fence call, and an unlinked saved host's
|
||||
intent reaching its offline alert.
|
||||
`internal/monitoring/issue1913_host_continuity_test.go`
|
||||
(`TestManualLinkToSupplementalGuestHoldsWithAndWithoutContinuity`) pins a saved
|
||||
agent's offline alert reading maintenance set on the guest it is linked into,
|
||||
and `monitor_host_agent_removal_lifecycle_test.go`
|
||||
(`TestHostAgentRemovalLifecycleHonorsSavedHostIntentAfterRestart`) pins the
|
||||
restarted monitor's own host-offline check raising no alert for a saved host
|
||||
the operator marked intentionally offline.
|
||||
|
||||
Alert restore precedes resource-store attachment during startup. Once the
|
||||
adapter attaches the persisted operator-policy resolver, monitoring asks Alerts
|
||||
to reconcile the restored set and refreshes shared alert state if it changed.
|
||||
@@ -5814,3 +5861,24 @@ that actually accepted the same occurrence. The connected
|
||||
HTTP 200/503 split, reopens the persistent queue, and requires the old accepted
|
||||
destination's recovery plus the new firing, with no recovery to the unannounced
|
||||
destination. Quiet hours and disabled recovery controls still apply.
|
||||
|
||||
### Broadcast host coalescing honours operator splits
|
||||
|
||||
The websocket broadcast coalesced top-level host views with
|
||||
`CoalescePresentationHostResources`, which applies no merge exclusions, while
|
||||
the resources API's `ResourceRegistry.ListForPresentation` applies the
|
||||
registry's. A host pair the operator split (unlink or report-merge) could
|
||||
therefore be one broadcast row and two REST rows.
|
||||
`Monitor.buildBroadcastFrontendStateFromSnapshotWithClock` now calls
|
||||
`coalesceResourcesForPresentation`, which asks the view's read state for
|
||||
`MonitorAdapter.CoalesceForPresentation` when its registry is store-backed
|
||||
(the resource store's adapter, or a host-continuity overlay that loaded the
|
||||
store's exclusions afresh), and otherwise the resource store's adapter: the
|
||||
mock-mode view lists through a store-less registry that carries no operator
|
||||
decisions. Both surfaces share `presentationExclusionFilter`. Polling, rebuild cadence,
|
||||
alert evaluation and the broadcast payload shape are unchanged.
|
||||
`TestBroadcastPresentationCoalesceHonoursMergeExclusions` compares broadcast
|
||||
and REST row counts with and without the split, and
|
||||
`TestBroadcastPresentationCoalescePrefersTheListingReadState` pins the
|
||||
listing read state over an older store generation and the store's exclusions
|
||||
for a mock-style view.
|
||||
|
||||
@@ -329,13 +329,14 @@ or historical proof/counting for resolved-only work.
|
||||
shared parent node, or explicit correlated finding IDs. Grouping must not
|
||||
infer causality from title or description prose, and the queue's affected
|
||||
resource count must continue to count canonical resources rather than
|
||||
visual groups. Because a
|
||||
contextual Assistant handoff from that workflow is still a first-party Patrol
|
||||
starter for the same governed journey, it must record content-free workflow
|
||||
prompt activity through the shared marker route with the `pulse_patrol`
|
||||
surface before the drawer opens; it must not include finding IDs, prompt
|
||||
text, resource context, model output, or direct-action payloads in that
|
||||
marker.
|
||||
visual groups. A contextual Assistant handoff from that workflow opens
|
||||
through `frontend-modern/src/components/AI/FindingsPanel.tsx` and records no
|
||||
workflow starter today, so the first-party `pulse_patrol` marker has no
|
||||
browser producer. If that handoff starts recording one, it must write
|
||||
content-free workflow prompt activity through the shared marker route with
|
||||
the `pulse_patrol` surface before the drawer opens, with no finding IDs,
|
||||
prompt text, resource context, model output, or direct-action payloads in
|
||||
that marker.
|
||||
A compact Patrol work-group row may appear above the list only when it adds
|
||||
cross-source current-work grouping that the row title alone cannot express:
|
||||
pending approvals, failed governed actions, a failed/latest Patrol check,
|
||||
@@ -471,8 +472,11 @@ or historical proof/counting for resolved-only work.
|
||||
compact progress label must describe current work state (`ready`, `needs
|
||||
attention`, `approval needed`, `outcome verified`, `no active work`) instead
|
||||
of repeating the selected mode; the Patrol mode selector/header
|
||||
owns current-mode copy, and local Patrol state must expose work evidence as
|
||||
owns current-mode copy, and any local Patrol work evidence must be
|
||||
Patrol-owned issue/work counts rather than legacy proof counts.
|
||||
No Patrol surface reads issue-evidence, governed-action decision, or
|
||||
verified-outcome counts today, so `usePatrolIntelligenceState.ts` computes
|
||||
none of them.
|
||||
When no active finding or pending approval remains, terminal verified or
|
||||
rejected outcomes must read as history behind a `no active work` current
|
||||
state; current-state copy must not ask operators to reconcile old approved
|
||||
@@ -771,12 +775,12 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead
|
||||
only block the selected run view while the run references finding ids that
|
||||
still need the direct Patrol findings payload.
|
||||
Patrol page refresh state must also separate background data loads from the
|
||||
operator-clicked Update status action: slow or stalled supporting
|
||||
intelligence reads may continue in the background, but they must not make the
|
||||
shared header action spin or stay disabled once Patrol findings and status
|
||||
remain visible. The header action is a status/history sync affordance; it
|
||||
must not read like another Patrol run or imply that it changes
|
||||
infrastructure.
|
||||
operator-clicked Retry on the stale-data banner: background polls hold no
|
||||
busy state, and slow or stalled supporting intelligence reads may continue
|
||||
in the background, but they must not make Retry spin or stay disabled once
|
||||
Patrol findings and status remain visible. Retry is a status/history sync
|
||||
affordance; it must not read like another Patrol run or imply that it
|
||||
changes infrastructure.
|
||||
Patrol trigger-status copy in the default header and activity strip must stay
|
||||
actionable. Runtime policy pauses for alert/anomaly-triggered background
|
||||
checks, such as the local development safety guard, may explain a blocked
|
||||
@@ -986,7 +990,7 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
|
||||
answer. Finding
|
||||
handoffs must be assembled through the Patrol-owned handoff model so the
|
||||
prompt, visible briefing, model-only finding context, resource reference,
|
||||
bounded action reference, and request-local approval-required posture stay in
|
||||
bounded action reference, and drawer approval-required disclosure stay in
|
||||
sync. The model-only context may include
|
||||
current finding status, recurrence, investigation record facts, evidence,
|
||||
verification, approval state, dry-run posture, existing action artifact
|
||||
@@ -997,8 +1001,8 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
|
||||
prompt-only local shortcut: pass approval ID/status/risk/target plus safe
|
||||
summary/count metadata as review context, attach the target resource
|
||||
reference, include bounded `handoff_actions` for live approvals or structured
|
||||
action artifacts when present, force the request-local approval-required mode,
|
||||
attach the Patrol-owned visible drawer briefing for the pending approval or
|
||||
action artifacts when present, mark the drawer's approval-required
|
||||
disclosure, attach the Patrol-owned visible drawer briefing for the pending approval or
|
||||
queued-fix recovery state, and never paste the approval command or
|
||||
action command text into the chat prompt. Existing remediation-plan or
|
||||
action-plan artifacts follow the same boundary: plan status, risk, and
|
||||
@@ -1006,11 +1010,12 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
|
||||
the LLM to critique, but visible handoffs must not render Patrol step lists
|
||||
or suggested prompt chips, and command or rollback command text stays in the
|
||||
governed remediation or approval surface. Generic finding
|
||||
discussion handoffs must also force request-local approval-required mode for
|
||||
any non-empty Patrol `finding_id`, including context-only findings and
|
||||
findings that reference a live approval, action artifact, fix outcome, or
|
||||
remediation plan, so default autonomous Assistant settings cannot bypass the
|
||||
Patrol action-governance boundary. The assembled handoff must still pass
|
||||
discussion handoffs for any non-empty Patrol `finding_id`, including
|
||||
context-only findings and findings that reference a live approval, action
|
||||
artifact, fix outcome, or remediation plan, run in the approval-required
|
||||
mode `/api/ai/chat` sets server-side for every request; the browser sends no
|
||||
execution-mode field, so default autonomous Assistant settings cannot bypass
|
||||
the Patrol action-governance boundary. The assembled handoff must still pass
|
||||
through the Assistant runtime's resource-policy sanitizer before prompt
|
||||
injection, so Patrol-owned prose
|
||||
cannot leak governed resource names, IDs, aliases, nodes, paths, or
|
||||
@@ -1190,8 +1195,9 @@ fix`, or `Explain` based on current finding state), while secondary
|
||||
the model-only run context: it resolves the run ID from Patrol history,
|
||||
rebuilds bounded run facts, scoped resource references, sanitized analysis,
|
||||
and classified failure detail server-side, and rehydrates the same context
|
||||
from stored metadata on follow-up turns. It must force request-local
|
||||
approval-required mode, present a source-named visible drawer briefing, and
|
||||
from stored metadata on follow-up turns. The exchange runs in the
|
||||
server-set approval-required mode; the handoff must present a source-named
|
||||
visible drawer briefing and
|
||||
frame Assistant as explanation and next-step review rather than execution or
|
||||
automatic retry authority.
|
||||
9. Keep the normal Patrol status summary plain and operator-first rather
|
||||
@@ -1581,17 +1587,18 @@ suppress stale healthy summary headlines such as `Health A · 100/100` even if
|
||||
the last summary payload still looks healthy. Legacy `/ai` entry points must
|
||||
redirect into that same Patrol-owned shell rather than preserving a second
|
||||
canonical route.
|
||||
That same browser proof now covers the Patrol control and advanced-settings
|
||||
split. The advanced Patrol settings drawer must stay within the desktop
|
||||
viewport, avoid duplicating the inline Patrol control policy, expose
|
||||
provider/model, schedule, trigger, and user-level model checks directly, and surface
|
||||
the backend's concrete license/validation reason when a settings change is
|
||||
rejected instead of replacing it with a generic `Failed to save advanced
|
||||
settings` toast. That inline failure no longer
|
||||
opens Assistant. If it does again, the handoff is model-only explanation
|
||||
context: raw command, script, credential, and provider-detail payloads stay
|
||||
redacted, Assistant opens with `autonomousMode:false`, and the Patrol control
|
||||
panel closes so the operator is not left behind an overlapping popover.
|
||||
That same browser proof now covers where Patrol settings are saved. The
|
||||
Patrol page has no settings drawer or popover: Patrol schedule, triggers, and
|
||||
the Patrol model override live on Settings > Pulse Intelligence > Patrol
|
||||
(`Save Patrol settings`), and providers and the shared default model on
|
||||
Settings > Provider & Models. A rejected save there raises an error
|
||||
notification carrying the backend's concrete license/validation reason
|
||||
instead of generic fallback copy, and it offers no Assistant handoff. If a
|
||||
Patrol settings popover or an Assistant handoff from a settings failure
|
||||
returns, the handoff is model-only explanation context: raw command, script,
|
||||
credential, and provider-detail payloads stay redacted, Assistant opens with
|
||||
`autonomousMode:false`, and the popover closes so the operator is not left
|
||||
behind it.
|
||||
Patrol-control save-failure sessions saved by earlier builds keep the
|
||||
compatible `handoff_metadata.kind=patrol_configuration_failure` plus only the
|
||||
runtime-failure boolean needed for drawer/session presentation, so they restore
|
||||
@@ -1626,27 +1633,32 @@ after a page render: recoverable Patrol provider/model settings saves must
|
||||
persist and echo structured readiness cause metadata, manual run requests must
|
||||
return the structured readiness reason if a stale UI still submits, and
|
||||
scheduled or scoped alert/anomaly runs must skip before calling the model while
|
||||
preserving the blocked reason and cause in Patrol status. The Patrol
|
||||
control state owner must also clamp stale investigation/remediation
|
||||
autonomy back to findings-only `monitor` and clear stale full-mode unlock state
|
||||
before persisting Patrol control when the safe-remediation entitlement is not
|
||||
effective, so an expired or downgraded plan cannot turn a recoverable control
|
||||
review into a Pro-only save failure. Patrol renders no inline error
|
||||
preserving the blocked reason and cause in Patrol status. When the
|
||||
safe-remediation entitlement is not effective, the Patrol header must present
|
||||
findings-only `monitor` as the effective mode and the Patrol control state
|
||||
owner must refuse any investigation/remediation level when a save starts;
|
||||
the `monitor` save it does send is accepted without the entitlement, and the
|
||||
API clears stale full-mode unlock and Autopilot activation state on it, so an
|
||||
expired or downgraded plan cannot turn a recoverable control review into a
|
||||
Pro-only save failure. Patrol renders no inline error
|
||||
surface for these failures today: switching Patrol on or off while it is not
|
||||
ready raises a fixed warning notification, a failed Patrol mode save raises an error
|
||||
notification, and neither offers an Assistant handoff. A provider or model save
|
||||
in Settings > Provider & Models reports the readiness summary, provider and
|
||||
model in its own save message.
|
||||
The Patrol mode selector in that header and configuration dialog must
|
||||
compose the shared `frontend-modern/src/components/shared/FilterButtonGroup.tsx`
|
||||
instead of rebuilding a local active-button group. The wide default Patrol
|
||||
header uses the segmented layout; the constrained configuration dialog may use
|
||||
the shared prominent layout so all four mode labels remain readable without
|
||||
inventing a Patrol-local selector. Patrol owns the default visible four-level
|
||||
The Patrol mode selector in that header must compose the shared
|
||||
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` instead of
|
||||
rebuilding a local active-button group. When the header shows Patrol mode
|
||||
options, it renders the selector in the segmented layout inside its `Mode and
|
||||
automation` disclosure; a constrained surface that hosts it may use the shared
|
||||
prominent layout so all four mode labels remain readable without inventing a
|
||||
Patrol-local selector. Patrol owns the default visible four-level
|
||||
policy presentation (`Watch only`, `Ask first`, `Safe auto-fix`,
|
||||
`Autopilot`), entitlement locks, and the rule that choosing the highest
|
||||
Autopilot level sends `full_mode_unlocked:true` while choosing any lower level clears that
|
||||
acknowledgement. The shared primitive owns pressed-state semantics,
|
||||
Autopilot level opens the acknowledgement dialog and saves `full` only with a
|
||||
recorded `acknowledgement_id` (the API derives the compatibility
|
||||
`full_mode_unlocked` value itself), while saving any lower level lets the API
|
||||
clear the Autopilot activation. The shared primitive owns pressed-state semantics,
|
||||
disabled-option behavior, and active/inactive selector styling.
|
||||
That same Patrol-owned presentation rule also applies to the findings empty
|
||||
state: `frontend-modern/src/components/AI/FindingsPanel.tsx` must not treat
|
||||
@@ -2189,10 +2201,13 @@ requester identity as safe metadata, without copying the approval command
|
||||
payload into Assistant.
|
||||
That same store now owns the Patrol dashboard load bundle as well, so the
|
||||
page refresh path stays aligned on a single orchestrated AI bundle instead of
|
||||
repeating the individual summary, findings, circuit-breaker, and approval
|
||||
fetches inline. Neither that bundle nor the Patrol refresh path fetches the
|
||||
global learned-correlation list: the Patrol page renders no correlations, so
|
||||
the store holds no copy of that list.
|
||||
repeating the individual summary, findings, and approval fetches inline.
|
||||
Neither that bundle nor the Patrol refresh path fetches the global
|
||||
learned-correlation list: the Patrol page renders no correlations, so
|
||||
the store holds no copy of that list. Nor do they fetch the provider
|
||||
circuit-breaker status: an open breaker already reaches the page as the
|
||||
`circuit_open` Patrol runtime block on `/api/ai/patrol/status`, so the store
|
||||
holds no separate breaker state.
|
||||
The shared
|
||||
`frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and
|
||||
`frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
|
||||
|
||||
@@ -1092,11 +1092,13 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
mounted, report errors out of band, and must not rely on Solid
|
||||
`createResource` in a way that can bubble into the route-level Suspense
|
||||
fallback or the setup/welcome shell.
|
||||
Workload row drawer expansion, local row focus, and summary group pinning
|
||||
are local interaction state. Inbound Workloads deep links may hydrate
|
||||
`resource` and `summaryGroup` into the focused row or group, but opening,
|
||||
closing, or clearing an inline drawer must not write those params back to
|
||||
route state, schedule router navigation, or trip the app-shell fallback.
|
||||
Workload row drawer expansion and local row focus are local interaction
|
||||
state. Inbound Workloads deep links may hydrate `resource` into the focused
|
||||
row, but opening, closing, or clearing an inline drawer must not write that
|
||||
param back to route state, schedule router navigation, or trip the
|
||||
app-shell fallback. Workloads has no group focus, so it reads no
|
||||
`summaryGroup` param: row expansion stopped writing it and no link builder
|
||||
passes one, so its hydration was removed with the group pin.
|
||||
URL synchronization for Workloads stays limited to filter-owned scope in
|
||||
`frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts`.
|
||||
The Workloads page may show a bounded partial-inventory banner when a
|
||||
@@ -1125,6 +1127,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
8. Render workload row identity directly from the shared canonical workload helper so row selection, hover, and fallback metadata lookup stay aligned with the same workload contract
|
||||
9. Format infrastructure sensor labels through the shared `frontend-modern/src/utils/textPresentation.ts` presentation helper instead of maintaining a local title-casing implementation in `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`
|
||||
10. Extend workload row contract and per-row hot-path derivations through `frontend-modern/src/components/Workloads/guestRowModel.tsx` and `frontend-modern/src/components/Workloads/useGuestRowState.ts`, and extend tooltip-backed row cell presentation through `frontend-modern/src/components/Workloads/GuestRowCells.tsx`, rather than rebuilding column metadata, row identity, cell tooltips, or anomaly correlation inside `frontend-modern/src/components/Workloads/GuestRow.tsx`
|
||||
Guest metric thresholds read the guest's alert policy tags through one
|
||||
`getWorkloadAlertPolicyTags` memo per row and per drawer, beside the
|
||||
existing scope and override-candidate memos, so `pulse-relaxed` bar
|
||||
colouring adds no per-render tag parsing and no extra store reads.
|
||||
On coarse-pointer touch layouts, those cell details must not spawn floating
|
||||
hover tooltips from synthesized mouse events; a row tap remains the primary
|
||||
drawer action. Tooltip suppression belongs to the shared hover-capability
|
||||
@@ -1473,13 +1479,16 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
`TableCardHeader` inside both the populated and filtered-empty table card.
|
||||
A platform page must not place a parallel floating title above its filters;
|
||||
the title/count band belongs to the same framed table surface as the rows.
|
||||
`WorkloadPanel` owns the mutually exclusive host/guest drawer handoff:
|
||||
clicking a grouped host row while a guest drawer is open must clear the
|
||||
selected guest and keep/focus the host summary group so the node drawer can
|
||||
replace the guest drawer. Platform pages that render a dedicated host
|
||||
table above an embedded Workloads table may disable the grouped host drawer
|
||||
through the explicit Workloads surface option, but standalone Workloads
|
||||
must keep the inline grouped-row drawer as the default.
|
||||
`WorkloadPanel` renders only the guest drawer. Grouped host rows are
|
||||
static dividers: no click handler, hover preview, group pin, or inline
|
||||
node drawer. They keep `data-summary-group-id` so a click on a divider
|
||||
does not clear an open guest drawer. Host details open from the platform
|
||||
page's own hosts table (`ProxmoxNodesTable` mounts `NodeDrawer`;
|
||||
`VsphereHostsTable` owns its detail row). The former
|
||||
`groupNodeDrawerMode` option defaulted to an inline grouped-row drawer
|
||||
that only the retired standalone Workloads page used; both production
|
||||
mounts passed `disabled`, so the option, the inline `NodeDrawer` mount
|
||||
and the Workloads group hover/pin scope were removed.
|
||||
Compact icon headers inside `WorkloadTableHeader.tsx` may stay visually
|
||||
dense for responsive workload tables, but the icon must be decorative and
|
||||
the column label must remain present through an `sr-only` label so the
|
||||
@@ -1691,18 +1700,26 @@ shell clickable behind another overlay.
|
||||
scoped table metric-history interaction that requested them. Platform
|
||||
landing should stay route-module warm and data-light until the selected
|
||||
platform page owns its normal resource/table query.
|
||||
Platform pages that embed `WorkloadsSurface` reuse the canonical
|
||||
workloads filter toolbar through the `showFilterToolbar` +
|
||||
`suppressPlatformFilter` props in `WorkloadsSurfaceProps`. The page
|
||||
keeps `tableOnly` to hide the dashboard cards and summary strip but
|
||||
opts in to the same shared `FilterBar`, `GroupedTableModeSegmentedControl`,
|
||||
`ColumnPicker`, status/type chips, and search-history primitives that
|
||||
the global Workloads page renders, so platform operators get
|
||||
dense-table search, sort, grouping, view, status, and column controls
|
||||
on every embedded workloads tab without spawning a forked toolbar.
|
||||
The platform scope flows through `forcedPlatform` as a typed page
|
||||
input; `suppressPlatformFilter` drops the now-redundant Platform chip
|
||||
from the rendered toolbar so the user never sees a removable lock.
|
||||
Two platform pages embed `WorkloadsSurface`: the Proxmox and vSphere
|
||||
Overview tabs (`frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx`
|
||||
and `frontend-modern/src/features/vmware/VmwarePageSurface.tsx`). Each page
|
||||
builds the workloads state itself with `useWorkloadsState`, renders the
|
||||
one shared `WorkloadsFilter` toolbar (search, status filter, a type
|
||||
filter that vSphere suppresses, and the View options for grouping, metric display, and columns on the
|
||||
shared `FilterBar`) from that state, and hands the same state to `WorkloadsSurface` through its
|
||||
`state` prop with `suppressFilterToolbar`, so the surface skips its own
|
||||
filter row and the page never stacks two toolbars wired to one state.
|
||||
Proxmox also passes that state's search, plus its page-owned metric mode
|
||||
and history range, to the nodes table above the guests, so one toolbar
|
||||
drives both tables. `WorkloadsSurface` has no `tableOnly` mode, no
|
||||
`showFilterToolbar` prop, and no dashboard cards or summary strip to
|
||||
hide: it renders its filter row (unless suppressed or in kiosk mode), the
|
||||
workloads table, or an empty-state table card. The platform scope flows
|
||||
through `forcedPlatform` as a typed page input to `useWorkloadsState`,
|
||||
the toolbar, and the surface. `suppressPlatformFilter: true` makes the
|
||||
state expose no Platform filter config and both pages pass
|
||||
`platformFilter={undefined}` to the toolbar, so the user never sees the
|
||||
locked platform as a removable chip.
|
||||
|
||||
### Navigation resolves from a bounded request
|
||||
|
||||
@@ -1788,6 +1805,22 @@ final measurements and host-load limitation remain recorded in
|
||||
`records/resource-payload-static-metadata-2026-08-24.md`. They do not establish
|
||||
a controlled performance improvement or satisfy the open SLO qualification.
|
||||
|
||||
### Window resize listeners do not grow with rows
|
||||
|
||||
Table rows must not each register a window `resize` listener.
|
||||
`frontend-modern/src/hooks/useBreakpoint.ts` keeps one module-level width
|
||||
signal, fed by a single rAF-debounced listener while at least one caller is
|
||||
mounted, and a new caller starts from the live `innerWidth`. Shared tooltip
|
||||
state in `frontend-modern/src/components/shared/useTooltipState.ts` listens
|
||||
only while its tooltip is visible. Measured on 2026-10-07 on a mock-mode
|
||||
`/proxmox` page with 140 workload rows at 1440 px, the active listener count
|
||||
fell from 859 (699 tooltip instances, 156 breakpoint callers) to 5; at 390 px
|
||||
with 36 rows it fell from 203 to 5. An open tooltip adds one listener and
|
||||
removes it on close, and crossing the phone breakpoint in either direction
|
||||
still switches the workload table and row layout. `useBreakpoint.test.ts`
|
||||
proves one listener for 50 callers, that back-to-back resizes leave one pending
|
||||
frame, and that every caller crosses a breakpoint when that frame runs.
|
||||
|
||||
### Large API responses negotiate gzip without corrupting edge cases
|
||||
|
||||
The main listener compresses eligible JSON and frontend text assets for
|
||||
@@ -2003,7 +2036,10 @@ because both remaining production mounts (Proxmox and vSphere overview)
|
||||
set it unconditionally, so the non-compact path no longer rendered
|
||||
anywhere. A new embedded consumer that needs host
|
||||
stats beside its workloads must render its own hosts table rather than
|
||||
re-growing metric cells in the shared group row.
|
||||
re-growing metric cells in the shared group row. The same applies to host
|
||||
interaction: the group row has no drawer, hover preview, or pin, and the
|
||||
Workloads state no longer builds per-group summary scopes for every
|
||||
filtered-guest change.
|
||||
|
||||
WorkloadsSurface stays monitoring-first. It must not render a persistent
|
||||
aggregate banner just because running VMs or system containers lack an
|
||||
@@ -2066,7 +2102,7 @@ the hovered timestamp, so density does not remove point-in-time metric detail.
|
||||
Proxmox node thermals follow that same drawer-only rule: temperature remains a
|
||||
node-context metric, not a universal Workloads table column, and the node detail
|
||||
drawer may add one compact `Thermals` history group beside utilization and I/O
|
||||
only after a grouped node row is selected. Host-agent CPU temperature must be
|
||||
only after a node's drawer is opened from the Proxmox nodes table. Host-agent CPU temperature must be
|
||||
persisted into the same metrics-store history stream as other agent metrics,
|
||||
with current node temperature displayed only as a drawer-local current reading
|
||||
while history is still accumulating; it must not become a synthetic history
|
||||
@@ -2106,7 +2142,7 @@ still reads nothing extra.
|
||||
The Proxmox node drawer overview should follow the existing guest drawer
|
||||
compact detail-section pattern and expose node-specific context such as platform,
|
||||
kernel, hardware, raw capacity, telemetry, and thermal facts rather than
|
||||
repeating the metric cells already visible in the grouped table row.
|
||||
repeating the metric cells already visible in the Proxmox nodes table row.
|
||||
Object drawers across workload, node, Docker, and unified-resource surfaces
|
||||
must keep their default render bounded to active attention plus a small
|
||||
additive context projection. Full provider-specific support workflows remain
|
||||
@@ -2416,16 +2452,25 @@ nothing renders: the retired `Jump to row` visibility check did exactly that,
|
||||
costing 128 `getComputedStyle` calls across eight row hovers and 320 across
|
||||
ten scroll steps on the 12-node mock storage table, and none once removed.
|
||||
That same hot-path contract also owns the row-emphasis paint. Workload guest
|
||||
and group rows and storage pool, group, and disk rows must expose
|
||||
rows and storage pool, group, and disk rows must expose
|
||||
summary-linked activity through the shared `data-summary-row-active` marker and let the shared frontend
|
||||
primitive render the emphasis, instead of layering lane-local row-fill classes
|
||||
that diverge across pages or wash out inline metric bars.
|
||||
`frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts` must
|
||||
write workload selection back into the workloads route through the shared
|
||||
same-path route-state scheduler, but the actual shell-position handoff for
|
||||
query-only row focus must go through `frontend-modern/src/utils/appShellScrollRestoration.ts`
|
||||
plus the root `frontend-modern/src/App.tsx` shell so opening a focused
|
||||
workload does not look like a full page reload. The shared same-path scheduler must also own cleanup
|
||||
`frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts` keeps
|
||||
workload selection local and never writes it back into the route; an inbound
|
||||
`resource` link only hydrates it. Local row focus keeps the scroll position
|
||||
through `preserveScrollableAncestorVerticalOffset` in
|
||||
`frontend-modern/src/components/shared/contextualFocus.ts` and reveals the
|
||||
opened detail through `summaryTableFocus.ts`, so opening a focused workload
|
||||
does not look like a full page reload. The root `frontend-modern/src/App.tsx`
|
||||
shell applies a pending `frontend-modern/src/utils/appShellScrollRestoration.ts`
|
||||
restore on the next route change, whatever caused it, so only the shared
|
||||
same-path route-state scheduler may stage one, from the shell position at the
|
||||
moment it navigates. Local row focus stages none: a position staged without a
|
||||
navigation waits for an unrelated one, and before this rule a Type filter
|
||||
change made at the top of the Proxmox overview jumped the shell back down to
|
||||
the row toggled earlier, because the scheduler stages nothing from scrollTop 0.
|
||||
The shared same-path scheduler must also own cleanup
|
||||
for every deferred scroll-restore timeout and animation frame it creates, so
|
||||
route-state cleanup cannot leave hot-path replay work running after the owning
|
||||
surface unmounts.
|
||||
@@ -2445,10 +2490,9 @@ That same hot-path ownership now also covers deliberate inline-detail reveal.
|
||||
When a focused workload or infrastructure row opens its inline detail, the hot
|
||||
path may preserve scroll across same-route state writes, but the actual reveal
|
||||
must still flow through the shared contextual-focus and summary-table helpers.
|
||||
Direct row toggles that already have the row in view must capture the current
|
||||
app-shell scroll position before the focus write and let the remounted root
|
||||
shell restore that position, so the interaction stays anchored instead of
|
||||
looking like a full refresh. Once the drawer is mounted, the shared reveal
|
||||
Direct row toggles that already have the row in view stay anchored through
|
||||
that contextual-focus offset preservation, not through a staged app-shell
|
||||
restore, so the interaction does not look like a full refresh. Once the drawer is mounted, the shared reveal
|
||||
helper must still take over whenever the opened detail would land below the
|
||||
fold, marking that movement as deliberate so route-state restore does not
|
||||
replay over it and then scrolling only enough to keep the row header plus the
|
||||
@@ -2738,11 +2782,12 @@ can still lose the aggregate to a live agent while its own filesystem rows
|
||||
remain; such a guest stays on the Proxmox rule. The agent's OS, addresses and
|
||||
physical disk health carry no stale cue yet.
|
||||
Guest, node, and Docker-host drawer headers follow the same frontend-primitives dependency
|
||||
boundary for collapse: Workloads owns which inline row is selected and the
|
||||
close handler, while
|
||||
boundary for collapse: the owning table (Workloads for guests, the Proxmox
|
||||
nodes table for nodes, the Docker hosts table for Docker hosts) owns which
|
||||
inline row is selected and the close handler, while
|
||||
`frontend-modern/src/components/shared/ObjectDrawerHeader.tsx` owns the
|
||||
full-width semantic header button, phone target height, focus treatment, and
|
||||
collapse chevron. `WorkloadPanel` must pass the grouped node close handler into
|
||||
collapse chevron. `ProxmoxNodesTable` must pass its node close handler into
|
||||
`NodeDrawer`, the Docker hosts table must pass its close handler into
|
||||
`DockerHostDrawer`, and those shells must not fall back to a small icon-only
|
||||
collapse target or wrap interactive header actions inside a local button.
|
||||
@@ -2800,20 +2845,23 @@ counts wrap independently so a narrow toolbar cannot strand View on a line by
|
||||
itself or force page-level horizontal overflow. The compact Add filter control
|
||||
joins that cluster only in the default state where no active menu chip,
|
||||
contextual Clear action, or leading action needs its own filter row; active
|
||||
scope editing keeps Add filter beside the chips. The sparkline range remains
|
||||
inline only while trends are active because it is the frequently changed
|
||||
analytical window, and its compact control must include a visible `Trend range`
|
||||
label so the values are not an orphaned set of durations. Columns must expand
|
||||
scope editing keeps Add filter beside the chips. The sparkline range is a
|
||||
trailing inline control, not a View option, because it is the frequently
|
||||
changed analytical window. `WorkloadsFilter` renders it whenever the metric
|
||||
mode is Trends, and in Bars only at desktop widths while Row hover is `History`
|
||||
(the default); Bars with `Details` hover, or Bars below the 768px `isMobile`
|
||||
breakpoint, hides it. Its compact control carries the visible `History` label
|
||||
so the values are not an orphaned set of durations. Columns must expand
|
||||
as a full-width responsive checkbox row inside the View disclosure instead of
|
||||
opening a nested absolute panel or a tall single-column desktop list. On
|
||||
Proxmox, the non-default Host basis must remain visible in the workload memory
|
||||
column header after the View disclosure closes. No workload summary chart
|
||||
section renders today, so the chart-visibility control in
|
||||
`frontend-modern/src/components/Workloads/WorkloadsFilter.tsx` stays hidden
|
||||
(no surface passes `onChartsToggle`). If a summary section returns, that
|
||||
control must expose explicit `Show charts` / `Hide charts` pressed state, and
|
||||
hiding charts must remove the summary section rather than leaving an empty
|
||||
collapsed summary band on screen.
|
||||
section renders today, and `WorkloadsFilter` carries no chart-visibility
|
||||
control or `onChartsToggle` / `chartsCollapsed` props. A summary section that
|
||||
returns through a governed product decision must bring its own visibility
|
||||
control with explicit `Show charts` / `Hide charts` pressed state, and hiding
|
||||
charts must remove the summary section rather than leaving an empty collapsed
|
||||
summary band on screen.
|
||||
The Workloads-owned filter-config assembly now lives in
|
||||
`frontend-modern/src/components/Workloads/useWorkloadsState.ts`, so future
|
||||
filter runtime changes must extend through those owners instead of
|
||||
|
||||
@@ -5836,6 +5836,7 @@
|
||||
"frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts",
|
||||
"frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts",
|
||||
"frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.test.tsx",
|
||||
"frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.test.tsx",
|
||||
"frontend-modern/src/features/platformPage/platformSearchSuggestions.test.ts"
|
||||
]
|
||||
},
|
||||
@@ -7822,7 +7823,6 @@
|
||||
"frontend-modern/src/types/recovery.ts",
|
||||
"frontend-modern/src/utils/recoveryArtifactModePresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryDatePresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryItemTypePresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryOutcomePresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryTimelineChartPresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryTimelinePresentation.ts",
|
||||
@@ -7867,7 +7867,6 @@
|
||||
"frontend-modern/src/types/recovery.ts",
|
||||
"frontend-modern/src/utils/recoveryArtifactModePresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryDatePresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryItemTypePresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryOutcomePresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryTimelineChartPresentation.ts",
|
||||
"frontend-modern/src/utils/recoveryTimelinePresentation.ts"
|
||||
|
||||
@@ -2845,6 +2845,11 @@ the tenant resource store. Runtime reconciliation may visit every live monitor,
|
||||
but each alert manager resolves policy through its own tenant-scoped store, so a
|
||||
matching provider ID in another organization cannot import the mutation. The
|
||||
existing route scopes and authenticated actor attribution remain unchanged.
|
||||
Patrol's finding operator-state provider follows the same rule: it resolves a
|
||||
finding's reference through the read state of that organization's own monitor
|
||||
(`resolveMonitorForOrg`) and then reads that organization's store, so a
|
||||
source-native or link-folded reference never resolves through another tenant's
|
||||
registry.
|
||||
|
||||
### Secret-bearing configuration transfer fails closed before data access
|
||||
|
||||
@@ -3025,6 +3030,18 @@ Removing the disconnected recorder does not alter alert, action approval or
|
||||
operator authority. An unrelated organization receives no default archive
|
||||
fallback.
|
||||
|
||||
### Report GETs cannot spend AI budget
|
||||
|
||||
`Router.wireReportingAIResolvers` in `internal/api/router.go` wires a second
|
||||
reporting resolver, `SetExistingFindingsResolver`, backed by
|
||||
`AISettingsHandler.ExistingAIService`.
|
||||
A `GET /api/admin/reports/generate` uses only that resolver and the
|
||||
deterministic narrator, so it makes no AI provider call, writes no cost-ledger
|
||||
entry and never constructs a tenant AI service. GET passes the demo-mode guard
|
||||
and the CSRF check, and a SameSite=Lax session cookie rides a cross-site
|
||||
top-level navigation, so AI narration of a single-resource report now requires
|
||||
a CSRF-checked `POST`. No authentication, scope or session rule changed.
|
||||
|
||||
### Node setup action v7 compatibility
|
||||
|
||||
Workflow consumers pin `actions/setup-node` to
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1609,7 +1609,12 @@ Source freshness is a separate unified-resource status input, not a fixed
|
||||
global health timer. Snapshot rebuilds, resource seeding, supplemental record
|
||||
overlays, and cloned read-state overlays must preserve the monitoring-provided
|
||||
stale thresholds for Proxmox, PBS, and PMG sources so resources do not flap to
|
||||
warning/degraded between successful configured poll cycles.
|
||||
warning/degraded between successful configured poll cycles. A source merge
|
||||
into an existing row judges metric freshness by the ingest's thresholds and
|
||||
the stale pass's own threshold rule, so the merge's freshness gate never
|
||||
calls a source current that the stale pass marks stale, or the reverse. The
|
||||
gate decides only where no stronger rule does: a hypervisor-managed guest
|
||||
keeps its platform CPU even while that source is stale.
|
||||
|
||||
Service-discovery readiness is a unified-resource payload contract, not a
|
||||
drawer-local decoration. Resource list/detail payloads that expose a
|
||||
@@ -2484,8 +2489,10 @@ signal source instead of advertising empty object browsers. The
|
||||
canonical TrueNAS adapter (`internal/truenas/provider.go::
|
||||
truenasRecordsFromSnapshot`) already emits the top-level TrueNAS
|
||||
appliance as a unified `agent` row tagged with the `truenas`
|
||||
platform, so TrueNAS defaults to `/truenas/overview` (the Systems
|
||||
sub-tab); the embedded `StorageSurface` lives at `/truenas/storage`.
|
||||
platform, so TrueNAS defaults to `/truenas/overview`, whose Overview
|
||||
sub-tab lists those systems in `TrueNASSystemsTable`. `/truenas/storage`
|
||||
renders the TrueNAS-owned `TrueNASStorageTopologyTable`, not
|
||||
`StorageSurface`; only the Proxmox Storage tab embeds `StorageSurface`.
|
||||
Any future platform that wants to default to a Systems / Hosts
|
||||
overview must first have its canonical resource adapter project the
|
||||
platform's top-level system as a unified resource so the builder
|
||||
@@ -2596,10 +2603,12 @@ application resource-provider or WebSocket lifecycle.
|
||||
in place through that shared runtime and the root app-shell restore path,
|
||||
instead of looking like a page refresh or remount.
|
||||
12. Keep summary-chart visibility a display preference, not a unified-resource
|
||||
filter. Platform/runtime pages may hide or restore chart sections through
|
||||
shared presentation controls, but those controls must not mutate resource
|
||||
identity, table membership, source scope, or summary-hover state. The
|
||||
retired top-level `/infrastructure` page, its summary chart strip, and its
|
||||
filter. The workload and infrastructure summary chart sections are
|
||||
retired and no platform page exposes a chart show/hide control today. If a
|
||||
governed product decision brings one back, hiding or restoring it must not
|
||||
mutate resource identity, table membership, source scope, or summary-hover
|
||||
state. The retired top-level
|
||||
`/infrastructure` page, its summary chart strip, and its
|
||||
saved-view/route-state machinery must not be reintroduced for this purpose.
|
||||
15. Keep operator-local resource search on the operator's own names.
|
||||
Resource-policy redaction is a transmission boundary (`docs/PRIVACY.md`),
|
||||
@@ -2763,7 +2772,7 @@ application resource-provider or WebSocket lifecycle.
|
||||
(`canonical_id` ↔ machine ID / DMI UUID / cluster / hostname) in the
|
||||
`resource_identities` table (`ResourceStore.UpsertResourceIdentityPins`
|
||||
/ `ListResourceIdentityPins`, written diff-aware after monitor-adapter
|
||||
rebuilds) and completes weak incoming identities from those pins
|
||||
rebuilds; manual-link sides write through `ReplaceResourceIdentityPins`) and completes weak incoming identities from those pins
|
||||
before matching and ID derivation
|
||||
(`internal/unifiedresources/canonical_id_pins.go`). Persisted primary
|
||||
hostnames preserve their normalized dotted form. The pin index resolves
|
||||
@@ -2779,7 +2788,15 @@ application resource-provider or WebSocket lifecycle.
|
||||
must not mint host canonical IDs from snapshot-content-dependent
|
||||
identity subsets without consulting the pins, and pin writes stay on
|
||||
the durable store-backed registry (ephemeral per-request registries
|
||||
consult, never write). Pinned hostnames preserve the full dotted name
|
||||
consult, never write). A pin records the identity a resource derives
|
||||
from its own sources rather than a manual link's merged projection:
|
||||
each side of a link writes the pin captured before the link merged
|
||||
them, keeping its row's other fields unless another resource holds that
|
||||
key, and a pinnable folded side keeps its pin under its own canonical
|
||||
ID, less any strong key a live resource or an earlier folded side
|
||||
holds. Capture follows pin completion, so a key an earlier release
|
||||
borrowed into a side's row can still reach the capture (see "Operator
|
||||
links reach record-ingested resources"). Pinned hostnames preserve the full dotted name
|
||||
(`NormalizeFullHostname`): distinct machines that share a short
|
||||
hostname (`cloud.rnd-lax1` vs `cloud.gce-or1`) must keep distinct
|
||||
pins, distinct pin-index buckets, and distinct presentation host rows.
|
||||
@@ -2814,7 +2831,9 @@ application resource-provider or WebSocket lifecycle.
|
||||
ID and drops the superseded pin row (`ApplyCanonicalIDSuccessions`
|
||||
in `internal/unifiedresources/canonical_id_succession.go`).
|
||||
Successions never fire while the old ID still belongs to a live
|
||||
resource, never follow a contradicting machine key (a reinstalled
|
||||
resource or to one a manual link folded into its primary (see
|
||||
"Operator links reach record-ingested resources"), never follow a
|
||||
contradicting machine key (a reinstalled
|
||||
machine mints fresh, it does not absorb the old host's operator
|
||||
state), and never rewrite change-journal rows. Known limitation:
|
||||
machine-keyless hosts whose history already merged under a collapsed
|
||||
@@ -2895,8 +2914,9 @@ application resource-provider or WebSocket lifecycle.
|
||||
`ResourceRegistry.IngestRecords` applies the same
|
||||
`ApplyCanonicalIDSuccessions` semantics as pin-driven successions
|
||||
(operator state and action audits re-key, the superseded pin row
|
||||
drops, never while the old ID belongs to a live resource, journal
|
||||
rows never rewritten). Regression coverage:
|
||||
drops, never while the old ID belongs to a live resource or one a
|
||||
manual link folded into its primary, journal rows never rewritten).
|
||||
Regression coverage:
|
||||
`TestRegistryIngestRecordsKeepsSameHostnameSystemsDistinct` /
|
||||
`TestIngestRecordsSucceedLegacyHostnameScopedCanonicalIDs` /
|
||||
`TestIngestRecordsDoNotCompleteTrueNASIdentityFromPins` in
|
||||
@@ -3045,10 +3065,11 @@ canonical WebSocket snapshot, including the public `vmware-vsphere` alias for
|
||||
the raw `vmware` source, rather than degrading a source-scoped page to periodic
|
||||
REST refreshes. VMware Overview passes that same source-scoped snapshot into
|
||||
the embedded Workloads state, so hosts and VMs share one inventory generation
|
||||
and one explicit refresh path. Beyond disabling the grouped host drawer, it
|
||||
passes no option that adds host metrics to grouped rows: the shared Workloads
|
||||
group row carries host identity only, and per-host stats stay in the page's
|
||||
own hosts table.
|
||||
and one explicit refresh path. It passes no option that adds host metrics to
|
||||
grouped rows: the shared Workloads group row carries host identity only, with
|
||||
no drawer, hover preview, or group pin, and per-host stats and details stay in
|
||||
the page's own hosts table. The Workloads route model no longer parses a
|
||||
`summaryGroup` param, because no group focus remains to hydrate.
|
||||
Its page-owned workload toolbar consumes the complete shared
|
||||
`getWorkloadsMetricFilterProps` binding, so vSphere VMs expose the same Bars,
|
||||
Trends, Details, History, range, and first-use discovery contract as every
|
||||
@@ -3193,9 +3214,10 @@ in the same pass. The K8s Namespaces and Deployments drawer "Open Pods" /
|
||||
route to `/kubernetes/workloads` rather than building
|
||||
retired aggregate workload URLs; the legacy context/namespace query filter
|
||||
does not carry forward because the new Kubernetes Workloads tab does not
|
||||
consume those parameters. Workloads, storage, and recovery route-state helpers
|
||||
in `frontend-modern/src/routing/resourceLinks.ts` now serialize query state
|
||||
only; the owning platform/runtime route supplies the pathname. New drawer or
|
||||
consume those parameters. The workloads and storage route-state helpers in
|
||||
`frontend-modern/src/routing/resourceLinks.ts` serialize query state only, and
|
||||
the owning platform/runtime route supplies the pathname; the recovery
|
||||
serializer was deleted once nothing read it. New drawer or
|
||||
correlation surfaces must anchor on platform routes (or stay-in-place drawer
|
||||
expansion) instead of resurrecting the retired top-level paths.
|
||||
The Proxmox Backups route-state vocabulary includes the opaque `location`
|
||||
@@ -3217,8 +3239,9 @@ recreate raw labelled `<select>` controls for those filters locally.
|
||||
The Proxmox platform page is a route-level consumer of canonical unified
|
||||
resources, not a new resource source. It filters the existing resource snapshot
|
||||
to Proxmox VE, Backup Server, Mail Gateway, storage, disk, and workload rows,
|
||||
then composes the existing Workloads, Storage, Recovery, and infrastructure
|
||||
table owners. Proxmox host row version, uptime, temperature, CPU, memory, disk,
|
||||
then composes the existing Workloads and Storage surfaces, its own Backups
|
||||
tab tables, and the infrastructure table owners; no Recovery surface exists to
|
||||
compose. Proxmox host row version, uptime, temperature, CPU, memory, disk,
|
||||
network I/O, and disk I/O presentation must derive from canonical resource
|
||||
facets and the shared `nodeFromResource` adapter; platform pages must not
|
||||
rebuild resource identity, merge policy, or metric-target inference locally.
|
||||
@@ -4102,10 +4125,13 @@ inline drawer through local component state, most through
|
||||
while the Docker hosts and Proxmox nodes tables keep their own selection
|
||||
signal, and write no route state. An infrastructure table that moves drawer selection
|
||||
into route state must tag the opened detail with the canonical active resource
|
||||
ID; direct toggles of a row already in view must capture the current
|
||||
`.app-scroll-shell` position through
|
||||
`frontend-modern/src/utils/appShellScrollRestoration.ts`, so the remounted root
|
||||
shell in `frontend-modern/src/App.tsx` stays anchored, and then hand off to the
|
||||
ID; its route write must go through the shared same-path route-state scheduler
|
||||
in `frontend-modern/src/utils/routeStateNavigation.ts`, which alone stages the
|
||||
`.app-scroll-shell` position in
|
||||
`frontend-modern/src/utils/appShellScrollRestoration.ts` at the moment it
|
||||
navigates, so the root shell in `frontend-modern/src/App.tsx` stays anchored.
|
||||
The row toggle itself stages nothing: the root shell applies a staged position
|
||||
on the next route change, whatever caused it. The table must then hand off to the
|
||||
shared summary-table/contextual-focus helpers when the opened drawer would fall
|
||||
below the fold. That reveal scrolls only enough to keep the row header plus the
|
||||
start of the detail visible, never leaving the drawer clipped or hard-centering
|
||||
@@ -4776,32 +4802,30 @@ canonical workload identity (`<instance>:<node>:<vmid>`) in the shared
|
||||
`resource` query rather than an opaque unified-resource id, so direct route
|
||||
loads and cross-surface drill-downs reopen the correct workload drawer instead
|
||||
of landing on an unselected table state.
|
||||
That same routing contract now also owns recovery route-state vocabulary for
|
||||
unified resources without restoring the retired `/recovery` top-level route.
|
||||
Infrastructure drawers and other cross-surface consumers must carry canonical
|
||||
`platform` and `node` query state into an owning platform/runtime route instead
|
||||
of rebuilding drawer-local recovery links, assuming only PBS services can
|
||||
expose recovery handoffs from infrastructure, or sending operators to a
|
||||
standalone aggregate workspace URL.
|
||||
When shared recovery links include protected-inventory posture, they must use
|
||||
the recovery-owned `state` query instead of overloading event `status`. Event
|
||||
outcome `status` remains recovery-history state, and compatibility input such
|
||||
as legacy `stale=1` must be normalized by the recovery route owner before
|
||||
cross-surface links are rebuilt.
|
||||
That same shared routing boundary now also owns alert-investigation handoffs.
|
||||
Resource-incident panels and other alert-side resource drill-down consumers
|
||||
must route operators back through canonical infrastructure resource detail and
|
||||
then into platform-owned workload, storage, and recovery surfaces via
|
||||
`frontend-modern/src/routing/resourceLinks.ts` route-state vocabulary, instead
|
||||
of treating alert investigation as a provider-local dead end, freezing
|
||||
per-surface route strings outside the unified-resource contract, or reviving
|
||||
Unified resources carry no recovery route-state vocabulary. The recovery
|
||||
query serializer (`RECOVERY_QUERY_PARAMS`, `parseRecoveryLinkSearch`,
|
||||
`buildRecoveryRouteSearch`) was deleted from
|
||||
`frontend-modern/src/routing/resourceLinks.ts` after the aggregate Recovery page
|
||||
that read it went on 2026-05-26, and the protected-inventory `state`, event
|
||||
`status`, and legacy `stale=1` rules went with it. Infrastructure drawers and
|
||||
other cross-surface consumers that expose recovery context must land in the
|
||||
owning platform tab (the Proxmox Backups tab or the TrueNAS Protection tab)
|
||||
instead of rebuilding drawer-local recovery links, assuming only PBS services
|
||||
can expose recovery from infrastructure, or sending operators to a standalone
|
||||
aggregate workspace URL.
|
||||
Alert investigation offers no resource cross-jump links. The
|
||||
resource-incident panel names its resource through the unified-resource lookup
|
||||
and links to no other surface (its Pulse Assistant handoff aside): its
|
||||
cross-jump chips were retired on 2026-05-16, and an alert card's single
|
||||
resource link opens the owning platform page. Alert-side consumers must not freeze
|
||||
per-surface route strings outside the unified-resource contract or revive
|
||||
retired aggregate workspace URLs.
|
||||
That same routing contract also owns Patrol finding handoffs. Expanded
|
||||
Patrol-finding rows and scoped-run finding snapshots must resolve the backing
|
||||
unified resource and surface the same platform-owned workload/storage/recovery
|
||||
route-state vocabulary there, including exact workload and physical-disk route
|
||||
state when the selected resource is itself the workload or disk, instead of
|
||||
stopping at finding text, rebuilding patrol-local route strings, or reviving
|
||||
Patrol findings stay in place the same way: expanded Patrol-finding rows
|
||||
carry the finding's own primary action and manual controls rather than
|
||||
surface-link chips, and
|
||||
`frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts` keeps
|
||||
`buildResolvedResourceSurfaceLinks` and `useResources()` out of the panel. A
|
||||
future finding handoff must not rebuild patrol-local route strings or revive
|
||||
retired aggregate workspace URLs.
|
||||
That drawer shell now routes its canonical timeline filter, facet-bundle, and
|
||||
resource-intelligence state through
|
||||
@@ -5665,50 +5689,14 @@ contract for reporting/ignored infrastructure state; future settings-row
|
||||
grouping or reporting-surface scope changes must be routed through that backend
|
||||
projection instead of teaching the frontend to reinterpret raw resource facets
|
||||
or removed-runtime arrays locally.
|
||||
Canonical route-state helpers must also preserve recovery-specific drill-down
|
||||
state when they serialize governed resource views. Recovery timeline day
|
||||
selection is part of the durable route-state contract, so recovery query state
|
||||
must round-trip the selected day inside an owning platform/runtime route
|
||||
instead of dropping it as transient local UI state or restoring `/recovery`.
|
||||
The same recovery route-state contract also applies to the selected timeline
|
||||
range: canonical recovery query state must preserve explicit non-default chart
|
||||
windows such as `7d`, `90d`, and `1y` so recovery drill-down transport does
|
||||
not widen back to the default `30d` window on reload or shared navigation.
|
||||
That same shared recovery route-state contract also owns the primary recovery
|
||||
workspace selection. When an operator explicitly switches between protected
|
||||
items and recovery events, canonical recovery query state must round-trip that
|
||||
`view` selection unless the active `rollupId` or selected day already implies
|
||||
the default recovery-events workspace.
|
||||
That same shared recovery route helper contract now also owns canonical
|
||||
protected-inventory posture encoding. Visible recovery inventory filters must
|
||||
round-trip through the owned `state=<value>` query form instead of leaking ad
|
||||
hoc booleans, overloading event `status`, or disappearing from shared links on
|
||||
reload. Legacy `stale=1` may be parsed only as compatibility input that
|
||||
rewrites to canonical inventory state.
|
||||
That same route-state contract now also owns the canonical recovery `itemType`
|
||||
query. Recovery query state must round-trip a provider-neutral item category
|
||||
such as `vm`, `dataset`, or `pvc`, and
|
||||
`frontend-modern/src/routing/resourceLinks.ts` may canonicalize provider-native
|
||||
aliases like `proxmox-vm` into that shared vocabulary during parse/build, but
|
||||
recovery route state must not drift back to raw platform-specific
|
||||
`subjectType` values in shared navigation.
|
||||
That same route-state contract also owns the canonical recovery `platform`
|
||||
query. Recovery query state must emit `platform=<owned-source-key>` as the
|
||||
shared operator-facing shape, while accepted legacy `provider` aliases may be
|
||||
parsed only as compatibility input that rewrites back to canonical platform
|
||||
route state. `frontend-modern/src/routing/resourceLinks.ts` must not keep
|
||||
legacy `provider` as a first-class build option once parse-time compatibility
|
||||
has converted it back to canonical recovery route state.
|
||||
Recovery-linked consumers that decode `/api/recovery/*` payloads must likewise
|
||||
prefer canonical `platform` / `platforms` response fields over legacy
|
||||
`provider` aliases, so unified resource drill-downs and shared recovery links
|
||||
carry the same platform vocabulary on both route and payload boundaries.
|
||||
That same shared drill-down contract also owns which primary recovery workspace
|
||||
an upstream surface is targeting. Infrastructure service links that open
|
||||
platform-level recovery activity must emit canonical recovery route state with
|
||||
`view=events` inside an owning platform/runtime destination instead of
|
||||
inheriting the inventory default, and those entry links should describe the
|
||||
destination as recovery events rather than platform-specific backup wording.
|
||||
No shared helper serializes recovery drill-down state any more: the
|
||||
recovery timeline day, chart range, workspace `view`, inventory `state`,
|
||||
`itemType`, and `platform` route-state rules served the deleted aggregate
|
||||
Recovery page. The Proxmox Backups tab owns its own day selection through
|
||||
`PROXMOX_BACKUPS_QUERY_PARAMS.day`; its chart range is component state. Readers
|
||||
of `/api/recovery/*` payloads must still prefer canonical `platform` /
|
||||
`platforms` response fields over legacy `provider` aliases, which
|
||||
`frontend-modern/src/utils/recoveryPlatformModel.ts` rewrites at decode time.
|
||||
Shared API consumers now also depend on a single registry-list snapshot per
|
||||
request when deriving canonical type aggregations for resource list and stats
|
||||
responses. Re-reading `registry.List()` for the same `/api/resources` request
|
||||
@@ -6597,6 +6585,224 @@ full heading. This shared drawer rule applies across resource types. The 390px
|
||||
browser qualification checks heading fit and keyboard expansion; no data model,
|
||||
authorisation, action dispatch or resource admission contract changes.
|
||||
|
||||
### Operator links reach record-ingested resources
|
||||
|
||||
Every registry ingest path applies the operator's manual links: snapshot
|
||||
ingest, resource ingest and record ingest (`IngestRecords`,
|
||||
`IngestRecordsWithStaleThresholds` and the read-state overlay's record pass).
|
||||
Either side of a link can arrive only as a supplemental record, such as a
|
||||
vSphere or TrueNAS VM with a Pulse agent inside, or an agent on a TrueNAS host,
|
||||
and the monitor's rebuild ingests those records after the snapshot. While only
|
||||
snapshot and resource ingest applied links, the rebuilt registry behind the
|
||||
websocket broadcast and `/api/state` kept both rows. The resources API, which
|
||||
seeds through resource ingest, showed them merged, and so did the published
|
||||
inventory whenever an unrelated absent machine made continuity hydration
|
||||
clone the registry. Mock mode builds its unified view from fixtures without
|
||||
the link store, so links do not reach the mock broadcast; that boundary is
|
||||
separate.
|
||||
|
||||
- The monitor adapter passes its configured stale thresholds to record
|
||||
ingest, so a link joined there judges each side's metrics the way a
|
||||
snapshot-time link does. Ordinary source merges into an existing row use
|
||||
the same thresholds on every ingest path: snapshot and record ingest hold
|
||||
the caller's thresholds while they run, and `mergeInto` judges each
|
||||
metric's freshness with them through `sourceStaleThreshold`, the rule the
|
||||
stale pass reads. Before this, those merges used the defaults, so a
|
||||
Proxmox node polled every two minutes showed a silent auto-linked agent's
|
||||
readings while the stale pass called the node's poll current, and a
|
||||
supplemental refresh of a linked vSphere VM kept the agent's memory over
|
||||
vSphere's current reading. Outside mock mode the monitor configures only
|
||||
Proxmox, PBS and PMG; mock mode also configures TrueNAS, vSphere and
|
||||
availability. Regression coverage:
|
||||
`TestMonitorAdapterSourceMergesUseConfiguredStaleThresholds` (rebuild,
|
||||
supplemental refresh, overlay, node with auto-linked agent),
|
||||
`TestSourceMergesJudgeFreshnessByTheIngestStaleThresholds` and
|
||||
`TestMetricMergeFreshnessMatchesTheStalePass`.
|
||||
- Continuity records never merge into a link. A saved enrollment only fills
|
||||
an absent machine, so it stays its own row instead of lending its linked
|
||||
guest an offline verdict and an old agent payload. The link still names one
|
||||
identity, so the pass holds the saved row beside the primary
|
||||
(`holdLinkedResourceLocked`): every reference that resolves to the saved
|
||||
row (its canonical ID, `agent:<host ID>`, its machine ID or an alias it
|
||||
shares with the primary) resolves to the primary instead, through
|
||||
`ResolveReferenceID`, `GetByReference` and the alias index, as a
|
||||
reference to a folded resource does. An availability check resolved after
|
||||
the hold, as the resources API replays checks onto its seed, attaches to
|
||||
the primary too; the monitor's read state does not re-resolve checks after
|
||||
continuity, so a check naming the saved agent stays as the published
|
||||
registry left it. Reference lookups by the saved agent's ID (a resource
|
||||
detail read, the agent resource context), operator-state reads and writes
|
||||
through the resources API, Patrol's finding projection and alert intent
|
||||
therefore all reach the guest the agent was folded into before the
|
||||
restart, while each row keeps its own telemetry. Saved rows carry an
|
||||
in-memory marker (`Resource.continuityOnly`) that rides clones, so the
|
||||
resources API, which
|
||||
seeds a registry from the monitor's read state and re-applies the links,
|
||||
holds them too instead of merging the saved agent into the guest; an
|
||||
observation that later merges into the row clears it. A live member never
|
||||
resolves to a saved one: when the saved row is the link's primary (a link
|
||||
made from an agent's page to a resource that is not its guest), both rows
|
||||
answer for themselves until the agent reports and the link folds them, so
|
||||
intent set on the pair under the agent's ID does not reach the live
|
||||
member's references in that window. Holds are recomputed on every link
|
||||
pass; a hold whose primary a later link folds follows the fold, a saved
|
||||
row held under a saved primary follows that primary's hold, and a cycle of
|
||||
saved rows resolves each to itself. Exact reads are untouched: `Get` still
|
||||
returns the saved row, so the saved agent's own drawer reads its own facet
|
||||
bundle and history, while its operator-state section reads and writes the
|
||||
guest's state.
|
||||
- Holds cover a link whose two members are both listed. A saved member
|
||||
whose partner an earlier link already folded into a third resource, such
|
||||
as an agent linked into a VM that is itself linked into another resource,
|
||||
is not held: the monitor's published registry folds the VM before
|
||||
continuity adds the agent, so the agent answers for itself until it
|
||||
reports. Live chains already depend on the order their links are stored
|
||||
in, which this does not change.
|
||||
- A link merge keeps the TrueNAS and vSphere payloads the primary lacks, so
|
||||
an agent chosen as primary over a TrueNAS system still carries the
|
||||
system's facet, and the system's pools re-parent to the merged row.
|
||||
- The registry remembers each resource a manual link folded into its
|
||||
primary. A folded resource is still observed, so neither pin-driven nor
|
||||
record-declared succession may succeed its ID: that re-keyed the link onto
|
||||
the primary itself, which split the pair on the next rebuild, and already
|
||||
broke snapshot links whose primary is a machine-keyless agent-type row,
|
||||
such as a Proxmox node.
|
||||
- Each side of a link is pinned only from its own identity, captured when
|
||||
the link first joins it in a rebuild (`recordLinkOwnPin`). A side with no
|
||||
pinnable identity (not agent-shaped, or no strong key) writes no pin, and
|
||||
a pinnable folded side keeps its pin under its own canonical ID. The merged
|
||||
primary used to be pinned with both sides' keys, and the store's
|
||||
one-owner-per-key rule then deleted the folded side's pin. A Proxmox node's
|
||||
pin records its qualified endpoint as the hostname, so the node's next
|
||||
rebuild completed the linked agent's machine key from that merged 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.
|
||||
- A link side rewrites its whole row (`ReplaceResourceIdentityPins`)
|
||||
instead of upserting it. Like an upsert it keeps the stored fields its
|
||||
own pin leaves empty, so evidence from an earlier observation survives a
|
||||
weaker one, but it never keeps a machine, DMI or cluster-and-hostname key
|
||||
another resource holds this rebuild, including a cluster key a kept
|
||||
hostname would complete. That is how a key an earlier release borrowed
|
||||
into the row from the merged identity leaves it; a key a resource reports
|
||||
now also beats one a link side's row only kept, as the store already
|
||||
hands a moved key to the new pin. The store gives each strong key one
|
||||
owner and deletes any other row holding a key a written pin carries, so a
|
||||
folded side drops each own key held by a live non-link resource's
|
||||
upserted row (an upsert cannot drop a field), a live link side's own pin,
|
||||
or a folded side earlier in canonical-ID order. It writes no pin when no
|
||||
strong key is left, or when it would lose the machine or DMI key it
|
||||
reports: a cluster-only row reads as a machine-keyless host's, which
|
||||
cluster-slot succession lets a later machine in that slot absorb. A row kept for a side that writes no pin goes
|
||||
when another written pin carries one of its keys, such as the folded
|
||||
agent's own pin carrying the machine key an earlier release borrowed into
|
||||
a Proxmox node's row. That hand-back only reaches a row the node cannot
|
||||
complete from: while the node's qualified endpoint, or its cluster and
|
||||
name, still resolve to that row, completion supplies the borrowed key
|
||||
before any link applies, so the pair merges by ID and succession rewrites
|
||||
the link into a self-link, as it did before this change. A link side whose
|
||||
row loses a borrowed machine key becomes a machine-keyless row, open to
|
||||
the cluster-slot succession any machine-keyless host gets. Two live resources that share a strong key
|
||||
still take it from each other on alternate rebuilds, as before. Evidence
|
||||
a later record pass of the same rebuild merges into an already-joined row
|
||||
reaches neither side's pin.
|
||||
- A folded resource's canonical ID resolves to the primary holding it. The
|
||||
registry indexes the folded IDs named by the merged resource's link fold
|
||||
record (`linkFolds`, `recordManualLinkFold`), which covers IDs folded along
|
||||
a chain of links, both when a link applies and when resource seeding
|
||||
copies the record in. The monitor's rebuild, a registry seeded from its listing (the resources API,
|
||||
which then replays availability checks) and a read-state overlay therefore
|
||||
resolve them alike. The index follows the holding rows, so a holder that a
|
||||
later link merges away or that is re-keyed in place keeps the answer
|
||||
consistent. An availability check configured against an agent later
|
||||
linked into its guest lands on the merged row, as a check configured
|
||||
against the primary would. "A manual link keeps only the primary's own
|
||||
checks" still holds: the merge never carries the folded resource's
|
||||
projected checks; only the check's own reference resolves again.
|
||||
`ResolveReferenceID` and `GetByReference` follow the fold as well, as the
|
||||
folded agent's alert reference (`agent:<host ID>`) already did through the
|
||||
merged row's canonical aliases. Alert intent, operator-state reads and
|
||||
writes through the resources API, Patrol's finding operator-state
|
||||
projection and resource lookups by the folded ID
|
||||
(`GET /api/resources/{id}`) therefore reach the merged row. Patrol
|
||||
resolves a finding's reference through the monitor's current read
|
||||
state before it reads, so a finding raised before the link reads the
|
||||
merged row's maintenance and monitoring mode, not an operator-state
|
||||
row left under the folded ID, whenever that read state folds the
|
||||
agent, or holds the saved agent beside the guest after a restart
|
||||
(above). History does
|
||||
not follow the fold: a history binding persists, and
|
||||
`expandHistoryAliases` walks bindings in both directions, so binding a
|
||||
folded ID would join the two journals for good. Folded IDs are never
|
||||
`SupersededCanonicalIDs`, which alert-configuration and availability-link
|
||||
migrations rewrite permanently; the stored link stays as the operator
|
||||
wrote it. A live row with a folded ID answers for itself. Two rows holding
|
||||
one folded ID resolve to neither, without falling through to weaker
|
||||
matches such as a hostname alias. Folds ride in-memory clones only; a
|
||||
serialized listing loses them, as it loses source parents.
|
||||
`availability_link_test.go` pins both link directions through two
|
||||
rebuilds in all three registries
|
||||
(`TestAvailabilityLinkToALinkFoldedResourceFollowsItsPrimary`), a chain
|
||||
(`TestAvailabilityLinkFollowsChainedLinkFolds`) and the index cases
|
||||
(`TestLinkFoldIndexFollowsItsHolders`).
|
||||
`TestResourceUnlinkSplitsManuallyLinkedPair` pins the API lookup: the
|
||||
folded agent's ID answers with the VM while linked and with the agent's own
|
||||
row once unlinked.
|
||||
`internal/api/ai_handlers_more_test.go`
|
||||
(`TestPatrolFindingOperatorStateFollowsLinkFoldedReference`) pins the
|
||||
Patrol read against the resources API's write.
|
||||
|
||||
`registry_merge_policy_test.go` pins the record links in both directions
|
||||
through two rebuilds and the live supplemental refresh, and checks that a
|
||||
registry seeded from the monitor's listing through resource ingest, as the
|
||||
resources API seeds, lists the same rows
|
||||
(`TestManualLinksJoinRecordIngestedResourcesInMonitorRebuild`). The same file
|
||||
pins the node case (`TestManualLinkKeepsItsEndpointsThroughIdentityPinPersistence`),
|
||||
record-declared succession (`TestRecordDeclaredSuccessionSparesALinkFoldedResource`)
|
||||
and both payloads (`TestManualLinkKeepsProviderPayloadsThePrimaryLacks`).
|
||||
It also pins each side's own pin for a qualified-endpoint node linked to an
|
||||
agent, standalone and clustered, in both directions, through three rebuilds
|
||||
with no steady-state pin writes and a separate pair once the link is removed
|
||||
(`TestManualLinkPinsEachSideFromItsOwnIdentity`), an unclaimed stored field
|
||||
kept without a rewrite every rebuild
|
||||
(`TestManualLinkSideKeepsAnUnclaimedStoredField`), a shared
|
||||
cluster-and-hostname key kept by the live primary while the folded side keeps
|
||||
its machine key, and handed to the folded side when the primary's row only
|
||||
kept it (`TestManualLinkFoldedSideNeverTakesALiveResourcesKey`), a kept
|
||||
hostname refused where it would complete another resource's cluster key
|
||||
(`TestManualLinkSideKeepsNoHostnameThatCompletesAHeldClusterKey`), no pin for a
|
||||
folded side whose machine key another host holds
|
||||
(`TestManualLinkFoldedSideWritesNoPinWithoutItsMachineKey`), a folded side
|
||||
re-pinned after the primary's new pin deletes its widened row
|
||||
(`TestManualLinkFoldedSideKeepsItsPinWhenAPrimaryClaimsItsWiderRow`), and the
|
||||
agent's own pin taking back the machine key an earlier release borrowed into
|
||||
a node's row (`TestManualLinkReturnsABorrowedMachineKeyToItsAgent`).
|
||||
`store_test.go` (`TestResourceIdentityPinReplaceClearsFieldsUpsertKeeps`) pins
|
||||
replace against upsert on both stores.
|
||||
`monitor_adapter_read_state_test.go`
|
||||
(`TestMonitorAdapterJoinsLinkedRecordsWithConfiguredStaleThresholds`) pins the
|
||||
thresholds on the rebuild, supplemental and overlay paths, and
|
||||
`internal/monitoring/issue1913_host_continuity_test.go`
|
||||
(`TestManualLinkToSupplementalGuestHoldsWithAndWithoutContinuity`) pins the
|
||||
published inventory with no continuity, an unrelated absent machine and the
|
||||
linked agent's own saved enrollment, and in each case that the read state,
|
||||
alert intent and a registry seeded from the read state resolve the agent to
|
||||
the guest without merging the saved agent into it.
|
||||
`resolve_test.go` pins the hold in the read state and the
|
||||
resources-API registry, the fold once the agent reports
|
||||
(`TestContinuityAgentLinkedIntoLiveGuestAnswersToTheGuest`), a saved primary
|
||||
that leaves the live member its own identity
|
||||
(`TestContinuityPrimaryLeavesLiveLinkMemberItsOwnIdentity`), an observation
|
||||
merging into a saved row (`TestObservationMergedIntoASavedRowLetsItsLinkFold`)
|
||||
and holds through later folds, saved primaries and cycles
|
||||
(`TestLinkHoldsFollowTheirPrimaryThroughThePass`); `clone_test.go`
|
||||
(`TestCloneResourceKeepsContinuityMarker`) pins the marker riding clones and
|
||||
resource seeding.
|
||||
`internal/api/resources_link_continuity_test.go`
|
||||
(`TestResourcesAPIAndPatrolAgreeOnContinuityAgentLinkedIntoGuest`) pins the
|
||||
resources API's write and Patrol's read on the same store.
|
||||
|
||||
### Provider link network corroboration
|
||||
|
||||
An existing reciprocal host/provider link remains authoritative, including a
|
||||
@@ -6776,3 +6982,155 @@ above the hosts table without its filter toolbar. Nothing renders when
|
||||
vCenter reports no signals. The Overview's own model, workload snapshot and
|
||||
navigation facets still come from the Overview query; the Health tab keeps
|
||||
the filterable table.
|
||||
|
||||
### Unlink replaces the pair's operator link
|
||||
|
||||
`POST /api/resources/{id}/link` records a `ResourceLink`; unlink and
|
||||
report-merge record a `ResourceExclusion`. An operator decides each resource
|
||||
pair one way, and the later decision replaces the earlier: the store's
|
||||
`AddExclusion` deletes the pair's link rows and `AddLink` deletes its
|
||||
exclusion rows, in the same transaction as the write, matching both ID
|
||||
orders because canonical-ID succession can store either. Unlink used to add
|
||||
only the exclusion. The link row survived, and `applyManualLinks`, which
|
||||
never consults exclusions, kept the pair folded in the monitor's rebuilds and
|
||||
in the resources API's registry, so the agent facet stayed on the VM and the
|
||||
agent row stayed hidden although the API answered "Resources unlinked".
|
||||
`GetLinks` and `GetExclusions` return only each pair's latest decision
|
||||
(`effectiveManualPairDecisions` in `manual_link_decisions.go`), so a store
|
||||
that still holds both rows for a pair, written before this change or
|
||||
rewritten onto one pair by succession, reads its later write, and a tie
|
||||
reads as apart. Every registry loads its links and exclusions from its store
|
||||
when it is constructed, so the monitor's rebuild and the resources API's
|
||||
store-backed registry apply one decision with no exclusion check in
|
||||
`applyManualLinks`. Unlink keeps the exclusion, so identity matching and
|
||||
presentation coalescing also keep a pair apart that would otherwise match
|
||||
automatically.
|
||||
|
||||
Identity pins honour the exclusion too. While a Proxmox node was linked to a
|
||||
standalone 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 in `completeIdentityFromPins`, minted the agent's machine-derived ID and
|
||||
folded into the agent by ID, a path no exclusion check sees, so the pair
|
||||
stayed merged with the link row gone. A pin whose canonical ID the operator
|
||||
excluded from the ID the record takes without the pin (its `chooseNewID` or
|
||||
source-specific ID) now completes nothing. This covers pins already written
|
||||
that way, not only new ones. Merges through an agent's own declared node link
|
||||
(`resolveLinkedResource`) still ignore exclusions.
|
||||
|
||||
The resources API seeds its registry from the monitor's listing, which
|
||||
already has the link applied, so it cannot split the pair itself: 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. REST applies a new link at once, because its own registry applies
|
||||
the store's links on top of the seed. The mock-mode view applies no operator
|
||||
links, so in mock mode REST splits a manually linked pair as soon as the
|
||||
unlink is recorded. Mock fixtures that identity matching merged stay merged
|
||||
on every surface: the fixture graph is built without the store's exclusions.
|
||||
|
||||
The websocket broadcast coalesced host views with
|
||||
`CoalescePresentationHostResources`, which honours no exclusions, while the
|
||||
resources API's `ListForPresentation` honours the registry's, so a host pair
|
||||
the operator split could be one broadcast row and two REST rows. The
|
||||
broadcast now coalesces through `Monitor.coalesceResourcesForPresentation`,
|
||||
which asks the read state that listed the rows for
|
||||
`MonitorAdapter.CoalesceForPresentation` when its registry is store-backed
|
||||
(the resource store's adapter, or a host-continuity overlay that loaded the
|
||||
store's exclusions afresh). The mock view and other views built from an
|
||||
already-unified list sit on a store-less registry, which declines, so the
|
||||
resource store's adapter supplies the exclusions instead. Either applies its
|
||||
current registry's exclusions through the same `presentationExclusionFilter`
|
||||
that `ListForPresentation` uses; a rebuild published between listing the
|
||||
rows and coalescing them is judged by the newer generation's exclusions for
|
||||
that one broadcast. Both surfaces share one known limit: the filter 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.
|
||||
|
||||
A write leaves one row for its pair: `AddLink` and `AddExclusion` also delete
|
||||
a row of their own kind that succession stored in the other order, and the
|
||||
in-memory store replaces a pair's rows on every write and stamps `CreatedAt`
|
||||
as SQLite does. Reads keep the newest link per pair, so an older reversed copy
|
||||
cannot win with its primary, and drop exclusions older than it; duplicate
|
||||
exclusions already stored stay, which changes no decision. A registry resolves its `GetLinks` and `GetExclusions` results
|
||||
together in `loadOverrides`, so an unlink committed between the two reads
|
||||
cannot load both decisions for one generation. Report-merge undoes a link
|
||||
the same way (see "Report-merge splits operator-linked resources" below).
|
||||
`TestManualPairDecisionReplacesThePreviousOne` and
|
||||
`TestManualPairDecisionReadsResolveStoredConflictsByRecency` pin the store
|
||||
rule on SQLite and the in-memory store,
|
||||
`TestUnlinkSplitsHostPairWhosePinTookTheOtherSidesKey` links and unlinks
|
||||
node and agent pairs (IP and FQDN endpoints, either side primary) through
|
||||
three monitor rebuilds per step,
|
||||
`TestRegistryResolvesDecisionsAcrossItsTwoReads` loads a link read before an
|
||||
unlink and the exclusion read after it,
|
||||
`TestResourceUnlinkSplitsManuallyLinkedPair` drives link, unlink and relink
|
||||
through the API handlers against a monitor adapter rebuilt twice per step,
|
||||
and `TestMonitorAdapterCoalesceForPresentationHonoursExclusions`,
|
||||
`TestBroadcastPresentationCoalesceHonoursMergeExclusions` and
|
||||
`TestBroadcastPresentationCoalescePrefersTheListingReadState` pin broadcast
|
||||
and REST row counts for a split host pair.
|
||||
|
||||
### Report-merge splits operator-linked resources
|
||||
|
||||
`POST /api/resources/{id}/report-merge` records exclusions for the reported
|
||||
sources. For each reported source mapped to the merged resource it excludes
|
||||
the merged resource's ID and the candidate ID that source's record takes when
|
||||
no match is allowed (`ResourceRegistry.SourceTargets`), as before, which
|
||||
keeps an identity match apart. That candidate is derived from the merged
|
||||
resource's type, so for a source an operator link brought in it named
|
||||
neither side of the link: an agent linked into a VM got a `vm-`
|
||||
candidate, and an agent linked with a node or a Docker host keyed by its
|
||||
machine ID got a source-specific one. No link row matched, the link stayed
|
||||
applied, and the API answered "Merge reported" while the drawer's Split
|
||||
dialog toasted "Resource split applied". Two linked agents share their only
|
||||
source, so the API refused them as "Resource is not merged"; an applied link
|
||||
now marks a merge as well (the drawer still offers Split only for a resource
|
||||
listing two sources).
|
||||
|
||||
`applyManualLinks` now records each link it applies on the merged resource
|
||||
(`recordManualLinkFold`): the link row's own pair, the resource that took the
|
||||
other in and the one folded into it, plus the sources the folded side
|
||||
brought, including anything links had already folded into it, so every link
|
||||
along a chain keeps its own pair. A pair is recorded once, since record
|
||||
ingest can recreate a folded side whose source mapping points at a holder of
|
||||
another type and fold it again, so a repeated pair adds its sources to the
|
||||
fold already recorded (the holder still carries what the earlier fold
|
||||
brought). The record is unexported and rides in-memory
|
||||
clones, so it reaches the resources API's registry, which seeds from the
|
||||
monitor's already-linked listing and never holds the folded row.
|
||||
Report-merge excludes the pair of every link whose folded side brought a
|
||||
reported source category (`ResourceRegistry.ManualLinkFolds`), and the
|
||||
store's one-decision-per-pair rule deletes that link row, so the pair splits
|
||||
on every surface from the monitor's next rebuild, as with unlink. The drawer
|
||||
reports every merged source. Categories are not attributed to individual
|
||||
contributors: naming one that some folded side carried undoes that link even
|
||||
where the holder carries it too, and a source that arrived along a chain of
|
||||
links undoes every link on its way to the merged resource.
|
||||
|
||||
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, so a row whose re-key collided with the successor's own row
|
||||
kept the retired endpoint and took the successor as its primary.
|
||||
`applyManualLinks` honoured that primary and merged the successor through a
|
||||
row no exclusion of the merged pair removes, so an unlink or report-merge of
|
||||
that pair came back on the next rebuild. A primary that names neither side
|
||||
of its row now falls back to the row's first side, which leaves such a row
|
||||
inert while the retired ID is gone.
|
||||
|
||||
`TestResourceReportMergeSplitsOperatorLinkedPair` links and reports through
|
||||
the API handlers against a monitor adapter on the same SQLite store, rebuilt
|
||||
three times per step, for VM/agent, node/agent and agent/Docker pairs linked
|
||||
from either side and for two linked agents. Every two-source case but a node
|
||||
linked from its agent fails with the previous handler, whose candidate for
|
||||
that node happened to be the node's own source-specific ID.
|
||||
`TestResourceReportMergeSplitsPairBehindASuccessionShadowedRow` reports a
|
||||
pair that a shadowed row also named,
|
||||
`TestResourceReportMergeSourceFilterSelectsLinks` pins the source filter,
|
||||
`TestResourceReportMergeSplitsIdentityMergedPair` keeps an identity-merged
|
||||
agent and Docker host apart through the rebuilds,
|
||||
`TestManualLinkFoldsNameEachLinkPairAlongAChain` pins a chain's pairs on the
|
||||
monitor's registry and on one seeded from its listing, and
|
||||
`TestManualLinkFoldsRecordEachPairOnceAcrossRecordIngests` folds a TrueNAS
|
||||
system into a VM across repeated record ingests, and
|
||||
`TestManualLinkFoldRepeatedPairKeepsEarlierSources` refolds a side with fewer
|
||||
sources than it first brought. `TestResourceAPIReportMergeExcludesRegistryLinkFolds`
|
||||
keeps the handler on the registry's fold record.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c5416b6800add376e674ab64292df3e4d8e88f7f",
|
||||
"verified_at": "2026-10-07T22:31:44Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx",
|
||||
"frontend-modern/src/features/platformPage/sensorSetup.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx": "404d9a23881683e94ec74400120a33c432ffa4b8f24238be3d127a6a7e243abc",
|
||||
"frontend-modern/src/features/platformPage/sensorSetup.ts": "153a28a603520156129738bd09d3d5dedab03ab8f5de2075edeb57b777aed778"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox",
|
||||
"/proxmox/storage"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"height": 1024
|
||||
},
|
||||
{
|
||||
"width": 414,
|
||||
"height": 896
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (8-node estate; every node carries an operator-style display name such as West Production C for pve3) with a local-only generator overlay: pve1, pve3 and standalone2 report the legacy sensors -j payload with a SATA disk lacking a current temperature, so the registry sets sensorSetupOutdated on those three nodes. Frontend served by Vite from this tree; backend binary built from dace9295c6 with the overlay (main's later commits do not touch this data path).",
|
||||
"Three affected nodes: 'Affected: Automation Lab Standalone (standalone2), West Production A (pve1), West Production C (pve3).', sorted by table label, on Overview and Storage at all four viewports, with no notice or page overflow and no console errors; the action link targets /settings/infrastructure.",
|
||||
"The nodes table container measures 1320, 695 and 385px at 1440, 768 and 414 wide and shows display names; at 375 wide it measures 346px (its narrow layout) and labels rows standalone2, standalone1, pve1. The notice carries both names, so each layout can match it to a row.",
|
||||
"Single affected node (pve3 only, earlier revision of this change with the same label output): 'West Production C (pve3) is using an older temperature monitoring setup ...'.",
|
||||
"Before control on the same stack with origin/main's sensorSetup.ts and PlatformOutdatedSensorSetupNotice.tsx swapped in: 'Affected: pve1, pve3, standalone2.' above rows labelled Automation Lab Standalone, West Production A and West Production C.",
|
||||
"Labels that already contain the node name (pve2.lan, 'Rack 4 (pve3)', quoted, case-only) and labels that only resemble it (PVE3 for pve-3, pve5-backup, pve10 for pve1, a Kelvin-sign lookalike) are covered by sensorSetup.test.ts only; every mock node carries a distinct display name. The last edit after the pass was a code comment.",
|
||||
"Mock data only; not a real pre-rc.6 SSH node or a real operator-renamed cluster."
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded /proxmox and /proxmox/storage by URL in headless Chromium at each viewport and read the notice text, node row labels, table container width and overflow from the DOM; screenshots kept locally.",
|
||||
"Restarted the backend with two more legacy nodes to move from the single-node sentence to the multi-node list.",
|
||||
"Swapped origin/main's two source files in and out through Vite HMR for the before control, then restored this change byte-for-byte and re-read the notice."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "421d1e5423e5b83b7923ea8c3b133ad520cd5f60",
|
||||
"verified_at": "2026-10-07T15:20:38Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "f1e249c36f0f0f9810330a37cc9a80cb43baa1cd123db1defd3f27720d39912a",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "cb0a6962ed929238a974a3bbe5f69f4c89961ff30c88c2c94398073b9ef99a1c"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/patrol (Mode and automation disclosure)",
|
||||
"/settings/pulse-intelligence/patrol",
|
||||
"/settings/pulse-intelligence"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent tree (333e51d2de; rebased onto 421d1e5423, which changes only contract docs) and this tree, each on a private DEMO_MODE mock stack with a fresh data dir; the same Playwright script ran against both and produced identical results on all 31 observed fields at both widths.",
|
||||
"Patrol on/off save whose PUT /api/settings/ai/update response (page.route) reports patrol_readiness not_ready: switching off and back on each raise only the warning toast 'Patrol setting was saved, but Patrol is not ready to run.' (role=alert inside the Notifications region, close button only); no role=alert outside the toast region, no 'needs attention' text, no readiness summary rendered inline, no Assistant action.",
|
||||
"Failed Patrol mode save (Pro mode selector unlocked by adding ai_autofix to runtime-capabilities via page.route; PUT /api/ai/patrol/autonomy answered 500 {error}): only the error toast with the server reason; mode badge returns to Watch only; no inline error or 'needs attention' state.",
|
||||
"Successful Patrol mode save (PUT 200, reconcile GET returns approval): Ask first becomes pressed and the badge reads Ask first; no new toast and no inline alert.",
|
||||
"Settings > Pulse Intelligence > Patrol save (Schedule -> Every hour) and Settings > Provider & Models save with a not_ready response: only the warning toast '<saved label>, but Patrol is not ready: The selected model cannot run Patrol tools. \u00b7 Provider: OpenRouter \u00b7 Model: openrouter:deepseek/deepseek-r1'; no inline alert.",
|
||||
"Patrol status with readiness not_ready (page.route on /api/ai/patrol/status): the page's own 'Patrol model issue' banner names summary, provider and model, and the header shows Fix setup, at both widths.",
|
||||
"Dist proof: 187 files each side; after normalising chunk hashes and SRI strings only assets/AIIntelligence.js differs (935 bytes smaller): the removed failure builder, details helper and surface callback replaced by one status check, toast string byte-identical; check-bundle-size.mjs passes."
|
||||
],
|
||||
"interactions": [
|
||||
"Demo sign-in; on /patrol click Toggle Patrol twice (off, then on) with a not-ready settings response; open 'Mode and automation' and click Ask first with a rejected save, then again with an accepted save; desktop mouse and phone touch (isMobile) contexts.",
|
||||
"On /settings/pulse-intelligence/patrol select Schedule 'Every hour' and click Save Patrol settings; on /settings/pulse-intelligence click Save provider settings; both with a not-ready response.",
|
||||
"Screenshots of each toast state captured and inspected at both widths."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "1a094646d7f21217eda051af6ba0252e208f283e",
|
||||
"verified_at": "2026-10-07T22:33:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Alerts/AlertResourceRelaxedTriggers.tsx",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx",
|
||||
"frontend-modern/src/components/Alerts/alertResourceTableModel.ts",
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts",
|
||||
"frontend-modern/src/components/Workloads/useGuestRowState.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadTopology.ts",
|
||||
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts",
|
||||
"frontend-modern/src/features/alerts/thresholds/tableTypes.ts",
|
||||
"frontend-modern/src/stores/alertsActivation.ts",
|
||||
"frontend-modern/src/utils/metricThresholds.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Alerts/AlertResourceRelaxedTriggers.tsx": "761c794a8a5a6ccaac10967e0d9dc54e5f1bc798c6915cd3d72b60abed22fe0b",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx": "7804d039237b8149aa3c1de49b2f28a1b7efd9d5c84efdf2b305cc67c5c2bc6f",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableRow.tsx": "2f1f42acaf25f25b754d07127e39d65dd9760f6be82216706a4d43f236867a9c",
|
||||
"frontend-modern/src/components/Alerts/alertResourceTableModel.ts": "c36b7bede475ccb1b9fa9f8a293d9008f45cea103c5b9fe214cc0dee7bb55e70",
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "80be72488a4828823b24013b960e081d92ced5ade3015084c933b10b7f133f6a",
|
||||
"frontend-modern/src/components/Workloads/useGuestRowState.ts": "6816bb90d9abc48ddad0999aea0f471882f1640b63eb8ef7d9fae00a37d41904",
|
||||
"frontend-modern/src/components/Workloads/workloadTopology.ts": "2ad72c28b21d38f0bff17ddafb963627d42dd5199fff9fa354014adea9ddd3c0",
|
||||
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsGuestData.ts": "bb6c362860337aa3162106226eeaeeca3404eb5936930af5701acf7969bbc9d6",
|
||||
"frontend-modern/src/features/alerts/thresholds/tableTypes.ts": "cc8f8df70b75c6abe00af8938a826659b475be6e303d88fa233ede882add3ec1",
|
||||
"frontend-modern/src/stores/alertsActivation.ts": "1a6b2fb1cf1afc3ed035c7f1801b5733250c6228e9f22d301839c24513830ceb",
|
||||
"frontend-modern/src/utils/metricThresholds.ts": "38a1cd937b010afb9f42a801d89c04c281d5cb1d47c5dd9402cce9d1e882e40c"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox",
|
||||
"/alerts/thresholds/proxmox"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 2560,
|
||||
"height": 1100
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 800,
|
||||
"height": 780
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock-mode stack (backend :7867, Vite :5387) built from this branch with a dev-only -overlay tagging the ci-runner-01 and analytics-batch-01 demo profiles pulse-relaxed (68 relaxed Proxmox VMs); default guest thresholds CPU 80/75, memory 85/80, disk 90/85",
|
||||
"Before the change on the same stack: relaxed ci-runner-117 at 88% memory drew a red (critical) memory bar while the engine raises no alert below 92%, and its Alerts thresholds row showed 80/85/90 with nothing naming the tag",
|
||||
"Proxmox guests filtered by tags:pulse-relaxed: all 66 running relaxed rows coloured by the relaxed band (warning from 80%, critical from 92% memory); analytics-batch-70 at 87% memory reads warning (yellow) at 800px and at 375px",
|
||||
"Control: untagged Proxmox guests in the same band stay critical (red): redis-cache-259 92%, redis-cache-379 91%, checkout-web-02 92%, postgres-primary-293 86%, at desktop and 375px",
|
||||
"Alerts > Thresholds > Proxmox > VMs & Containers, analytics-batch-70: card layout (default page width, every viewport up to 2560px) and table layout (full-width mode at 2560px) both show \"Proxmox tag pulse-relaxed: alerts at CPU 95% \u00b7 Memory 92% \u00b7 Disk 95%\" under the name, with the explanatory title; the metric cells keep the configured 80% / 85% / 90%",
|
||||
"At 375px the line sits at full card width under the card header in two lines; pulse-relaxed and each threshold stay unbroken (whitespace-nowrap spans); no horizontal page scroll (scrollWidth 375)",
|
||||
"Untagged guest postgres-primary-293 on the thresholds page shows no relaxed line",
|
||||
"No console errors from the final build; one transient ReferenceError came from an intermediate HMR state between two edits and did not recur after reload",
|
||||
"After this pass the only change to these files was prettier wrapping the JSX attributes of AlertResourceRelaxedTriggers.tsx onto separate lines (no output change), plus a rebase onto main that touched none of the changed paths",
|
||||
"After a review, relaxGuestThresholdValue now fills a legacy bare trigger number with the backend's fixed five-point clear instead of the configured hysteresis margin, matching ensureHysteresisThreshold. The current backend sends hysteresis objects, so this branch is not reachable from the stack exercised above; the rendered bars and lines checked here resolve through the unchanged object path, and the numeric branch is pinned by metricThresholds.test.ts at margins 5 and 10"
|
||||
],
|
||||
"interactions": [
|
||||
"Searched the Proxmox guest list by name and by tags:pulse-relaxed and read each memory bar segment fill against its percentage",
|
||||
"Expanded VMs & Containers on Alerts > Thresholds > Proxmox and searched for the relaxed and an untagged guest",
|
||||
"Opened the row editor on analytics-batch-70, set Memory to 96 without saving: the line updated to \"alerts at CPU 95% \u00b7 Disk 95%\"; cancelled and it returned to all three",
|
||||
"Switched full-width mode on to reach the table layout at 2560px, then restored it",
|
||||
"Repeated the thresholds and Proxmox passes at 375x812"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "fd36869223864faae2dd0969a758cde6232f011c",
|
||||
"verified_at": "2026-10-07T23:02:23Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
|
||||
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts",
|
||||
"frontend-modern/src/components/shared/FilterToolbar.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "54f320df506ff23e31ebfb05c927d4ba1bf3760620962200dd19fad72636eeeb",
|
||||
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "09e9c57fc2358564c5f929f06ac28daced329ef961dff3c52df9554f9359c0c2",
|
||||
"frontend-modern/src/components/shared/FilterToolbar.tsx": "8b7d3b4ffd7218042a2c24384f4175ffb0c9d2080f2a02cbca801b48b097bed6"
|
||||
},
|
||||
"routes": ["/proxmox/overview", "/vmware/overview"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Production vite builds of the change (after) and of main's three runtime files (before) served by vite preview on pulse-dev, proxied to the 50-node mock perf rig; the after bundle carries no 'Show charts' copy, the before bundle does",
|
||||
"Proxmox overview, 929 guests: Workloads View preferences disclosure open on desktop (Layout, Metrics, Row hover, Memory relative to, Inventory totals, Table) and phone (Layout, Metrics, Memory relative to, Inventory totals, Table)",
|
||||
"vSphere overview: View preferences disclosure open on desktop (Layout, Metrics, Row hover, Inventory totals, Table) and phone (Layout, Metrics, Inventory totals, Table)",
|
||||
"Before and after identical in every case: same section headings, same controls and pressed states, no Summary heading, no Charts / Show charts / Hide charts button, no horizontal page overflow, no console or page errors"
|
||||
],
|
||||
"interactions": [
|
||||
"Signed in to the dev rig, opened /proxmox/overview and /vmware/overview at each viewport",
|
||||
"Desktop: clicked View to open the inline View preferences disclosure",
|
||||
"Phone: opened Filters, then View, to reach the same disclosure",
|
||||
"Captured every visible heading, button and pressed state inside the disclosure plus page-wide Charts buttons and Summary labels, then diffed before against after"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "1b01ab6e1bbbb12996d39ea4c8b64cf379ce2e01",
|
||||
"verified_at": "2026-10-07T23:54:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "ef09be2978d90248ad165176d49b131939ea1231b02e4fe239bb01e9da11e4d0"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/patrol (Activity > Finding options and history)",
|
||||
"/patrol (Pulse Assistant drawer)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Follow-up receipt for merging the repaired #2695 head (32fad38aa4, which contains current main) into this branch: the hook's final content is that head's hook minus the circuit-breaker supporting read this branch removes.",
|
||||
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of 32fad38aa4 and of this merged tree (1b01ab6e1b), run on pulse-dev because local memory pressure refused heavy work; each on a private DEMO_MODE mock stack with a fresh data dir; the same Playwright script ran against both at both widths.",
|
||||
"Patrol load, tab hide/show, stale-data banner forced by failing GET /api/settings/ai and Retry: GET /api/ai/circuit/status drops from one request per phase on the parent build to none; the banner shows and Retry clears it on both builds; no console errors on either.",
|
||||
"Served active finding, Activity > Finding options and history, Resolve or dismiss > Open in Assistant: the Pulse Assistant drawer opens with the finding attached on both builds.",
|
||||
"Patrol status forced to runtime_state blocked with blocked_cause circuit_open (page.route): both builds show 'Patrol paused' with 'circuit breaker is open'.",
|
||||
"Across the run the only other endpoint difference is one GET /api/updates/plan from the update banner, which fires once the server's background update check completes and does not depend on this change.",
|
||||
"Dist proof on pulse-dev: 187 files each side; after normalising chunk hashes, SRI strings and short identifiers, only the removed store loader, signal, getter and API client differ in index.js and the removed supporting read in AIIntelligence.js."
|
||||
],
|
||||
"interactions": [
|
||||
"Demo auto sign-in; on /patrol open the Activity tab and 'Finding options and history', expand the served finding, open 'Resolve or dismiss', click 'Open in Assistant', close the drawer.",
|
||||
"Dispatch visibilitychange hidden then visible; reload with GET /api/settings/ai failing, wait for the banner, restore the route and click Retry, wait for the banner to clear; reload /patrol with a circuit_open Patrol status.",
|
||||
"Desktop 1440x900 and phone 390x844 contexts; screenshots of the Patrol page, Finding options and history, the Assistant drawer, the stale-data banner and the paused banner inspected."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "1a48d27aa0ddfcbc4df0354a08c2312cc1849603",
|
||||
"verified_at": "2026-10-07T23:28:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "9bdafb9fa51b259ddada914607c80265f43cb4055e1c56128648c6ed0135c1a4"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/patrol (Activity > Finding options and history)",
|
||||
"/patrol (Pulse Assistant drawer)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Follow-up receipt for the merge of origin/main (7f45148d15) into this branch: the hook's final content differs from both parents only by main's own removals of summaryStats (#2690) and the loadCorrelations supporting read (#2677).",
|
||||
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of origin/main 7f45148d15 and of this merged tree (1a48d27aa0), run on pulse-dev because local memory pressure refused heavy work; each on a private DEMO_MODE mock stack with a fresh data dir; the same Playwright script ran against both at both widths.",
|
||||
"Patrol load with one served active finding (page.route on /api/ai/patrol/findings and /api/ai/unified/findings), Activity > Finding options and history, Resolve or dismiss > Open in Assistant: the Pulse Assistant drawer opens with the finding attached on both builds; no console errors on either.",
|
||||
"Tab hidden then shown (visibilitychange) and the stale-data banner forced by failing GET /api/settings/ai on reload: 'Patrol could not refresh' shows on both builds and Retry clears it on both, at both widths (state-based waits; a first run with fixed 5 s sleeps missed the banner on the loaded host for timing reasons).",
|
||||
"Across the whole run both builds hit the same set of /api endpoints; per-endpoint counts differ by one to three only for timer-driven attention, pending-action and active-alert polls, which this change does not touch.",
|
||||
"Dist proof on pulse-dev: 187 files each side; after normalising chunk hashes, SRI strings and short identifiers, only the removed hook code differs in AIIntelligence.js, the unused pulse_patrol constant drops out of aiChat.js, and index.js reorders the same preload list."
|
||||
],
|
||||
"interactions": [
|
||||
"Demo auto sign-in; on /patrol open the Activity tab and 'Finding options and history', expand the served finding, open 'Resolve or dismiss', click 'Open in Assistant', close the drawer.",
|
||||
"Dispatch visibilitychange hidden then visible; reload with GET /api/settings/ai failing, wait for the banner, restore the route and click Retry, wait for the banner to clear.",
|
||||
"Desktop 1440x900 and phone 390x844 contexts; screenshots of the Patrol page, Finding options and history, the Assistant drawer and the stale-data banner inspected."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "2cf213a77d2a50dcd47c0b0ab07c49f97bdd0534",
|
||||
"verified_at": "2026-10-07T15:52:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx": "04ef5ff520eb84b7350d203799a406c22252a35aa6947de6aa7c922e5f4050b4",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "c217fd2affd44a102ef39ba440519b5ca931db54b0f46555a23924d968bc7beb"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/patrol (Activity > Finding options and history)",
|
||||
"/patrol (Pulse Assistant drawer)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent tree (92abdcd9ad; rebased onto 2cf213a77d, which changes only patrol-intelligence.md) and this tree, each on a private DEMO_MODE mock stack with its own data dir and port; the same Playwright script ran against both at both widths.",
|
||||
"Patrol load with one served active finding (page.route on /api/ai/patrol/findings and /api/ai/unified/findings): Inbox renders, Activity > Finding options and history lists the finding, no console errors, same /api endpoint set as the parent build.",
|
||||
"Finding expanded, Resolve or dismiss menu open, Open in Assistant clicked: the Pulse Assistant drawer opens with the finding attached ('High CPU usage on web-server (host)'), same requests as the parent build and no workflow-starter POST on either build.",
|
||||
"Tab hidden then shown (visibilitychange): polling pauses and one reload runs; same endpoint set as the parent build.",
|
||||
"Stale-data banner forced by failing GET /api/settings/ai on reload: 'Patrol could not refresh' with Retry shows on both builds; after unrouting, Retry clears the banner on both builds.",
|
||||
"Dist proof: 187 files each side; after normalising chunk hashes, SRI strings and short identifiers, only the removed hook code differs in AIIntelligence.js, the now-unused pulse_patrol constant drops out of aiChat.js, and index.js reorders the same AIIntelligence preload list; check-bundle-size.mjs passes."
|
||||
],
|
||||
"interactions": [
|
||||
"Demo auto sign-in; on /patrol open the Activity tab and 'Finding options and history', expand the served finding, open 'Resolve or dismiss', click 'Open in Assistant', close the drawer.",
|
||||
"Dispatch visibilitychange hidden then visible; reload with GET /api/settings/ai failing, then click Retry with the route restored.",
|
||||
"Desktop 1440x900 and phone 390x844 contexts; screenshots of the Patrol page, the Assistant drawer and the stale-data banner inspected at both widths."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "32fad38aa486ac116f885cb77e27317ea351c957",
|
||||
"verified_at": "2026-10-07T23:52:39Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "789f7e4a6f28e07d6d1d28abc29843ab02a90eef4f787fa024f0ba383abc8563"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol (header 'Mode and automation' disclosure)",
|
||||
"/patrol (Activate Autopilot acknowledgement dialog)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Removal of the test-only resolvePatrolAutonomyLevelForSave / resolvePatrolAutonomySettingsForSave exports; no production code called them.",
|
||||
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent 32fad38aa4 and of this change, run on pulse-dev because local memory pressure refused heavy work. The two dists are byte-identical (187 files, every sha256 equal): Rollup had already dropped both helpers.",
|
||||
"Each build on its own private DEMO_MODE mock stack with a fresh data dir, one at a time; the same Playwright script ran against both at both widths. Results compared field by field: 6 sessions, 80 recorded fields, 0 differences.",
|
||||
"Paid entitlement (ai_autofix added to /api/license/runtime-capabilities via page.route): all four Patrol mode choices enabled; Ask first, Watch only and Autopilot each saved through PUT /api/ai/patrol/autonomy (stubbed 200 plus a GET reconcile rewrite); Autopilot opened the acknowledgement dialog with no PUT until 'Record acknowledgement and activate' (one stubbed acknowledgement POST). Every PUT body carried only autonomy_level, investigation_budget, investigation_timeout_sec and, for Autopilot, acknowledgement_id; none carried full_mode_unlocked.",
|
||||
"Runtime lock under demo presentation (ai_autofix blocked with paid_runtime_required): demo forces hideCommercial, so the header shows Watch only with no mode selector and no save path.",
|
||||
"Runtime lock with the demo presentation flags cleared in the browser (GET /api/security/status rewritten): 'Pulse Pro runtime required' notice, Watch only selected, Ask first / Safe auto-fix / Autopilot disabled; forced clicks on them sent no PUT and opened no Autopilot dialog; Watch only sent exactly autonomy_level monitor plus the investigation budget and timeout.",
|
||||
"Console/network noise identical on both builds and unrelated to the change: 403 POST /api/ai/workflow-prompts/activity (demo write guard on the starter record) in the paid sessions, 404 GET /api/license/commercial-posture in the cleared-presentation sessions."
|
||||
],
|
||||
"interactions": [
|
||||
"Demo auto sign-in; open /patrol, expand 'Mode and automation', read the selector's pressed and disabled states.",
|
||||
"Paid: click Ask first, then Watch only, then Autopilot (re-clicking only while the previous save was still reconciling), tick the acknowledgement checkbox and click 'Record acknowledgement and activate'.",
|
||||
"Locked (selector visible): force-click Ask first, Safe auto-fix and Autopilot, then click Watch only.",
|
||||
"Desktop 1440x900 and phone 390x844 contexts; screenshots of the initial, Autopilot dialog and final states inspected."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "1a094646d7f21217eda051af6ba0252e208f283e",
|
||||
"verified_at": "2026-10-07T23:22:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Storage/storagePageState.ts",
|
||||
"frontend-modern/src/components/Storage/useStorageExpansionState.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts",
|
||||
"frontend-modern/src/components/shared/summaryTableFocus.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Storage/storagePageState.ts": "63b20e139475680eda3e637eedc9ce2628108c6a91d0d2fab7648c67fd679f16",
|
||||
"frontend-modern/src/components/Storage/useStorageExpansionState.ts": "b0b012ef7c386a6377dfe0852b5ed81be056efdc6acd2ab244b2c175bc696605",
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts": "fe35c36b9478d03f1de21427d842b6f6f795b8965fc5293fff12aa4d4dae1431",
|
||||
"frontend-modern/src/components/shared/summaryTableFocus.ts": "8ba44d5fb5d782dd96d1cec8bcb57a6696425d4eb5ab7fd546f77c4a073640d7"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/storage",
|
||||
"/proxmox/storage?group=node",
|
||||
"/proxmox/storage?group=type",
|
||||
"/proxmox/storage?tab=disks",
|
||||
"/proxmox/storage?resource=ceph-rbd",
|
||||
"/proxmox/storage?group=node&resource=ceph-rbd",
|
||||
"/proxmox/storage?group=node&summaryGroup=storage:node:West Production F",
|
||||
"/proxmox",
|
||||
"/proxmox?resource=<guest>"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private 12-node mock stack (PULSE_MOCK_MODE, live websocket updates) served by Vite from this branch; headless Chromium on pulse-dev through an SSH tunnel, same stack swapped between the PR 2709 code (before) and this change (after)",
|
||||
"Pools view with an open pool after scrolling the app shell to the top: app-shell scrollTop sampled every 500 ms for 12 s. Before: 0, 0, 1008, 1111 and 1111 for the remaining 20 samples (yanked back to the open pool). After: 0 for all 24 samples. Physical Disks view and the Workloads table read 0 for all 24 samples both before and after",
|
||||
"Storage grouped by node with an open pool, its group collapsed by the Collapse button and, separately, by a group-header click: group state sampled every 500 ms. Before: open in all 12 samples (bounced straight back). Bridge fix alone: the header-collapsed group reopened about 1.5 s later and console probes showed the expansion sync re-adding the collapsed key on a live update. After: shut in all 32 samples (16 s) for both paths",
|
||||
"Regrouping after a collapse: node grouping with one group collapsed shows 33 of 35 pool rows; switching to type, then ungrouped, then back to node shows all 35 rows",
|
||||
"Final pass on the rebased tree (PR 2709 merged): the scroll and collapse samples above unchanged, no page errors at either width"
|
||||
],
|
||||
"interactions": [
|
||||
"Click a lower pool row: inline detail opens, row header and top of the detail in view, then Escape closes it (1440x900 and 390x844)",
|
||||
"Click a lower physical disk row on ?tab=disks: detail opens with the row header and detail top in view (both widths)",
|
||||
"Click a lower Workloads guest row on /proxmox: detail opens in view and Escape closes it; ?resource=<guest> deep link opens the guest detail in view (both widths)",
|
||||
"Load ?resource=ceph-rbd: the Ceph pool detail opens in view (both widths)",
|
||||
"With ?group=node, collapse the shared group with its disclosure button, then navigate in-app to ?group=node&resource=ceph-rbd: the group reopens and the Ceph detail is in view (both widths)",
|
||||
"Load ?group=node&summaryGroup=<last group>: the pinned header scrolls into view and is marked active (scrollTop 1189 desktop and 1103 phone, as before the change)",
|
||||
"Hover eight Storage and Workloads rows: rows mark active with no change in window scroll listeners or style reads versus before",
|
||||
"Known unchanged edge recorded: with a pool already open and its group collapsed, an in-app link to that same pool does not reopen the group, because focus does not change"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "1a094646d7f21217eda051af6ba0252e208f283e",
|
||||
"verified_at": "2026-10-07T22:57:56Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
|
||||
"frontend-modern/src/components/shared/MetadataBadge.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "cd82ec5bd4dbf1ab54c9b4dd291814f728567700836799c794e2f71046624725",
|
||||
"frontend-modern/src/components/shared/MetadataBadge.tsx": "0b87b1a7b3b56ee384874a56710cf260a7787388dd43ec67b8ca144412474ad1"
|
||||
},
|
||||
"routes": [
|
||||
"/docker",
|
||||
"/kubernetes",
|
||||
"/proxmox",
|
||||
"/standalone/machines"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 393,
|
||||
"height": 851
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack built from this branch (50 Proxmox nodes, 2 Docker hosts, 1 Kubernetes cluster, 2 generic hosts), Chromium headless",
|
||||
"Baseline on origin/main at 390px: 92 inline drawers opened across the four routes, 53 descendants past the drawer content shell's right edge, all of them Aliases chips in the Identity section (Docker container drawers 12, Kubernetes drawers 41), for example proxmox-lxc-docker:Production West:pve1:113 at +82px and k8s-production-1:deployment:redis-ffcd-b17c78f012 at +114px; 0 at 768px",
|
||||
"After the change: 140 inline drawers across the four routes (Proxmox node and guest drawers included) at 390px and 768px, 0 identity chips past the shell; the one remaining element is the Deployments tab inside the Kubernetes cluster drawer's role=tablist scroller (scrolls 102px), reachable by swiping",
|
||||
"Docker container drawer nginx and Kubernetes drawer checkout-api at 390px: alias and IP chips wrap at spaces and hyphens inside the value cell, full values readable, chip right edge 326px against a 343px shell, +N overflow chip unchanged",
|
||||
"Same drawers with the two runtime files swapped back to origin/main: chip right edges 352 to 457px, cut off at the cell with no ellipsis",
|
||||
"Docker container drawer nginx at 1440px: Identity card unchanged, every alias chip on one line",
|
||||
"Truncating Primary ID and Parent values keep their ellipsis; Docker labels chips unchanged",
|
||||
"Rebased onto main after the phone drawer width change (#2703, drawer content now fills the table's visible width): Docker container drawer at Pixel 5 and iPhone 12 sizes has no Identity-section element or text past the detail cell, table wrapper or drawer shell; with the two runtime files reverted the proxmox-lxc-docker alias still sits 71px past the cell and app-container-<hash> 20px"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened each sampled row's inline drawer and expanded every nested disclosure inside it before measuring",
|
||||
"Tapped a Docker container row at Pixel 5 and iPhone 12 sizes (the disclosure chevron is screen-reader only there) and checked that no Identity-section element or text crosses the detail cell, table wrapper or drawer shell",
|
||||
"Re-ran that check with the runtime files reverted: it failed on every long alias chip",
|
||||
"Closed each drawer before opening the next"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "03767f1e956c68c7b826635990638ea45fd9ddbd",
|
||||
"verified_at": "2026-10-08T00:45:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts",
|
||||
"frontend-modern/src/utils/appShellScrollRestoration.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts": "4b125b316052a801d9db7568cd584e7165b73995795c1943770bd9133c363f3c",
|
||||
"frontend-modern/src/utils/appShellScrollRestoration.ts": "b20770a6edd2196ba436de1aadf1bb8dcdf84baf0da492afa6e5255ebfd91fd5"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview",
|
||||
"/proxmox/overview?type=vm",
|
||||
"/proxmox/overview?resource=Analytics%3Apve34%3A740",
|
||||
"/proxmox/storage",
|
||||
"/docker/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Embedded build of this exact tree (both runtime files checked by sha256 on the test host) on a mock DEMO_MODE stack on pulse-dev; baseline is an embedded build of origin/main 7f45148d15 run with the same probe script, which instruments every scripted scrollTop write on .app-scroll-shell",
|
||||
"Baseline repro at 1440x900 and 390x844: guest drawer opened at shell scrollTop 3000 (URL unchanged), shell scrolled to 0, Type filter set to VMs; URL became ?type=vm and 74 ms (desktop) / 126 ms (phone) later a scripted write moved the shell to 3000",
|
||||
"This tree, same steps at both widths: shell stays at 0 after ?type=vm, no scripted shell write",
|
||||
"Controls unchanged on both builds: same scroll path without a drawer stays at 0; filter clicked at scrollTop 500 after a drawer toggle keeps 500 (phone: 41 after the Filters sheet opens); Proxmox Storage sub-tab (both widths) and Docker tab (desktop; on phones the tab is not in the visible nav) after a drawer toggle land at 0",
|
||||
"Opening a guest drawer keeps the shell at 3000 with the URL unchanged (local selection, contextual-focus offset preservation)",
|
||||
"?resource= deep link opens the matching guest drawer and reveals it (shell 2960 desktop, 3143 phone); closing it keeps that position",
|
||||
"No page errors at either width"
|
||||
],
|
||||
"interactions": [
|
||||
"Row disclosure button click (desktop) and row tap (phone) to open and close a guest drawer",
|
||||
"Type filter VMs button (desktop inline group; phone via the toolbar Filters sheet)",
|
||||
"Proxmox Storage sub-tab link and Docker platform tab link",
|
||||
"Drawer close button on a deep-linked drawer",
|
||||
"rAF sampling of .app-scroll-shell scrollTop and URL for 2.5 s after each action"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "3e6e9c3ffb8762b032efab544fda2f339cdb3d96",
|
||||
"verified_at": "2026-10-07T16:28:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/ai.ts",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
|
||||
"frontend-modern/src/stores/aiIntelligence.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/ai.ts": "8ab01c7c5daac6c2088985423d3bb4584561b67ec306331ff92bf32cb9551738",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "dba595d1677002a9c55a31b45b4a4b5c600df7b4866afa8eb6bbe6dafbb75503",
|
||||
"frontend-modern/src/stores/aiIntelligence.ts": "d10124d374625cb4a6b2e5b17a357211f73b724473bba9b7a4d0d0d15ccaf6b8"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/patrol (Activity > Finding options and history)",
|
||||
"/patrol (Pulse Assistant drawer)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent tree (3e6e9c3ffb, the merge of #2677 into #2695) and this tree, each on a private DEMO_MODE mock stack with a fresh data dir and its own port; the same Playwright script ran against both at both widths.",
|
||||
"Patrol load, tab hide/show, forced stale-data banner and Retry: GET /api/ai/circuit/status goes from 1-2 requests per phase on the parent build to 0 on this build; every other /api endpoint per phase is identical, and neither build logs a console error.",
|
||||
"Served active finding, Activity > Finding options and history, Resolve or dismiss > Open in Assistant: the Pulse Assistant drawer opens with the finding attached on both builds, with identical requests.",
|
||||
"Patrol status forced to runtime_state blocked with blocked_cause circuit_open (page.route): both builds show 'Patrol paused' with 'circuit breaker is open', so breaker state still reaches the page through Patrol status alone.",
|
||||
"Dist proof: 187 files each side; after normalising chunk hashes, SRI strings and short identifiers, only the removed store loader, signal, getter and API client differ in index.js and the removed supporting read in AIIntelligence.js; check-bundle-size.mjs passes."
|
||||
],
|
||||
"interactions": [
|
||||
"Demo auto sign-in; on /patrol open the Activity tab and 'Finding options and history', expand the served finding, open 'Resolve or dismiss', click 'Open in Assistant', close the drawer.",
|
||||
"Dispatch visibilitychange hidden then visible; reload with GET /api/settings/ai failing, then click Retry with the route restored; reload /patrol with a circuit_open Patrol status.",
|
||||
"Desktop 1440x900 and phone 390x844 contexts; screenshots of the Patrol page, Assistant drawer, stale-data banner and paused banner inspected."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "77d21197927a670076a74e87634e73ea999a566a",
|
||||
"verified_at": "2026-10-07T19:05:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Settings/reportingCatalogModel.ts",
|
||||
"frontend-modern/src/components/Settings/reportingPanelModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Settings/reportingCatalogModel.ts": "fc3ab83f49ac62aaf7ef97b415a0bff1c4e445ca17771efc18304295e94e3df0",
|
||||
"frontend-modern/src/components/Settings/reportingPanelModel.ts": "e4366a8c50ea897dcb4907b4e488a2c5f758e710d7a9be0d5fe0d3e047e2d9c5"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/support/reporting",
|
||||
"/api/admin/reports/generate"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Pro dev licence active, Pulse Assistant enabled on a local stub OpenAI-compatible provider that logs each call, real admin session login (no admin bypass)",
|
||||
"Data & Reports panel with one mock agent selected, then two",
|
||||
"Downloaded single-resource PDF opened: POST copy carries the stub AI narrative and provenance footer; GET copy carries the deterministic summary and the new GET note in place of the Configure Pulse Assistant tip",
|
||||
"AI usage summary after the run: report_narrative recorded only for the two UI POST downloads"
|
||||
],
|
||||
"interactions": [
|
||||
"Generate Report (1 resource) as PDF at 1280x800 and 390x844: one POST /api/admin/reports/generate with a JSON body, 200 application/pdf, download saved, stub provider called once each",
|
||||
"Generate Report (1 resource) as CSV at both widths: POST, 200 text/csv, stub provider not called",
|
||||
"Generate Report (2 resources) as PDF at both widths: POST /api/admin/reports/generate-multi unchanged, 200 application/pdf",
|
||||
"Top-level browser navigation to GET /api/admin/reports/generate?format=pdf with the session cookie: PDF downloaded, stub provider not called",
|
||||
"HEAD, PUT, DELETE and PATCH return 405 with Allow GET, POST; a session POST without the CSRF header returns 403",
|
||||
"No console errors on the reporting panel at either width"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "1a094646d7f21217eda051af6ba0252e208f283e",
|
||||
"verified_at": "2026-10-07T22:40:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestRow.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadSelectionModel.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
|
||||
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx",
|
||||
"frontend-modern/src/routing/resourceLinks.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestRow.tsx": "b345738d55505aeffb16b20f104f8010008e9fcc73fafe084c7e75dbc8138e1a",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "a8b2e107f2143d01f3cb038d29526a9d38d35dcc6df5cdac076c02aa143b3e5f",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "e48d1be3998e7e3ece47d9eb01673872e74f0f46e0cc242142898af3f2eea80d",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "2742be27628c947aac825ca70af4f19b7518931d3629e6bf3b9068cc98eae19e",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "e7bd98fc4a091ee66595b59d2407b7ea9cca9e52f1df2b7527ff41cfdecc99b9",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts": "2c99c812cb19b6c31b376ec5db56c3ec16403d8556fbe5f9028fd9018e310915",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "5ac8410704ad9fbcf8237291432a30d16a31527b38fd34f0179eb57b90b92ca4",
|
||||
"frontend-modern/src/components/Workloads/workloadSelectionModel.ts": "82b8f740e51630aba916d7ae617cd57213f4c910d17e843ea378384b63f9ffe9",
|
||||
"frontend-modern/src/components/Workloads/workloadSelectors.ts": "3b39341dc6e068db4bc84a5745f6a8508454b0019a84c7abfd8c6ee826d8ed93",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "f2ec48c3f9932d4e17410cda69c2cc4452c8d11fe3d6d120940cbe8ced40c6bd",
|
||||
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "561645e9b339738484a53a0c044721d69831ce6812d721d826233443efb0f0e1",
|
||||
"frontend-modern/src/routing/resourceLinks.ts": "7b3a287fb80c7aa66aea6a40a3433662f99460888e734950ea3b455a04f3d53a"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview",
|
||||
"/proxmox/overview?resource=Analytics%3Apve31%3A675",
|
||||
"/proxmox/overview?summaryGroup=Analytics-pve31",
|
||||
"/vmware/overview",
|
||||
"/vmware/overview?summaryGroup=etl-batch-01-esxi-01.lab.local"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Embedded build of this exact tree (fingerprinted runtime files checked by sha256 on the test host) on a mock DEMO_MODE stack on pulse-dev; baseline is an embedded build of origin/main 36d3f8c557 run with the same probe script (group interaction code is unchanged on main since)",
|
||||
"Proxmox overview grouped by node: 8 group dividers, static (no aria-expanded, cursor auto, no inline node drawer in the guests table), same as baseline",
|
||||
"vSphere overview grouped by host: 18 group dividers in the current mock estate, static, same as baseline",
|
||||
"Group-row attributes this change removes are absent: data-summary-group-series-count (baseline 18) and data-summary-row-active (baseline \"false\"); data-summary-group-id kept",
|
||||
"Hand-typed ?summaryGroup=Analytics-pve31 on Proxmox: baseline pinned 18 member rows and highlighted the group row; now 0 rows pinned and no group highlight. vSphere ?summaryGroup= pinned 0 rows on both",
|
||||
"Proxmox nodes table NodeDrawer still opens from its own row toggle (aria-expanded true, one inline node detail row)",
|
||||
"?resource= deep link opens the matching guest drawer",
|
||||
"Phone 390x844 Proxmox: tapping a group divider pins nothing and opens no node drawer; tapping a guest opens its drawer",
|
||||
"No page errors at either width"
|
||||
],
|
||||
"interactions": [
|
||||
"Hover the first group divider (right half, away from the node link): 0 preview member rows",
|
||||
"Click the first group divider: 0 pinned rows, 0 inline node drawers",
|
||||
"Open the first guest drawer, then click a group divider: drawer stays open 5/5 on Proxmox and 5/5 on vSphere (baseline the same); Escape closes it",
|
||||
"Hover a guest row: exactly one guest row carries data-summary-row-active=true",
|
||||
"Toggle the first Proxmox nodes table row disclosure: NodeDrawer opens, then closes",
|
||||
"Load the ?summaryGroup= and ?resource= URLs above and count pinned rows and open drawers",
|
||||
"Phone width: tap a group divider, then tap a guest row"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "a9f23501ebbdf3ca9cb2bccdb7891211e9d6cf22",
|
||||
"verified_at": "2026-10-08T04:58:10Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "8c3557c19ac17c2e7baa937af259d2f0375f876d4cd359ac21a431abbfafeb7f"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol (header 'Mode and automation' disclosure)",
|
||||
"/patrol (Activate Autopilot acknowledgement dialog)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Follow-up receipt for the #2740 merge (a9f23501eb): main's usePatrolIntelligenceState.ts combines #2704's removal of the circuit-breaker read with #2740's removal of the test-only autonomy save helpers, so its final content matches neither parent.",
|
||||
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of 986cead30c (main just before the merge, Canonical Governance green) and of a9f23501eb, run on pulse-dev because local memory pressure refused heavy work. The two dists are byte-identical (189 files, every sha256 equal).",
|
||||
"Each build on its own private DEMO_MODE mock stack with a fresh data dir, one at a time; the same Playwright script ran against both at both widths.",
|
||||
"Paid entitlement (ai_autofix added to /api/license/runtime-capabilities via page.route): Ask first, Watch only and Autopilot each saved through PUT /api/ai/patrol/autonomy (stubbed 200 plus a GET reconcile rewrite); Autopilot opened the acknowledgement dialog and saved only after 'Record acknowledgement and activate'. Same PUT sequence on both builds; no PUT carried full_mode_unlocked.",
|
||||
"Runtime lock under demo presentation: Watch only with no selector and no save on both builds. Runtime lock with the demo presentation flags cleared (GET /api/security/status rewritten): paid choices disabled, forced clicks saved nothing and opened no dialog, Watch only sent exactly autonomy_level monitor plus the investigation budget and timeout, on both builds.",
|
||||
"Neither build requested GET /api/ai/circuit/status in any session (#2704's change). The per-session /api path sets match except GET /api/replication/jobs, a timer-driven poll that appears in some sessions on each build.",
|
||||
"Timing on the loaded host (load 25-53): on the 986cead30c build the desktop badge was read before the Ask first reconcile applied and Watch only took three clicks while that save finished; the PUTs were the same. tsc --noEmit and the Patrol hook, page, header and resource-boundary suites pass on a9f23501eb."
|
||||
],
|
||||
"interactions": [
|
||||
"Demo auto sign-in; open /patrol, expand 'Mode and automation', read the selector's pressed and disabled states.",
|
||||
"Paid: click Ask first, then Watch only, then Autopilot (re-clicking only while the previous save was still reconciling), tick the acknowledgement checkbox and click 'Record acknowledgement and activate'.",
|
||||
"Locked (selector visible): force-click Ask first, Safe auto-fix and Autopilot, then click Watch only.",
|
||||
"Desktop 1440x900 and phone 390x844 contexts; screenshots of the initial, Autopilot dialog and final states inspected."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c5416b6800add376e674ab64292df3e4d8e88f7f",
|
||||
"verified_at": "2026-10-07T22:32:23Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/aiChat.ts",
|
||||
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts",
|
||||
"frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts",
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx",
|
||||
"frontend-modern/src/components/AI/Chat/types.ts",
|
||||
"frontend-modern/src/stores/aiChat.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/aiChat.ts": "ed10500c7796f4c860e7d44855e154fca1523faace9e2c56adb50726ea55ad30",
|
||||
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "5c07c9c5811f5171fcc6df6ba74057b6d9646dec10f55cf120265e46715e14e2",
|
||||
"frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts": "1bcbadf3f89382985272008e354e6608f8afb22a36444d9359df84adc963e421",
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx": "87168e37367850a743d97c2775ef422a2dae7869f6383838382d956627d91b98",
|
||||
"frontend-modern/src/components/AI/Chat/types.ts": "291984a8b043781dd103e252c330f6697421c0c4978209ff61ee729b5e4e6f52",
|
||||
"frontend-modern/src/stores/aiChat.ts": "5954f3bab0f419c8916f1971f0c06464ae68a7e76476cee67a3395239606f6a8"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview",
|
||||
"/patrol"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Proxmox overview with the Assistant drawer open from \"Ask Pulse Assistant about Proxmox\": plain chat, saved control level Read-only then Chat actions; pill reads \"Chat: Chat actions\" and the chat actions warning shows, as before",
|
||||
"Plain chat turn sent and answered by the mock Assistant: captured /api/ai/chat body keys are only prompt; no autonomous_mode key",
|
||||
"Patrol inbox issue (ZFS device sdb2 FAULTED) opened with Start review and Evidence and history expanded",
|
||||
"Explain with Assistant scoped handoff with saved control level Chat actions: drawer shows the Pulse Patrol briefing, \"Approval required before any action.\", pill \"Chat: Ask first\", no chat actions warning",
|
||||
"Explain request body keys are handoff_context, handoff_resources (the sdb2 device id), prompt; no autonomous_mode key",
|
||||
"Follow-up turn in the same handoff session: body keys model, prompt, session_id; drawer keeps the approval-required note and Ask first pill",
|
||||
"Same Explain flow at 390x844: identical request keys, approval note, Ask first pill, no warning"
|
||||
],
|
||||
"interactions": [
|
||||
"Clicked Ask Pulse Assistant about Proxmox to open the drawer",
|
||||
"Switched the drawer chat action mode from Read-only to Chat actions through the mode picker (saved control_level read back as autonomous)",
|
||||
"Typed and sent a plain prompt from the drawer composer with Enter",
|
||||
"Clicked Start review and expanded Evidence and history on the Patrol page",
|
||||
"Clicked Explain with Assistant (aiChatStore.explain path) at 1440x900 and again at 390x844",
|
||||
"Typed and sent a follow-up prompt in the handoff session",
|
||||
"Captured every /api/ai/chat request body with a page-level fetch wrapper and checked its keys"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "36d3f8c5571daa1e041abf41c4f7c394ca14111f",
|
||||
"verified_at": "2026-10-07T22:02:19Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/shared/useTooltipState.ts",
|
||||
"frontend-modern/src/hooks/useBreakpoint.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/shared/useTooltipState.ts": "d3726fcd6498be9d1ca56d6116a741f1000e0ba4e645b3c0eee009b5017ec494",
|
||||
"frontend-modern/src/hooks/useBreakpoint.ts": "e2f67292f467ea5f1fb3d36db1abee117c87e68f481f29aeb20e9cf3f764d865"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock-mode stack on this branch (backend :7901, Vite :5401), headless Chromium with an init script wrapping EventTarget add/removeEventListener to track live window resize listeners by registering stack frame",
|
||||
"Before (origin/main 77d2119792), 1440x900, 140 workload rows: 859 active resize listeners (699 from useTooltipState useTooltipLayoutState, 156 from useBreakpoint, 4 page-level); one dispatched resize ran 859 callbacks",
|
||||
"Before, 390x844, 36 rows: 203 active (153 tooltip, 46 useBreakpoint, 4 page-level); 203 callbacks per resize",
|
||||
"After, 1440x900, 140 rows: 5 active (1 retainViewportWidthListener in useBreakpoint plus the 4 page-level listeners); 5 callbacks per resize; unchanged after scrolling the table",
|
||||
"After, 390x844, 36 rows: 5 active, 5 callbacks per resize",
|
||||
"After, open memory sparkline tooltip on a workload row: active count 6 while open; after resizing to 1440x700 and then 1380x640 the tooltip stayed open, its svg viewBox read '0 0 1440 700' then '0 0 1380 640' and it stayed inside the viewport; after moving the pointer away the tooltip closed and the count returned to 5",
|
||||
"After, breakpoint crossing 1440 -> 390 -> 1440 and 390 -> 1440 -> 390: the workload table class switched workload-table--desktop <-> workload-table--mobile and every row's name cell style switched 'width: 27.6596%' <-> 'width: 38%' each way; listener count stayed 5 throughout; no page errors"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded /proxmox at 1440x900 and at 390x844, counted active window resize listeners and their registering sources, dispatched one synthetic resize and counted callbacks",
|
||||
"Scrolled the workload table down in twelve 600px steps and back to the top, recounted listeners",
|
||||
"Hovered row cells right to left until the memory sparkline tooltip opened, resized the window twice while it stayed open, moved the pointer away to close it, recounted listeners",
|
||||
"Resized the viewport across the md breakpoint (desktop to phone and back, phone to desktop and back) and read the workload table mode and row layout after each step"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "1a094646d7f21217eda051af6ba0252e208f283e",
|
||||
"verified_at": "2026-10-07T22:32:43Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/routing/resourceLinks.ts",
|
||||
"frontend-modern/src/utils/recoveryItemTypePresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/routing/resourceLinks.ts": "420b501bf1d305bc4287ef50655764cbff4c19db4823e8258e34ca9097edaec4",
|
||||
"frontend-modern/src/utils/recoveryItemTypePresentation.ts": "deleted"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/backups/date",
|
||||
"/proxmox/backups/coverage",
|
||||
"/truenas/protection"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private Vite dev server from this change's tree (verified on origin/main 15322e1573 plus the change, then rebased onto 1a094646d7, whose intervening frontend changes touch summary focus, Patrol and workload guest presentation, not the Proxmox Backups or TrueNAS Protection code) against two backends: the shared non-mock :7655 backend with real PBS/PVE backup history and no TrueNAS system, and a peer's mock-mode backend on :7891 with TrueNAS replication and snapshot recovery points",
|
||||
"The change only deletes exports no production module reads (RECOVERY_QUERY_PARAMS, parseRecoveryLinkSearch, buildRecoveryRouteSearch, normalizeQueryBooleanFlag) and the unreachable recoveryItemTypePresentation.ts; no rendered component imports any of them",
|
||||
"/proxmox/backups/date: backup servers table, backup health posture strip, Backups per day chart and the date feed render; By date / Coverage tabs sit above the filter bar at both widths",
|
||||
"/proxmox/backups/date?day=2026-09-19 (real data): feed narrowed in place to one 7-backup day group; after reload the day is kept and the range control is back on 30d",
|
||||
"1y range: 364+ day columns, chart scrolls inside its card, document scrollWidth equals clientWidth (no page overflow); URL unchanged by range changes",
|
||||
"/proxmox/backups/coverage: coverage table with posture filter and Node / Backup location / Type filters; the date-only day facet is cleared on the switch",
|
||||
"/truenas/protection on the non-mock backend: 'No TrueNAS systems' empty state",
|
||||
"/truenas/protection on the mock backend: 10 snapshot and replication events with canonical dataset labels (display.itemLabel); Failed status filter leaves one row with the URL unchanged; expanded detail leads with Kind, Outcome, Dataset, Host, then replication and target sections, no raw JSON",
|
||||
"390x844: /proxmox/backups/date and /truenas/protection have no horizontal page overflow",
|
||||
"Console: only WebSocket proxy failures to the peer mock backend's /ws (its origin check, REST data unaffected); no other errors"
|
||||
],
|
||||
"interactions": [
|
||||
"Clicked an activity day column (Sat 19 Sept on real data, Wed 7 Oct on mock) and read the URL and day group headers",
|
||||
"Clicked the 1y range button and measured page overflow, then reloaded the day URL",
|
||||
"Clicked the Coverage tab",
|
||||
"Clicked the Failed status filter on the TrueNAS Protection table",
|
||||
"Expanded the details row for archive/backups",
|
||||
"Resized to 390x844 and 1280x800 and re-measured overflow and tab/filter order"
|
||||
]
|
||||
}
|
||||
@@ -836,9 +836,12 @@ delivery for all resources.
|
||||
- `GET /api/admin/reports/catalog` (admin, `settings:read`)
|
||||
- Returns the canonical reporting catalog for the settings surface, including locked-state teaser copy, enabled-surface guidance copy, performance-report options, canonical single-report filename subject, canonical fallback filename date style, and the nested VM inventory export definition.
|
||||
- Metadata route: readable without the `advanced_reporting` feature so locked admin surfaces can render the same reporting definition before upsell.
|
||||
- `GET /api/admin/reports/generate` (admin, `settings:read`)
|
||||
- Query params: `format` (pdf/csv, default `pdf`), `resourceType`, `resourceId`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
|
||||
- `POST /api/admin/reports/generate` (admin, `settings:read`)
|
||||
- Body fields: `format` (pdf/csv, default `pdf`), `resourceType`, `resourceId`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
|
||||
- If `title` is omitted, the backend applies the canonical default title for that resource report.
|
||||
- When Pulse Assistant is configured, the configured AI provider narrates a PDF's executive summary; each narration counts against the AI budget and appears in AI usage. If the call fails, times out or the budget is spent, the PDF falls back to the deterministic summary. CSV output is never narrated. The settings UI uses this method.
|
||||
- `GET /api/admin/reports/generate` (admin, `settings:read`)
|
||||
- Takes the same fields as query params and returns the same report, except that it never calls an AI provider: a PDF carries the deterministic summary and a note saying AI narration needs `POST`. Other methods return `405` with `Allow: GET, POST`.
|
||||
- `POST /api/admin/reports/generate-multi` (admin, `settings:read`)
|
||||
- Body fields: `resources` (1-50 entries of `{resourceType,resourceId}`), `format`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
|
||||
- If `title` is omitted, the backend applies the canonical default fleet report title.
|
||||
@@ -851,8 +854,8 @@ Validation and limits:
|
||||
- Malformed `start`/`end` values, `end` not strictly after `start`, or report windows over 366 days return `400 invalid_time_range`.
|
||||
- `metricType` must match `[a-zA-Z0-9._:-]+` and be <= 64 chars, otherwise `400 invalid_metric_type`.
|
||||
- `title` must be <= 256 chars, otherwise `400 invalid_title`.
|
||||
- Multi-report body max size is 1MB; oversized payloads return `400 body_too_large`.
|
||||
- Multi-report bodies reject trailing payload and unknown JSON fields with `400 invalid_body`.
|
||||
- Report body max size is 1MB; oversized payloads return `400 body_too_large`.
|
||||
- Report bodies reject trailing payload and unknown JSON fields with `400 invalid_body`.
|
||||
- VM inventory export only accepts `csv`.
|
||||
|
||||
Common reporting error codes:
|
||||
|
||||
@@ -260,10 +260,12 @@ Each client runtime (or organization) generates its own reports, scoped to
|
||||
that client's resources:
|
||||
|
||||
- **UI**: Settings → Data & Reports.
|
||||
- **API**: `GET /api/admin/reports/generate` (single resource) and
|
||||
- **API**: `POST /api/admin/reports/generate` (single resource) and
|
||||
`POST /api/admin/reports/generate-multi` (up to 50 resources per report),
|
||||
returning PDF or CSV. In shared-process mode, scope with `X-Pulse-Org-ID`
|
||||
or an org-bound token.
|
||||
or an org-bound token. `GET /api/admin/reports/generate` with query
|
||||
parameters still returns a single-resource report, but never adds the
|
||||
Pulse Assistant narrative.
|
||||
- **Schedules**: `GET`/`POST /api/admin/reports/schedules`,
|
||||
`PUT`/`DELETE /api/admin/reports/schedules/{id}`, and
|
||||
`POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit
|
||||
|
||||
@@ -2000,9 +2000,17 @@
|
||||
{ "path": "src/components/patrol/RunHistoryEntry.tsx" },
|
||||
{ "path": "src/components/patrol/RunToolCallTrace.tsx" },
|
||||
{ "path": "src/features/proxmox/proxmoxBackupsTableShared.tsx" },
|
||||
{ "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx" }
|
||||
{ "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx" },
|
||||
{ "path": "src/components/Infrastructure/ResourceDetailSummary.tsx" }
|
||||
],
|
||||
"forbiddenPatterns": [
|
||||
{
|
||||
"path": "src/components/Infrastructure/ResourceDetailSummary.tsx",
|
||||
"patterns": [
|
||||
"inline-flex items-center rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px]",
|
||||
"inline-flex items-center rounded-sm bg-blue-100 px-1.5 py-0.5 text-[10px]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/components/AI/FindingsPanel.tsx",
|
||||
"patterns": [
|
||||
@@ -3241,21 +3249,6 @@
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chart-visibility-toggle-button",
|
||||
"category": "display-toggle",
|
||||
"summary": "Summary-bearing filter surfaces must compose ChartVisibilityToggleButton for chart show/hide display actions instead of recreating one-off chart toggle buttons.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/FilterToolbar.tsx",
|
||||
"export": "ChartVisibilityToggleButton"
|
||||
},
|
||||
"requiredConsumers": [{ "path": "src/components/Workloads/WorkloadsFilter.tsx" }],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/FilterToolbar.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "column-picker-shell",
|
||||
"category": "column-picker",
|
||||
@@ -4787,25 +4780,6 @@
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chart-visibility-local-toggle-labels",
|
||||
"category": "display-toggle",
|
||||
"summary": "Summary-bearing surfaces must not copy the Show charts / Hide charts display-toggle labels owned by ChartVisibilityToggleButton.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/FilterToolbar.tsx",
|
||||
"export": "ChartVisibilityToggleButton"
|
||||
},
|
||||
"scopes": ["src/components/Workloads", "src/features", "src/pages"],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": ["Show charts", "Hide charts"],
|
||||
"legacyReason": "Retired migration debt. Chart visibility display actions must use ChartVisibilityToggleButton; local show/hide chart toggle copies are not allowed.",
|
||||
"allowedPaths": [],
|
||||
"ignoredPaths": ["src/components/Workloads/__tests__/WorkloadsFilter.test.tsx"],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "column-picker-local-column-chooser",
|
||||
"category": "column-picker",
|
||||
|
||||
@@ -21,7 +21,7 @@ import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
|
||||
// response-handling of the currently-uncovered AIAPI request functions:
|
||||
// testConnection, resetCostHistory, exportCostHistory, getAnomalies,
|
||||
// getLearningStatus, analyzeKubernetesCluster, approveRemediationPlan,
|
||||
// executeRemediationPlan, rollbackRemediationPlan, getCircuitBreakerStatus.
|
||||
// executeRemediationPlan, rollbackRemediationPlan.
|
||||
// Each test asserts the concrete request (final path + query string + method +
|
||||
// body) and the concrete parsed result for happy paths and each optional /
|
||||
// default-parameter branch arm.
|
||||
@@ -421,58 +421,4 @@ describe('AIAPI branch coverage (request shaping + response handling)', () => {
|
||||
expect(result).toStrictEqual({ success: false });
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------
|
||||
// getCircuitBreakerStatus
|
||||
// --------------------------------------------
|
||||
describe('getCircuitBreakerStatus', () => {
|
||||
it('GETs /api/ai/circuit/status and returns a closed-breaker payload verbatim', async () => {
|
||||
const payload = {
|
||||
state: 'closed' as const,
|
||||
can_patrol: true,
|
||||
consecutive_failures: 0,
|
||||
total_successes: 128,
|
||||
total_failures: 1,
|
||||
};
|
||||
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
|
||||
|
||||
const result = await AIAPI.getCircuitBreakerStatus();
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/circuit/status');
|
||||
expect(result).toStrictEqual(payload);
|
||||
});
|
||||
|
||||
it('round-trips an open-breaker payload that blocks patrol', async () => {
|
||||
const payload = {
|
||||
state: 'open' as const,
|
||||
can_patrol: false,
|
||||
consecutive_failures: 5,
|
||||
total_successes: 100,
|
||||
total_failures: 10,
|
||||
};
|
||||
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
|
||||
|
||||
const result = await AIAPI.getCircuitBreakerStatus();
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/circuit/status');
|
||||
expect(result).toStrictEqual(payload);
|
||||
expect(result.can_patrol).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips a half-open breaker probe state', async () => {
|
||||
const payload = {
|
||||
state: 'half-open' as const,
|
||||
can_patrol: true,
|
||||
consecutive_failures: 0,
|
||||
total_successes: 100,
|
||||
total_failures: 5,
|
||||
};
|
||||
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
|
||||
|
||||
const result = await AIAPI.getCircuitBreakerStatus();
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/circuit/status');
|
||||
expect(result.state).toBe('half-open');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1250,7 +1250,6 @@ describe('AIChatAPI', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ onStreamOpen },
|
||||
);
|
||||
|
||||
@@ -1608,7 +1607,7 @@ describe('AIChatAPI', () => {
|
||||
expect(releaseLock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('includes a per-request autonomous override when supplied', async () => {
|
||||
it('sends no execution-mode field because the server sets approval-required mode', async () => {
|
||||
const read = vi.fn().mockResolvedValueOnce({ done: true, value: undefined });
|
||||
const releaseLock = vi.fn();
|
||||
|
||||
@@ -1620,27 +1619,25 @@ describe('AIChatAPI', () => {
|
||||
} as unknown as Response);
|
||||
|
||||
await AIChatAPI.chat(
|
||||
'summarize dashboard',
|
||||
'explain this issue',
|
||||
'session-1',
|
||||
undefined,
|
||||
vi.fn(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
'finding-123',
|
||||
'Current evidence',
|
||||
[{ id: 'vm-100', name: 'web-server', type: 'vm', node: 'pve-1' }],
|
||||
);
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/ai/chat',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
prompt: 'summarize dashboard',
|
||||
session_id: 'session-1',
|
||||
model: undefined,
|
||||
autonomous_mode: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const init = apiFetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
expect(body).toMatchObject({
|
||||
prompt: 'explain this issue',
|
||||
finding_id: 'finding-123',
|
||||
handoff_context: 'Current evidence',
|
||||
});
|
||||
expect(body).not.toHaveProperty('autonomous_mode');
|
||||
});
|
||||
|
||||
it('includes browser-safe Patrol run handoff metadata when supplied', async () => {
|
||||
@@ -1662,7 +1659,6 @@ describe('AIChatAPI', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -1682,7 +1678,6 @@ describe('AIChatAPI', () => {
|
||||
prompt: 'discuss run',
|
||||
session_id: 'session-run',
|
||||
model: undefined,
|
||||
autonomous_mode: false,
|
||||
handoff_metadata: {
|
||||
kind: 'patrol_run',
|
||||
run_id: 'run-runtime-error',
|
||||
@@ -1714,7 +1709,6 @@ describe('AIChatAPI', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
'finding-provider-settings',
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -1731,7 +1725,6 @@ describe('AIChatAPI', () => {
|
||||
session_id: 'session-finding',
|
||||
model: undefined,
|
||||
finding_id: 'finding-provider-settings',
|
||||
autonomous_mode: false,
|
||||
handoff_metadata: {
|
||||
kind: 'patrol_finding',
|
||||
},
|
||||
@@ -1793,7 +1786,6 @@ describe('AIChatAPI', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
'[Alert Incident Context]\nIncident ID: incident-1',
|
||||
[
|
||||
{
|
||||
@@ -1830,7 +1822,6 @@ describe('AIChatAPI', () => {
|
||||
prompt: 'discuss incident',
|
||||
session_id: 'session-3',
|
||||
model: undefined,
|
||||
autonomous_mode: false,
|
||||
handoff_context: '[Alert Incident Context]\nIncident ID: incident-1',
|
||||
handoff_resources: [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createRoot } from 'solid-js';
|
||||
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { useRecoveryPoints } from '@/hooks/useRecoveryPoints';
|
||||
import {
|
||||
normalizeRecoveryPointsResponse,
|
||||
normalizeRecoveryRollupsResponse,
|
||||
} from '@/utils/recoveryPlatformModel';
|
||||
|
||||
const apiFetchJSONMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: apiFetchJSONMock }));
|
||||
|
||||
afterEach(() => {
|
||||
apiFetchJSONMock.mockReset();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
});
|
||||
|
||||
const productionSources = (dir: string): string[] =>
|
||||
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === '__tests__' || entry.name === '__fixtures__'
|
||||
? []
|
||||
: productionSources(path);
|
||||
}
|
||||
return /\.(ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name) ? [path] : [];
|
||||
});
|
||||
|
||||
describe('recovery transport', () => {
|
||||
it('normalizes legacy subject recovery fields onto canonical item fields', () => {
|
||||
expect(
|
||||
@@ -63,4 +87,81 @@ describe('recovery transport', () => {
|
||||
meta: { page: 1, limit: 100, total: 1, totalPages: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes legacy display aliases onto the canonical item labels', () => {
|
||||
const [point] = normalizeRecoveryPointsResponse({
|
||||
data: [
|
||||
{
|
||||
id: 'point-2',
|
||||
platform: 'truenas',
|
||||
kind: 'snapshot',
|
||||
mode: 'snapshot',
|
||||
outcome: 'success',
|
||||
display: { subjectLabel: ' tank/apps ', subjectType: 'dataset' },
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 100, total: 1, totalPages: 1 },
|
||||
}).data;
|
||||
expect(point.display).toEqual({ itemLabel: 'tank/apps', itemType: 'dataset' });
|
||||
});
|
||||
|
||||
it('hands the TrueNAS Protection tab normalized recovery points', async () => {
|
||||
apiFetchJSONMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'point-3',
|
||||
provider: 'truenas',
|
||||
kind: 'snapshot',
|
||||
mode: 'snapshot',
|
||||
outcome: 'success',
|
||||
subjectRef: { type: 'truenas-dataset', name: 'tank/apps' },
|
||||
display: { subjectLabel: 'tank/apps', subjectType: 'dataset' },
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 200, total: 1, totalPages: 1 },
|
||||
});
|
||||
let dispose = () => {};
|
||||
const points = createRoot((rootDispose) => {
|
||||
dispose = rootDispose;
|
||||
return useRecoveryPoints(() => ({ platform: 'truenas', page: 1, limit: 200 })).points;
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(points()).toHaveLength(1));
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
'/api/recovery/points?page=1&limit=200&platform=truenas',
|
||||
);
|
||||
expect(points()[0]).toEqual({
|
||||
id: 'point-3',
|
||||
platform: 'truenas',
|
||||
kind: 'snapshot',
|
||||
mode: 'snapshot',
|
||||
outcome: 'success',
|
||||
itemRef: { type: 'truenas-dataset', name: 'tank/apps' },
|
||||
display: { itemLabel: 'tank/apps', itemType: 'dataset' },
|
||||
});
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps useRecoveryPoints the only recovery-points reader', () => {
|
||||
// The rollup, facet and series hooks went with the aggregate Recovery
|
||||
// page, and the item-type helper with the recovery query serializer that
|
||||
// was its last reader. The scan matches literal /api/recovery/ paths only.
|
||||
const recoveryReaders = productionSources('src')
|
||||
.filter((path) => /['"`]\/api\/recovery\//.test(readFileSync(path, 'utf8')))
|
||||
.sort();
|
||||
expect(recoveryReaders).toEqual([
|
||||
join('src', 'hooks', 'useProtectionPostures.ts'),
|
||||
join('src', 'hooks', 'useRecoveryPoints.ts'),
|
||||
]);
|
||||
for (const retired of [
|
||||
'src/hooks/useRecoveryRollups.ts',
|
||||
'src/hooks/useRecoveryPointsFacets.ts',
|
||||
'src/hooks/useRecoveryPointsSeries.ts',
|
||||
'src/utils/recoveryItemTypePresentation.ts',
|
||||
]) {
|
||||
expect(existsSync(retired), retired).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,11 +276,6 @@ export class AIAPI {
|
||||
}) as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
// Circuit breaker status
|
||||
static async getCircuitBreakerStatus(): Promise<CircuitBreakerStatus> {
|
||||
return apiFetchJSON(`${this.baseUrl}/ai/circuit/status`) as Promise<CircuitBreakerStatus>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Investigation Fix Approvals
|
||||
// ============================================
|
||||
@@ -567,14 +562,6 @@ export interface RemediationExecutionResult {
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface CircuitBreakerStatus {
|
||||
state: 'closed' | 'open' | 'half-open';
|
||||
can_patrol: boolean;
|
||||
consecutive_failures: number;
|
||||
total_successes: number;
|
||||
total_failures: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Investigation Fix Approval Types
|
||||
// ============================================
|
||||
|
||||
@@ -442,7 +442,6 @@ export class AIChatAPI {
|
||||
signal?: AbortSignal,
|
||||
mentions?: ChatMention[],
|
||||
findingId?: string,
|
||||
autonomousMode?: boolean,
|
||||
handoffContext?: string,
|
||||
handoffResources?: ChatHandoffResource[],
|
||||
handoffActions?: ChatHandoffAction[],
|
||||
@@ -466,9 +465,6 @@ export class AIChatAPI {
|
||||
if (findingId) {
|
||||
body.finding_id = findingId;
|
||||
}
|
||||
if (typeof autonomousMode === 'boolean') {
|
||||
body.autonomous_mode = autonomousMode;
|
||||
}
|
||||
if (handoffContext && handoffContext.trim()) {
|
||||
body.handoff_context = handoffContext;
|
||||
}
|
||||
|
||||
@@ -2749,8 +2749,9 @@ describe('AIChat', () => {
|
||||
expect.stringContaining('Explain this issue'),
|
||||
undefined,
|
||||
'finding-1',
|
||||
expect.objectContaining({ autonomousMode: false, handoffContext: 'Current evidence' }),
|
||||
expect.objectContaining({ handoffContext: 'Current evidence' }),
|
||||
);
|
||||
expect(mockChat.sendMessage.mock.calls[0]?.[3]).not.toHaveProperty('autonomousMode');
|
||||
expect(textarea).toHaveValue('My unfinished question');
|
||||
expect(mockChat.newSession).not.toHaveBeenCalled();
|
||||
expect(mockChat.stop).not.toHaveBeenCalled();
|
||||
@@ -3102,7 +3103,7 @@ describe('AIChat', () => {
|
||||
prompt: 'queued scoped prompt',
|
||||
mentions: [{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
|
||||
findingId: 'finding-1',
|
||||
sendOptions: { autonomousMode: false, handoffContext: 'scoped context' },
|
||||
sendOptions: { handoffContext: 'scoped context' },
|
||||
timestamp: new Date('2026-06-06T08:00:00Z'),
|
||||
};
|
||||
mockChat.queuedFollowUps.mockReturnValue([queued]);
|
||||
@@ -3132,7 +3133,7 @@ describe('AIChat', () => {
|
||||
'edited scoped prompt',
|
||||
[{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
|
||||
'finding-1',
|
||||
{ autonomousMode: false, handoffContext: 'scoped context' },
|
||||
{ handoffContext: 'scoped context' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3519,7 +3520,6 @@ describe('AIChat', () => {
|
||||
mentions: [{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
|
||||
findingId: 'finding-1',
|
||||
sendOptions: {
|
||||
autonomousMode: false,
|
||||
handoffContext: 'scoped context',
|
||||
},
|
||||
timestamp: new Date(),
|
||||
@@ -3547,7 +3547,6 @@ describe('AIChat', () => {
|
||||
[{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
|
||||
'finding-1',
|
||||
{
|
||||
autonomousMode: false,
|
||||
handoffContext: 'scoped context',
|
||||
},
|
||||
);
|
||||
@@ -3859,7 +3858,6 @@ describe('AIChat', () => {
|
||||
mentions: [{ id: 'vm:pve:101', name: 'vm-101', type: 'vm', node: 'pve' }],
|
||||
findingId: 'finding-101',
|
||||
model: 'openrouter:deepseek/deepseek-chat',
|
||||
autonomousMode: false,
|
||||
handoffContext: '[Patrol Finding Context]\nVM 101 has stale guest tools',
|
||||
},
|
||||
});
|
||||
@@ -3885,7 +3883,6 @@ describe('AIChat', () => {
|
||||
[{ id: 'vm:pve:101', name: 'vm-101', type: 'vm', node: 'pve' }],
|
||||
'finding-101',
|
||||
expect.objectContaining({
|
||||
autonomousMode: false,
|
||||
handoffContext: '[Patrol Finding Context]\nVM 101 has stale guest tools',
|
||||
model: 'openrouter:deepseek/deepseek-chat',
|
||||
}),
|
||||
@@ -4601,9 +4598,7 @@ describe('AIChat', () => {
|
||||
fireEvent.input(textarea, { target: { value: 'what failed?' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
|
||||
expect(mockChat.sendMessage).toHaveBeenCalledWith('what failed?', undefined, undefined, {
|
||||
autonomousMode: false,
|
||||
});
|
||||
expect(mockChat.sendMessage).toHaveBeenCalledWith('what failed?', undefined, undefined);
|
||||
});
|
||||
|
||||
it('does not restore handoff context when loading a session fails', async () => {
|
||||
@@ -4893,7 +4888,6 @@ describe('AIChat', () => {
|
||||
'what changed?',
|
||||
undefined,
|
||||
'finding-context-only',
|
||||
{ autonomousMode: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5793,7 +5787,6 @@ describe('AIChat', () => {
|
||||
'summarize this dashboard',
|
||||
undefined,
|
||||
undefined,
|
||||
{ autonomousMode: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5906,7 +5899,6 @@ describe('AIChat', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
autonomousMode: false,
|
||||
handoffContext:
|
||||
'[Alert Incident Context]\nIncident ID: incident-1\nTimeline Event 1: Command event recorded',
|
||||
handoffResources: [
|
||||
@@ -5989,7 +5981,6 @@ describe('AIChat', () => {
|
||||
'what changed next?',
|
||||
undefined,
|
||||
undefined,
|
||||
{ autonomousMode: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ type TestStreamDispatch = (event: TestStreamEvent) => void;
|
||||
type TestStreamLifecycle = { onStreamOpen?: () => void };
|
||||
|
||||
const latestStreamLifecycle = (): TestStreamLifecycle | undefined =>
|
||||
mockChat.mock.calls.at(-1)?.[12] as TestStreamLifecycle | undefined;
|
||||
mockChat.mock.calls.at(-1)?.[11] as TestStreamLifecycle | undefined;
|
||||
|
||||
function dispatchTestStreamEvent(
|
||||
onEvent: (event: StreamEvent) => void,
|
||||
@@ -606,7 +606,6 @@ describe('useChat', () => {
|
||||
[{ id: 'vm-100', name: 'web-1', type: 'vm', node: 'pve-1' }],
|
||||
'finding-42',
|
||||
{
|
||||
autonomousMode: false,
|
||||
handoffContext: '[Patrol Finding Context]\nFinding ID: finding-42',
|
||||
handoffResources: [
|
||||
{
|
||||
@@ -640,9 +639,8 @@ describe('useChat', () => {
|
||||
expect(retryCall[0]).toBe('check this resource');
|
||||
expect(retryCall[5]).toEqual([{ id: 'vm-100', name: 'web-1', type: 'vm', node: 'pve-1' }]);
|
||||
expect(retryCall[6]).toBe('finding-42');
|
||||
expect(retryCall[7]).toBe(false);
|
||||
expect(retryCall[8]).toBe('[Patrol Finding Context]\nFinding ID: finding-42');
|
||||
expect(retryCall[9]).toEqual([
|
||||
expect(retryCall[7]).toBe('[Patrol Finding Context]\nFinding ID: finding-42');
|
||||
expect(retryCall[8]).toEqual([
|
||||
{
|
||||
id: 'vm-100',
|
||||
name: 'web-1',
|
||||
@@ -650,14 +648,14 @@ describe('useChat', () => {
|
||||
node: 'pve-1',
|
||||
},
|
||||
]);
|
||||
expect(retryCall[10]).toEqual([
|
||||
expect(retryCall[9]).toEqual([
|
||||
{
|
||||
findingId: 'finding-42',
|
||||
approvalId: 'approval-1',
|
||||
approvalStatus: 'pending',
|
||||
},
|
||||
]);
|
||||
expect(retryCall[11]).toEqual({
|
||||
expect(retryCall[10]).toEqual({
|
||||
kind: 'patrol_finding',
|
||||
});
|
||||
dispose();
|
||||
@@ -716,26 +714,11 @@ describe('useChat', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('passes a scoped autonomous-mode override to the API', async () => {
|
||||
mockChat.mockResolvedValue(undefined);
|
||||
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' }));
|
||||
await chat.sendMessage('summarize dashboard', undefined, undefined, {
|
||||
autonomousMode: false,
|
||||
});
|
||||
|
||||
const chatCall = mockChat.mock.calls[0];
|
||||
expect(chatCall[0]).toBe('summarize dashboard');
|
||||
expect(chatCall[7]).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('passes model-only handoff context and resource references to the API', async () => {
|
||||
mockChat.mockResolvedValue(undefined);
|
||||
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' }));
|
||||
await chat.sendMessage('explain this incident', undefined, undefined, {
|
||||
autonomousMode: false,
|
||||
handoffContext: '[Alert Incident Context]\nIncident ID: incident-1',
|
||||
handoffResources: [
|
||||
{
|
||||
@@ -755,9 +738,8 @@ describe('useChat', () => {
|
||||
});
|
||||
|
||||
const chatCall = mockChat.mock.calls[0];
|
||||
expect(chatCall[7]).toBe(false);
|
||||
expect(chatCall[8]).toBe('[Alert Incident Context]\nIncident ID: incident-1');
|
||||
expect(chatCall[9]).toEqual([
|
||||
expect(chatCall[7]).toBe('[Alert Incident Context]\nIncident ID: incident-1');
|
||||
expect(chatCall[8]).toEqual([
|
||||
{
|
||||
id: 'storage-1',
|
||||
name: 'tank',
|
||||
@@ -765,13 +747,14 @@ describe('useChat', () => {
|
||||
node: 'nas-1',
|
||||
},
|
||||
]);
|
||||
expect(chatCall[10]).toEqual([
|
||||
expect(chatCall[9]).toEqual([
|
||||
{
|
||||
findingId: 'finding-1',
|
||||
approvalId: 'approval-1',
|
||||
approvalStatus: 'pending',
|
||||
},
|
||||
]);
|
||||
expect(chatCall).toHaveLength(12);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -1134,7 +1117,7 @@ describe('useChat', () => {
|
||||
'second',
|
||||
[{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
|
||||
'finding-1',
|
||||
{ autonomousMode: false, handoffContext: 'scoped context' },
|
||||
{ handoffContext: 'scoped context' },
|
||||
);
|
||||
|
||||
const queued = chat.queuedFollowUps()[0];
|
||||
@@ -1146,7 +1129,7 @@ describe('useChat', () => {
|
||||
prompt: 'second',
|
||||
mentions: [{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
|
||||
findingId: 'finding-1',
|
||||
sendOptions: { autonomousMode: false, handoffContext: 'scoped context' },
|
||||
sendOptions: { handoffContext: 'scoped context' },
|
||||
});
|
||||
expect(chat.queuedFollowUpCount()).toBe(0);
|
||||
expect(chat.messages().some((message) => message.content === 'second')).toBe(false);
|
||||
@@ -1252,7 +1235,6 @@ describe('useChat', () => {
|
||||
_signal?: AbortSignal,
|
||||
_mentions?: ChatMention[],
|
||||
_findingId?: string,
|
||||
_autonomousMode?: boolean,
|
||||
_handoffContext?: string,
|
||||
_handoffResources?: unknown[],
|
||||
_handoffActions?: unknown[],
|
||||
@@ -3803,7 +3785,6 @@ describe('useChat', () => {
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess-undo' }));
|
||||
await chat.sendMessage('inspect vm-101', mentions, 'finding-101', {
|
||||
model: 'openrouter:deepseek/deepseek-chat',
|
||||
autonomousMode: false,
|
||||
handoffContext: '[Patrol Finding Context]\nVM 101 has stale guest tools',
|
||||
});
|
||||
|
||||
@@ -3818,7 +3799,6 @@ describe('useChat', () => {
|
||||
mentions,
|
||||
findingId: 'finding-101',
|
||||
model: 'openrouter:deepseek/deepseek-chat',
|
||||
autonomousMode: false,
|
||||
handoffContext: '[Patrol Finding Context]\nVM 101 has stale guest tools',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -50,12 +50,13 @@ describe('explicit issue explanations', () => {
|
||||
});
|
||||
await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
|
||||
expect(send).toHaveBeenCalledWith(EXPLAIN_SELECTED_ISSUE_PROMPT, 'finding-1', {
|
||||
autonomousMode: false,
|
||||
handoffContext: 'Current finding evidence',
|
||||
handoffResources: resources,
|
||||
handoffActions: actions,
|
||||
handoffMetadata: { kind: 'patrol_finding' },
|
||||
});
|
||||
// The drawer still discloses the approval-required posture the server sets.
|
||||
expect(aiChatStore.context.autonomousMode).toBe(false);
|
||||
aiChatStore.close();
|
||||
aiChatStore.open();
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -111,7 +111,6 @@ export interface UseChatOptions {
|
||||
|
||||
export interface SendMessageOptions {
|
||||
model?: string;
|
||||
autonomousMode?: boolean;
|
||||
handoffContext?: string;
|
||||
handoffResources?: ChatHandoffResource[];
|
||||
handoffActions?: ChatHandoffAction[];
|
||||
@@ -1158,9 +1157,6 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
if (request.model) {
|
||||
cloned.model = request.model;
|
||||
}
|
||||
if (typeof request.autonomousMode === 'boolean') {
|
||||
cloned.autonomousMode = request.autonomousMode;
|
||||
}
|
||||
if (request.handoffContext) {
|
||||
cloned.handoffContext = request.handoffContext;
|
||||
}
|
||||
@@ -1187,9 +1183,6 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
if (modelRoute) {
|
||||
requestContext.model = modelRoute;
|
||||
}
|
||||
if (typeof sendOptions.autonomousMode === 'boolean') {
|
||||
requestContext.autonomousMode = sendOptions.autonomousMode;
|
||||
}
|
||||
if (sendOptions.handoffContext) {
|
||||
requestContext.handoffContext = sendOptions.handoffContext;
|
||||
}
|
||||
@@ -1216,9 +1209,6 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
if (modelRoute) {
|
||||
next.model = modelRoute;
|
||||
}
|
||||
if (typeof sendOptions?.autonomousMode === 'boolean') {
|
||||
next.autonomousMode = sendOptions.autonomousMode;
|
||||
}
|
||||
if (sendOptions?.handoffContext) {
|
||||
next.handoffContext = sendOptions.handoffContext;
|
||||
}
|
||||
@@ -1263,9 +1253,6 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
if (request.model) {
|
||||
sendOptions.model = request.model;
|
||||
}
|
||||
if (typeof request.autonomousMode === 'boolean') {
|
||||
sendOptions.autonomousMode = request.autonomousMode;
|
||||
}
|
||||
if (request.handoffContext) {
|
||||
sendOptions.handoffContext = request.handoffContext;
|
||||
}
|
||||
@@ -2106,7 +2093,6 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
abortController.signal,
|
||||
mentions,
|
||||
findingId,
|
||||
requestSendOptions?.autonomousMode,
|
||||
requestSendOptions?.handoffContext,
|
||||
requestSendOptions?.handoffResources,
|
||||
requestSendOptions?.handoffActions,
|
||||
|
||||
@@ -7,7 +7,6 @@ export const EXPLAIN_SELECTED_ISSUE_PROMPT =
|
||||
|
||||
export function explanationSendOptions(context: AIChatContext): SendMessageOptions {
|
||||
return {
|
||||
autonomousMode: false,
|
||||
handoffContext: context.handoffContext,
|
||||
handoffResources: context.handoffResources,
|
||||
handoffActions: context.handoffActions,
|
||||
|
||||
@@ -1609,9 +1609,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
if (request.model) {
|
||||
sendOptions.model = request.model;
|
||||
}
|
||||
if (typeof request.autonomousMode === 'boolean') {
|
||||
sendOptions.autonomousMode = request.autonomousMode;
|
||||
}
|
||||
if (request.handoffContext) {
|
||||
sendOptions.handoffContext = request.handoffContext;
|
||||
}
|
||||
@@ -3504,9 +3501,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
? sendOptionsFromRestoredRequest(restoredDraft.request)
|
||||
: {};
|
||||
if (!queuedDraft && !restoredDraft) {
|
||||
if (typeof ctx.autonomousMode === 'boolean') {
|
||||
sendOptions.autonomousMode = ctx.autonomousMode;
|
||||
}
|
||||
if (ctx.handoffContext && ctx.handoffContext.trim()) {
|
||||
sendOptions.handoffContext = ctx.handoffContext;
|
||||
}
|
||||
@@ -3522,7 +3516,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
}
|
||||
const hasSendOptions =
|
||||
Boolean(sendOptions.model) ||
|
||||
typeof sendOptions.autonomousMode === 'boolean' ||
|
||||
Boolean(sendOptions.handoffContext) ||
|
||||
Boolean(sendOptions.handoffResources?.length) ||
|
||||
Boolean(sendOptions.handoffActions?.length) ||
|
||||
|
||||
@@ -156,7 +156,6 @@ export interface ChatMessageRequestContext {
|
||||
mentions?: ChatMention[];
|
||||
findingId?: string;
|
||||
model?: string;
|
||||
autonomousMode?: boolean;
|
||||
handoffContext?: string;
|
||||
handoffResources?: ChatHandoffResource[];
|
||||
handoffActions?: ChatHandoffAction[];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { For } from 'solid-js';
|
||||
|
||||
import type { AlertResourceRelaxedTriggerSummary } from './alertResourceTableModel';
|
||||
|
||||
/**
|
||||
* One line under a pulse-relaxed guest's threshold row naming the tag and the
|
||||
* thresholds it raises. The tag and each threshold never wrap mid-token, so a
|
||||
* narrow card does not split "pulse-relaxed" at its hyphen.
|
||||
*/
|
||||
export function AlertResourceRelaxedTriggers(props: {
|
||||
summary: AlertResourceRelaxedTriggerSummary;
|
||||
class: string;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
class={props.class}
|
||||
title={props.summary.title}
|
||||
data-testid="alert-resource-relaxed-triggers"
|
||||
>
|
||||
Proxmox tag <span class="whitespace-nowrap">pulse-relaxed</span>: alerts at{' '}
|
||||
<For each={props.summary.raised}>
|
||||
{(threshold, index) => (
|
||||
<>
|
||||
{index() > 0 ? ' · ' : ''}
|
||||
<span class="whitespace-nowrap">{threshold}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
getAlertResourceMetricBounds,
|
||||
getAlertResourceMetricDisplayValue,
|
||||
getAlertResourceMetricStep,
|
||||
getAlertResourceRelaxedTriggerSummary,
|
||||
isAlertResourceMetricOff,
|
||||
isAlertResourceMetricOverridden,
|
||||
normalizeAlertResourceMetricKey,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
resolveAlertResourceMetricEnableValue,
|
||||
} from './alertResourceTableModel';
|
||||
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
|
||||
import { AlertResourceRelaxedTriggers } from './AlertResourceRelaxedTriggers';
|
||||
import type { OfflineState, ResourceTableProps } from './ResourceTable';
|
||||
|
||||
interface AlertResourceTableMobileProps {
|
||||
@@ -368,6 +370,14 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
||||
const displayValue = (metric: string) => getDisplayValue(resource, metric, isEditing());
|
||||
const isOverridden = (metric: string) =>
|
||||
isAlertResourceMetricOverridden(resource, metric);
|
||||
const relaxedTriggerSummary = () =>
|
||||
props.table.globalDisableFlag?.()
|
||||
? null
|
||||
: getAlertResourceRelaxedTriggerSummary(
|
||||
resource,
|
||||
props.table.editingThresholds(),
|
||||
isEditing(),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -477,6 +487,12 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={relaxedTriggerSummary()}>
|
||||
{(summary) => (
|
||||
<AlertResourceRelaxedTriggers summary={summary()} class="text-xs text-muted" />
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={isEditing()}>
|
||||
<FormTextarea
|
||||
label="Override note"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getAlertResourceMetricBounds,
|
||||
getAlertResourceMetricDisplayValue,
|
||||
getAlertResourceMetricStep,
|
||||
getAlertResourceRelaxedTriggerSummary,
|
||||
isAlertResourceMetricOff,
|
||||
isAlertResourceMetricOverridden,
|
||||
normalizeAlertResourceMetricKey,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { AlertIntentSignal } from '@/api/alertIntentPolicies';
|
||||
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
|
||||
import { AlertResourceRelaxedTriggers } from './AlertResourceRelaxedTriggers';
|
||||
|
||||
type OfflineState = 'off' | 'warning' | 'critical';
|
||||
|
||||
@@ -94,6 +96,14 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
|
||||
isEditing(),
|
||||
);
|
||||
const isOverridden = (metric: string) => isAlertResourceMetricOverridden(props.resource, metric);
|
||||
const relaxedTriggerSummary = () =>
|
||||
props.globalDisableFlag?.()
|
||||
? null
|
||||
: getAlertResourceRelaxedTriggerSummary(
|
||||
props.resource,
|
||||
props.editingThresholds(),
|
||||
isEditing(),
|
||||
);
|
||||
const preferredIntentSignal = (): AlertIntentSignal => {
|
||||
for (const metric of ['cpu', 'memory', 'disk'] as const) {
|
||||
if (
|
||||
@@ -322,6 +332,11 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
|
||||
onInput={(e) => props.setEditingNote(e.currentTarget.value)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={relaxedTriggerSummary()}>
|
||||
{(summary) => (
|
||||
<AlertResourceRelaxedTriggers summary={summary()} class="mt-1 text-xs text-muted" />
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!isEditing() && props.resource.note}>
|
||||
<p class="mt-2 text-xs italic text-muted wrap-break-word">
|
||||
{props.resource.note as string}
|
||||
|
||||
@@ -586,6 +586,82 @@ describe('ResourceTable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pulse-relaxed guests', () => {
|
||||
const relaxedGuest = (overrides: Partial<Resource> = {}) =>
|
||||
makeResource({
|
||||
id: 'vm-1',
|
||||
name: 'batch-vm',
|
||||
type: 'guest',
|
||||
pulseRelaxed: true,
|
||||
thresholds: {},
|
||||
defaults: { cpu: 80, memory: 85, disk: 90 },
|
||||
...overrides,
|
||||
});
|
||||
const renderGrouped = (resource: Resource, overrides: Record<string, any> = {}) =>
|
||||
render(() => (
|
||||
<ResourceTable
|
||||
{...makeProps({
|
||||
resources: undefined,
|
||||
groupedResources: { node1: [resource] },
|
||||
columns: ['CPU %', 'Memory %', 'Disk %'],
|
||||
...overrides,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
it.each([
|
||||
['desktop', false],
|
||||
['mobile', true],
|
||||
])('states the thresholds the tag raises on the %s row', (_layout, mobile) => {
|
||||
mockIsMobile.mockReturnValue(mobile);
|
||||
renderGrouped(relaxedGuest());
|
||||
|
||||
const line = screen.getByTestId('alert-resource-relaxed-triggers');
|
||||
expect(line).toHaveTextContent(
|
||||
'Proxmox tag pulse-relaxed: alerts at CPU 95% · Memory 92% · Disk 95%',
|
||||
);
|
||||
expect(line.getAttribute('title')).toContain('Remove the tag in Proxmox');
|
||||
// The row keeps showing the configured values the editor changes.
|
||||
expect(screen.getByText('80')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits metrics the tag leaves alone', () => {
|
||||
renderGrouped(relaxedGuest({ thresholds: { cpu: -1, memory: 96 } }));
|
||||
|
||||
expect(screen.getByTestId('alert-resource-relaxed-triggers')).toHaveTextContent(
|
||||
'Proxmox tag pulse-relaxed: alerts at Disk 95%',
|
||||
);
|
||||
});
|
||||
|
||||
it('follows unsaved edits', () => {
|
||||
renderGrouped(relaxedGuest(), {
|
||||
editingId: () => 'vm-1',
|
||||
editingThresholds: () => ({ cpu: 97, memory: 93, disk: 70 }),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('alert-resource-relaxed-triggers')).toHaveTextContent(
|
||||
'Proxmox tag pulse-relaxed: alerts at Disk 95%',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays hidden when the tag raises nothing or alerts are off', () => {
|
||||
renderGrouped(relaxedGuest({ thresholds: { cpu: 99, memory: 99, disk: 99 } }));
|
||||
expect(screen.queryByTestId('alert-resource-relaxed-triggers')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
renderGrouped(relaxedGuest({ disabled: true }));
|
||||
expect(screen.queryByTestId('alert-resource-relaxed-triggers')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
renderGrouped(relaxedGuest(), { globalDisableFlag: () => true });
|
||||
expect(screen.queryByTestId('alert-resource-relaxed-triggers')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
renderGrouped(relaxedGuest({ pulseRelaxed: false }));
|
||||
expect(screen.queryByTestId('alert-resource-relaxed-triggers')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('metric display values', () => {
|
||||
it('shows formatted metric values using formatMetricValue', () => {
|
||||
const formatMetricValue = vi.fn((metric: string, value: number | undefined) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Resource as UnifiedResource, ResourcePolicy } from '@/types/resource';
|
||||
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
|
||||
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
|
||||
import { getPulseRelaxedGuestTrigger, type MetricType } from '@/utils/metricThresholds';
|
||||
|
||||
const COLUMN_TOOLTIP_LOOKUP: Record<string, string> = {
|
||||
'cpu %': 'Percent CPU utilization allowed before an alert fires.',
|
||||
@@ -430,6 +431,54 @@ export function getAlertResourceMetricDisplayValue(
|
||||
return fallback !== undefined ? fallback : 0;
|
||||
}
|
||||
|
||||
const RELAXED_TRIGGER_METRIC_LABELS: Record<MetricType, string> = {
|
||||
cpu: 'CPU',
|
||||
memory: 'Memory',
|
||||
disk: 'Disk',
|
||||
};
|
||||
|
||||
export interface AlertResourceRelaxedTriggerSummary {
|
||||
/** Raised thresholds in metric order, e.g. "CPU 95%". */
|
||||
raised: string[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The alert thresholds a guest's pulse-relaxed tag raises above the values
|
||||
* its row shows, as the alert engine applies them, or null when the tag
|
||||
* raises none. Reads the values being edited, so the line follows an edit
|
||||
* before it is saved.
|
||||
*/
|
||||
export function getAlertResourceRelaxedTriggerSummary(
|
||||
resource: AlertResourceTableResourceLike,
|
||||
editingThresholds?: AlertResourceThresholdMap,
|
||||
isEditing = false,
|
||||
): AlertResourceRelaxedTriggerSummary | null {
|
||||
if (resource.pulseRelaxed !== true || resource.disabled === true) {
|
||||
return null;
|
||||
}
|
||||
const rowThresholds = isEditing ? editingThresholds : resource.thresholds;
|
||||
const raised = (['cpu', 'memory', 'disk'] as const).flatMap((metric) => {
|
||||
const configured =
|
||||
parseAlertMetricNumber(rowThresholds?.[metric]) ??
|
||||
parseAlertMetricNumber(resource.defaults?.[metric]);
|
||||
const trigger = getPulseRelaxedGuestTrigger(metric, configured);
|
||||
if (trigger === null || (configured !== undefined && trigger <= configured)) {
|
||||
return [];
|
||||
}
|
||||
return [`${RELAXED_TRIGGER_METRIC_LABELS[metric]} ${trigger}%`];
|
||||
});
|
||||
if (raised.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
raised,
|
||||
title:
|
||||
'This guest is tagged pulse-relaxed in Proxmox, which raises these alert thresholds ' +
|
||||
'above the values in this row. Remove the tag in Proxmox to alert at the row values.',
|
||||
};
|
||||
}
|
||||
|
||||
export function isAlertResourceMetricOverridden(
|
||||
resource: AlertResourceTableResourceLike,
|
||||
metric: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, Show, type Component, type JSX } from 'solid-js';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { isContainerUpdatePinned } from '@/components/shared/containerUpdateBadgeModel';
|
||||
import { DetailSectionTable } from '@/components/shared/DetailSectionTable';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
import {
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
@@ -234,12 +235,9 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={props.drawer.identityIpValues()}>
|
||||
{(ip) => (
|
||||
<span
|
||||
class="inline-flex items-center rounded-sm bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/25 dark:text-blue-200"
|
||||
title={ip}
|
||||
>
|
||||
<MetadataBadge tone="info" size="xs" shape="rounded" wrap title={ip}>
|
||||
{ip}
|
||||
</span>
|
||||
</MetadataBadge>
|
||||
)}
|
||||
</For>
|
||||
</div>,
|
||||
@@ -259,20 +257,17 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={props.drawer.aliasPreviewValues()}>
|
||||
{(value) => (
|
||||
<span
|
||||
class="inline-flex items-center rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px]"
|
||||
title={value}
|
||||
>
|
||||
<MetadataBadge size="xs" shape="rounded" wrap title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</MetadataBadge>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.drawer.hasAliasOverflow()}>
|
||||
<span class="inline-flex items-center rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px] text-muted">
|
||||
<MetadataBadge tone="muted" size="xs" shape="rounded">
|
||||
+
|
||||
{props.drawer.identityAliasValues().length -
|
||||
props.drawer.aliasPreviewValues().length}
|
||||
</span>
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
</div>,
|
||||
)
|
||||
|
||||
+2
@@ -906,6 +906,8 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
|
||||
expect(inlineAliases.closest('summary')).toBeNull();
|
||||
expect(inlineRender.container.querySelectorAll('details')).toHaveLength(1);
|
||||
expect(inlineRender.getByText('agent-inline-1')).toBeInTheDocument();
|
||||
// A long alias wraps inside the drawer cell instead of spilling past it.
|
||||
expect(inlineRender.getByText('agent-inline-1')).toHaveClass('max-w-full', 'wrap-anywhere');
|
||||
expect(inlineRender.getAllByText('inline-host.local').length).toBeGreaterThan(0);
|
||||
|
||||
const overflowResource = baseResource({
|
||||
|
||||
@@ -35,6 +35,7 @@ const baseCatalog = {
|
||||
title: 'Performance Reports',
|
||||
description: 'Historical performance reporting',
|
||||
singleResourceEndpoint: '/api/admin/reports/generate',
|
||||
singleResourceMethod: 'POST',
|
||||
multiResourceEndpoint: '/api/admin/reports/generate-multi',
|
||||
singleFilenamePrefix: 'report',
|
||||
singleFilenameSubject: 'resource_id',
|
||||
|
||||
+14
@@ -42,6 +42,7 @@ const basePerformanceReport = () => ({
|
||||
title: 'Performance Reports',
|
||||
description: 'Historical performance reporting',
|
||||
singleResourceEndpoint: '/api/admin/reports/generate',
|
||||
singleResourceMethod: 'POST',
|
||||
multiResourceEndpoint: '/api/admin/reports/generate-multi',
|
||||
singleFilenamePrefix: 'report',
|
||||
singleFilenameSubject: 'resource_id',
|
||||
@@ -453,6 +454,19 @@ describe('parseReportingPerformanceReportDefinition (via catalog.performanceRepo
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when singleResourceMethod is neither GET nor POST', () => {
|
||||
expectInvalid({
|
||||
...baseCatalog(),
|
||||
performanceReport: { ...basePerformanceReport(), singleResourceMethod: 'PUT' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a missing singleResourceMethod as GET, the only method older backends accept', () => {
|
||||
const { singleResourceMethod: _omitted, ...withoutMethod } = basePerformanceReport();
|
||||
const parsed = parseReportingCatalog({ ...baseCatalog(), performanceReport: withoutMethod });
|
||||
expect(parsed.performanceReport.singleResourceMethod).toBe('GET');
|
||||
});
|
||||
|
||||
it('throws when multiResourceEndpoint is not a string', () => {
|
||||
expectInvalid({
|
||||
...baseCatalog(),
|
||||
|
||||
@@ -22,6 +22,7 @@ const baseCatalogPayload = {
|
||||
title: 'Performance Reports',
|
||||
description: 'Historical performance reporting',
|
||||
singleResourceEndpoint: '/api/admin/reports/generate',
|
||||
singleResourceMethod: 'POST',
|
||||
multiResourceEndpoint: '/api/admin/reports/generate-multi',
|
||||
singleFilenamePrefix: 'report',
|
||||
singleFilenameSubject: 'resource_id',
|
||||
@@ -74,6 +75,7 @@ describe('reporting catalog model', () => {
|
||||
const catalog = buildLegacyReportingCatalogFallback();
|
||||
|
||||
expect(catalog.performanceReport.singleResourceEndpoint).toBe('/api/reporting');
|
||||
expect(catalog.performanceReport.singleResourceMethod).toBe('GET');
|
||||
expect(catalog.performanceReport.multiResourceEndpoint).toBe('/api/reporting/generate-multi');
|
||||
expect(catalog.vmInventoryExport).toBeNull();
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ const performanceDefinition: ReportingPerformanceReportDefinition = {
|
||||
title: 'Performance Reports',
|
||||
description: 'Historical performance reporting',
|
||||
singleResourceEndpoint: '/api/admin/reports/generate',
|
||||
singleResourceMethod: 'POST',
|
||||
multiResourceEndpoint: '/api/admin/reports/generate-multi',
|
||||
singleFilenamePrefix: 'report',
|
||||
singleFilenameSubject: 'resource_id',
|
||||
@@ -75,12 +76,52 @@ describe('reporting panel model', () => {
|
||||
);
|
||||
|
||||
expect(request.filename).toBe('report-agent-1-20260320.pdf');
|
||||
// POST, never GET: only POST may narrate the PDF with Pulse Assistant.
|
||||
expect(request.request.url).toBe('/api/admin/reports/generate');
|
||||
expect(request.request.init).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(JSON.parse(String(request.request.init?.body))).toEqual({
|
||||
resourceType: 'agent',
|
||||
resourceId: 'agent-1',
|
||||
format: 'pdf',
|
||||
start: '2026-03-19T12:34:56.000Z',
|
||||
end: now.toISOString(),
|
||||
metricType: 'cpu',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the query-string GET transport for backends whose catalog names GET', () => {
|
||||
const now = new Date('2026-03-20T12:34:56.000Z');
|
||||
const request = buildReportingRequest(
|
||||
{
|
||||
end: now.toISOString(),
|
||||
format: 'csv',
|
||||
metricType: 'cpu',
|
||||
now,
|
||||
resources: [{ id: 'vm-1', type: 'vm', name: 'vm-a' }],
|
||||
start: '2026-03-19T12:34:56.000Z',
|
||||
title: '',
|
||||
},
|
||||
{
|
||||
...performanceDefinition,
|
||||
singleResourceEndpoint: '/api/reporting',
|
||||
singleResourceMethod: 'GET',
|
||||
},
|
||||
);
|
||||
|
||||
expect(request.request.init).toBeUndefined();
|
||||
expect(request.request.url).toContain('/api/admin/reports/generate?');
|
||||
expect(request.request.url).toContain('resourceType=agent');
|
||||
expect(request.request.url).toContain('resourceId=agent-1');
|
||||
expect(request.request.url).toContain('metricType=cpu');
|
||||
expect(request.request.url).not.toContain('title=');
|
||||
const url = new URL(request.request.url, 'http://pulse.local');
|
||||
expect(url.pathname).toBe('/api/reporting');
|
||||
expect(Object.fromEntries(url.searchParams)).toEqual({
|
||||
resourceType: 'vm',
|
||||
resourceId: 'vm-1',
|
||||
format: 'csv',
|
||||
start: '2026-03-19T12:34:56.000Z',
|
||||
end: now.toISOString(),
|
||||
metricType: 'cpu',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a fleet reporting request body and filename', () => {
|
||||
@@ -158,9 +199,9 @@ describe('reporting panel model', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(request.request.url).not.toContain('title=');
|
||||
expect(request.request.url).not.toContain('metricType=');
|
||||
expect(request.request.url).not.toContain('Custom+fleet+title');
|
||||
const body = JSON.parse(String(request.request.init?.body));
|
||||
expect(body).not.toHaveProperty('title');
|
||||
expect(body).not.toHaveProperty('metricType');
|
||||
});
|
||||
|
||||
it('passes through explicit custom titles without inventing defaults', () => {
|
||||
@@ -186,7 +227,7 @@ describe('reporting panel model', () => {
|
||||
performanceDefinition,
|
||||
);
|
||||
|
||||
expect(request.request.url).toContain('title=Custom+report+title');
|
||||
expect(JSON.parse(String(request.request.init?.body)).title).toBe('Custom report title');
|
||||
});
|
||||
|
||||
it('derives canonical range starts from the selected preset', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ const catalogPayload = {
|
||||
title: 'Performance Reports',
|
||||
description: 'Historical performance reporting',
|
||||
singleResourceEndpoint: '/api/admin/reports/generate',
|
||||
singleResourceMethod: 'POST',
|
||||
multiResourceEndpoint: '/api/admin/reports/generate-multi',
|
||||
singleFilenamePrefix: 'report',
|
||||
singleFilenameSubject: 'resource_id',
|
||||
@@ -478,6 +479,48 @@ describe('useReportingPanelState', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('posts a single-resource report as a JSON body through apiFetch', async () => {
|
||||
apiFetchMock.mockImplementation((url: string) => {
|
||||
if (url === '/api/admin/reports/generate') {
|
||||
return Promise.resolve(new Response('%PDF', { status: 200 }));
|
||||
}
|
||||
if (url === '/api/admin/reports/schedules') {
|
||||
return Promise.resolve(jsonResponse(schedulesPayload));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(catalogPayload));
|
||||
});
|
||||
const { createObjectURL, revokeObjectURL } = URL;
|
||||
URL.createObjectURL = vi.fn(() => 'blob:report');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
try {
|
||||
const { hookState, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
|
||||
hookState.setSelectedResources([{ id: 'vm-1', type: 'vm', name: 'vm-a' }]);
|
||||
await hookState.handleGenerate();
|
||||
|
||||
const generate = apiFetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).startsWith('/api/admin/reports/generate'),
|
||||
);
|
||||
expect(generate).toHaveLength(1);
|
||||
expect(generate[0][0]).toBe('/api/admin/reports/generate');
|
||||
expect(generate[0][1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(JSON.parse(String(generate[0][1].body))).toMatchObject({
|
||||
resourceType: 'vm',
|
||||
resourceId: 'vm-1',
|
||||
format: catalogPayload.performanceReport.defaultFormat,
|
||||
});
|
||||
expect(showWarningMock).not.toHaveBeenCalled();
|
||||
dispose();
|
||||
} finally {
|
||||
URL.createObjectURL = createObjectURL;
|
||||
URL.revokeObjectURL = revokeObjectURL;
|
||||
}
|
||||
});
|
||||
|
||||
it('saves a Patrol weekly summary without a resource scope', async () => {
|
||||
const savedSchedule = {
|
||||
id: 'digest-1',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
export type ReportingFormat = 'pdf' | 'csv';
|
||||
export type ReportingFilenameDateStyle = 'utc_yyyymmdd';
|
||||
export type ReportingFilenameSubject = 'resource_id';
|
||||
export type ReportingSingleResourceMethod = 'GET' | 'POST';
|
||||
|
||||
export interface ReportingFormatDefinition {
|
||||
value: ReportingFormat;
|
||||
@@ -24,6 +25,7 @@ export interface ReportingPerformanceReportDefinition {
|
||||
title: string;
|
||||
description: string;
|
||||
singleResourceEndpoint: string;
|
||||
singleResourceMethod: ReportingSingleResourceMethod;
|
||||
multiResourceEndpoint: string;
|
||||
singleFilenamePrefix: string;
|
||||
singleFilenameSubject: ReportingFilenameSubject;
|
||||
@@ -85,6 +87,7 @@ export function buildLegacyReportingCatalogFallback(): ReportingCatalog {
|
||||
description:
|
||||
'Generate PDF summaries or CSV metric exports from historical monitoring data for one or more selected resources.',
|
||||
singleResourceEndpoint: '/api/reporting',
|
||||
singleResourceMethod: 'GET',
|
||||
multiResourceEndpoint: '/api/reporting/generate-multi',
|
||||
singleFilenamePrefix: 'report',
|
||||
singleFilenameSubject: 'resource_id',
|
||||
@@ -176,6 +179,9 @@ function parseReportingPerformanceReportDefinition(
|
||||
typeof candidate.title !== 'string' ||
|
||||
typeof candidate.description !== 'string' ||
|
||||
typeof candidate.singleResourceEndpoint !== 'string' ||
|
||||
(candidate.singleResourceMethod !== undefined &&
|
||||
candidate.singleResourceMethod !== 'GET' &&
|
||||
candidate.singleResourceMethod !== 'POST') ||
|
||||
typeof candidate.multiResourceEndpoint !== 'string' ||
|
||||
typeof candidate.singleFilenamePrefix !== 'string' ||
|
||||
candidate.singleFilenameSubject !== 'resource_id' ||
|
||||
@@ -211,6 +217,8 @@ function parseReportingPerformanceReportDefinition(
|
||||
title: candidate.title,
|
||||
description: candidate.description,
|
||||
singleResourceEndpoint: candidate.singleResourceEndpoint,
|
||||
// Backends that predate the field accept only GET.
|
||||
singleResourceMethod: candidate.singleResourceMethod ?? 'GET',
|
||||
multiResourceEndpoint: candidate.multiResourceEndpoint,
|
||||
singleFilenamePrefix: candidate.singleFilenamePrefix,
|
||||
singleFilenameSubject: candidate.singleFilenameSubject,
|
||||
|
||||
@@ -96,6 +96,7 @@ export function buildReportingRequest(
|
||||
| 'singleFilenamePrefix'
|
||||
| 'singleFilenameSubject'
|
||||
| 'singleResourceEndpoint'
|
||||
| 'singleResourceMethod'
|
||||
| 'supportsCustomTitle'
|
||||
| 'supportsMetricFilter'
|
||||
>,
|
||||
@@ -105,23 +106,41 @@ export function buildReportingRequest(
|
||||
|
||||
if (context.resources.length === 1) {
|
||||
const resource = context.resources[0];
|
||||
const params = new URLSearchParams({
|
||||
const fields = {
|
||||
resourceType: toReportingResourceType(resource.type),
|
||||
resourceId: resource.id,
|
||||
format: context.format,
|
||||
start: context.start,
|
||||
end: context.end,
|
||||
});
|
||||
title: customTitle || undefined,
|
||||
metricType: metricType || undefined,
|
||||
};
|
||||
const filename = buildReportingFilename(context.format, resource.id, context.now, definition);
|
||||
|
||||
if (metricType) {
|
||||
params.append('metricType', metricType);
|
||||
}
|
||||
if (customTitle) {
|
||||
params.append('title', customTitle);
|
||||
// Only POST may narrate a PDF with Pulse Assistant; the catalog names
|
||||
// GET only for backends that accept nothing else.
|
||||
if (definition.singleResourceMethod === 'POST') {
|
||||
return {
|
||||
filename,
|
||||
request: {
|
||||
url: definition.singleResourceEndpoint,
|
||||
init: {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
filename: buildReportingFilename(context.format, resource.id, context.now, definition),
|
||||
filename,
|
||||
request: {
|
||||
url: `${definition.singleResourceEndpoint}?${params.toString()}`,
|
||||
},
|
||||
|
||||
@@ -75,6 +75,8 @@ let wsState: any = {
|
||||
const reconnectSpy = vi.fn();
|
||||
|
||||
let hookResources: Resource[] = [];
|
||||
// Bump to deliver hookResources as a live update to an already rendered page.
|
||||
const [hookResourcesVersion, setHookResourcesVersion] = createSignal(0);
|
||||
let hookLoading = false;
|
||||
let hookError: unknown = undefined;
|
||||
let alertsDetectionEnabled = true;
|
||||
@@ -344,8 +346,12 @@ vi.mock('@/hooks/useUnifiedResources', () => ({
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
useUnifiedResources: (options?: { cacheKey?: string }) => ({
|
||||
resources: () =>
|
||||
options?.cacheKey === 'storage-page' ? [...nodeResources, ...hookResources] : nodeResources,
|
||||
resources: () => {
|
||||
hookResourcesVersion();
|
||||
return options?.cacheKey === 'storage-page'
|
||||
? [...nodeResources, ...hookResources]
|
||||
: nodeResources;
|
||||
},
|
||||
loading: () => (options?.cacheKey === 'storage-page' ? hookLoading : false),
|
||||
error: () => (options?.cacheKey === 'storage-page' ? hookError : undefined),
|
||||
refetch: vi.fn(),
|
||||
@@ -481,6 +487,65 @@ describe('Storage', () => {
|
||||
expect(screen.queryByText('Local-LVM-PVE2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives a platform embed the canonical toolbar with source and node scope locked', async () => {
|
||||
mockLocationSearch = '?node=node-2&source=truenas';
|
||||
hookResources = [
|
||||
buildStorageResource('storage-1', 'Local-LVM-PVE1', 'pve1', {
|
||||
parentId: 'node-1',
|
||||
parentName: 'pve1',
|
||||
includePlatformNode: false,
|
||||
}),
|
||||
buildStorageResource('storage-2', 'Local-LVM-PVE2', 'pve2', {
|
||||
parentId: 'node-2',
|
||||
parentName: 'pve2',
|
||||
includePlatformNode: false,
|
||||
}),
|
||||
buildStorageResource('storage-3', 'Tank-TrueNAS', 'truenas-1', {
|
||||
platformId: 'truenas-main',
|
||||
platformType: 'truenas',
|
||||
}),
|
||||
];
|
||||
|
||||
render(() => (
|
||||
<Storage
|
||||
forcedSourceFilter="proxmox-all"
|
||||
suppressNodeFilter
|
||||
filterAriaLabel="Proxmox storage filters"
|
||||
filterSearchPlaceholder="Search Proxmox storage by pool, datastore, node, or device"
|
||||
/>
|
||||
));
|
||||
|
||||
// No prop opts the embed into the toolbar or keeps it out of the view tabs.
|
||||
expect(screen.getByRole('group', { name: 'Proxmox storage filters' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search Proxmox storage by pool, datastore, node, or device'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('tablist', { name: 'Storage view' })).toBeInTheDocument();
|
||||
|
||||
// The forced source outranks the URL source, and the URL node resets to all.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Local-LVM-PVE1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Local-LVM-PVE2')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Tank-TrueNAS')).not.toBeInTheDocument();
|
||||
expect(navigateSpy.mock.calls.some(([path]) => String(path).includes('source='))).toBe(false);
|
||||
|
||||
// Neither lock surfaces as a chip, an Add filter option, or a clear action.
|
||||
expect(queryStorageChip('Source')).toBeNull();
|
||||
expect(queryStorageChip('Node')).toBeNull();
|
||||
const addFilter = screen.getByRole('combobox', { name: 'Filter' });
|
||||
expect(within(addFilter).getByRole('option', { name: 'Status: Warning' })).toBeInTheDocument();
|
||||
expect(
|
||||
within(addFilter).queryByRole('option', { name: /^(Source|Node):/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /charts/i })).not.toBeInTheDocument();
|
||||
|
||||
const viewOptions = openStorageViewOptions();
|
||||
expect(within(viewOptions).getByLabelText('Group by')).toBeInTheDocument();
|
||||
expect(within(viewOptions).getByLabelText('Sort by')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders per-pool growth from the shared storage summary history contract', async () => {
|
||||
const gib = 1024 * 1024 * 1024;
|
||||
const storageSummarySpy = vi.spyOn(ChartsAPI, 'getStorageSummaryCharts').mockResolvedValue({
|
||||
@@ -877,6 +942,73 @@ describe('Storage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not scroll back to an open pool when a live update rebuilds the pools', async () => {
|
||||
const scrollTo = vi.fn();
|
||||
const originalScrollTo = Object.getOwnPropertyDescriptor(window, 'scrollTo');
|
||||
Object.defineProperty(window, 'scrollTo', { configurable: true, value: scrollTo });
|
||||
hookResources = [
|
||||
buildStorageResource('storage-1', 'Node-Store', 'pve1'),
|
||||
buildStorageResource('storage-2', 'Edge-Store', 'pve2'),
|
||||
];
|
||||
mockLocationSearch = '?group=node';
|
||||
|
||||
try {
|
||||
render(() => <Storage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Expand Node-Store' }));
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('tr[data-inline-detail-for]')).toBeTruthy();
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
// Count only once the reveal's settle frames stop scrolling.
|
||||
let previousScrolls = -1;
|
||||
while (scrollTo.mock.calls.length !== previousScrolls) {
|
||||
previousScrolls = scrollTo.mock.calls.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const scrollsAfterFocus = scrollTo.mock.calls.length;
|
||||
|
||||
hookResources = hookResources.map((resource) => ({ ...resource, lastSeen: Date.now() }));
|
||||
setHookResourcesVersion((version) => version + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Collapse Node-Store' })).toBeInTheDocument();
|
||||
expect(scrollTo).toHaveBeenCalledTimes(scrollsAfterFocus);
|
||||
} finally {
|
||||
if (originalScrollTo) {
|
||||
Object.defineProperty(window, 'scrollTo', originalScrollTo);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the group of an open pool collapsed across live updates', async () => {
|
||||
hookResources = [
|
||||
buildStorageResource('storage-1', 'Node-Store', 'pve1'),
|
||||
buildStorageResource('storage-2', 'Edge-Store', 'pve2'),
|
||||
];
|
||||
mockLocationSearch = '?group=node';
|
||||
|
||||
render(() => <Storage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Expand Node-Store' }));
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('tr[data-inline-detail-for]')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse pve1' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(screen.queryByText('Node-Store')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Expand pve1' })).toBeInTheDocument();
|
||||
|
||||
hookResources = hookResources.map((resource) => ({ ...resource, lastSeen: Date.now() }));
|
||||
setHookResourcesVersion((version) => version + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(screen.queryByText('Node-Store')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Expand pve1' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears pinned storage group scope from the content-card header action', async () => {
|
||||
hookResources = [
|
||||
buildStorageResource('storage-1', 'Node-Store', 'pve1'),
|
||||
@@ -1395,7 +1527,7 @@ describe('Storage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the storage view selector available in table-only embedding', async () => {
|
||||
it('keeps the storage view selector available in a platform-page embed', async () => {
|
||||
mockLocationPath = '/proxmox/storage';
|
||||
hookResources = [
|
||||
buildPhysicalDiskResource('sda', 'node-1', 'pve1'),
|
||||
|
||||
-18
@@ -4,7 +4,6 @@ import {
|
||||
getActiveStorageNodeOptions,
|
||||
getStorageFilterGroupBy,
|
||||
storageResourceMatchesSourceFilter,
|
||||
syncExpandedStorageGroups,
|
||||
toStorageHealthFilterValue,
|
||||
type StoragePageNodeOption,
|
||||
type StorageStatusFilterValue,
|
||||
@@ -134,21 +133,4 @@ describe('storagePageState branch coverage 0723pm', () => {
|
||||
expect(storageResourceMatchesSourceFilter(node, 'proxmox-pve')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncExpandedStorageGroups (changed=false arm)', () => {
|
||||
it('returns the previous set unchanged (same reference) when every key is already present', () => {
|
||||
// No new keys => `changed` stays false => the `: previous` arm fires and
|
||||
// the input reference is returned verbatim.
|
||||
const previous = new Set(['A', 'B']);
|
||||
const result = syncExpandedStorageGroups(previous, ['A', 'B']);
|
||||
expect(result).toBe(previous);
|
||||
expect(result).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
|
||||
it('returns previous unchanged when allKeys is empty but previous is non-empty', () => {
|
||||
const previous = new Set(['A']);
|
||||
const result = syncExpandedStorageGroups(previous, []);
|
||||
expect(result).toBe(previous);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,8 +36,9 @@ import {
|
||||
normalizeStorageView,
|
||||
readStorageRouteValue,
|
||||
storageResourceMatchesSourceFilter,
|
||||
syncExpandedStorageGroups,
|
||||
toggleExpandedStorageGroup,
|
||||
haveSameStorageGroups,
|
||||
resolveExpandedStorageGroups,
|
||||
toggleCollapsedStorageGroup,
|
||||
toStorageHealthFilterValue,
|
||||
writeStorageRouteValue,
|
||||
} from '@/components/Storage/storagePageState';
|
||||
@@ -207,10 +208,14 @@ describe('storagePageState', () => {
|
||||
});
|
||||
|
||||
it('keeps expanded storage groups canonical across data refreshes', () => {
|
||||
expect(syncExpandedStorageGroups(new Set(), ['A', 'B'])).toEqual(new Set(['A', 'B']));
|
||||
expect(syncExpandedStorageGroups(new Set(['A']), ['A', 'B'])).toEqual(new Set(['A', 'B']));
|
||||
expect(toggleExpandedStorageGroup(new Set(['A']), 'A')).toEqual(new Set());
|
||||
expect(toggleExpandedStorageGroup(new Set(['A']), 'B')).toEqual(new Set(['A', 'B']));
|
||||
expect(resolveExpandedStorageGroups(['A', 'B'], new Set())).toEqual(new Set(['A', 'B']));
|
||||
expect(resolveExpandedStorageGroups(['A', 'B'], new Set(['B']))).toEqual(new Set(['A']));
|
||||
expect(resolveExpandedStorageGroups(['A', 'B'], new Set(['A', 'B']))).toEqual(new Set());
|
||||
expect(toggleCollapsedStorageGroup(new Set(['A']), 'A')).toEqual(new Set());
|
||||
expect(toggleCollapsedStorageGroup(new Set(['A']), 'B')).toEqual(new Set(['A', 'B']));
|
||||
expect(haveSameStorageGroups(new Set(['A', 'B']), new Set(['B', 'A']))).toBe(true);
|
||||
expect(haveSameStorageGroups(new Set(['A']), new Set(['B']))).toBe(false);
|
||||
expect(haveSameStorageGroups(new Set(['A']), new Set(['A', 'B']))).toBe(false);
|
||||
});
|
||||
|
||||
it('derives active storage filters canonically', () => {
|
||||
|
||||
@@ -2,14 +2,17 @@ import { renderHook } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useStorageExpansionState } from '@/components/Storage/useStorageExpansionState';
|
||||
import type { StorageGroupKey } from '@/components/Storage/useStorageModel';
|
||||
|
||||
describe('useStorageExpansionState', () => {
|
||||
it('syncs expanded groups from grouped keys and toggles them', () => {
|
||||
const [groupedKeys] = createSignal(['alpha', 'beta']);
|
||||
const [view] = createSignal<'pools' | 'disks'>('pools');
|
||||
const [groupBy] = createSignal<StorageGroupKey>('node');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStorageExpansionState({
|
||||
groupBy,
|
||||
groupedKeys,
|
||||
view,
|
||||
}),
|
||||
@@ -21,12 +24,67 @@ describe('useStorageExpansionState', () => {
|
||||
expect([...result.expandedGroups()]).toEqual(['beta']);
|
||||
});
|
||||
|
||||
it('clears expanded pool state when switching away from pools', () => {
|
||||
const [groupedKeys] = createSignal(['alpha']);
|
||||
const [view, setView] = createSignal<'pools' | 'disks'>('pools');
|
||||
it('keeps collapsed groups collapsed when live updates rebuild the group keys', () => {
|
||||
const [groupedKeys, setGroupedKeys] = createSignal(['alpha', 'beta']);
|
||||
const [view] = createSignal<'pools' | 'disks'>('pools');
|
||||
const [groupBy] = createSignal<StorageGroupKey>('node');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStorageExpansionState({
|
||||
groupBy,
|
||||
groupedKeys,
|
||||
view,
|
||||
}),
|
||||
);
|
||||
|
||||
result.toggleGroup('alpha');
|
||||
setGroupedKeys(['alpha', 'beta']);
|
||||
expect([...result.expandedGroups()]).toEqual(['beta']);
|
||||
|
||||
// A group that appears later opens by default.
|
||||
setGroupedKeys(['alpha', 'beta', 'gamma']);
|
||||
expect([...result.expandedGroups()]).toEqual(['beta', 'gamma']);
|
||||
|
||||
// Collapsing every group sticks too.
|
||||
result.toggleGroup('beta');
|
||||
result.toggleGroup('gamma');
|
||||
setGroupedKeys(['alpha', 'beta', 'gamma']);
|
||||
expect([...result.expandedGroups()]).toEqual([]);
|
||||
|
||||
result.toggleGroup('alpha');
|
||||
expect([...result.expandedGroups()]).toEqual(['alpha']);
|
||||
});
|
||||
|
||||
it('opens every group when the grouping changes', () => {
|
||||
// A node labelled like the headerless ungrouped container must not hide it.
|
||||
const [groupedKeys, setGroupedKeys] = createSignal(['All', 'pve1']);
|
||||
const [groupBy, setGroupBy] = createSignal<StorageGroupKey>('node');
|
||||
const [view] = createSignal<'pools' | 'disks'>('pools');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStorageExpansionState({
|
||||
groupBy,
|
||||
groupedKeys,
|
||||
view,
|
||||
}),
|
||||
);
|
||||
|
||||
result.toggleGroup('All');
|
||||
expect([...result.expandedGroups()]).toEqual(['pve1']);
|
||||
|
||||
setGroupBy('none');
|
||||
setGroupedKeys(['All']);
|
||||
expect([...result.expandedGroups()]).toEqual(['All']);
|
||||
});
|
||||
|
||||
it('clears expanded pool state when switching away from pools', () => {
|
||||
const [groupedKeys] = createSignal(['alpha']);
|
||||
const [view, setView] = createSignal<'pools' | 'disks'>('pools');
|
||||
const [groupBy] = createSignal<StorageGroupKey>('node');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStorageExpansionState({
|
||||
groupBy,
|
||||
groupedKeys,
|
||||
view,
|
||||
}),
|
||||
|
||||
@@ -416,24 +416,17 @@ export const buildStorageNodeOnlineByLabel = (nodes: Resource[]): Map<string, bo
|
||||
return map;
|
||||
};
|
||||
|
||||
export const syncExpandedStorageGroups = (
|
||||
previous: Set<string>,
|
||||
allKeys: string[],
|
||||
): Set<string> => {
|
||||
if (previous.size === 0) return new Set(allKeys);
|
||||
// Groups open by default and only the groups the operator collapsed are stored,
|
||||
// so a live update that rebuilds the group list never reopens a collapsed one.
|
||||
export const resolveExpandedStorageGroups = (
|
||||
allKeys: readonly string[],
|
||||
collapsed: ReadonlySet<string>,
|
||||
): Set<string> => new Set(allKeys.filter((key) => !collapsed.has(key)));
|
||||
|
||||
const next = new Set(previous);
|
||||
let changed = false;
|
||||
for (const key of allKeys) {
|
||||
if (!next.has(key)) {
|
||||
next.add(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : previous;
|
||||
};
|
||||
export const haveSameStorageGroups = (a: ReadonlySet<string>, b: ReadonlySet<string>): boolean =>
|
||||
a.size === b.size && [...a].every((key) => b.has(key));
|
||||
|
||||
export const toggleExpandedStorageGroup = (previous: Set<string>, key: string): Set<string> => {
|
||||
export const toggleCollapsedStorageGroup = (previous: Set<string>, key: string): Set<string> => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { createEffect, createSignal, type Accessor } from 'solid-js';
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from 'solid-js';
|
||||
import {
|
||||
syncExpandedStorageGroups,
|
||||
toggleExpandedStorageGroup,
|
||||
haveSameStorageGroups,
|
||||
resolveExpandedStorageGroups,
|
||||
toggleCollapsedStorageGroup,
|
||||
type StorageView,
|
||||
} from './storagePageState';
|
||||
import type { StorageGroupKey } from './useStorageModel';
|
||||
|
||||
type UseStorageExpansionStateOptions = {
|
||||
groupBy: Accessor<StorageGroupKey>;
|
||||
groupedKeys: Accessor<string[]>;
|
||||
view: Accessor<StorageView>;
|
||||
};
|
||||
|
||||
export const useStorageExpansionState = (options: UseStorageExpansionStateOptions) => {
|
||||
const [expandedGroups, setExpandedGroups] = createSignal<Set<string>>(new Set());
|
||||
const [collapsedGroups, setCollapsedGroups] = createSignal<Set<string>>(new Set());
|
||||
const [expandedPoolId, setExpandedPoolId] = createSignal<string | null>(null);
|
||||
const expandedGroups = createMemo(
|
||||
() => resolveExpandedStorageGroups(options.groupedKeys(), collapsedGroups()),
|
||||
undefined,
|
||||
{ equals: haveSameStorageGroups },
|
||||
);
|
||||
|
||||
// Group keys are bare labels, so collapse state belongs to one grouping:
|
||||
// regrouping opens every group, including the headerless ungrouped one.
|
||||
createEffect(() => {
|
||||
setExpandedGroups((prev) => syncExpandedStorageGroups(prev, options.groupedKeys()));
|
||||
options.groupBy();
|
||||
setCollapsedGroups(new Set<string>());
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
@@ -25,7 +36,7 @@ export const useStorageExpansionState = (options: UseStorageExpansionStateOption
|
||||
});
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups((prev) => toggleExpandedStorageGroup(prev, key));
|
||||
setCollapsedGroups((prev) => toggleCollapsedStorageGroup(prev, key));
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -165,6 +165,7 @@ export const useStoragePageModel = (options: UseStoragePageModelOptions = {}) =>
|
||||
setExpandedPoolId: setExpandedPoolIdRaw,
|
||||
toggleGroup,
|
||||
} = useStorageExpansionState({
|
||||
groupBy,
|
||||
groupedKeys: () => groupedRecords().map((group) => group.key),
|
||||
view,
|
||||
});
|
||||
|
||||
@@ -338,11 +338,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||
}
|
||||
data-workload-alert-accent={alertAccentTone()}
|
||||
data-summary-series-id={guestId()}
|
||||
data-summary-group-member-active={
|
||||
props.summaryGroupMemberState && props.summaryGroupMemberState !== 'default'
|
||||
? props.summaryGroupMemberState
|
||||
: undefined
|
||||
}
|
||||
data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'}
|
||||
on:click={rowActionProps.onClick ? nativeRowClickTarget : undefined}
|
||||
on:mousedown={rowActionProps.onClick ? selectionGuard.onMouseDown : undefined}
|
||||
|
||||
@@ -5,26 +5,18 @@ import {
|
||||
GROUPED_TABLE_ROW_BADGE_CLASS,
|
||||
getGroupedTableRowCellClass,
|
||||
getGroupedTableRowClass,
|
||||
getInteractiveGroupedTableRowClass,
|
||||
} from '@/components/shared/groupedTableRowPresentation';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
|
||||
import { createSummaryInteractiveRowPreviewHandlers } from '@/components/shared/summaryInteractionA11y';
|
||||
import { buildSummaryDisclosureControlsId } from '@/components/shared/summaryInteractionA11y';
|
||||
import { TableBody, TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { getAlertsForResource, getAlertStyles } from '@/utils/alerts';
|
||||
import { guestOverrideIdCandidates } from '@/features/alerts/guestOverrideIdentity';
|
||||
import { isNodeOnline } from '@/utils/status';
|
||||
import { getCanonicalWorkloadId, getWorkloadMetadataIdCandidates } from '@/utils/workloads';
|
||||
import {
|
||||
resolveSummaryGroupMemberInteractionState,
|
||||
type SummarySeriesGroupScope,
|
||||
} from '@/components/shared/summaryCardInteraction';
|
||||
|
||||
import { GuestDrawer } from './GuestDrawer';
|
||||
import { GuestRow } from './GuestRow';
|
||||
import { NodeDrawer } from './NodeDrawer';
|
||||
import { buildWorkloadSummaryGroupScope } from './workloadSelectors';
|
||||
import { getWorkloadGuestMetadataRecord } from './workloadGuestMetadataRecord';
|
||||
import type { WorkloadsState } from './useWorkloadsState';
|
||||
|
||||
@@ -34,27 +26,19 @@ type WorkloadPanelProps = Pick<
|
||||
| 'alertsEnabled'
|
||||
| 'bottomSpacerHeight'
|
||||
| 'getGroupLabel'
|
||||
| 'getNodeTemperatureThresholds'
|
||||
| 'groupedGuests'
|
||||
| 'groupedWindowing'
|
||||
| 'groupLabelBadges'
|
||||
| 'guestMetadata'
|
||||
| 'guestParentNodeMap'
|
||||
| 'groupNodeDrawerMode'
|
||||
| 'groupingMode'
|
||||
| 'handleCustomUrlUpdate'
|
||||
| 'handleTagClick'
|
||||
| 'activeSummaryWorkloadGroupScope'
|
||||
| 'activeSummaryWorkloadId'
|
||||
| 'focusedSummaryWorkloadGroupScope'
|
||||
| 'focusedSummaryWorkloadGroupId'
|
||||
| 'hoveredSummaryWorkloadGroupScope'
|
||||
| 'nestedWorkloadContextByGuestId'
|
||||
| 'nodeByInstance'
|
||||
| 'search'
|
||||
| 'selectedGuestId'
|
||||
| 'setFocusedWorkloadGroupScope'
|
||||
| 'setHoveredWorkloadGroupScope'
|
||||
| 'setHoveredWorkloadId'
|
||||
| 'setSelectedGuestId'
|
||||
| 'setTableBodyRef'
|
||||
@@ -100,52 +84,12 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
);
|
||||
const groupGuestIds = createMemo(() => groupGuests().map(getCanonicalWorkloadId));
|
||||
const node = () => props.nodeByInstance()[groupKey];
|
||||
const groupSummaryScope = createMemo<SummarySeriesGroupScope | null>(() => {
|
||||
if (props.groupingMode() !== 'grouped') {
|
||||
return null;
|
||||
}
|
||||
return buildWorkloadSummaryGroupScope(
|
||||
groupKey,
|
||||
fullGroupGuests(),
|
||||
props.getGroupLabel(groupKey, fullGroupGuests()),
|
||||
);
|
||||
});
|
||||
const isSummaryGroupHighlighted = createMemo(
|
||||
() => props.activeSummaryWorkloadGroupScope()?.id === groupKey,
|
||||
);
|
||||
const shouldShowNodeDrawer = createMemo(
|
||||
() =>
|
||||
props.groupNodeDrawerMode() === 'inline' &&
|
||||
Boolean(node()) &&
|
||||
props.focusedSummaryWorkloadGroupId() === groupKey &&
|
||||
props.selectedGuestId() === null,
|
||||
);
|
||||
const canOpenNodeDrawer = () => props.groupNodeDrawerMode() === 'inline';
|
||||
const handleGroupHoverChange = (next: SummarySeriesGroupScope | null) => {
|
||||
props.setHoveredWorkloadGroupScope(next);
|
||||
};
|
||||
const handleGroupFocusToggle = () => {
|
||||
if (!canOpenNodeDrawer()) return;
|
||||
const scope = groupSummaryScope();
|
||||
const selectedGuestId = props.selectedGuestId();
|
||||
const nextFocusedScope =
|
||||
scope &&
|
||||
props.focusedSummaryWorkloadGroupId() === scope.id &&
|
||||
selectedGuestId === null
|
||||
? null
|
||||
: scope;
|
||||
if (nextFocusedScope && selectedGuestId !== null) {
|
||||
props.setSelectedGuestId(null);
|
||||
}
|
||||
props.setFocusedWorkloadGroupScope(nextFocusedScope);
|
||||
};
|
||||
const groupRowInteraction = createSummaryInteractiveRowPreviewHandlers({
|
||||
onPreview: () => handleGroupHoverChange(groupSummaryScope()),
|
||||
onPreviewClear: () => handleGroupHoverChange(null),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Group rows are identity-only dividers: host details open from the
|
||||
platform page's own hosts table. data-summary-group-id keeps a click
|
||||
on a divider from clearing an open guest drawer. */}
|
||||
<Show
|
||||
when={
|
||||
props.groupingMode() === 'grouped' && groupGuests()[0] === fullGroupGuests()[0]
|
||||
@@ -154,20 +98,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
<Show
|
||||
when={node()}
|
||||
fallback={
|
||||
<TableRow
|
||||
class={
|
||||
canOpenNodeDrawer()
|
||||
? getInteractiveGroupedTableRowClass()
|
||||
: getGroupedTableRowClass()
|
||||
}
|
||||
data-summary-group-id={groupKey}
|
||||
data-summary-group-series-count={String(
|
||||
groupSummaryScope()?.seriesIds.length ?? 0,
|
||||
)}
|
||||
data-summary-row-active={isSummaryGroupHighlighted() ? 'true' : 'false'}
|
||||
onClick={canOpenNodeDrawer() ? handleGroupFocusToggle : undefined}
|
||||
{...(canOpenNodeDrawer() ? groupRowInteraction : {})}
|
||||
>
|
||||
<TableRow class={getGroupedTableRowClass()} data-summary-group-id={groupKey}>
|
||||
<TableCell
|
||||
colspan={props.totalColumns()}
|
||||
class={getGroupedTableRowCellClass()}
|
||||
@@ -197,45 +128,10 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
node={node()!}
|
||||
renderAs="tr"
|
||||
colspan={props.totalColumns()}
|
||||
trClass={
|
||||
canOpenNodeDrawer()
|
||||
? 'cursor-pointer select-none duration-150 [&>td>div]:flex-nowrap'
|
||||
: 'select-none duration-150 [&>td>div]:flex-nowrap'
|
||||
}
|
||||
trProps={{
|
||||
'aria-expanded': canOpenNodeDrawer()
|
||||
? shouldShowNodeDrawer()
|
||||
? 'true'
|
||||
: 'false'
|
||||
: undefined,
|
||||
'data-summary-group-id': groupKey,
|
||||
'data-summary-group-series-count': String(
|
||||
groupSummaryScope()?.seriesIds.length ?? 0,
|
||||
),
|
||||
'data-summary-row-active': isSummaryGroupHighlighted() ? 'true' : 'false',
|
||||
onClick: canOpenNodeDrawer() ? handleGroupFocusToggle : undefined,
|
||||
...(canOpenNodeDrawer() ? groupRowInteraction : {}),
|
||||
}}
|
||||
trClass="select-none duration-150 [&>td>div]:flex-nowrap"
|
||||
trProps={{ 'data-summary-group-id': groupKey }}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={shouldShowNodeDrawer()}>
|
||||
<InlineDetailTableRow
|
||||
colspan={props.totalColumns()}
|
||||
data-inline-node-detail-for={groupKey}
|
||||
>
|
||||
<NodeDrawer
|
||||
node={node()!}
|
||||
onClose={() => props.setFocusedWorkloadGroupScope(null)}
|
||||
temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}
|
||||
alerts={getAlertsForResource(
|
||||
[node()!.id],
|
||||
props.activeAlerts,
|
||||
props.alertsEnabled(),
|
||||
node()!.name,
|
||||
)}
|
||||
/>
|
||||
</InlineDetailTableRow>
|
||||
</Show>
|
||||
</Show>
|
||||
<For each={groupGuestIds()} fallback={<></>}>
|
||||
{(keyedGuestId) => {
|
||||
@@ -284,11 +180,6 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
}
|
||||
isExpanded={props.selectedGuestId() === guestId()}
|
||||
isSummaryHighlighted={props.activeSummaryWorkloadId() === guestId()}
|
||||
summaryGroupMemberState={resolveSummaryGroupMemberInteractionState({
|
||||
seriesId: guestId(),
|
||||
hoveredGroupScope: props.hoveredSummaryWorkloadGroupScope(),
|
||||
focusedGroupScope: props.focusedSummaryWorkloadGroupScope(),
|
||||
})}
|
||||
ioEmphasis={props.workloadIOEmphasis()}
|
||||
metricDisplayMode={props.workloadMetricDisplayMode()}
|
||||
metricHoverMode={props.workloadMetricHoverMode()}
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
type FilterDef,
|
||||
type FilterSelectOption,
|
||||
} from '@/components/shared/FilterBar';
|
||||
import {
|
||||
ChartVisibilityToggleButton,
|
||||
FilterActionButton,
|
||||
FilterSegmentedControl,
|
||||
} from '@/components/shared/FilterToolbar';
|
||||
import { FilterActionButton, FilterSegmentedControl } from '@/components/shared/FilterToolbar';
|
||||
import { GroupedTableModeSegmentedControl } from '@/components/shared/GroupedTableModeSegmentedControl';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||
@@ -426,19 +422,6 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.onChartsToggle}>
|
||||
<div>
|
||||
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
|
||||
Summary
|
||||
</div>
|
||||
<ChartVisibilityToggleButton
|
||||
class="inline-flex!"
|
||||
collapsed={props.chartsCollapsed?.() ?? false}
|
||||
onToggle={() => props.onChartsToggle?.()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.columnVisibility}>
|
||||
{(visibility) => (
|
||||
<div>
|
||||
|
||||
@@ -173,9 +173,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
|
||||
props.memoryDisplayBasis ? state.workloadMemoryDisplayBasis : undefined
|
||||
}
|
||||
forcedPlatform={props.forcedPlatform}
|
||||
pinnedSelectionActive={() =>
|
||||
Boolean(state.selectedGuestId() || state.focusedSummaryWorkloadGroupId())
|
||||
}
|
||||
pinnedSelectionActive={() => Boolean(state.selectedGuestId())}
|
||||
onClearPinnedSelection={state.clearPinnedSummaryScope}
|
||||
/>
|
||||
</div>
|
||||
@@ -193,29 +191,21 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
|
||||
alertsEnabled={state.alertsEnabled}
|
||||
bottomSpacerHeight={state.bottomSpacerHeight}
|
||||
getGroupLabel={state.getGroupLabel}
|
||||
getNodeTemperatureThresholds={state.getNodeTemperatureThresholds}
|
||||
groupedGuests={state.groupedGuests}
|
||||
groupedWindowing={state.groupedWindowing}
|
||||
groupLabelBadges={state.groupLabelBadges}
|
||||
guestMetadata={state.guestMetadata}
|
||||
guestParentNodeMap={state.guestParentNodeMap}
|
||||
groupNodeDrawerMode={state.groupNodeDrawerMode}
|
||||
groupingMode={state.groupingMode}
|
||||
handleCustomUrlUpdate={state.handleCustomUrlUpdate}
|
||||
handleSort={state.handleSort}
|
||||
handleTagClick={state.handleTagClick}
|
||||
activeSummaryWorkloadGroupScope={state.activeSummaryWorkloadGroupScope}
|
||||
activeSummaryWorkloadId={state.activeSummaryWorkloadId}
|
||||
focusedSummaryWorkloadGroupScope={state.focusedSummaryWorkloadGroupScope}
|
||||
focusedSummaryWorkloadGroupId={state.focusedSummaryWorkloadGroupId}
|
||||
hoveredSummaryWorkloadGroupScope={state.hoveredSummaryWorkloadGroupScope}
|
||||
isMobile={state.isMobile}
|
||||
nestedWorkloadContextByGuestId={state.nestedWorkloadContextByGuestId}
|
||||
nodeByInstance={state.nodeByInstance}
|
||||
search={state.search}
|
||||
selectedGuestId={state.selectedGuestId}
|
||||
setFocusedWorkloadGroupScope={state.setFocusedWorkloadGroupScope}
|
||||
setHoveredWorkloadGroupScope={state.setHoveredWorkloadGroupScope}
|
||||
setHoveredWorkloadId={state.setHoveredWorkloadId}
|
||||
setSelectedGuestId={state.setSelectedGuestId}
|
||||
setTableRootRef={state.setTableRootRef}
|
||||
|
||||
@@ -18,29 +18,21 @@ type WorkloadsTableProps = Pick<
|
||||
| 'alertsEnabled'
|
||||
| 'bottomSpacerHeight'
|
||||
| 'getGroupLabel'
|
||||
| 'getNodeTemperatureThresholds'
|
||||
| 'groupedGuests'
|
||||
| 'groupedWindowing'
|
||||
| 'groupLabelBadges'
|
||||
| 'guestMetadata'
|
||||
| 'guestParentNodeMap'
|
||||
| 'groupNodeDrawerMode'
|
||||
| 'groupingMode'
|
||||
| 'handleCustomUrlUpdate'
|
||||
| 'handleSort'
|
||||
| 'handleTagClick'
|
||||
| 'activeSummaryWorkloadGroupScope'
|
||||
| 'activeSummaryWorkloadId'
|
||||
| 'focusedSummaryWorkloadGroupScope'
|
||||
| 'focusedSummaryWorkloadGroupId'
|
||||
| 'hoveredSummaryWorkloadGroupScope'
|
||||
| 'isMobile'
|
||||
| 'nestedWorkloadContextByGuestId'
|
||||
| 'nodeByInstance'
|
||||
| 'search'
|
||||
| 'selectedGuestId'
|
||||
| 'setFocusedWorkloadGroupScope'
|
||||
| 'setHoveredWorkloadGroupScope'
|
||||
| 'setHoveredWorkloadId'
|
||||
| 'setSelectedGuestId'
|
||||
| 'setTableRootRef'
|
||||
@@ -133,27 +125,19 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
|
||||
alertsEnabled={props.alertsEnabled}
|
||||
bottomSpacerHeight={props.bottomSpacerHeight}
|
||||
getGroupLabel={props.getGroupLabel}
|
||||
getNodeTemperatureThresholds={props.getNodeTemperatureThresholds}
|
||||
groupedGuests={props.groupedGuests}
|
||||
groupedWindowing={props.groupedWindowing}
|
||||
groupLabelBadges={props.groupLabelBadges}
|
||||
guestMetadata={props.guestMetadata}
|
||||
guestParentNodeMap={props.guestParentNodeMap}
|
||||
groupNodeDrawerMode={props.groupNodeDrawerMode}
|
||||
groupingMode={props.groupingMode}
|
||||
handleCustomUrlUpdate={props.handleCustomUrlUpdate}
|
||||
handleTagClick={props.handleTagClick}
|
||||
activeSummaryWorkloadGroupScope={props.activeSummaryWorkloadGroupScope}
|
||||
activeSummaryWorkloadId={props.activeSummaryWorkloadId}
|
||||
focusedSummaryWorkloadGroupScope={props.focusedSummaryWorkloadGroupScope}
|
||||
focusedSummaryWorkloadGroupId={props.focusedSummaryWorkloadGroupId}
|
||||
hoveredSummaryWorkloadGroupScope={props.hoveredSummaryWorkloadGroupScope}
|
||||
nestedWorkloadContextByGuestId={props.nestedWorkloadContextByGuestId}
|
||||
nodeByInstance={props.nodeByInstance}
|
||||
search={props.search}
|
||||
selectedGuestId={props.selectedGuestId}
|
||||
setFocusedWorkloadGroupScope={props.setFocusedWorkloadGroupScope}
|
||||
setHoveredWorkloadGroupScope={props.setHoveredWorkloadGroupScope}
|
||||
setHoveredWorkloadId={props.setHoveredWorkloadId}
|
||||
setSelectedGuestId={props.setSelectedGuestId}
|
||||
setTableBodyRef={props.setTableBodyRef}
|
||||
|
||||
@@ -126,6 +126,8 @@ vi.mock('@/components/shared/workloadTypeBadges', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../workloadTopology', () => ({
|
||||
getWorkloadAlertPolicyTags: (guest: WorkloadGuest) =>
|
||||
Array.isArray(guest.tags) ? guest.tags : [],
|
||||
getWorkloadAlertResourceIdCandidates: (guest: WorkloadGuest) => [guest.id],
|
||||
getWorkloadAlertThresholdScope: (guest: WorkloadGuest) =>
|
||||
guest.workloadType === 'app-container' ? 'docker' : 'guest',
|
||||
|
||||
@@ -931,37 +931,6 @@ describe('WorkloadsFilter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('charts toggle', () => {
|
||||
it('renders the Charts button when onChartsToggle is provided', () => {
|
||||
render(() => (
|
||||
<WorkloadsFilter
|
||||
{...makeProps({
|
||||
chartsCollapsed: vi.fn(() => false),
|
||||
onChartsToggle: vi.fn(),
|
||||
})}
|
||||
/>
|
||||
));
|
||||
expect(screen.queryByRole('button', { name: 'Hide charts' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(openViewPreferences()).getByRole('button', { name: 'Hide charts' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the Charts button as a show action when charts are collapsed', () => {
|
||||
render(() => (
|
||||
<WorkloadsFilter
|
||||
{...makeProps({
|
||||
chartsCollapsed: vi.fn(() => true),
|
||||
onChartsToggle: vi.fn(),
|
||||
})}
|
||||
/>
|
||||
));
|
||||
expect(
|
||||
within(openViewPreferences()).getByRole('button', { name: 'Show charts' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('column picker', () => {
|
||||
it('renders ColumnPicker when columnVisibility is provided', () => {
|
||||
render(() => (
|
||||
|
||||
+21
-40
@@ -438,28 +438,23 @@ describe('Workloads platform-page embed contract', () => {
|
||||
expect(nodesTableSource).toContain("series: 'nodes',");
|
||||
});
|
||||
|
||||
it('lets platform pages move grouped host drawers to a dedicated host table owner', async () => {
|
||||
it('keeps grouped host rows as dividers and host drawers on the platform hosts table', async () => {
|
||||
const stateSource = (await import('../useWorkloadsState.ts?raw')).default;
|
||||
expect(stateSource).toContain("groupNodeDrawerMode?: 'inline' | 'disabled';");
|
||||
expect(stateSource).toContain(
|
||||
"groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',",
|
||||
);
|
||||
expect(stateSource).not.toContain('groupNodeDrawerMode');
|
||||
expect(stateSource).not.toContain('summaryGroupScopes');
|
||||
|
||||
const tableSource = (await import('../WorkloadsTable.tsx?raw')).default;
|
||||
expect(tableSource).toContain(`| 'groupNodeDrawerMode'`);
|
||||
expect(tableSource).toContain('groupNodeDrawerMode={props.groupNodeDrawerMode}');
|
||||
const selectionSource = (await import('../useWorkloadSelectionState.ts?raw')).default;
|
||||
expect(selectionSource).not.toContain('hoveredGroupScope');
|
||||
expect(selectionSource).not.toContain('focusedGroupScope');
|
||||
|
||||
const panelSource = (await import('../WorkloadPanel.tsx?raw')).default;
|
||||
expect(panelSource).toContain("props.groupNodeDrawerMode() === 'inline'");
|
||||
expect(panelSource).toContain(
|
||||
'onClick: canOpenNodeDrawer() ? handleGroupFocusToggle : undefined',
|
||||
);
|
||||
expect(panelSource).not.toContain('NodeDrawer');
|
||||
expect(panelSource).not.toContain('handleGroupFocusToggle');
|
||||
expect(panelSource).not.toContain('createSummaryInteractiveRowPreviewHandlers');
|
||||
expect(panelSource).toContain("trProps={{ 'data-summary-group-id': groupKey }}");
|
||||
|
||||
const proxmoxPageSource = (await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw'))
|
||||
.default;
|
||||
const proxmoxNodesSource = (await import('../../../features/proxmox/ProxmoxNodesTable.tsx?raw'))
|
||||
.default;
|
||||
expect(proxmoxPageSource).toContain('groupNodeDrawerMode="disabled"');
|
||||
expect(proxmoxNodesSource).toContain('NodeDrawer');
|
||||
expect(proxmoxNodesSource).toContain('data-inline-node-detail-for={node.id}');
|
||||
});
|
||||
@@ -1301,20 +1296,11 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadsSource).not.toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
|
||||
expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
|
||||
expect(workloadPanelSource).toContain('groupGuestById().get(keyedGuestId)');
|
||||
expect(workloadPanelSource).toContain('buildWorkloadSummaryGroupScope');
|
||||
expect(workloadPanelSource).toContain('data-summary-group-id');
|
||||
expect(workloadPanelSource).toContain('setHoveredWorkloadGroupScope');
|
||||
expect(workloadPanelSource).toContain('getGroupedTableRowClass');
|
||||
expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass');
|
||||
expect(workloadPanelSource).not.toContain('setHoveredWorkloadGroupScope');
|
||||
expect(workloadPanelSource).toContain('getGroupedTableRowCellClass');
|
||||
expect(workloadPanelSource).toContain('? getInteractiveGroupedTableRowClass()');
|
||||
expect(workloadPanelSource).toContain(': getGroupedTableRowClass()');
|
||||
expect(workloadPanelSource).toContain(
|
||||
'onClick={canOpenNodeDrawer() ? handleGroupFocusToggle : undefined}',
|
||||
);
|
||||
expect(workloadPanelSource).toContain(
|
||||
'{...(canOpenNodeDrawer() ? groupRowInteraction : {})}',
|
||||
);
|
||||
expect(workloadPanelSource).toContain('class={getGroupedTableRowClass()}');
|
||||
expect(workloadPanelSource).not.toContain('getInteractiveGroupedTableRowClass');
|
||||
expect(workloadPanelSource).not.toContain('cursor-pointer bg-surface-alt');
|
||||
expect(workloadsWorkloadTableSource).not.toContain(
|
||||
'createMemo(() => getCanonicalWorkloadId(guest()))',
|
||||
@@ -1593,11 +1579,10 @@ describe('Workloads performance contract', () => {
|
||||
expect(nodeDrawerModelSource).toContain('HOST_METRICS_HISTORY_GROUPS');
|
||||
expect(nodeDrawerModelSource).toContain("group.id !== 'disk-io'");
|
||||
expect(nodeDrawerModelSource).not.toContain("id: 'thermals'");
|
||||
expect(workloadPanelSource).toContain('NodeDrawer');
|
||||
expect(workloadPanelSource).toContain('data-inline-node-detail-for');
|
||||
expect(workloadPanelSource).toContain('const selectedGuestId = props.selectedGuestId()');
|
||||
expect(workloadPanelSource).toContain('props.setSelectedGuestId(null)');
|
||||
expect(workloadPanelSource).toContain('selectedGuestId === null');
|
||||
expect(workloadPanelSource).not.toContain('NodeDrawer');
|
||||
expect(workloadPanelSource).not.toContain('data-inline-node-detail-for');
|
||||
expect(workloadPanelSource).toContain('data-inline-detail-for={guestId()}');
|
||||
expect(workloadPanelSource).toContain('onClose={() => props.setSelectedGuestId(null)}');
|
||||
expect(guestDrawerSource).not.toContain('const guestId = () =>');
|
||||
expect(guestDrawerSource).not.toContain('const infrastructureHref = () =>');
|
||||
expect(guestDrawerSource).not.toContain('Filesystems');
|
||||
@@ -1720,19 +1705,15 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadPanelSource).toContain('NodeGroupHeader');
|
||||
expect(workloadPanelSource).toContain('GuestDrawer');
|
||||
expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
|
||||
expect(workloadPanelSource).toContain('createSummaryInteractiveRowPreviewHandlers');
|
||||
expect(workloadPanelSource).toContain('resolveSummaryGroupMemberInteractionState');
|
||||
expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass');
|
||||
expect(workloadPanelSource).not.toContain('createSummaryInteractiveRowPreviewHandlers');
|
||||
expect(workloadPanelSource).not.toContain('resolveSummaryGroupMemberInteractionState');
|
||||
expect(workloadPanelSource).toContain('getGroupedTableRowCellClass');
|
||||
expect(workloadPanelSource).not.toContain('style={{');
|
||||
expect(workloadPanelSource).not.toContain('style={');
|
||||
expect(workloadPanelSource).not.toContain('kind="scope"');
|
||||
expect(workloadPanelSource).not.toContain('leadingAction={');
|
||||
expect(workloadsSelectionStateSource).toContain('activeSummaryWorkloadGroupScope');
|
||||
expect(workloadsSelectionStateSource).toContain('focusedSummaryWorkloadGroupScope');
|
||||
expect(workloadsSelectionStateSource).toContain('setHoveredWorkloadGroupScope');
|
||||
expect(workloadsWorkloadTableSource).toContain('focusedSummaryWorkloadGroupScope');
|
||||
expect(workloadsWorkloadTableSource).toContain('hoveredSummaryWorkloadGroupScope');
|
||||
expect(workloadsSelectionStateSource).not.toContain('WorkloadGroupScope');
|
||||
expect(workloadsWorkloadTableSource).not.toContain('SummaryWorkloadGroupScope');
|
||||
expect(workloadPanelSource).not.toContain('TableHead');
|
||||
});
|
||||
|
||||
|
||||
+59
-107
@@ -2,15 +2,21 @@ import { renderHook } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import {
|
||||
clearPendingAppShellRestoreTop,
|
||||
readPendingAppShellRestoreTop,
|
||||
} from '@/utils/appShellScrollRestoration';
|
||||
import {
|
||||
ROUTE_STATE_REPLACE_OPTIONS,
|
||||
createRouteStateNavigateScheduler,
|
||||
} from '@/utils/routeStateNavigation';
|
||||
|
||||
import { resolveWorkloadResourceSelection } from '../workloadSelectionModel';
|
||||
import { useWorkloadSelectionState } from '../useWorkloadSelectionState';
|
||||
|
||||
let locationSearch = '?resource=cluster-a:node-1:101';
|
||||
const navigateSpy = vi.fn();
|
||||
const emptySummaryGroupScopes = () => new Map<string, SummarySeriesGroupScope>();
|
||||
|
||||
vi.mock('@solidjs/router', () => ({
|
||||
useLocation: () => ({
|
||||
@@ -46,15 +52,11 @@ describe('useWorkloadSelectionState', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.selectedGuestId()).toBe('cluster-a:node-1:101');
|
||||
expect(resolveWorkloadResourceSelection(locationSearch)).toEqual({
|
||||
resourceId: 'cluster-a:node-1:101',
|
||||
summaryGroupId: null,
|
||||
});
|
||||
expect(resolveWorkloadResourceSelection(locationSearch)).toBe('cluster-a:node-1:101');
|
||||
});
|
||||
|
||||
it('clears stale hovered workload ids when filtered guests change', () => {
|
||||
@@ -71,7 +73,6 @@ describe('useWorkloadSelectionState', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -89,15 +90,13 @@ describe('useWorkloadSelectionState', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.selectedGuestId()).toBe('app-container:truenas-main:nextcloud');
|
||||
expect(resolveWorkloadResourceSelection(locationSearch)).toEqual({
|
||||
resourceId: 'app-container:truenas-main:nextcloud',
|
||||
summaryGroupId: null,
|
||||
});
|
||||
expect(resolveWorkloadResourceSelection(locationSearch)).toBe(
|
||||
'app-container:truenas-main:nextcloud',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens workload row selection locally without route navigation', () => {
|
||||
@@ -107,7 +106,6 @@ describe('useWorkloadSelectionState', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -118,40 +116,28 @@ describe('useWorkloadSelectionState', () => {
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears pinned workload scope locally without mutating route filters', () => {
|
||||
it('clears the pinned workload row locally without mutating route filters', () => {
|
||||
locationSearch =
|
||||
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud&summaryGroup=docker-host%3Atruenas-main';
|
||||
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud';
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
|
||||
const summaryScopes = () =>
|
||||
new Map<string, SummarySeriesGroupScope>([
|
||||
[
|
||||
'docker-host:truenas-main',
|
||||
{
|
||||
id: 'docker-host:truenas-main',
|
||||
label: 'TrueNAS Main (4 workloads)',
|
||||
seriesIds: ['app-container:truenas-main:nextcloud'],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: summaryScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.selectedGuestId()).toBe('app-container:truenas-main:nextcloud');
|
||||
result.clearPinnedSummaryScope();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(result.selectedGuestId()).toBeNull();
|
||||
expect(result.focusedSummaryWorkloadGroupId()).toBeNull();
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes Escape through workload scope clearing and additional page reset work', () => {
|
||||
locationSearch =
|
||||
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud&summaryGroup=docker-host%3Atruenas-main';
|
||||
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud';
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
|
||||
const clearAdditionalPageStateOnEscape = vi.fn();
|
||||
|
||||
@@ -159,7 +145,6 @@ describe('useWorkloadSelectionState', () => {
|
||||
useWorkloadSelectionState({
|
||||
clearAdditionalPageStateOnEscape,
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -177,7 +162,6 @@ describe('useWorkloadSelectionState', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -231,7 +215,6 @@ describe('useWorkloadSelectionState', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -284,7 +267,8 @@ describe('useWorkloadSelectionState', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks hovered workload groups without letting them override entity selection outside scope', () => {
|
||||
it('ignores a summaryGroup param because grouped rows no longer pin a group scope', () => {
|
||||
locationSearch = '?summaryGroup=cluster-b&resource=cluster-a:node-1:101';
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([
|
||||
{
|
||||
id: 'cluster-a:node-1:101',
|
||||
@@ -294,87 +278,18 @@ describe('useWorkloadSelectionState', () => {
|
||||
node: 'node-1',
|
||||
vmid: 101,
|
||||
} as unknown as WorkloadGuest,
|
||||
{
|
||||
id: 'cluster-b:node-2:202',
|
||||
name: 'guest-2',
|
||||
status: 'running',
|
||||
instance: 'cluster-b',
|
||||
node: 'node-2',
|
||||
vmid: 202,
|
||||
} as unknown as WorkloadGuest,
|
||||
]);
|
||||
const groupScope: SummarySeriesGroupScope = {
|
||||
id: 'cluster-b',
|
||||
label: 'Cluster B (1 workload)',
|
||||
seriesIds: ['cluster-b:node-2:202'],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(result.selectedGuestId()).toBe('cluster-a:node-1:101');
|
||||
result.setHoveredWorkloadGroupScope(groupScope);
|
||||
|
||||
expect(result.activeSummaryWorkloadGroupScope()?.id).toBe('cluster-b');
|
||||
expect(result.activeSummaryWorkloadId()).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the pinned-scope fallback only after a pinned workload group scrolls out of view', () => {
|
||||
locationSearch = '?summaryGroup=cluster-a';
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([
|
||||
{
|
||||
id: 'cluster-a:node-1:101',
|
||||
name: 'guest-1',
|
||||
status: 'running',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 101,
|
||||
} as unknown as WorkloadGuest,
|
||||
]);
|
||||
const summaryScopes = () =>
|
||||
new Map<string, SummarySeriesGroupScope>([
|
||||
[
|
||||
'cluster-a',
|
||||
{
|
||||
id: 'cluster-a',
|
||||
label: 'Cluster A (1 workload)',
|
||||
seriesIds: ['cluster-a:node-1:101'],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
let top = 120;
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: summaryScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
const tableWrapper = document.createElement('div');
|
||||
const groupRow = document.createElement('div');
|
||||
groupRow.dataset.summaryGroupId = 'cluster-a';
|
||||
groupRow.getBoundingClientRect = vi.fn(() => ({
|
||||
top,
|
||||
bottom: top + 40,
|
||||
left: 0,
|
||||
right: 320,
|
||||
width: 320,
|
||||
height: 40,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
})) as unknown as typeof groupRow.getBoundingClientRect;
|
||||
tableWrapper.appendChild(groupRow);
|
||||
document.body.appendChild(tableWrapper);
|
||||
|
||||
result.setTableWrapperRef(tableWrapper as HTMLDivElement);
|
||||
|
||||
expect(result.focusedSummaryWorkloadGroupId()).toBe('cluster-a');
|
||||
expect(result.activeSummaryWorkloadId()).toBe('cluster-a:node-1:101');
|
||||
expect(Object.keys(result).filter((key) => /group/i.test(key))).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears row focus locally without leaving behind an inferred agent filter', () => {
|
||||
@@ -384,7 +299,6 @@ describe('useWorkloadSelectionState', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -396,4 +310,42 @@ describe('useWorkloadSelectionState', () => {
|
||||
expect(result.selectedGuestId()).toBeNull();
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stages no app-shell restore for a local row toggle that a later route change could replay', () => {
|
||||
locationSearch = '';
|
||||
clearPendingAppShellRestoreTop();
|
||||
const shell = document.createElement('div');
|
||||
shell.className = 'app-scroll-shell';
|
||||
shell.scrollTop = 3000;
|
||||
document.body.appendChild(shell);
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({
|
||||
filteredGuests,
|
||||
}),
|
||||
);
|
||||
|
||||
result.setSelectedGuestId('cluster-a:node-1:101');
|
||||
vi.runAllTimers();
|
||||
expect(result.selectedGuestId()).toBe('cluster-a:node-1:101');
|
||||
expect(readPendingAppShellRestoreTop()).toBeNull();
|
||||
|
||||
result.setSelectedGuestId(null);
|
||||
vi.runAllTimers();
|
||||
expect(readPendingAppShellRestoreTop()).toBeNull();
|
||||
|
||||
// Back at the top, a filter change writes the URL. The scheduler stages a
|
||||
// restore only from a scrolled shell, so App.tsx would replay anything the
|
||||
// row toggle had staged and jump the page back to 3000.
|
||||
shell.scrollTop = 0;
|
||||
const navigate = vi.fn();
|
||||
const scheduler = createRouteStateNavigateScheduler(navigate, () => '/proxmox/overview');
|
||||
scheduler.schedule('/proxmox/overview?type=vm');
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith('/proxmox/overview?type=vm', ROUTE_STATE_REPLACE_OPTIONS);
|
||||
expect(readPendingAppShellRestoreTop()).toBeNull();
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,28 +9,18 @@ import {
|
||||
|
||||
describe('workloadSelectionModel', () => {
|
||||
it('resolves workloads resource deep links into focused guest ids without inventing filters', () => {
|
||||
expect(resolveWorkloadResourceSelection('?resource=cluster-a:node-1:101')).toEqual({
|
||||
resourceId: 'cluster-a:node-1:101',
|
||||
summaryGroupId: null,
|
||||
});
|
||||
expect(resolveWorkloadResourceSelection('?resource=cluster-a:node-1:101')).toBe(
|
||||
'cluster-a:node-1:101',
|
||||
);
|
||||
expect(
|
||||
resolveWorkloadResourceSelection(
|
||||
'?type=app-container&resource=app-container:truenas-main:nextcloud',
|
||||
),
|
||||
).toEqual({
|
||||
resourceId: 'app-container:truenas-main:nextcloud',
|
||||
summaryGroupId: null,
|
||||
});
|
||||
).toBe('app-container:truenas-main:nextcloud');
|
||||
expect(
|
||||
resolveWorkloadResourceSelection('?resource=app-container:docker-main:container-123'),
|
||||
).toEqual({
|
||||
resourceId: 'app-container:docker-main:container-123',
|
||||
summaryGroupId: null,
|
||||
});
|
||||
expect(resolveWorkloadResourceSelection('?resource=guest-1')).toEqual({
|
||||
resourceId: 'guest-1',
|
||||
summaryGroupId: null,
|
||||
});
|
||||
).toBe('app-container:docker-main:container-123');
|
||||
expect(resolveWorkloadResourceSelection('?resource=guest-1')).toBe('guest-1');
|
||||
expect(resolveWorkloadResourceSelection('')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -50,10 +40,7 @@ describe('workloadSelectionModel', () => {
|
||||
expect(workloadsHasHoveredWorkload(guests, 'cluster-a:node-1:102')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves summary group deep links separately from local row expansion', () => {
|
||||
expect(resolveWorkloadResourceSelection('?summaryGroup=docker-host%3Atruenas-main')).toEqual({
|
||||
resourceId: null,
|
||||
summaryGroupId: 'docker-host:truenas-main',
|
||||
});
|
||||
it('ignores summary group params, which no longer pin a workload group', () => {
|
||||
expect(resolveWorkloadResourceSelection('?summaryGroup=docker-host%3Atruenas-main')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
-156
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Node } from '@/types/api';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import {
|
||||
buildWorkloadSummaryGroupScope,
|
||||
buildWorkloadSummaryGroupScopeMap,
|
||||
createWorkloadSortComparator,
|
||||
filterWorkloads,
|
||||
getDiskUsagePercent,
|
||||
@@ -38,26 +35,6 @@ const makeGuest = (i: number, overrides?: Partial<WorkloadGuest>): WorkloadGuest
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeNode = (id: string, instance: string, name: string): Node => ({
|
||||
id,
|
||||
name,
|
||||
displayName: name,
|
||||
instance,
|
||||
host: `${name}.local`,
|
||||
status: 'online',
|
||||
type: 'pve',
|
||||
cpu: 0,
|
||||
memory: { total: 1, used: 0, free: 1, usage: 0 },
|
||||
disk: { total: 1, used: 0, free: 1, usage: 0 },
|
||||
uptime: 1,
|
||||
loadAverage: [0, 0, 0],
|
||||
kernelVersion: 'test',
|
||||
pveVersion: 'test',
|
||||
cpuInfo: { model: 'test', cores: 1, sockets: 1, mhz: '1' },
|
||||
lastSeen: new Date().toISOString(),
|
||||
connectionHealth: 'online',
|
||||
});
|
||||
|
||||
describe('workloadSelectors (branch coverage)', () => {
|
||||
describe('createWorkloadSortComparator', () => {
|
||||
it('sorts by memory usage in both directions and falls back to 0 for null memory', () => {
|
||||
@@ -703,139 +680,6 @@ describe('workloadSelectors (branch coverage)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWorkloadSummaryGroupScope', () => {
|
||||
it('returns null when all guests produce empty canonical IDs', () => {
|
||||
const guest = makeGuest(1, { id: '', type: 'app-container', workloadType: 'app-container' });
|
||||
expect(buildWorkloadSummaryGroupScope('grp', [guest], { type: 'T', name: 'N' })).toBeNull();
|
||||
});
|
||||
|
||||
it('builds singular label for a single guest', () => {
|
||||
const guest = makeGuest(1, {
|
||||
id: 'cid-1',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
});
|
||||
const scope = buildWorkloadSummaryGroupScope('grp', [guest], {
|
||||
type: 'Container',
|
||||
name: 'frigate',
|
||||
});
|
||||
expect(scope).not.toBeNull();
|
||||
expect(scope!.label).toBe('frigate · Container (1 workload)');
|
||||
expect(scope!.seriesIds).toEqual(['cid-1']);
|
||||
});
|
||||
|
||||
it('builds plural label and deduplicates canonical IDs for multiple guests', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'dup-id', type: 'app-container', workloadType: 'app-container' }),
|
||||
makeGuest(2, { id: 'dup-id', type: 'app-container', workloadType: 'app-container' }),
|
||||
];
|
||||
const scope = buildWorkloadSummaryGroupScope('grp', guests, { type: 'C', name: 'host' });
|
||||
expect(scope).not.toBeNull();
|
||||
expect(scope!.label).toBe('host · C (2 workloads)');
|
||||
expect(scope!.seriesIds).toEqual(['dup-id']); // deduped
|
||||
});
|
||||
|
||||
it('uses workload count alone when label name and type are both empty', () => {
|
||||
const guest = makeGuest(1, {
|
||||
id: 'x-1',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
});
|
||||
const scope = buildWorkloadSummaryGroupScope('grp', [guest], { type: '', name: '' });
|
||||
expect(scope).not.toBeNull();
|
||||
expect(scope!.label).toBe('1 workload');
|
||||
});
|
||||
|
||||
it('trims the groupId for the scope id', () => {
|
||||
const guest = makeGuest(1, {
|
||||
id: 'x-2',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
});
|
||||
const scope = buildWorkloadSummaryGroupScope(' padded-grp ', [guest], {
|
||||
type: 'T',
|
||||
name: 'N',
|
||||
});
|
||||
expect(scope).not.toBeNull();
|
||||
expect(scope!.id).toBe('padded-grp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWorkloadSummaryGroupScopeMap', () => {
|
||||
it('returns an empty map when groupingMode is flat', () => {
|
||||
const result = buildWorkloadSummaryGroupScopeMap({
|
||||
guests: [makeGuest(1)],
|
||||
nodes: [],
|
||||
groupingMode: 'flat',
|
||||
sortComparator: null,
|
||||
});
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('resolves node display name via buildNodeByInstance for the group label', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { type: 'vm', workloadType: 'vm', instance: 'cluster-a', node: 'node-a' }),
|
||||
];
|
||||
const nodes = [makeNode('n1', 'cluster-a', 'node-a')];
|
||||
|
||||
const result = buildWorkloadSummaryGroupScopeMap({
|
||||
guests,
|
||||
nodes,
|
||||
groupingMode: 'grouped',
|
||||
sortComparator: null,
|
||||
});
|
||||
|
||||
// groupKey = 'cluster-a-node-a' matches node via instance-name key
|
||||
// getNodeDisplayName -> 'node-a' -> label includes it
|
||||
const scope = result.get('cluster-a-node-a');
|
||||
expect(scope).toBeDefined();
|
||||
expect(scope!.label).toBe('node-a (1 workload)');
|
||||
expect(scope!.seriesIds).toEqual(['cluster-a:node-a:101']);
|
||||
});
|
||||
|
||||
it('falls back to lowercase badge key when exact-case lookup misses', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'c-1',
|
||||
name: 'frigate',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
contextLabel: 'Frigate', // capital F -> groupKey 'app-container:Frigate'
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildWorkloadSummaryGroupScopeMap({
|
||||
guests,
|
||||
nodes: [],
|
||||
groupingMode: 'grouped',
|
||||
sortComparator: null,
|
||||
groupLabelBadges: {
|
||||
'app-container:frigate': { label: 'Docker' }, // lowercase key
|
||||
},
|
||||
});
|
||||
|
||||
// badge found via lowercase fallback -> label includes 'Docker'
|
||||
const scope = result.get('app-container:Frigate');
|
||||
expect(scope).toBeDefined();
|
||||
expect(scope!.label).toBe('Frigate · Docker (1 workload)');
|
||||
});
|
||||
|
||||
it('omits groups whose scope is null (empty canonical IDs)', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: '', type: 'app-container', workloadType: 'app-container' }),
|
||||
];
|
||||
|
||||
const result = buildWorkloadSummaryGroupScopeMap({
|
||||
guests,
|
||||
nodes: [],
|
||||
groupingMode: 'grouped',
|
||||
sortComparator: null,
|
||||
});
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupWorkloads', () => {
|
||||
it('sorts the single flat group when a comparator is provided', () => {
|
||||
const guests = [
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getDiskUsagePercent,
|
||||
getWorkloadGroupKey,
|
||||
getWorkloadGroupLabel,
|
||||
buildWorkloadSummaryGroupScopeMap,
|
||||
groupWorkloads,
|
||||
selectVisibleWorkloadInventory,
|
||||
} from '@/components/Workloads/workloadSelectors';
|
||||
@@ -946,32 +945,6 @@ describe('workloadSelectors', () => {
|
||||
type: 'LXC',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes group identity badges into summary group scope labels', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'container-1',
|
||||
name: 'frigate',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
contextLabel: 'frigate-host',
|
||||
}),
|
||||
];
|
||||
|
||||
const scopes = buildWorkloadSummaryGroupScopeMap({
|
||||
guests,
|
||||
nodes: [],
|
||||
groupingMode: 'grouped',
|
||||
sortComparator: null,
|
||||
groupLabelBadges: {
|
||||
'app-container:frigate-host': { label: 'LXC' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(scopes.get('app-container:frigate-host')?.label).toBe(
|
||||
'frigate-host · LXC (1 workload)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workloadNodeScopeId', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getWorkloadContainerHostId,
|
||||
getDiscoveryResourceIdForWorkload,
|
||||
getKubernetesContextKey,
|
||||
getWorkloadAlertPolicyTags,
|
||||
getWorkloadDockerHostId,
|
||||
workloadNodeScopeId,
|
||||
} from '@/components/Workloads/workloadTopology';
|
||||
@@ -239,4 +240,54 @@ describe('workloadTopology', () => {
|
||||
expect(getDiscoveryResourceIdForWorkload(vm)).toBe('103');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert policy tags', () => {
|
||||
it('passes only Proxmox guest tags to the threshold resolver', () => {
|
||||
const tags = ['batch', 'pulse-relaxed'];
|
||||
const proxmoxVm = makeGuest(1, {
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
platformType: 'proxmox-pve',
|
||||
tags,
|
||||
});
|
||||
const proxmoxLxc = makeGuest(2, {
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
platformScopes: ['proxmox-pve', 'agent'],
|
||||
tags,
|
||||
});
|
||||
const legacyVm = makeGuest(3, { type: 'vm', workloadType: 'vm', tags });
|
||||
const vsphereVm = makeGuest(4, {
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
platformType: 'vmware-vsphere',
|
||||
tags,
|
||||
});
|
||||
const appContainer = makeGuest(5, {
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformType: 'docker',
|
||||
tags,
|
||||
});
|
||||
|
||||
expect(getWorkloadAlertPolicyTags(proxmoxVm)).toEqual(tags);
|
||||
expect(getWorkloadAlertPolicyTags(proxmoxLxc)).toEqual(tags);
|
||||
expect(getWorkloadAlertPolicyTags(legacyVm)).toEqual(tags);
|
||||
// The alert engine never reads Pulse control tags from these.
|
||||
expect(getWorkloadAlertPolicyTags(vsphereVm)).toEqual([]);
|
||||
expect(getWorkloadAlertPolicyTags(appContainer)).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits a legacy raw tag string', () => {
|
||||
const guest = makeGuest(6, {
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
tags: 'batch;pulse-relaxed' as unknown as string[],
|
||||
});
|
||||
expect(getWorkloadAlertPolicyTags(guest)).toEqual(['batch', 'pulse-relaxed']);
|
||||
expect(getWorkloadAlertPolicyTags(makeGuest(7, { type: 'vm', tags: null as never }))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ describe('workloadUrlSyncModel', () => {
|
||||
cluster: '',
|
||||
agent: 'node-a',
|
||||
resource: 'guest-1',
|
||||
summaryGroup: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
|
||||
import type { ColumnDef } from '@/hooks/useColumnVisibility';
|
||||
import type { SummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction';
|
||||
import {
|
||||
PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT,
|
||||
getPlatformTableWeightedColumnWidthStyle,
|
||||
@@ -118,7 +117,6 @@ export interface GuestRowProps {
|
||||
onClick?: () => void;
|
||||
isExpanded?: boolean;
|
||||
isSummaryHighlighted?: boolean;
|
||||
summaryGroupMemberState?: SummaryGroupMemberInteractionState;
|
||||
ioEmphasis?: WorkloadIOEmphasis;
|
||||
metricDisplayMode?: 'bars' | 'sparklines';
|
||||
metricHoverMode?: WorkloadsMetricHoverMode;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
getDiscoveryHostIdForWorkload,
|
||||
getDiscoveryResourceIdForWorkload,
|
||||
getWorkloadAlertPolicyTags,
|
||||
getWorkloadAlertResourceIdCandidates,
|
||||
getWorkloadAlertThresholdScope,
|
||||
} from './workloadTopology';
|
||||
@@ -66,12 +67,14 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
const alertResourceIdCandidates = createMemo(() =>
|
||||
getWorkloadAlertResourceIdCandidates(props.guest),
|
||||
);
|
||||
const alertPolicyTags = createMemo(() => getWorkloadAlertPolicyTags(props.guest));
|
||||
const metricThresholds = (metric: DisplayMetricType) =>
|
||||
createMemo(() =>
|
||||
alertsActivation.getMetricThresholds(
|
||||
alertThresholdScope(),
|
||||
metric,
|
||||
alertResourceIdCandidates(),
|
||||
alertPolicyTags(),
|
||||
),
|
||||
);
|
||||
const diskThresholds = metricThresholds('disk');
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getAvailabilityProbePresentation } from '@/utils/availabilityProbePrese
|
||||
import { getWorkloadTypeBadge } from '@/components/shared/workloadTypeBadges';
|
||||
|
||||
import {
|
||||
getWorkloadAlertPolicyTags,
|
||||
getWorkloadAlertResourceIdCandidates,
|
||||
getWorkloadAlertThresholdScope,
|
||||
getWorkloadDockerHostId,
|
||||
@@ -61,12 +62,14 @@ export function useGuestRowState(props: GuestRowProps) {
|
||||
const alertResourceIdCandidates = createMemo(() =>
|
||||
getWorkloadAlertResourceIdCandidates(props.guest),
|
||||
);
|
||||
const alertPolicyTags = createMemo(() => getWorkloadAlertPolicyTags(props.guest));
|
||||
const metricThresholds = (metric: DisplayMetricType) =>
|
||||
createMemo(() =>
|
||||
alertsActivation.getMetricThresholds(
|
||||
alertThresholdScope(),
|
||||
metric,
|
||||
alertResourceIdCandidates(),
|
||||
alertPolicyTags(),
|
||||
),
|
||||
);
|
||||
const cpuThresholds = metricThresholds('cpu');
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { useLocation } from '@solidjs/router';
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from 'solid-js';
|
||||
import { createEffect, createSignal, type Accessor } from 'solid-js';
|
||||
|
||||
import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus';
|
||||
import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus';
|
||||
import {
|
||||
isSummarySeriesInGroupScope,
|
||||
type SummarySeriesGroupScope,
|
||||
} from '@/components/shared/summaryCardInteraction';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { capturePendingAppShellRestoreTop } from '@/utils/appShellScrollRestoration';
|
||||
import {
|
||||
workloadsHasHoveredWorkload,
|
||||
workloadsHasVisibleWorkloadGroupScope,
|
||||
resolveWorkloadResourceSelection,
|
||||
} from './workloadSelectionModel';
|
||||
|
||||
interface UseWorkloadsSelectionStateOptions {
|
||||
clearAdditionalPageStateOnEscape?: () => void;
|
||||
filteredGuests: Accessor<WorkloadGuest[]>;
|
||||
summaryGroupScopes: Accessor<Map<string, SummarySeriesGroupScope>>;
|
||||
routeStateEnabled?: Accessor<boolean>;
|
||||
}
|
||||
|
||||
@@ -26,42 +19,25 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
|
||||
const location = useLocation();
|
||||
|
||||
const [selectedGuestId, setSelectedGuestIdRaw] = createSignal<string | null>(null);
|
||||
const [selectedWorkloadGroupId, setSelectedWorkloadGroupIdRaw] = createSignal<string | null>(
|
||||
null,
|
||||
);
|
||||
const [hoveredWorkloadId, setHoveredWorkloadId] = createSignal<string | null>(null);
|
||||
const [hoveredWorkloadGroupScope, setHoveredWorkloadGroupScope] =
|
||||
createSignal<SummarySeriesGroupScope | null>(null);
|
||||
const [handledResourceId, setHandledResourceId] = createSignal<string | null>(null);
|
||||
const [handledWorkloadGroupId, setHandledWorkloadGroupId] = createSignal<string | null>(null);
|
||||
const [revealedGuestId, setRevealedGuestId] = createSignal<string | null>(null);
|
||||
|
||||
const [tableWrapperRef, setTableWrapperRefSignal] = createSignal<HTMLDivElement | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [tableBodyRef, setTableBodyRef] = createSignal<HTMLTableSectionElement | null>(null);
|
||||
const focusedWorkloadGroupScope = createMemo<SummarySeriesGroupScope | null>(() => {
|
||||
const selectedGroupId = selectedWorkloadGroupId();
|
||||
if (!selectedGroupId) {
|
||||
return null;
|
||||
}
|
||||
return options.summaryGroupScopes().get(selectedGroupId) ?? null;
|
||||
});
|
||||
|
||||
const clearPinnedSummaryScope = () => {
|
||||
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => {
|
||||
setSelectedGuestIdRaw(null);
|
||||
setSelectedWorkloadGroupIdRaw(null);
|
||||
});
|
||||
};
|
||||
|
||||
const summaryInteraction = useSummaryPageInteractionState({
|
||||
clearPinnedScope: clearPinnedSummaryScope,
|
||||
hoveredSeriesId: hoveredWorkloadId,
|
||||
hoveredGroupScope: hoveredWorkloadGroupScope,
|
||||
focusedSeriesId: selectedGuestId,
|
||||
focusedGroupId: selectedWorkloadGroupId,
|
||||
focusedGroupScope: focusedWorkloadGroupScope,
|
||||
onEscapeClear: () => {
|
||||
clearPinnedSummaryScope();
|
||||
options.clearAdditionalPageStateOnEscape?.();
|
||||
@@ -81,60 +57,27 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
|
||||
summaryInteraction.setClearSurfaceRootRef(element);
|
||||
};
|
||||
|
||||
const setSelectedGuestIdState = (id: string | null) => {
|
||||
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => {
|
||||
setSelectedGuestIdRaw(id);
|
||||
});
|
||||
};
|
||||
|
||||
// Row focus is local: it never navigates, so it must not stage an app-shell
|
||||
// restore that the next unrelated route change would replay.
|
||||
const setSelectedGuestId = (id: string | null) => {
|
||||
capturePendingAppShellRestoreTop();
|
||||
const activeFocusedGroupScope = focusedWorkloadGroupScope();
|
||||
const nextGroupScope =
|
||||
activeFocusedGroupScope && !isSummarySeriesInGroupScope(activeFocusedGroupScope, id)
|
||||
? null
|
||||
: activeFocusedGroupScope;
|
||||
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => {
|
||||
setSelectedGuestIdRaw(id);
|
||||
setSelectedWorkloadGroupIdRaw(nextGroupScope?.id ?? null);
|
||||
});
|
||||
};
|
||||
|
||||
const setFocusedWorkloadGroupScopeState = (scope: SummarySeriesGroupScope | null) => {
|
||||
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => {
|
||||
setSelectedWorkloadGroupIdRaw(scope?.id ?? null);
|
||||
if (scope && !isSummarySeriesInGroupScope(scope, selectedGuestId())) {
|
||||
setSelectedGuestIdRaw(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setFocusedWorkloadGroupScope = (scope: SummarySeriesGroupScope | null) => {
|
||||
setFocusedWorkloadGroupScopeState(scope);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (options.routeStateEnabled && !options.routeStateEnabled()) return;
|
||||
const selection = resolveWorkloadResourceSelection(location.search);
|
||||
if (!selection) {
|
||||
const resourceId = resolveWorkloadResourceSelection(location.search);
|
||||
if (!resourceId) {
|
||||
if (handledResourceId() !== null) {
|
||||
setSelectedGuestIdState(null);
|
||||
setSelectedGuestId(null);
|
||||
setHandledResourceId(null);
|
||||
}
|
||||
if (handledWorkloadGroupId() !== null) {
|
||||
setSelectedWorkloadGroupIdRaw(null);
|
||||
setHandledWorkloadGroupId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { resourceId, summaryGroupId } = selection;
|
||||
if (summaryGroupId !== handledWorkloadGroupId()) {
|
||||
setSelectedWorkloadGroupIdRaw(summaryGroupId);
|
||||
setHandledWorkloadGroupId(summaryGroupId);
|
||||
}
|
||||
if (resourceId !== handledResourceId()) {
|
||||
setSelectedGuestIdState(resourceId);
|
||||
setSelectedGuestId(resourceId);
|
||||
setHandledResourceId(resourceId);
|
||||
}
|
||||
});
|
||||
@@ -155,50 +98,13 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const groupScope = hoveredWorkloadGroupScope();
|
||||
if (!groupScope) {
|
||||
return;
|
||||
}
|
||||
if (!workloadsHasVisibleWorkloadGroupScope(options.filteredGuests(), groupScope)) {
|
||||
setHoveredWorkloadGroupScope(null);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const selectedGroupId = selectedWorkloadGroupId();
|
||||
if (!selectedGroupId) {
|
||||
return;
|
||||
}
|
||||
if (!focusedWorkloadGroupScope()) {
|
||||
setFocusedWorkloadGroupScope(null);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const selectedId = selectedGuestId();
|
||||
const groupScope = focusedWorkloadGroupScope();
|
||||
if (!selectedId || !groupScope) {
|
||||
return;
|
||||
}
|
||||
if (!isSummarySeriesInGroupScope(groupScope, selectedId)) {
|
||||
setSelectedGuestId(null);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeSummaryWorkloadGroupScope: summaryInteraction.activeGroupScope,
|
||||
activeSummaryWorkloadId: summaryInteraction.activeSeriesId,
|
||||
clearPinnedSummaryScope,
|
||||
focusedSummaryWorkloadGroupScope: focusedWorkloadGroupScope,
|
||||
hoveredWorkloadId,
|
||||
hoveredSummaryWorkloadGroupScope: hoveredWorkloadGroupScope,
|
||||
focusedSummaryWorkloadGroupId: selectedWorkloadGroupId,
|
||||
revealedGuestId,
|
||||
selectedGuestId,
|
||||
setClearSurfaceRootRef,
|
||||
setFocusedWorkloadGroupScope,
|
||||
setHoveredWorkloadGroupScope,
|
||||
setHoveredWorkloadId,
|
||||
setSelectedGuestId,
|
||||
setTableBodyRef,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type RuntimeInventorySourcesResponse,
|
||||
} from '@/api/runtimeInventorySources';
|
||||
import { recordWorkloadHistoryActivity } from '@/api/workloadHistoryActivity';
|
||||
import { nodeOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
|
||||
import type { VM, Container, Node } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { ViewMode, WorkloadGuest, WorkloadType } from '@/types/workloads';
|
||||
@@ -28,7 +27,6 @@ import { getCanonicalWorkloadId } from '@/utils/workloads';
|
||||
import { nodeFromResource } from '@/utils/resourceStateAdapters';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import {
|
||||
buildWorkloadSummaryGroupScopeMap,
|
||||
createWorkloadSortComparator,
|
||||
filterWorkloads,
|
||||
selectVisibleWorkloadInventory,
|
||||
@@ -105,10 +103,6 @@ export interface WorkloadsSurfaceProps {
|
||||
}>;
|
||||
resourceSnapshotRefetch?: () => Promise<unknown>;
|
||||
statusModeStorageScope?: string;
|
||||
// Default Workloads behavior owns grouped host row drawers inline. Platform
|
||||
// pages with a dedicated host table can disable that drawer so host details
|
||||
// open from the host-owned table instead of the embedded guest table.
|
||||
groupNodeDrawerMode?: 'inline' | 'disabled';
|
||||
// When a platform page owns the metric display mode + sparkline range
|
||||
// (so the same toggle drives both the page's hosts table and this
|
||||
// embedded workloads surface), pass the accessors + change handlers.
|
||||
@@ -423,9 +417,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
reconnect();
|
||||
};
|
||||
|
||||
const getNodeTemperatureThresholds = (node: Node) =>
|
||||
alertsActivation.getMetricThresholds('node', 'temperature', nodeOverrideIdCandidates(node));
|
||||
|
||||
createEffect(() => {
|
||||
if (!workloadsEnabled() || props.inventorySourcesQuery) return;
|
||||
const handle = window.setInterval(() => {
|
||||
@@ -480,29 +471,14 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
const groupLabelBadges = createMemo<Record<string, WorkloadGroupLabelBadge>>(
|
||||
() => props.groupLabelBadges ?? {},
|
||||
);
|
||||
const summaryGroupScopes = createMemo(() =>
|
||||
buildWorkloadSummaryGroupScopeMap({
|
||||
guests: filteredGuests(),
|
||||
nodes: infrastructureNodes(),
|
||||
groupingMode: groupingMode(),
|
||||
sortComparator: guestSortComparator(),
|
||||
groupLabelBadges: groupLabelBadges(),
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
activeSummaryWorkloadGroupScope,
|
||||
activeSummaryWorkloadId,
|
||||
clearPinnedSummaryScope,
|
||||
focusedSummaryWorkloadGroupScope,
|
||||
focusedSummaryWorkloadGroupId,
|
||||
hoveredSummaryWorkloadGroupScope,
|
||||
hoveredWorkloadId,
|
||||
revealedGuestId,
|
||||
selectedGuestId,
|
||||
setClearSurfaceRootRef,
|
||||
setFocusedWorkloadGroupScope,
|
||||
setHoveredWorkloadGroupScope,
|
||||
setHoveredWorkloadId,
|
||||
setSelectedGuestId,
|
||||
setTableBodyRef,
|
||||
@@ -515,7 +491,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
resetWorkloadRouteFilters();
|
||||
},
|
||||
filteredGuests,
|
||||
summaryGroupScopes,
|
||||
routeStateEnabled: props.routeStateEnabled,
|
||||
});
|
||||
|
||||
@@ -606,7 +581,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
allGuests,
|
||||
inventoryStats,
|
||||
isScrollToTopVisible,
|
||||
activeSummaryWorkloadGroupScope,
|
||||
activeSummaryWorkloadId,
|
||||
clearPinnedSummaryScope,
|
||||
bottomSpacerHeight,
|
||||
@@ -623,10 +597,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
workloadsInfrastructureEmptyState,
|
||||
workloadsLoadingState,
|
||||
filteredGuests,
|
||||
focusedSummaryWorkloadGroupScope,
|
||||
focusedSummaryWorkloadGroupId,
|
||||
getGroupLabel,
|
||||
getNodeTemperatureThresholds,
|
||||
groupedGuests,
|
||||
groupedWindowing,
|
||||
guestMetadata,
|
||||
@@ -638,7 +609,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
handleTagClick,
|
||||
hasInfrastructureSources,
|
||||
hostFilterConfig,
|
||||
hoveredSummaryWorkloadGroupScope,
|
||||
infrastructureSourceStateReady,
|
||||
infrastructureNodes,
|
||||
initialDataReceived,
|
||||
@@ -667,9 +637,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
selectedNode,
|
||||
setContainerRuntime,
|
||||
setClearSurfaceRootRef,
|
||||
setFocusedWorkloadGroupScope,
|
||||
setGroupingMode,
|
||||
setHoveredWorkloadGroupScope,
|
||||
setHoveredWorkloadId,
|
||||
setSearch,
|
||||
setSelectedCluster,
|
||||
@@ -725,7 +693,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
||||
workloadsNoInventoryState,
|
||||
ws,
|
||||
groupingMode,
|
||||
groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',
|
||||
groupLabelBadges,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,12 @@
|
||||
import { parseWorkloadsLinkSearch } from '@/routing/resourceLinks';
|
||||
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { getCanonicalWorkloadId } from '@/utils/workloads';
|
||||
|
||||
export interface WorkloadsResourceSelection {
|
||||
resourceId: string | null;
|
||||
summaryGroupId: string | null;
|
||||
}
|
||||
|
||||
export const resolveWorkloadResourceSelection = (
|
||||
search: string,
|
||||
): WorkloadsResourceSelection | null => {
|
||||
const { resource: resourceId, summaryGroup: summaryGroupId } = parseWorkloadsLinkSearch(search);
|
||||
if (!resourceId && !summaryGroupId) return null;
|
||||
|
||||
return {
|
||||
resourceId: resourceId || null,
|
||||
summaryGroupId: summaryGroupId || null,
|
||||
};
|
||||
};
|
||||
/** Resolves the inbound `resource` deep link to the guest whose drawer opens. */
|
||||
export const resolveWorkloadResourceSelection = (search: string): string | null =>
|
||||
parseWorkloadsLinkSearch(search).resource || null;
|
||||
|
||||
export const workloadsHasHoveredWorkload = (
|
||||
filteredGuests: WorkloadGuest[],
|
||||
hoveredId: string,
|
||||
): boolean => filteredGuests.some((guest) => getCanonicalWorkloadId(guest) === hoveredId);
|
||||
|
||||
export const workloadsHasVisibleWorkloadGroupScope = (
|
||||
filteredGuests: WorkloadGuest[],
|
||||
groupScope: SummarySeriesGroupScope,
|
||||
): boolean => {
|
||||
const filteredGuestIds = new Set(
|
||||
filteredGuests.map((guest) => getCanonicalWorkloadId(guest)).filter(Boolean),
|
||||
);
|
||||
return groupScope.seriesIds.some((seriesId) => filteredGuestIds.has(seriesId));
|
||||
};
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type { Node } from '@/types/api';
|
||||
import type { WorkloadGuest, ViewMode, WorkloadType } from '@/types/workloads';
|
||||
import {
|
||||
getWorkloadInfoValue,
|
||||
type IODistributionStats,
|
||||
type WorkloadIOEmphasis,
|
||||
} from './guestRowModel';
|
||||
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
|
||||
import { parseFilterStack, evaluateFilterStack, splitSearchExclusions } from '@/utils/searchQuery';
|
||||
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
|
||||
import { OFFLINE_HEALTH_STATUSES } from '@/utils/status';
|
||||
import { hasFailedAttachedAvailabilityCheck } from '@/utils/availabilityProbePresentation';
|
||||
import { getNodeDisplayName } from '@/utils/nodes';
|
||||
import {
|
||||
isContainerWorkloadViewMode,
|
||||
getCanonicalWorkloadId,
|
||||
getWorkloadCPUPercent,
|
||||
resolveWorkloadType,
|
||||
workloadMatchesPlatformScope,
|
||||
@@ -21,7 +17,6 @@ import {
|
||||
} from '@/utils/workloads';
|
||||
import { getWorkloadTypePresentation } from '@/utils/workloadTypePresentation';
|
||||
import {
|
||||
buildNodeByInstance,
|
||||
getKubernetesContextKey,
|
||||
getWorkloadHostHintCandidates,
|
||||
workloadHostScopeId,
|
||||
@@ -417,29 +412,6 @@ export const getWorkloadGroupLabel = (
|
||||
return { type: '', name: context };
|
||||
};
|
||||
|
||||
export const buildWorkloadSummaryGroupScope = (
|
||||
groupId: string,
|
||||
guests: WorkloadGuest[],
|
||||
label: { type: string; name: string },
|
||||
): SummarySeriesGroupScope | null => {
|
||||
const seriesIds = Array.from(
|
||||
new Set(guests.map((guest) => getCanonicalWorkloadId(guest)).filter(Boolean)),
|
||||
);
|
||||
if (seriesIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryLabelParts = [label.name.trim(), label.type.trim()].filter(Boolean);
|
||||
const scopeLabel = summaryLabelParts.join(' · ');
|
||||
const workloadCountLabel = `${guests.length} workload${guests.length === 1 ? '' : 's'}`;
|
||||
|
||||
return {
|
||||
id: groupId.trim(),
|
||||
label: scopeLabel ? `${scopeLabel} (${workloadCountLabel})` : workloadCountLabel,
|
||||
seriesIds,
|
||||
};
|
||||
};
|
||||
|
||||
export const groupWorkloads = (
|
||||
guests: WorkloadGuest[],
|
||||
mode: 'grouped' | 'flat',
|
||||
@@ -471,46 +443,6 @@ export const groupWorkloads = (
|
||||
return groups;
|
||||
};
|
||||
|
||||
export const buildWorkloadSummaryGroupScopeMap = ({
|
||||
guests,
|
||||
nodes,
|
||||
groupingMode,
|
||||
sortComparator,
|
||||
groupLabelBadges,
|
||||
}: {
|
||||
guests: WorkloadGuest[];
|
||||
nodes: Node[];
|
||||
groupingMode: 'grouped' | 'flat';
|
||||
sortComparator: ((a: WorkloadGuest, b: WorkloadGuest) => number) | null;
|
||||
groupLabelBadges?: Record<string, { label: string }>;
|
||||
}): Map<string, SummarySeriesGroupScope> => {
|
||||
if (groupingMode !== 'grouped') {
|
||||
return new Map<string, SummarySeriesGroupScope>();
|
||||
}
|
||||
|
||||
const grouped = groupWorkloads(guests, groupingMode, sortComparator);
|
||||
const nodeByInstance = buildNodeByInstance(nodes);
|
||||
const scopes = new Map<string, SummarySeriesGroupScope>();
|
||||
for (const [groupKey, groupGuests] of Object.entries(grouped)) {
|
||||
const node = nodeByInstance[groupKey];
|
||||
const badge = groupLabelBadges?.[groupKey] ?? groupLabelBadges?.[groupKey.toLowerCase()];
|
||||
const scope = buildWorkloadSummaryGroupScope(
|
||||
groupKey,
|
||||
groupGuests,
|
||||
getWorkloadGroupLabel(
|
||||
groupKey,
|
||||
groupGuests,
|
||||
node ? getNodeDisplayName(node) : null,
|
||||
badge?.label,
|
||||
),
|
||||
);
|
||||
if (scope) {
|
||||
scopes.set(scope.id, scope);
|
||||
}
|
||||
}
|
||||
return scopes;
|
||||
};
|
||||
|
||||
export const computeWorkloadStats = (guests: WorkloadGuest[]): WorkloadStats => {
|
||||
let running = 0;
|
||||
let degraded = 0;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { guestOverrideIdCandidates } from '@/features/alerts/guestOverrideIdentity';
|
||||
import {
|
||||
getCanonicalWorkloadId,
|
||||
getWorkloadPlatformScopes,
|
||||
resolveDiscoveryTargetForWorkload,
|
||||
resolveWorkloadType,
|
||||
} from '@/utils/workloads';
|
||||
@@ -55,6 +56,24 @@ export const getWorkloadAlertThresholdScope = (guest: WorkloadGuest): AlertThres
|
||||
return type === 'app-container' ? 'docker' : 'guest';
|
||||
};
|
||||
|
||||
/**
|
||||
* Tags the alert engine reads for this workload's guest thresholds. It reads
|
||||
* Pulse control tags such as pulse-relaxed only from Proxmox VMs and LXCs, so
|
||||
* a vSphere VM or a container keeps its thresholds whatever it is tagged.
|
||||
* Rows without platform scopes are the legacy Proxmox shape.
|
||||
*/
|
||||
export const getWorkloadAlertPolicyTags = (guest: WorkloadGuest): string[] => {
|
||||
const type = resolveWorkloadType(guest);
|
||||
if (type !== 'vm' && type !== 'system-container') return [];
|
||||
const platformScopes = getWorkloadPlatformScopes(guest);
|
||||
if (platformScopes.length > 0 && !platformScopes.includes('proxmox-pve')) return [];
|
||||
const tags = guest.tags;
|
||||
if (Array.isArray(tags)) return tags;
|
||||
// Legacy rows carry the raw tag string: Proxmox separates with ';',
|
||||
// older Pulse payloads with ','.
|
||||
return typeof tags === 'string' ? tags.split(/[;,]/) : [];
|
||||
};
|
||||
|
||||
export const getWorkloadAlertResourceIdCandidates = (guest: WorkloadGuest): string[] => {
|
||||
const type = resolveWorkloadType(guest);
|
||||
if (type === 'vm' || type === 'system-container') {
|
||||
|
||||
@@ -75,8 +75,6 @@ export interface WorkloadsFilterProps {
|
||||
namespaceFilter?: WorkloadsToolbarFilterConfig;
|
||||
clusterFilter?: WorkloadsToolbarFilterConfig;
|
||||
containerRuntimeFilter?: WorkloadsToolbarFilterConfig;
|
||||
chartsCollapsed?: () => boolean;
|
||||
onChartsToggle?: () => void;
|
||||
metricDisplayMode?: () => WorkloadsMetricDisplayMode;
|
||||
setMetricDisplayMode?: (value: WorkloadsMetricDisplayMode) => void;
|
||||
metricHoverMode?: () => WorkloadsMetricHoverMode;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { For, createSignal } from 'solid-js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ChartVisibilityToggleButton,
|
||||
FilterHeader,
|
||||
FilterPopoverTrigger,
|
||||
FilterToolbarPanel,
|
||||
@@ -124,34 +123,6 @@ describe('FilterHeader', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps chart visibility as an explicit display toggle action', () => {
|
||||
const [collapsed, setCollapsed] = createSignal(false);
|
||||
const onToggle = vi.fn(() => setCollapsed((current) => !current));
|
||||
|
||||
render(() => (
|
||||
<ChartVisibilityToggleButton
|
||||
collapsed={collapsed()}
|
||||
onToggle={onToggle}
|
||||
data-testid="charts-toggle"
|
||||
/>
|
||||
));
|
||||
|
||||
const hideButton = screen.getByRole('button', { name: 'Hide charts' });
|
||||
expect(hideButton).toHaveTextContent('Charts');
|
||||
expect(hideButton).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(hideButton).toHaveAttribute('title', 'Hide charts');
|
||||
expect(hideButton).toHaveClass('hidden');
|
||||
expect(hideButton).toHaveClass('lg:inline-flex');
|
||||
|
||||
hideButton.click();
|
||||
|
||||
const showButton = screen.getByRole('button', { name: 'Show charts' });
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
expect(showButton).toHaveTextContent('Charts');
|
||||
expect(showButton).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(showButton).toHaveAttribute('title', 'Show charts');
|
||||
});
|
||||
|
||||
it('keeps shared toggle behavior on shell, runtime, and model owners', () => {
|
||||
expect(toggleSource).toContain('useToggleState');
|
||||
expect(toggleSource).toContain('getToggleTrackClass');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, For, JSX, Show, splitProps } from 'solid-js';
|
||||
import BarChartIcon from 'lucide-solid/icons/bar-chart';
|
||||
import ChevronDownIcon from 'lucide-solid/icons/chevron-down';
|
||||
import ListFilterIcon from 'lucide-solid/icons/list-filter';
|
||||
import { FormSelect } from './FormSelect';
|
||||
@@ -410,33 +409,6 @@ export const FilterPopoverTrigger: Component<FilterPopoverTriggerProps> = (props
|
||||
);
|
||||
};
|
||||
|
||||
interface ChartVisibilityToggleButtonProps extends Omit<
|
||||
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'aria-label' | 'aria-pressed' | 'children' | 'onClick' | 'title'
|
||||
> {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const ChartVisibilityToggleButton: Component<ChartVisibilityToggleButtonProps> = (props) => {
|
||||
const [local, buttonProps] = splitProps(props, ['collapsed', 'onToggle', 'class']);
|
||||
const label = () => (local.collapsed ? 'Show charts' : 'Hide charts');
|
||||
return (
|
||||
<FilterActionButton
|
||||
{...buttonProps}
|
||||
class={`hidden lg:inline-flex ${local.class ?? ''}`.trim()}
|
||||
active={!local.collapsed}
|
||||
aria-label={label()}
|
||||
aria-pressed={!local.collapsed}
|
||||
title={label()}
|
||||
onClick={() => local.onToggle()}
|
||||
>
|
||||
<BarChartIcon class="h-3 w-3" />
|
||||
Charts
|
||||
</FilterActionButton>
|
||||
);
|
||||
};
|
||||
|
||||
interface FilterToolbarPanelProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
children: JSX.Element;
|
||||
widthClass?: string;
|
||||
|
||||
@@ -47,4 +47,24 @@ describe('MetadataBadge', () => {
|
||||
expect(badgeClass).toContain('text-orange-700');
|
||||
expect(badgeClass).toContain('text-[10px]');
|
||||
});
|
||||
|
||||
it('keeps chips on one line unless a long identity value opts into wrapping', () => {
|
||||
const defaultClass = getMetadataBadgeClass({ size: 'xs', shape: 'rounded' }).split(' ');
|
||||
expect(defaultClass).toContain('whitespace-nowrap');
|
||||
expect(defaultClass).not.toContain('wrap-anywhere');
|
||||
|
||||
render(() => (
|
||||
<MetadataBadge size="xs" shape="rounded" wrap>
|
||||
proxmox-lxc-docker:Production West:pve1:113
|
||||
</MetadataBadge>
|
||||
));
|
||||
|
||||
const badge = screen.getByText('proxmox-lxc-docker:Production West:pve1:113');
|
||||
expect(badge).toHaveClass('max-w-full');
|
||||
expect(badge).toHaveClass('min-w-0');
|
||||
expect(badge).toHaveClass('whitespace-normal');
|
||||
expect(badge).toHaveClass('wrap-anywhere');
|
||||
expect(badge).toHaveClass('text-left');
|
||||
expect(badge).not.toHaveClass('whitespace-nowrap');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,11 +22,19 @@ export interface MetadataBadgeProps extends Omit<JSX.HTMLAttributes<HTMLSpanElem
|
||||
appearance?: MetadataBadgeAppearance;
|
||||
fit?: boolean;
|
||||
uppercase?: boolean;
|
||||
/**
|
||||
* Lets a long identity value (an alias, an address) wrap inside a narrow
|
||||
* container instead of spilling past it. Breaks prefer spaces and hyphens
|
||||
* and split a token only when nothing else fits.
|
||||
*/
|
||||
wrap?: boolean;
|
||||
class?: string;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
const METADATA_BADGE_BASE_CLASS = 'inline-flex items-center gap-1 font-medium whitespace-nowrap';
|
||||
const METADATA_BADGE_BASE_CLASS = 'inline-flex items-center gap-1 font-medium';
|
||||
const METADATA_BADGE_NOWRAP_CLASS = 'whitespace-nowrap';
|
||||
const METADATA_BADGE_WRAP_CLASS = 'min-w-0 max-w-full whitespace-normal wrap-anywhere text-left';
|
||||
|
||||
const METADATA_BADGE_SIZE_CLASSES: Record<MetadataBadgeSize, string> = {
|
||||
xs: 'px-1.5 py-0.5 text-[10px]',
|
||||
@@ -81,6 +89,7 @@ export function getMetadataBadgeClass(props: {
|
||||
appearance?: MetadataBadgeAppearance;
|
||||
fit?: boolean;
|
||||
uppercase?: boolean;
|
||||
wrap?: boolean;
|
||||
class?: string;
|
||||
}): string {
|
||||
const tone = props.tone ?? 'neutral';
|
||||
@@ -90,6 +99,7 @@ export function getMetadataBadgeClass(props: {
|
||||
|
||||
return [
|
||||
METADATA_BADGE_BASE_CLASS,
|
||||
props.wrap ? METADATA_BADGE_WRAP_CLASS : METADATA_BADGE_NOWRAP_CLASS,
|
||||
METADATA_BADGE_SIZE_CLASSES[size],
|
||||
METADATA_BADGE_SHAPE_CLASSES[shape],
|
||||
METADATA_BADGE_TONE_CLASSES[appearance][tone],
|
||||
@@ -109,6 +119,7 @@ export const MetadataBadge: Component<MetadataBadgeProps> = (props) => {
|
||||
'appearance',
|
||||
'fit',
|
||||
'uppercase',
|
||||
'wrap',
|
||||
'class',
|
||||
'children',
|
||||
]);
|
||||
@@ -123,6 +134,7 @@ export const MetadataBadge: Component<MetadataBadgeProps> = (props) => {
|
||||
appearance: local.appearance,
|
||||
fit: local.fit,
|
||||
uppercase: local.uppercase,
|
||||
wrap: local.wrap,
|
||||
class: local.class,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -1356,8 +1356,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(summaryCardInteractionSource).toContain('resolveSummaryScopeState');
|
||||
|
||||
expect(workloadSelectionStateSource).toContain('preserveScrollableAncestorVerticalOffset');
|
||||
expect(workloadSelectionStateSource).toContain('hoveredWorkloadGroupScope');
|
||||
expect(workloadSelectionStateSource).toContain('activeSummaryWorkloadGroupScope');
|
||||
expect(workloadSelectionStateSource).not.toContain('const scrollTop = scroller?.scrollTop');
|
||||
|
||||
expect(summaryTableFocusSource).toContain('export function useSummaryTableFocusBridge');
|
||||
@@ -1367,6 +1365,9 @@ describe('shared primitive guardrails', () => {
|
||||
expect(summaryTableFocusSource).toContain('findInlineDetailElement');
|
||||
expect(summaryTableFocusSource).toContain('revealInlineDetailInViewport');
|
||||
expect(summaryTableFocusSource).toContain('MutationObserver');
|
||||
expect(summaryTableFocusSource).toContain(
|
||||
'untrack(() => revealFocusedSeries(root, focusedId))',
|
||||
);
|
||||
expect(summaryTableFocusSource).toContain('clearPinnedScope?: () => void;');
|
||||
expect(summaryTableFocusSource).toContain('onEscapeClear?: () => void;');
|
||||
expect(summaryTableFocusSource).toContain('setClearSurfaceRootRef');
|
||||
@@ -1404,7 +1405,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(groupedTableRowPresentationSource).not.toContain('GROUPED_TABLE_ROW_DIVIDER_CLASS');
|
||||
|
||||
expect(guestRowSource).toContain('data-summary-row-active');
|
||||
expect(guestRowSource).toContain('data-summary-group-member-active');
|
||||
expect(guestRowStateSource).not.toContain('bg-sky-50/70');
|
||||
expect(guestRowStateSource).not.toContain('ring-sky-400/25');
|
||||
|
||||
@@ -1422,7 +1422,7 @@ describe('shared primitive guardrails', () => {
|
||||
expect(storageGroupPresentationSource).toContain('getGroupedTableRowCellClass');
|
||||
expect(nodeGroupHeaderSource).toContain('getGroupedTableRowClass');
|
||||
expect(nodeGroupHeaderSource).toContain('getGroupedTableRowCellClass');
|
||||
expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass');
|
||||
expect(workloadPanelSource).toContain('class={getGroupedTableRowClass()}');
|
||||
expect(workloadPanelSource).toContain('getGroupedTableRowCellClass');
|
||||
expect(alertHistoryTableGroupRowSource).toContain('getGroupedTableRowClass');
|
||||
expect(alertHistoryTableGroupRowSource).toContain('getGroupedTableRowCellClass');
|
||||
@@ -1573,56 +1573,25 @@ describe('shared primitive guardrails', () => {
|
||||
expect(storagePoolsTableSource).not.toContain('HEADER_SORT_BUTTON');
|
||||
});
|
||||
|
||||
it('keeps chart visibility display actions on the shared toolbar toggle', () => {
|
||||
it('keeps the retired summary chart-visibility toggle out of the shared toolbar', () => {
|
||||
// The workload summary chart section this toggle controlled is deleted and
|
||||
// no other page exposes a chart show/hide control. A returning summary
|
||||
// section is a governed product decision that must register its own
|
||||
// primitive.
|
||||
const registry = JSON.parse(sharedTemplateRegistrySource) as {
|
||||
rules?: Array<{
|
||||
id: string;
|
||||
canonical?: { path?: string; export?: string };
|
||||
requiredConsumers?: Array<{ path?: string }>;
|
||||
}>;
|
||||
patternGuards?: Array<{
|
||||
id: string;
|
||||
canonical?: { path?: string; export?: string };
|
||||
allPatterns?: string[];
|
||||
scopes?: string[];
|
||||
pathIncludes?: string[];
|
||||
pathExcludes?: string[];
|
||||
allowedPaths?: string[];
|
||||
ignoredPaths?: string[];
|
||||
}>;
|
||||
rules?: Array<{ id: string; canonical?: { export?: string } }>;
|
||||
patternGuards?: Array<{ id: string; canonical?: { export?: string } }>;
|
||||
};
|
||||
const registeredRule = registry.rules?.find(
|
||||
(rule) => rule.id === 'chart-visibility-toggle-button',
|
||||
);
|
||||
const registeredGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'chart-visibility-local-toggle-labels',
|
||||
);
|
||||
const retiredIds = ['chart-visibility-toggle-button', 'chart-visibility-local-toggle-labels'];
|
||||
for (const entry of [...(registry.rules ?? []), ...(registry.patternGuards ?? [])]) {
|
||||
expect(retiredIds).not.toContain(entry.id);
|
||||
expect(entry.canonical?.export).not.toBe('ChartVisibilityToggleButton');
|
||||
}
|
||||
|
||||
expect(registeredRule?.canonical?.path).toBe('src/components/shared/FilterToolbar.tsx');
|
||||
expect(registeredRule?.canonical?.export).toBe('ChartVisibilityToggleButton');
|
||||
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
|
||||
'src/components/Workloads/WorkloadsFilter.tsx',
|
||||
]);
|
||||
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/FilterToolbar.tsx');
|
||||
expect(registeredGuard?.canonical?.export).toBe('ChartVisibilityToggleButton');
|
||||
expect(registeredGuard?.allPatterns).toEqual(['Show charts', 'Hide charts']);
|
||||
expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0);
|
||||
expect(registeredGuard?.ignoredPaths).toEqual([
|
||||
'src/components/Workloads/__tests__/WorkloadsFilter.test.tsx',
|
||||
]);
|
||||
expect(registeredGuard?.scopes).toEqual(
|
||||
expect.arrayContaining(['src/components/Workloads', 'src/features', 'src/pages']),
|
||||
);
|
||||
|
||||
expect(filterToolbarSource).toContain('export const ChartVisibilityToggleButton');
|
||||
expect(filterToolbarSource).toContain("local.collapsed ? 'Show charts' : 'Hide charts'");
|
||||
expect(filterToolbarSource).toContain('active={!local.collapsed}');
|
||||
expect(filterToolbarSource).toContain('aria-pressed={!local.collapsed}');
|
||||
expect(filterToolbarSource).toContain('title={label()}');
|
||||
|
||||
expect(workloadsFilterSource).toContain('ChartVisibilityToggleButton');
|
||||
expect(workloadsFilterSource).not.toContain('Show charts');
|
||||
expect(workloadsFilterSource).not.toContain('Hide charts');
|
||||
expect(filterToolbarSource).not.toContain('ChartVisibilityToggleButton');
|
||||
expect(workloadsFilterSource).not.toContain('ChartVisibilityToggleButton');
|
||||
expect(workloadsFilterSource).not.toContain('onChartsToggle');
|
||||
expect(workloadsFilterSource).not.toContain('chartsCollapsed');
|
||||
});
|
||||
|
||||
it('keeps grouped/list table-mode controls on one shared presentation contract', () => {
|
||||
@@ -1728,7 +1697,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(storageGroupRowSource).not.toContain('hideWhenRowTappableOnMobile={false}');
|
||||
expect(storageGroupRowSource).toContain('props.onToggle();');
|
||||
|
||||
expect(workloadPanelSource).toContain('createSummaryInteractiveRowPreviewHandlers');
|
||||
expect(workloadPanelSource).not.toContain('kind="scope"');
|
||||
expect(storageGroupRowSource).not.toContain('kind="scope"');
|
||||
|
||||
@@ -2621,6 +2589,7 @@ describe('shared primitive guardrails', () => {
|
||||
'src/components/patrol/RunToolCallTrace.tsx',
|
||||
'src/features/proxmox/proxmoxBackupsTableShared.tsx',
|
||||
'src/features/patrol/PatrolIntelligenceWorkspace.tsx',
|
||||
'src/components/Infrastructure/ResourceDetailSummary.tsx',
|
||||
]);
|
||||
expect(roleRule?.canonical?.path).toBe('src/components/shared/OrganizationBadges.tsx');
|
||||
expect(roleRule?.canonical?.export).toBe('OrganizationRoleBadge');
|
||||
@@ -2688,6 +2657,18 @@ describe('shared primitive guardrails', () => {
|
||||
expect(metadataBadgeSource).toContain("muted: 'bg-surface-alt text-muted'");
|
||||
expect(metadataBadgeSource).toContain("warning: 'bg-amber-100 text-amber-800");
|
||||
expect(metadataBadgeSource).not.toContain(["muted: 'bg", 'slate', '100'].join('-'));
|
||||
// Drawer identity values differ at their ends and a phone has no hover to
|
||||
// show a title, so they wrap inside the drawer cell instead of spilling
|
||||
// past the drawer edge, where the content shell clips them.
|
||||
expect(metadataBadgeSource).toContain(
|
||||
"METADATA_BADGE_WRAP_CLASS = 'min-w-0 max-w-full whitespace-normal wrap-anywhere text-left'",
|
||||
);
|
||||
expect(resourceDetailSummarySource).toContain(
|
||||
'<MetadataBadge tone="info" size="xs" shape="rounded" wrap title={ip}>',
|
||||
);
|
||||
expect(resourceDetailSummarySource).toContain(
|
||||
'<MetadataBadge size="xs" shape="rounded" wrap title={value}>',
|
||||
);
|
||||
expect(organizationBadgesSource).toContain('MetadataBadge');
|
||||
expect(organizationBadgesSource).toContain('getOrganizationRoleBadgeTone');
|
||||
expect(organizationBadgesSource).toContain('getOrganizationShareStatusBadgeTone');
|
||||
@@ -8411,6 +8392,11 @@ describe('shared primitive guardrails', () => {
|
||||
expect(tooltipStateSource).toContain('resolveTooltipPosition');
|
||||
expect(tooltipStateSource).toContain('sanitizeTooltipContent');
|
||||
expect(tooltipStateSource).toContain('supportsHoverTooltips');
|
||||
// Hidden tooltips sit on every table row; only an open one may hold a
|
||||
// window resize listener (TooltipPortal.test.tsx proves the behaviour).
|
||||
expect(tooltipStateSource).toMatch(
|
||||
/if \(typeof window === 'undefined' \|\| !options\.visible\(\)\) return;\s*updateViewport\(\);\s*const handleResize/,
|
||||
);
|
||||
|
||||
expect(sharedTooltipHookSource).toContain('supportsHoverTooltips');
|
||||
expect(hoverCapabilitySource).toContain('(hover: hover) and (pointer: fine)');
|
||||
@@ -8667,7 +8653,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(workloadsFilterSource).toContain('trailingControls={');
|
||||
expect(workloadsFilterSource).not.toContain('ViewOptionsMenu');
|
||||
expect(workloadsFilterSource).toContain('GroupedTableModeSegmentedControl');
|
||||
expect(workloadsFilterSource).toContain('ChartVisibilityToggleButton');
|
||||
expect(workloadsFilterSource).toContain('<ColumnPicker');
|
||||
expect(workloadsFilterSource).toContain('onClearAll={handleClearAll}');
|
||||
expect(workloadsFilterSource).toContain('showClearAll={showClearAll}');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { For, createSignal } from 'solid-js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, waitFor } from '@solidjs/testing-library';
|
||||
import { TooltipPortal } from '@/components/shared/TooltipPortal';
|
||||
@@ -20,6 +20,7 @@ describe('TooltipPortal', () => {
|
||||
media: '(hover: hover) and (pointer: fine)',
|
||||
}),
|
||||
);
|
||||
const addListener = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
render(() => (
|
||||
<TooltipPortal when x={120} y={80}>
|
||||
@@ -28,6 +29,7 @@ describe('TooltipPortal', () => {
|
||||
));
|
||||
|
||||
expect(document.body.querySelector('[data-tooltip-portal="true"]')).toBeNull();
|
||||
expect(addListener.mock.calls.filter(([type]) => type === 'resize')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps tooltip portal on shell and runtime owners', () => {
|
||||
@@ -172,4 +174,143 @@ describe('TooltipPortal', () => {
|
||||
expect(position.y).toBeGreaterThan(initialPosition.y + 80);
|
||||
});
|
||||
});
|
||||
|
||||
it('holds a window resize listener only while the portal is visible', () => {
|
||||
const addListener = vi.spyOn(window, 'addEventListener');
|
||||
const removeListener = vi.spyOn(window, 'removeEventListener');
|
||||
const resizeCalls = (spy: { mock: { calls: unknown[][] } }) =>
|
||||
spy.mock.calls.filter(([type]) => type === 'resize').length;
|
||||
|
||||
const [when, setWhen] = createSignal(false);
|
||||
|
||||
render(() => (
|
||||
<>
|
||||
<For each={[1, 2, 3]}>
|
||||
{() => (
|
||||
<TooltipPortal when={false} x={40} y={40}>
|
||||
<span>Hidden row tooltip</span>
|
||||
</TooltipPortal>
|
||||
)}
|
||||
</For>
|
||||
<TooltipPortal when={when()} x={120} y={80}>
|
||||
<span>Memory Composition</span>
|
||||
</TooltipPortal>
|
||||
</>
|
||||
));
|
||||
|
||||
expect(resizeCalls(addListener)).toBe(0);
|
||||
|
||||
setWhen(true);
|
||||
expect(resizeCalls(addListener)).toBe(1);
|
||||
expect(resizeCalls(removeListener)).toBe(0);
|
||||
|
||||
setWhen(false);
|
||||
expect(resizeCalls(addListener)).toBe(1);
|
||||
expect(resizeCalls(removeListener)).toBe(1);
|
||||
});
|
||||
|
||||
it('follows a window resize while the portal is open', async () => {
|
||||
const innerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
|
||||
const setInnerWidth = (value: number) =>
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value, writable: true });
|
||||
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
||||
(callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 180,
|
||||
height: 40,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 180,
|
||||
bottom: 40,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
try {
|
||||
setInnerWidth(1024);
|
||||
render(() => (
|
||||
<TooltipPortal when x={900} y={120}>
|
||||
<span>Memory Composition</span>
|
||||
</TooltipPortal>
|
||||
));
|
||||
|
||||
const readPortal = () => {
|
||||
const portal = document.body.querySelector('foreignObject');
|
||||
return {
|
||||
viewBox: portal?.closest('svg')?.getAttribute('viewBox'),
|
||||
x: Number.parseFloat(portal?.getAttribute('x') ?? 'NaN'),
|
||||
};
|
||||
};
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readPortal().viewBox).toBe(`0 0 1024 ${window.innerHeight}`);
|
||||
expect(readPortal().x).toBe(810);
|
||||
});
|
||||
|
||||
setInnerWidth(600);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readPortal().viewBox).toBe(`0 0 600 ${window.innerHeight}`);
|
||||
// Clamped inside the narrower viewport: 600 - 180 wide - 4 padding.
|
||||
expect(readPortal().x).toBe(416);
|
||||
});
|
||||
} finally {
|
||||
if (innerWidth) Object.defineProperty(window, 'innerWidth', innerWidth);
|
||||
}
|
||||
});
|
||||
|
||||
it('opens against the live viewport after resizes it did not listen to', async () => {
|
||||
const innerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
|
||||
const setInnerWidth = (value: number) =>
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value, writable: true });
|
||||
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
||||
(callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 180,
|
||||
height: 40,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 180,
|
||||
bottom: 40,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
try {
|
||||
setInnerWidth(1024);
|
||||
const [when, setWhen] = createSignal(false);
|
||||
render(() => (
|
||||
<TooltipPortal when={when()} x={900} y={120}>
|
||||
<span>Memory Composition</span>
|
||||
</TooltipPortal>
|
||||
));
|
||||
|
||||
setInnerWidth(600);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
setWhen(true);
|
||||
|
||||
await waitFor(() => {
|
||||
const portal = document.body.querySelector('foreignObject');
|
||||
expect(portal?.closest('svg')?.getAttribute('viewBox')).toBe(
|
||||
`0 0 600 ${window.innerHeight}`,
|
||||
);
|
||||
expect(Number.parseFloat(portal?.getAttribute('x') ?? 'NaN')).toBe(416);
|
||||
});
|
||||
} finally {
|
||||
if (innerWidth) Object.defineProperty(window, 'innerWidth', innerWidth);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,96 @@ describe('useSummaryPageInteractionState', () => {
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 456, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
it('reveals once per focus change, not when state its reveal callback reads changes', () => {
|
||||
const [focusedSeriesId, setFocusedSeriesId] = createSignal<string | null>('pool-alpha');
|
||||
const groupKeyEntries: Array<[string, string]> = [
|
||||
['pool-alpha', 'node-a'],
|
||||
['pool-beta', 'node-b'],
|
||||
];
|
||||
const [groupKeys, setGroupKeys] = createSignal(new Map(groupKeyEntries));
|
||||
const [expandedGroups, setExpandedGroups] = createSignal(new Set(['node-a']));
|
||||
const scrollTo = vi.fn();
|
||||
const root = document.createElement('div');
|
||||
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
});
|
||||
vi.stubGlobal('scrollY', 0);
|
||||
|
||||
for (const [index, id] of ['pool-alpha', 'pool-beta'].entries()) {
|
||||
const row = document.createElement('div');
|
||||
const detail = document.createElement('div');
|
||||
row.setAttribute('data-summary-series-id', id);
|
||||
row.getBoundingClientRect = vi.fn(() => buildRect(680 + index * 300, 40));
|
||||
detail.setAttribute('data-inline-detail-for', id);
|
||||
detail.getBoundingClientRect = vi.fn(() => buildRect(724 + index * 300, 220));
|
||||
root.append(row, detail);
|
||||
}
|
||||
document.body.appendChild(root);
|
||||
|
||||
// Mirrors Storage: reopen the focused row's collapsed group.
|
||||
const revealActiveSeries = vi.fn((seriesId: string) => {
|
||||
const groupKey = groupKeys().get(seriesId);
|
||||
if (groupKey && !expandedGroups().has(groupKey)) {
|
||||
setExpandedGroups((current) => new Set(current).add(groupKey));
|
||||
}
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryPageInteractionState({
|
||||
focusedSeriesId,
|
||||
revealActiveSeries,
|
||||
}),
|
||||
);
|
||||
|
||||
result.setTableRootRef(root);
|
||||
|
||||
expect(revealActiveSeries).toHaveBeenCalledTimes(1);
|
||||
const scrollsAfterFocus = scrollTo.mock.calls.length;
|
||||
expect(scrollsAfterFocus).toBeGreaterThan(0);
|
||||
|
||||
// A live update rebuilds the lookup the callback read; focus is unchanged.
|
||||
setGroupKeys(new Map(groupKeyEntries));
|
||||
|
||||
expect(revealActiveSeries).toHaveBeenCalledTimes(1);
|
||||
expect(scrollTo).toHaveBeenCalledTimes(scrollsAfterFocus);
|
||||
|
||||
// The operator collapses the focused row's group, and it stays collapsed.
|
||||
setExpandedGroups(new Set<string>());
|
||||
|
||||
expect(revealActiveSeries).toHaveBeenCalledTimes(1);
|
||||
expect(expandedGroups().has('node-a')).toBe(false);
|
||||
|
||||
setFocusedSeriesId('pool-beta');
|
||||
|
||||
expect(revealActiveSeries).toHaveBeenCalledTimes(2);
|
||||
expect(revealActiveSeries).toHaveBeenLastCalledWith('pool-beta');
|
||||
expect(expandedGroups().has('node-b')).toBe(true);
|
||||
expect(scrollTo.mock.calls.length).toBeGreaterThan(scrollsAfterFocus);
|
||||
});
|
||||
|
||||
it('does not re-reveal when a derived focus accessor recomputes the same id', () => {
|
||||
const [metricIds, setMetricIds] = createSignal(new Map([['row-a', 'pool-alpha']]));
|
||||
const revealActiveSeries = vi.fn();
|
||||
const root = document.createElement('div');
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryPageInteractionState({
|
||||
focusedSeriesId: () => metricIds().get('row-a') ?? null,
|
||||
revealActiveSeries,
|
||||
}),
|
||||
);
|
||||
|
||||
result.setTableRootRef(root);
|
||||
expect(revealActiveSeries).toHaveBeenCalledTimes(1);
|
||||
|
||||
setMetricIds(new Map([['row-a', 'pool-alpha']]));
|
||||
|
||||
expect(revealActiveSeries).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears pinned scope when operators click table whitespace on a clear surface', () => {
|
||||
const [focusedSeriesId] = createSignal<string | null>('workload-a');
|
||||
const clearPinnedScope = vi.fn();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user