mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Merge branch 'refactor/drop-unread-patrol-state-members' into fix/drop-unread-circuit-breaker-read
This commit is contained in:
+10
-5
@@ -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:
|
||||
@@ -891,7 +894,9 @@ For an existing incident whose notifications should be paused without clearing
|
||||
it, use the snooze and unsnooze endpoints below.
|
||||
|
||||
- `GET /api/alerts/config`
|
||||
- `PUT /api/alerts/config`
|
||||
- `PUT /api/alerts/config` — replaces only the top-level keys the body
|
||||
carries; a key left out keeps its stored value, and the merged config is
|
||||
normalized as before. The body must be a JSON object.
|
||||
- `GET /api/alerts/deadman/config` — returns only whether an external watchdog
|
||||
is configured; `pingUrl` is `***REDACTED***` when present and never returns
|
||||
the credential-bearing URL
|
||||
|
||||
+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
|
||||
|
||||
@@ -248,6 +248,18 @@ Without comparable identity (a SAS, SCSI or USB replacement with no WWN on one
|
||||
side) the row still matches. Report admission and the SMART wire contract are
|
||||
unchanged; the regression is
|
||||
`TestHostAgentSMARTRowForSwappedOutDiskDoesNotLendItsIdentity`.
|
||||
A PVE disk that a still-reporting linked Agent's SMART row matches is marked
|
||||
`AgentSMARTReported` (internal poll evidence on `models.PhysicalDisk`, never
|
||||
serialized); disks the poller builds from that report when the Proxmox disk
|
||||
query fails go through the same merge, and an Agent whose lease lapsed owns
|
||||
none. The Agent then owns the disk's temperature alert
|
||||
through `CheckHost`, and the PVE disk temperature check closes its own as
|
||||
moved to the Agent, so one disk never alerts twice. Report admission, the SMART
|
||||
wire contract and Agent authority are unchanged;
|
||||
`TestMergeHostAgentSMARTIntoDisksMarksDisksTheAgentReports` in
|
||||
`internal/monitoring/physical_disk_roundtrip_test.go` and
|
||||
`TestPhysicalDiskAgentSMARTReportedStaysInternal` in
|
||||
`internal/models/deepcopy_test.go` pin the marker.
|
||||
|
||||
Assistant historical metric wiring uses the current monitor's retained store
|
||||
and registry metrics coordinates. Historical reads do not alter enrollment,
|
||||
@@ -765,6 +777,15 @@ not fenced: a live report already past its mock-mode check when mock mode is
|
||||
switched on can still be evaluated after the clear, and its alerts can persist
|
||||
until mock mode is left.
|
||||
|
||||
Every tenant monitor registers its own fixture agents, and their node links,
|
||||
in its own alert manager, so the toggle forgets the fixture agents of every
|
||||
running monitor, not only those of the monitor it was called through. A link
|
||||
left behind would keep owning its fixture node's usage alerts after mock mode
|
||||
ends. `TestLeavingMockModeReleasesFixtureAgentNodeLinksOnEveryRunningMonitor`
|
||||
in `internal/monitoring/monitor_host_agent_removal_lifecycle_test.go` switches
|
||||
through the default monitor and fails if the other tenant's node stays
|
||||
suppressed.
|
||||
|
||||
Physical-disk evidence collected by a host agent must survive projection back
|
||||
into monitoring's models. Absent evidence has to carry its declared sentinel
|
||||
rather than a zero value that reads as a real measurement: an absent
|
||||
@@ -3669,6 +3690,14 @@ Agent` secondary handoff against the live setup wizard instead of relying
|
||||
|
||||
## Current State
|
||||
|
||||
### Alert settings saves do not touch agent lifecycle
|
||||
|
||||
`PUT /api/alerts/config` in `internal/api/alerting/alerts.go` now keeps stored
|
||||
values for the top-level keys a client leaves out. Agent alert defaults
|
||||
(`agentDefaults`) follow the same rule as every other key; enrollment,
|
||||
reporting leases, commands and agent removal are unchanged.
|
||||
`internal/api/alerting/alerts_test.go` pins the handler.
|
||||
|
||||
### VM guest execution admission (backup precaution)
|
||||
|
||||
`AgentRegisterPayload.guest_exec_guard_version` is optional; version 1 on a
|
||||
@@ -4703,6 +4732,12 @@ agent-lifecycle operations. If those routes are called directly, the API must
|
||||
fail them as unsupported rather than presenting file diffs or reverts as
|
||||
agent command rollback, enrollment repair, update rollback, or fleet-control
|
||||
authority.
|
||||
Assistant session mutations (`/abort`, `/summarize`, `/fork`, `/undo`,
|
||||
`/redo`, `/steer` under `/api/ai/sessions/{id}`) accept only `POST`; any other
|
||||
method that reaches those handlers gets `405` before touching session state.
|
||||
That method guard
|
||||
is AI-runtime transport hardening in `internal/api/ai_handler.go`; it adds no
|
||||
agent command, enrollment, update, or fleet-control path.
|
||||
That same shared dependency now also assumes hosted cloud handoff authorizes
|
||||
tenant org access before browser lifecycle continues. Lifecycle-adjacent opens
|
||||
into hosted workspaces may depend on `internal/api/cloud_handoff_handlers.go`,
|
||||
@@ -7630,6 +7665,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
|
||||
@@ -8783,6 +8828,40 @@ Focused proof lives in `internal/monitoring/physical_disk_roundtrip_test.go`
|
||||
(`TestNestedProxmoxDefaultQEMUSerialsStayPerNode` and
|
||||
`TestTrueNASPlaceholderDiskSerialsStayPerApplianceAndShareOneHistoryKey`).
|
||||
|
||||
### Unraid serials key agent disk history
|
||||
|
||||
`internal/monitoring/monitor_agents.go` changed only so the SMART and disk I/O
|
||||
history writers key a host agent's disk by the serial its resource carries: the
|
||||
SMART row's own, else the one the host's Unraid inventory reports for that disk
|
||||
(`HostSMARTDiskMetricID`, `HostUnraidDeviceMetricID`). While the Unraid row
|
||||
names such a disk, its SMART and I/O history is written under that serial, which
|
||||
its chart already reads, instead of its WWN or host/device key. Agent
|
||||
registration, enrolment, install, update, removal, report identity and disk
|
||||
source IDs are unchanged. Focused proof lives
|
||||
in `internal/monitoring/monitor_host_agents_test.go`
|
||||
(`TestHostDiskIOMetricResourceIDFallbacks`) and
|
||||
`internal/monitoring/issue1595_collection_trust_test.go`
|
||||
(`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports`).
|
||||
|
||||
### Unraid temperatures chart agent disk history
|
||||
|
||||
`internal/monitoring/monitor_agents.go` changed only so the SMART history
|
||||
writer charts the temperature a host agent's disk shows. A SMART row without
|
||||
its own reading shows the one the host's Unraid inventory reports for the disk,
|
||||
when that inventory row describes the disk (`HostSMARTDiskTemperature`), and a
|
||||
disk only the Unraid inventory reports,
|
||||
such as a member the agent's SMART collection skips under `--disk-exclude`,
|
||||
shows that row's reading (`HostUnraidDiskTemperature`). Both are now written
|
||||
as `smart_temp` under the key the disk's metrics target reads
|
||||
(`HostSMARTDiskMetricID`, `HostUnraidDiskMetricID`); a reading the disk does
|
||||
not show as collected, a spun-down disk's or an expired host's, is not. Agent
|
||||
registration, enrolment, install, update, removal, report identity and disk
|
||||
source IDs are unchanged. Focused proof lives in
|
||||
`internal/monitoring/monitor_host_agents_test.go`
|
||||
(`TestApplyHostReportChartsUnraidTemperatureOfDiskWithoutSMART`) and
|
||||
`internal/monitoring/issue1595_collection_trust_test.go`
|
||||
(`TestAgentDiskChartsTheUnraidTemperatureItShows`).
|
||||
|
||||
### Windows braced MachineGuid does not abort agent startup
|
||||
|
||||
The Windows unified agent resolves host information through gopsutil's combined
|
||||
@@ -8902,3 +8981,20 @@ agent from its last report instead of from the PVE poll, and mark it stale at
|
||||
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.
|
||||
|
||||
### Demo write guard ignores websocket upgrade headers
|
||||
|
||||
`internal/api/demo_middleware.go` changed only so a `POST`, `PUT`, `PATCH` or
|
||||
`DELETE` carrying `Upgrade: websocket` is judged by the demo read-only guard
|
||||
like any other write instead of always reaching its handler. Agent command
|
||||
websocket handshakes to `/api/agent/ws` are HTTP/1.1 `GET` requests, the only
|
||||
transport its gorilla upgrader serves, and still connect on a demo instance.
|
||||
No agent registration, enrolment, install, update or removal path changed.
|
||||
|
||||
### Demo mode hides the pprof routes
|
||||
|
||||
`internal/api/demo_mode_operations.go` changed only so the demo guard on a
|
||||
`DEMO_MODE` instance answers `/debug/pprof` and every path below it with `404`
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -3556,6 +3575,12 @@ query...`, and `Reading storage...` before streamed tool arguments are
|
||||
answer (nothing streaming, no error block, no pending approval or
|
||||
question, and a user prompt available to re-send) because the undo
|
||||
boundary only operates on the last durable turn.
|
||||
Session mutations stay POST-only at the handler: abort, summarize, fork,
|
||||
undo, redo, and steer refuse every other method with `405` and
|
||||
`Allow: POST` before resolving the Assistant service. A `GET` or `HEAD`
|
||||
that the demo-mode read-only guard or the CSRF check admits as a read
|
||||
therefore cannot cancel a running turn, compact or rewind a transcript,
|
||||
or persist a fork.
|
||||
The empty Assistant drawer may surface recent non-empty sessions as direct
|
||||
resume actions using the backend session list already owned by the drawer;
|
||||
it must not create a parallel recent-chat store or product-authored prompt
|
||||
@@ -7515,6 +7540,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
|
||||
|
||||
@@ -525,7 +525,7 @@ active-state and intent-policy linkage while persisting through the stable
|
||||
A per-metric threshold is off whenever its trigger is `<= 0`. That boundary is
|
||||
engine truth (`internal/alerts/canonical_metric.go`,
|
||||
`internal/alerts/config_runtime.go`), not a display convention: `-1` is the
|
||||
value Pulse writes, and `0` disables the metric just as completely because
|
||||
value the editors stage, and `0` disables the metric just as completely because
|
||||
earlier builds advertised `0` as the disable value and those overrides are still
|
||||
on disk. Every threshold editor reads that same `<= 0` rule through
|
||||
`frontend-modern/src/components/Alerts/alertResourceTableModel.ts` rather than
|
||||
@@ -543,7 +543,28 @@ the metric's enabled default instead, and only clears the override when the
|
||||
inherited default is already enabled. Editors must also not manufacture an off
|
||||
state out of an empty input: a cleared box is mid-edit, and coercing it to `0`
|
||||
disabled the metric in the engine while the row still showed On. Disabling is
|
||||
the toggle's job, and the toggle writes the canonical `-1`.
|
||||
the toggle's job, and the toggle stages the canonical `-1`.
|
||||
|
||||
The save path writes the backend's default contract, not the editors' sentinel.
|
||||
Alert config normalization reads a negative trigger on a global default as
|
||||
unset and restores the factory threshold, so `buildAlertsConfigurationPayload`
|
||||
in `frontend-modern/src/features/alerts/alertsConfigurationModel.ts` writes
|
||||
every Off global default as trigger `0`. Sending the staged `-1` through made
|
||||
every Off default with a positive factory threshold come back On at that
|
||||
threshold after save and reload; only guest and node CPU, memory and disk
|
||||
escaped, because normalization leaves them alone. Overrides keep `-1`, which
|
||||
the override path stores as written. Because `-1` and `0` are the same
|
||||
setting, the Global Defaults "Custom" badge compares Off values as equal
|
||||
rather than by raw number, so the factory `-1` I/O defaults do not read
|
||||
Custom against a saved `0`. An unset factory value stays distinct from Off,
|
||||
since unset can mean the default follows another setting. A row's
|
||||
custom-threshold marker keeps comparing raw values: it drives the row's revert
|
||||
control and override removal, so an explicit `-1` override must stay visible
|
||||
against an Off default even though both alert the same way.
|
||||
`frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts`
|
||||
pins the payload for every section, and
|
||||
`internal/alerts/config_defaults_off_test.go` pins that a zero default stays
|
||||
off through `UpdateConfig` and raises no alert.
|
||||
|
||||
External availability-probe reporting loss owns one canonical
|
||||
`external-probe-unavailable` alert per assigned agent, not one alert per target.
|
||||
@@ -1093,6 +1114,43 @@ history and affirmative recovery. This is source-fixture lifecycle proof, not
|
||||
an installed registry/destination result or a diagnosis of a reporter's daily
|
||||
resolve/reopen cycle.
|
||||
|
||||
### A settings save keeps the alert settings it did not send
|
||||
|
||||
`PUT /api/alerts/config` applies the body to the stored configuration through
|
||||
`Manager.ApplyConfigUpdate` and `ApplyAlertConfigUpdate` in
|
||||
`internal/alerts/config/update.go`: a top-level key the client sent replaces
|
||||
that setting whole, a key it left out keeps the stored value, and the result
|
||||
is normalized as any update is. The thresholds page has no control for
|
||||
flapping detection (`flappingEnabled` and its window, threshold and cooldown),
|
||||
alert TTL cleanup (`maxAlertAgeDays`, `maxAcknowledgedAgeDays`,
|
||||
`autoAcknowledgeAfterHours`) or `customRules`, so it never sends them. The
|
||||
handler used to decode the body into a zero `AlertConfig`, so every thresholds
|
||||
save turned flapping detection and TTL cleanup off, reset the flapping tuning
|
||||
to its defaults, dropped custom rules, and persisted that. The page's
|
||||
`flapping`, `aggregation` and `ioNormalization` payload objects are not
|
||||
`AlertConfig` fields and never enabled anything. A stored config keeps what it
|
||||
holds, including values an earlier save zeroed; nothing rewrites them.
|
||||
|
||||
The body is decoded on its own, so sent keys mean exactly what the decoder
|
||||
makes of them, duplicates and case-folded keys included. Unsent fields are
|
||||
copied from a JSON round trip of the stored config, so the result shares no
|
||||
maps or slices with it. `ApplyConfigUpdate` reads, merges and applies under
|
||||
the manager lock, so two concurrent partial saves cannot revert each other in
|
||||
memory, and returns a JSON-cloned snapshot so that persistence, which
|
||||
normalizes the config it is handed in place, never writes into the live
|
||||
config's maps.
|
||||
|
||||
`internal/alerts/config/update_test.go` covers unsent, explicit-off,
|
||||
case-folded, duplicate-key, malformed and full-body updates, a sent object
|
||||
replacing its stored value whole (a sent `truenasDiskDefaults: {}` does not
|
||||
inherit the stored temperature) and the no-sharing rule.
|
||||
`TestUpdateConfigKeepsSettingsAThresholdsSaveDidNotSend` and
|
||||
`TestApplyConfigUpdateKeepsConcurrentPartialUpdates` in
|
||||
`config_validation_test.go` run saves through the manager, and
|
||||
`TestUpdateAlertConfig_KeepsStoredValuesForUnsentKeys` in
|
||||
`internal/api/alerting/alerts_test.go` drives the handler against a real
|
||||
manager.
|
||||
|
||||
### Confirmed empty storage is recovery evidence
|
||||
|
||||
Static storage capacity evaluation must admit a zero usage observation when
|
||||
@@ -1452,7 +1510,8 @@ That same guest-threshold owner also governs guest-derived lifecycle and
|
||||
posture alerts. Snapshot age, backup age, powered-off state, and
|
||||
configuration-change reevaluation must all construct a canonical lightweight
|
||||
guest snapshot and route threshold resolution through the shared
|
||||
guest-defaults → filter-driven custom rules → guest-override chain.
|
||||
guest-defaults → filter-driven custom rules → guest-override → `pulse-relaxed`
|
||||
chain.
|
||||
That canonical guest context must preserve the live guest name and tags for
|
||||
snapshot and backup posture evaluation. Ignored prefixes, `pulse-no-alerts`,
|
||||
configured ignored tags, and required-tag filtering must resolve through the
|
||||
@@ -1462,6 +1521,27 @@ operator's suppression policy.
|
||||
Passing `nil` guest context or resolving only overrides/defaults is forbidden
|
||||
because it silently bypasses custom guest rules and makes guest lifecycle
|
||||
alerting diverge from running-guest metric truth.
|
||||
The `pulse-relaxed` step lives in `effectiveAlertPolicyNoLock`, not in
|
||||
`CheckGuest`, so polling and config-save reevaluation judge a relaxed guest's
|
||||
alert by one threshold. Reevaluation rebuilds the guest from alert metadata,
|
||||
so `guestSnapshotFromAlert` reads back the `tags` the evaluator recorded
|
||||
(`[]string` live, `[]interface{}` after a restart). Without them a save judged
|
||||
the alert by unrelaxed thresholds, resolved one raised under an unset
|
||||
threshold's relaxed floor with a recovery notification, and the next poll
|
||||
raised it again. Those tags must stay current: `metricOptionsWithTags` drops
|
||||
the key from an open alert once its resource has no tags, or a removed
|
||||
`pulse-relaxed` would keep judging the alert by the relaxed trigger. The tag
|
||||
lifts CPU, memory and disk triggers to a 95/92/95 floor and gives an unset
|
||||
threshold that floor, but an Off threshold (trigger <= 0) stays Off: relaxing
|
||||
a guest must never raise an alert its config turned off. A filesystem's own
|
||||
override still replaces the relaxed guest disk threshold for that filesystem,
|
||||
on save as while polling. Proof:
|
||||
`TestConfigSaveJudgesRelaxedGuestAlertByRelaxedThresholds`,
|
||||
`TestConfigSaveKeepsRelaxedGuestFilesystemOverride`,
|
||||
`TestPulseRelaxedKeepsOffGuestThresholdOff` and the Off case of
|
||||
`TestApplyRelaxedGuestThresholds` in `internal/alerts/alerts_test.go`, and
|
||||
`TestCheckGuestDropsRemovedTagsFromOpenAlerts` in
|
||||
`internal/alerts/unified_eval_test.go`.
|
||||
That same guest-alert owner also has to retire per-disk guest alerts when the
|
||||
guest stops, disk alerting is disabled, or the reported disk set changes.
|
||||
Canonical guest disk identity is only valid while the guest still exposes that
|
||||
@@ -1830,14 +1910,84 @@ A disk is hot from its trigger. Alerts and Patrol findings stay open until the
|
||||
reading falls to the clear value, or under the trigger when there is no band
|
||||
below it. The Physical Disks verdict and the TrueNAS Health cell judge only the
|
||||
current reading against the trigger. PDF reports colour disk temperatures by
|
||||
the same thresholds. Judges that still differ: TrueNAS disk temperature alerts
|
||||
use `TrueNASDiskDefaults.Temperature` (a flat 55/50), and the TrueNAS disk
|
||||
drawer tones the reading from a fixed 55C.
|
||||
the same thresholds.
|
||||
`TestDiskTemperatureThresholdMatchesCheckHostPolicy` in
|
||||
`internal/alerts/threshold_resolution_shared_test.go` pins per-type resolution, a raised
|
||||
NVMe trigger that `CheckHost` also honours, the copy, the disabled default and
|
||||
the nil manager.
|
||||
|
||||
TrueNAS disk temperature alerts follow the policy too:
|
||||
`trueNASDiskTemperatureDefaultNoLock`
|
||||
(`internal/alerts/truenas_disk_temperature.go`) is the type-default tier of
|
||||
`effectiveAlertPolicyNoLock` for `truenas-disk`, so an unset
|
||||
`TrueNASDiskDefaults.Temperature` resolves per disk type (the
|
||||
`alertPolicyQuery.DiskType` the unified input carries from
|
||||
`PhysicalDisk.DiskType`). A TrueNAS-wide value the user saved under TrueNAS
|
||||
Disks replaces it for every TrueNAS disk, as a host Disk Temp override does for
|
||||
an agent's disks, 0 meaning off, and a per-disk override beats both. The alert
|
||||
records `diskType` so a config save re-judges it per type; one without it is
|
||||
held to the lowest per-type trigger. A retained reading
|
||||
(`diskinventory.TemperatureCollected` false) is no evidence and raises nothing.
|
||||
The factory value is unset. `normalizeTrueNASDiskTemperature` drops a stored
|
||||
55/50 only from a config without `TrueNASDiskTemperatureByType`, because the
|
||||
thresholds page wrote that flat factory value back on every save; the page
|
||||
writes the marker, so a 55 typed later is kept. Every config `GetConfig`
|
||||
returns carries the marker, so a client that reads and writes back keeps it.
|
||||
Without the marker (an old saved file, or a page loaded before the upgrade)
|
||||
55/50 is read as that unchosen factory value; a client building a config from
|
||||
scratch with an explicit TrueNAS-wide 55 has to send the marker. The TrueNAS
|
||||
Disks Global Defaults cell reads `By type` while unset
|
||||
(`globalDefaultFallbacks` on `ResourceTable`,
|
||||
`resolveAlertResourceGlobalDefaultCell`), or Off when the agent Disk Temp
|
||||
default switches the policy off, and switching it on then stages an explicit
|
||||
TrueNAS-wide 55 because unset would stay off. Each disk row inherits its type's
|
||||
trigger from the unsaved editor state (`resolveTrueNASDiskTemperatureDefault`).
|
||||
The TrueNAS disk drawer tones a current reading from the same per-type trigger
|
||||
(`getDiskTemperatureThresholds` passed into `buildTrueNASDetailSections`), so it
|
||||
no longer judges heat from a fixed 55C. Judges that still differ: the TrueNAS
|
||||
storage table and drawer do not apply a TrueNAS-wide value or per-disk
|
||||
override.
|
||||
`TestTrueNASDiskTemperatureAlertsFollowDiskTemperaturePolicy` and
|
||||
`TestTrueNASDiskTemperatureAlertIgnoresRetainedReading` in
|
||||
`internal/alerts/unified_eval_test.go` pin the tiers and retained readings;
|
||||
`TestTrueNASDiskTemperatureAlertsReevaluatePerDiskType` and
|
||||
`TestTrueNASDiskTemperatureAlertWithoutDiskTypeHeldAtLowestTrigger` in
|
||||
`internal/alerts/alerts_test.go` pin re-judging on a config save;
|
||||
`TestNormalizeTrueNASDiskTemperatureKeepsOnlyChosenValues` pins the
|
||||
saved-config migration.
|
||||
|
||||
Proxmox physical disks alert on temperature under the same policy.
|
||||
`CheckProxmoxDiskTemperature` (`internal/alerts/proxmox_disk_temperature.go`)
|
||||
raises a `diskTemperature` metric alert under the disk's PVE alert reference
|
||||
(`ProxmoxPhysicalDiskAlertResourceID`, resource type `proxmox-disk`), judged by
|
||||
`proxmoxDiskTemperatureThresholdNoLock`: the disk type's `DiskTempByType` entry
|
||||
under the agent Disk Temp default, which switches it off for every type. It
|
||||
carries the disk's serial, WWN and `diskType` like the health and wearout
|
||||
alerts, so `ProxmoxPhysicalDiskAlertIdentifiers` lists its
|
||||
`<reference>::metric-threshold:diskTemperature` identifier for history
|
||||
ownership, and `reevaluateActiveAlertsLocked` re-judges it per disk type (the
|
||||
lowest per-type trigger when the type was never recorded) instead of falling
|
||||
through to guest thresholds that have no disk temperature. A disk a
|
||||
still-reporting linked agent lists in its SMART report is the agent's: its
|
||||
alert closes with the `moved_to_agent` resolution and `CheckHost` raises the
|
||||
agent's own. The handover happens on the next disk poll, so for that one
|
||||
interval an agent newly linked to a disk with an open PVE alert can hold its
|
||||
own alert beside it. A lapsed agent owns nothing, so the node's own readings
|
||||
keep alerting while its agent is silent. An
|
||||
excluded disk, or a policy switched off, closes the alert; a poll without a
|
||||
current reading holds it. Like the health and wearout alerts, a disk that
|
||||
leaves the inventory keeps its alert until the 24-hour stale sweep. The
|
||||
`proxmox-disk` alert delay defaults to the factory 5 seconds, so a warning
|
||||
waits out the same 300-second noisy-gauge stability window agent and TrueNAS
|
||||
disk temperature warnings do, and a critical reading bypasses it.
|
||||
`TestProxmoxDiskTemperatureAlertsFollowDiskTemperaturePolicy`,
|
||||
`TestProxmoxDiskTemperatureAlertOwnershipAndHolds`,
|
||||
`TestProxmoxDiskTemperatureWarningWaitsOutStabilityWindow`,
|
||||
`TestProxmoxDiskTemperatureAlertsReevaluatePerDiskType` and
|
||||
`TestCheckDiskHealthAlertsCarryHistoryOwnershipIdentity` in
|
||||
`internal/alerts/alerts_test.go` pin the policy, ownership, holds, re-judging
|
||||
and identifiers.
|
||||
|
||||
### Agent disk temperature alerts clear when their disk leaves the report
|
||||
|
||||
`CheckHost` also clears a disk temperature alert, and drops its pending
|
||||
@@ -1867,6 +2017,78 @@ well as its alerts.
|
||||
`internal/alerts/host_unraid_lifecycle_test.go` pins the clear, the holds, the
|
||||
restarted count and the pending-run and count cleanup.
|
||||
|
||||
### Proxmox node cleanup keeps platform metric alerts
|
||||
|
||||
`CleanupAlertsForNodes` runs on every Proxmox node poll (and every mock alert
|
||||
tick) and removes alerts whose `Node` is not a current Proxmox node or PBS
|
||||
instance. Kubernetes, TrueNAS and vSphere metric alerts carry a cluster or
|
||||
platform host in `Node` and a display label such as `TrueNAS Disk` or
|
||||
`Kubernetes Pod` in `metadata.resourceType`, which the preserve list's
|
||||
platform words never matched. The cleanup used to delete them silently on
|
||||
each poll while their reducer incident stayed firing, so the next evaluation
|
||||
re-created each one and handed it to the alert callback again as a new
|
||||
alert. `shouldPreserveAlertOutsideNodeCleanup` now keeps any alert carrying
|
||||
`metadata.platformType`, or whose label maps through
|
||||
`CanonicalAlertResourceType` to a Kubernetes, TrueNAS or vSphere type.
|
||||
`TestCleanupAlertsForNodesKeepsPlatformMetricAlerts` in
|
||||
`internal/alerts/alerts_test.go` pins both signals, and its
|
||||
sibling pins that a guest alert on a removed Proxmox node is still removed.
|
||||
|
||||
### Agent disk temperature alerts judge the reading the disk shows
|
||||
|
||||
`CheckHost` judges each agent disk on the temperature, collection state and
|
||||
disk type the disk shows, read by `unifiedresources.HostDiskTemperatureReadings`
|
||||
from the same adapters the registry builds the disks with. A SMART row shows its
|
||||
own reading or, when the agent's probe returned none, the one the host's Unraid
|
||||
inventory reports for that disk (`HostSMARTDiskTemperature`), and keeps its
|
||||
`agent:<host>/disk_temp:<device>` resource. An Unraid inventory row whose disk
|
||||
key no SMART row carries, a disk only the inventory lists, shows that row's
|
||||
reading (`HostUnraidDiskTemperature`) and its own transport as disk type, under
|
||||
its kernel block device token, the resource a plain Linux SMART row for the
|
||||
same device uses. A SMART row and an Unraid row with one key are one registry
|
||||
disk even under different device labels, such as a controller member
|
||||
(`0 [megaraid,0]`) and its Unraid device (`sda`), and alert once, under the
|
||||
SMART row's resource. When smartctl and Unraid name
|
||||
one device with different serials the registry shows two disks on it, and the
|
||||
device's one alert judges the reading that stands highest against its own disk
|
||||
type's threshold (`judgedHostDiskTemperature`): over its trigger first, then
|
||||
inside its recovery band, then the larger margin over the trigger, with a tie
|
||||
keeping the reading listed first.
|
||||
Before this `CheckHost` read only the SMART row's own field, while the Physical
|
||||
Disks Temp cell, the Running Hot verdict ("at or above its alert threshold")
|
||||
and Patrol judged the Unraid reading against the same trigger, so such a disk
|
||||
could read as at its alert threshold with no alert. Unraid's own warning and
|
||||
critical disk temperature notifications do not make this redundant: Pulse
|
||||
already alerts on the smartctl readings and array state of the same hosts,
|
||||
which Unraid also notifies on, and a user who routes alerts through Pulse sets
|
||||
these thresholds here.
|
||||
|
||||
Only a reading collected now fires or resolves
|
||||
(`HostDiskTemperatureReading.Collected`: no standby row, and
|
||||
`diskinventory.TemperatureCollected`), so a spun-down disk holds the alert
|
||||
like a listed disk without a temperature. A disk only the
|
||||
inventory lists whose device matches the agent's `--disk-exclude` patterns
|
||||
(`fsfilters.MatchesDeviceExclude`) raises nothing, and an alert it raised before
|
||||
the exclusion resolves on the next report (`clearHostDiskTemperatureResources`),
|
||||
whatever the SMART list holds, unless another listed reading uses the same
|
||||
resource: `docs/UNIFIED_AGENT.md` documents exclusion as
|
||||
removing the disk from monitoring, and linked Proxmox hosts already judge an
|
||||
excluded disk healthy. A disk only the inventory lists is also judged in a
|
||||
report whose SMART list is empty, because the disk shows the inventory's
|
||||
reading in that report. The absence cleanup above still runs only on a
|
||||
non-empty SMART list, and such a report neither counts toward nor restarts a
|
||||
disk's three, so a disk that leaves the inventory while every report's SMART
|
||||
list stays empty keeps its alert until the 24-hour stale-alert sweep.
|
||||
`TestCheckHostJudgesTheUnraidTemperatureADiskShows` and
|
||||
`TestCheckHostHoldsUnraidDiskTemperatureAlertUntilAReadingOrDeparture` in
|
||||
`internal/alerts/host_unraid_lifecycle_test.go` pin the fallback, the
|
||||
inventory-only disk, the empty SMART list, two disks on one device (also of
|
||||
different types), a controller member under another device label, standby,
|
||||
exclusion, the disk type
|
||||
and host override thresholds, the holds, the handover to a SMART reading,
|
||||
departure, and the exclusion clear with and without SMART rows and beside a
|
||||
listed SMART row. `TestJudgedHostDiskTemperatureOutranks` pins the ranking.
|
||||
|
||||
### Configured flapping thresholds remain reachable
|
||||
|
||||
Every accepted positive `FlappingThreshold`, including values above ten, must
|
||||
@@ -1894,6 +2116,42 @@ controls in `internal/alerts/flapping_threshold_test.go` exercise both sweeps,
|
||||
drained and retained windows, dispatch callbacks and delivery diagnosis. These
|
||||
modeled-time controls are not installed notification-destination acceptance.
|
||||
|
||||
### Default thresholds keep a positive trigger's clear below it
|
||||
|
||||
PBS, node temperature, agent, Kubernetes, TrueNAS and vSphere global defaults
|
||||
go through `normalizeThresholdPointer` in `internal/alerts/config/normalize.go`
|
||||
from `UpdateConfig`. The shared persistence normalization in
|
||||
`internal/config/persistence.go` applies the same normalizer, through
|
||||
`NormalizeHysteresisThreshold`, to the agent, node temperature and storage
|
||||
pairs. A missing or negative threshold takes the factory default and a zero
|
||||
trigger is off. A positive trigger always keeps a clear below it: a missing
|
||||
clear sits five points under the trigger, floored at 0, and a clear at or
|
||||
above the trigger is repaired. Guest and node usage, storage and Docker
|
||||
defaults hold the same bound through `EnsureValidHysteresis` in
|
||||
`ValidateHysteresisThresholds`, `NormalizeStorageDefaults` and
|
||||
`NormalizeDockerThreshold`. The canonical per-type entries (`nvme`, `sata` and
|
||||
`hdd` in `DiskFillByType`; `nvme`, `sas` and `sata` in `DiskTempByType`, the
|
||||
types the thresholds page edits) keep a positive trigger and follow the same
|
||||
clear rule; a non-positive trigger resets the entry to its type default,
|
||||
because the page cannot switch a type off on its own. Other per-type keys are
|
||||
stored as written.
|
||||
|
||||
The thresholds page sends `max(0, trigger - 5)`, so a 1-5% default arrives
|
||||
with clear 0 and must not fall back to the factory clear. Agent defaults used
|
||||
to do that, storing `{trigger: 1, clear: 75}` for a 1% Machines CPU default,
|
||||
and per-type disk entries replaced the whole pair, trigger included. The
|
||||
evaluator already ignored a clear at or above the trigger
|
||||
(`buildCanonicalMetricSpec` drops it and the reducer then clears at the
|
||||
trigger), so those alerts fired and resolved the same way; `/api/alerts/config`
|
||||
served the factory clear and firing alerts reported it as `clearThreshold`. A
|
||||
repaired pair above the margin, such as `{50, 80}` written through the API,
|
||||
now has a real recovery band at 45 where it used to clear at the trigger.
|
||||
`internal/alerts/config/normalize_low_trigger_clear_test.go` pins every
|
||||
default family and both per-type maps, `TestUpdateConfigKeepsLowTriggerClearBelowTrigger` in
|
||||
`internal/alerts/config_validation_test.go` pins the saved config and the
|
||||
firing alert's clear level, and the low-trigger and stored-clear tests in
|
||||
`internal/config/persistence_test.go` pin the written file and load repair.
|
||||
|
||||
### Monitor-only delivery is terminal
|
||||
|
||||
Monitor-only alerts remain visible, but neither a firing nor a recovery
|
||||
@@ -2597,8 +2855,10 @@ Metric enablement is an explicit On/Off interaction in row, global-default,
|
||||
mobile, and bulk editors. Enabled numeric inputs accept positive trigger values
|
||||
only and user-facing copy must not expose the persisted disable sentinel.
|
||||
Internally, the canonical `<= 0` read rule remains intact for legacy data, while
|
||||
new Off actions write `-1`, so existing configuration and the API contract
|
||||
continue to round-trip without making `0` a second customer-facing disable path.
|
||||
new Off actions stage `-1` (saved as trigger `0` on global defaults, which the
|
||||
backend otherwise resets to factory), so existing configuration and the API
|
||||
contract continue to round-trip without making `0` a second customer-facing
|
||||
disable path.
|
||||
Within the Proxmox tab, render-heavy ownership now further routes through
|
||||
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx`,
|
||||
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx`,
|
||||
@@ -2745,6 +3005,22 @@ derived alert read-model and Last 24 Hours stat refresh for
|
||||
shared acknowledgement owner instead of keeping its own alert mutation fork.
|
||||
Future overview action behavior should extend that shared acknowledgement hook
|
||||
instead of putting acknowledge mutations back into render shells.
|
||||
Overview ages and bands read the shared clock. `AlertOverviewAlertCard.tsx`
|
||||
passes its own `useRelativeTimeNow` reading as `now` to
|
||||
`formatAlertOverviewStartedAgo` and `getMetricAlertPresentation`, and the Last
|
||||
24 Hours count in `useAlertOverviewState.ts` reads the same clock, so all three
|
||||
measure from the wall clock. The hook no longer exposes a minute `tick`: read
|
||||
as "now", that tick measured a card mounted between ticks from up to a minute
|
||||
earlier, so an alert raised 70 seconds ago read "this minute", an alert
|
||||
raised since the last tick stayed out of the 24h count until the next one, a
|
||||
reading already past the 10-minute stale cut-off led the card as live, and the
|
||||
stale flip and 24h drop landed up to a minute late. A mounted card now turns stale,
|
||||
and an alert leaves the 24h count, within one 30-second tick with no data
|
||||
change. The delivery diagnoses refresh is a server read and keeps its own
|
||||
minute interval rather than following the 30-second clock.
|
||||
`AlertOverviewAlertCard.clock.test.tsx` pins the mid-tick mount, the stale flip
|
||||
and the 24h drop under fake timers, and `useAlertOverviewState.test.tsx` pins
|
||||
the diagnoses cadence.
|
||||
Render-heavy alert overview ownership now routes through
|
||||
`frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`,
|
||||
`frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`,
|
||||
@@ -3088,6 +3364,76 @@ ownership is `internal/alerts/alert_identity_migration_test.go`,
|
||||
`internal/monitoring/monitor_alert_override_migration_test.go`, and
|
||||
`frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts`.
|
||||
|
||||
### Alert config snapshots are owned copies
|
||||
|
||||
`Manager.GetConfig()` returns `AlertConfig.Clone()`, a typed deep copy that
|
||||
shares no map, slice or pointer with the live config (custom rule filter
|
||||
values are copied as decoded JSON values). Callers encode, persist
|
||||
and edit that snapshot outside `m.mu`: the GET config handler, alert
|
||||
activation, the identity migration, backup evaluation and diagnostics. It used
|
||||
to be a shallow copy, so `SaveAlertConfig` normalizing it in place, or
|
||||
`UpdateConfig` re-normalizing the overrides of a re-applied snapshot, wrote the
|
||||
live maps while evaluation or another encoder read them, and Go aborted Pulse
|
||||
with a concurrent map write. `SaveAlertConfig` also clones before normalizing,
|
||||
because a config just handed to `UpdateConfig` is the live config. Persistence
|
||||
keeps an explicit `0` type-level delay as no delay on save and load, matching
|
||||
`NormalizeTimeThresholds`. It used to store and load it as the 5-second factory
|
||||
delay, which the noisy-gauge rule stretches to 300 seconds for memory and
|
||||
temperature, and the shared map write was what made the running manager agree.
|
||||
Pulse saves never wrote a `0`, so only a newly sent or hand-edited `0` changes
|
||||
meaning. `internal/alerts/config/clone_test.go`
|
||||
fills every field and fails when a new reference field is not copied.
|
||||
Regression ownership is `internal/alerts/resolved_lock_discipline_test.go` and
|
||||
`internal/config/persistence_alert_ownership_test.go`.
|
||||
|
||||
### Alert evaluation owns the thresholds it normalizes
|
||||
|
||||
Host, guest and Docker container evaluation copy what they read from
|
||||
`m.config` while holding `m.mu`, and only then fill in defaults.
|
||||
`CheckHost` and `CheckGuest` clone a per-disk
|
||||
override's `Disk` threshold before `ensureHysteresisThreshold` sets a missing
|
||||
`Clear` to the trigger minus 5. They used to normalize the live override's
|
||||
pointer after unlocking, so a `GetConfig()` clone under the read lock raced
|
||||
the write, and the saved override changed to whatever the last poll filled in.
|
||||
The live override now keeps the clear it was saved with, and the config API
|
||||
returns that value. Docker container evaluation resolves its thresholds from
|
||||
cloned `DockerDefaults` plus the override under the same lock, and reads the
|
||||
restart-loop and memory-limit settings under the lock. It used to point into
|
||||
`m.config.DockerDefaults` with no lock while `UpdateConfig` replaced
|
||||
`m.config`. The test-only `getThresholdForMetricFromConfig`, which normalized
|
||||
in place, is gone. `internal/alerts/alerts_test.go`
|
||||
`TestAlertEvaluationLeavesTheLiveConfigToTheLock` runs host, guest and Docker
|
||||
evaluation against `GetConfig()` and `UpdateConfig(GetConfig())` under
|
||||
`-race`.
|
||||
|
||||
### Config-save reevaluation judges a filesystem alert by its own threshold
|
||||
|
||||
`UpdateConfig` re-judges every active alert, and a filesystem usage alert's
|
||||
threshold resolves through the same per-filesystem helper its evaluator uses.
|
||||
`hostDiskUsageThresholdNoLock` resolves an agent filesystem
|
||||
(`agent:<host>/disk:<label>`): its own override, then the `DiskFillByType`
|
||||
threshold for a hardware type inferred from the device while the host's disk
|
||||
alerting is on, then the host's disk threshold. `guestDiskUsageThresholdNoLock`
|
||||
resolves a guest filesystem (`<guestID>-disk-<key>`): its `guest-disk:`
|
||||
override, then the guest's disk threshold. `CheckHost` and `CheckGuest` call
|
||||
them under the read lock, and `resolveHostAlertThresholdsNoLock` and
|
||||
`resolveGuestAlertThresholdsNoLock` call them for reevaluation. Both return an
|
||||
override or per-type threshold as an owned copy. A guest filesystem alert
|
||||
resolves its guest's threshold, override and identity-based custom rules by the
|
||||
canonical `instance:node:vmid` guest ID that `parseGuestAlertIdentity` reads
|
||||
from its resource ID. Custom rules that filter on live metrics still see no
|
||||
readings there, as for every reevaluated guest alert. Reevaluation used to
|
||||
judge agent filesystems by the host threshold, and guest filesystems without
|
||||
their `guest-disk:` override or the guest's own override, because the
|
||||
filesystem's resource ID names no guest. Any settings save resolved an alert a
|
||||
per-disk threshold of 70 raised at 80 under a default of 90, notifying
|
||||
recovery, and the next poll raised it again as a new alert.
|
||||
`internal/alerts/threshold_resolution_shared_test.go`
|
||||
`TestConfigSaveJudgesFilesystemAlertsByTheirOwnThreshold` covers an agent
|
||||
filesystem override, an NVMe fill threshold, a guest filesystem override and a
|
||||
guest override: an unchanged save must keep the alert, and a save lifting the
|
||||
trigger above the reading must resolve it.
|
||||
|
||||
### Versioned alert-intent policy
|
||||
|
||||
The alerts runtime owns one versioned alert-intent document and its durable
|
||||
|
||||
@@ -2090,6 +2090,20 @@ payload shape change when the portal presents compact client rows.
|
||||
index; clients must treat the endpoint's `accepted:true` as inbox
|
||||
receipt, not delivery. Browser clients must use the shared
|
||||
`AIChatAPI.steerSession(sessionId, { prompt, clientMessageId })` helper.
|
||||
Every Assistant session mutation sub-resource under
|
||||
`/api/ai/sessions/{id}` (`/abort`, `/summarize`, `/fork`, `/undo`,
|
||||
`/redo`, `/steer`) accepts only `POST`. Any other method that reaches the
|
||||
handler returns `405` with `Allow: POST` through
|
||||
`requireAssistantSessionMutationMethod` in `internal/api/ai_handler.go`,
|
||||
before the session service is resolved (earlier layers still answer
|
||||
first: `Router.ServeHTTP` returns `200` for `OPTIONS` preflight, and
|
||||
auth, scope, CSRF, and demo-mode guards keep their own refusals). The
|
||||
session router dispatches these sub-resources by path alone, and the
|
||||
demo-mode read-only guard and the CSRF check both treat `GET` and `HEAD`
|
||||
as reads, so the method contract belongs to the mutating handler rather
|
||||
than to its callers. `GET /api/ai/sessions/{id}/messages` stays the read
|
||||
path, and the unsupported `/diff`, `/revert`, and `/unrevert` handlers
|
||||
keep answering `501` for every method that reaches them.
|
||||
OpenCode-style file diff/revert session routes are deliberately not part
|
||||
of Pulse's supported Assistant session contract: Pulse sessions do not own
|
||||
local code-file edits, and infrastructure mutations must be reviewed
|
||||
@@ -3326,6 +3340,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
action artifact so the Assistant runtime can refresh finding and action posture
|
||||
from IDs and safe summaries instead of relying on pasted chat text
|
||||
and the dedicated `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owner, so recent-change counts and governed policy-posture fallbacks normalize once at the shared store boundary instead of as Patrol-hook-local payload repair
|
||||
and the Patrol findings list the state hook reads from `aiIntelligenceStore.patrolFindings`, so `usePatrolIntelligenceState.ts` does not re-derive page-level severity tallies from it: the hook's unread `summaryStats` tally has been removed, the hook derives no client-side severity tally from that list today (the backend's per-severity `findings_count` reaches it only inside the AI summary payload it passes through), and the Activity tab's `This week` card takes its open-by-severity counts from `GET /api/ai/patrol/digest` instead of recounting that list
|
||||
and the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` card, so learned correlations and correlation context stay rendered through one governed frontend card instead of separate page-local list loops
|
||||
and the same shared correlation card's ordering and truncation rule, so callers pass raw correlations instead of encoding their own top-N sort behavior
|
||||
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` cards' optional `buildResourceHref` input, so a surface that needs resource-filter links passes them into the shared card instead of rebuilding infrastructure URLs inline; the cards carry no link default since the cross-resource drilldown links were retired, and render resource labels as plain text
|
||||
@@ -3696,6 +3711,10 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
requested metric filters through the shared guest-chart batch loader in
|
||||
`internal/monitoring/monitor_metrics.go` instead of fetching the full guest
|
||||
metric set and trimming after the API payload is already assembled.
|
||||
`ChartsAPI.getInfrastructureSummaryCharts` is the only frontend client for
|
||||
`/api/charts/infrastructure`; the deprecated `getInfrastructureCharts`
|
||||
alias, which could not pass `metrics`, is removed and must not return as a
|
||||
second unfiltered entry point.
|
||||
39. Keep the retired compact dashboard overview route absent from that same
|
||||
shared API surface. `internal/api/resourceapi/resources.go`,
|
||||
`internal/api/router_routes_monitoring.go`, and
|
||||
@@ -4842,6 +4861,10 @@ transition and evidence times) from the frontend-primitives shared
|
||||
relative-time clock. That is presentation over the same `GET
|
||||
/api/ai/patrol/status`, run history and `GET /api/ai/patrol/attention/{id}`
|
||||
payloads: no field, request or polling cadence changes.
|
||||
The attention detail's suppression `expiresAt` and a finding's `remind_at` and
|
||||
`snoozed_until` from `GET /api/ai/patrol/findings` are future times, so the
|
||||
page presents them as countdowns from the same clock ("in 1d") instead of
|
||||
ages. That is presentation only: no field, request or polling cadence changes.
|
||||
|
||||
### Resource-list facets preserve scoped navigation evidence
|
||||
|
||||
@@ -7119,12 +7142,14 @@ introducing VMware-only mention payloads or provider-local inventory reads
|
||||
under `/api/vmware/*`. Runtime-specific container/app mentions remain shared
|
||||
unified-resource mentions as well; VMware network inventory does not create a
|
||||
provider-local mention family.
|
||||
That same `/api/ai/chat` payload boundary owns per-request execution-mode
|
||||
overrides. Dashboard Pulse Brief and other scoped handoffs may include
|
||||
`autonomous_mode:false` on the chat request to force approval-required command
|
||||
execution for that exchange, but the transport must treat the field as a
|
||||
request override only and must not mutate the user's persistent AI control
|
||||
setting.
|
||||
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
|
||||
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
|
||||
control setting.
|
||||
That same chat transport boundary owns new-session anchoring. When the request
|
||||
omits `session_id`, the handler may generate and stream a session ID
|
||||
immediately so the browser can anchor the visible turn, but that generated ID is
|
||||
@@ -7178,7 +7203,7 @@ not serialize the model-only `handoff_context`, runtime failure detail, action
|
||||
preflight/result bodies, remediation descriptions, raw commands, or approval
|
||||
command payloads, and it must not preserve Patrol-authored next-step
|
||||
recommendation fields from legacy handoffs.
|
||||
Patrol finding handoffs are stricter than ordinary chat requests: when a request
|
||||
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
|
||||
@@ -7641,6 +7666,34 @@ read-only demo account. That same hidden read-side boundary includes `GET` and
|
||||
`HEAD` reads for `/api/admin/users` and manual discovery at `/api/discover`;
|
||||
public demo mode may block writes generically, but it must not reveal that
|
||||
admin-user inventory or manual-discovery read routes exist.
|
||||
The same policy hides the Go runtime profiling family for every method: on a
|
||||
`DEMO_MODE` instance the demo guard answers `/debug/pprof`, `/debug/pprof/`
|
||||
and every path below it with `404`, ahead of route authentication.
|
||||
`registerDebugRoutes` gates those handlers on `RequireAdmin` plus a
|
||||
`settings:read` scope that session logins skip, so a demo that signs visitors
|
||||
in as the configured admin would otherwise hand every visitor goroutine and
|
||||
heap dumps, the process command line, and CPU profiles, execution traces and
|
||||
forced garbage collection that cost the whole process. `PULSE_PPROF_DISABLED`
|
||||
still removes the routes on any instance.
|
||||
`TestPublicDemoAdminOperationsPolicyHidesEveryDebugRoute` fails when a literal
|
||||
route the parsed router files register under `/debug/`, with or without a
|
||||
method prefix, escapes that policy for any common method.
|
||||
`TestContract_DemoModeHidesPprofFromDemoAdminSession` in
|
||||
`internal/api/contract_test.go` signs in as that admin through the full
|
||||
router, proves the same session still reads `/debug/pprof/cmdline` below the
|
||||
demo guard, and gets `404` through it for eight pprof paths with `GET`,
|
||||
`HEAD`, `OPTIONS` and `POST`.
|
||||
That generic write block keys on the request method alone. The demo
|
||||
middleware admits `GET`, `HEAD` and `OPTIONS` plus the login, logout, AI
|
||||
execute and OIDC login/callback allowlist; HTTP/1.1 websocket handshakes for
|
||||
`/ws` and `/api/agent/ws` are `GET` requests and connect through that read
|
||||
branch, so an `Upgrade: websocket` or `Connection: Upgrade` header never
|
||||
carries a `POST`, `PUT`, `PATCH` or `DELETE` past the guard. A demo deployment that signs
|
||||
visitors in as the configured admin, as the public demo does, has only this
|
||||
guard between that session and admin writes such as `/api/system/mock-mode`.
|
||||
`TestContract_DemoModeUpgradeHeadersDoNotExemptWrites` in
|
||||
`internal/api/contract_test.go` signs in as that admin through the full router
|
||||
and pins both halves.
|
||||
That shared infrastructure install boundary now also preserves copied shell
|
||||
command payload continuity: any privilege-escalation wrapper applied at
|
||||
`frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`
|
||||
@@ -9645,6 +9698,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`,
|
||||
@@ -10270,6 +10349,22 @@ boundaries. Alert evaluation after that save remains alerts-owned and must use
|
||||
the persisted trigger/recovery pair plus a valid derived critical threshold;
|
||||
the API must not invent a TrueNAS-specific threshold sidecar.
|
||||
|
||||
`PUT /api/alerts/config` replaces only the top-level keys the body carries
|
||||
and keeps the stored value of every key it leaves out, so a client that does
|
||||
not know a setting cannot turn it off by leaving it out: the thresholds page
|
||||
has no flapping-detection or alert-TTL controls, and an older browser bundle
|
||||
or API script predates later fields. A sent key replaces its stored value
|
||||
whole, so `"overrides": {}` removes every override rather than merging, and
|
||||
the merged config then goes through the same normalization as any update (for
|
||||
example, activation state is never cleared). The read, merge and apply run
|
||||
under the alert manager's lock, so two concurrent partial saves cannot revert
|
||||
each other in memory; the handler runs one save at a time and persists a
|
||||
snapshot of the applied config, so successful saves through this endpoint
|
||||
reach `alerts.json` in the order they were applied. A body that is not a JSON object, `null` included, is a 400 and changes
|
||||
nothing. `TestUpdateAlertConfig_KeepsStoredValuesForUnsentKeys`
|
||||
and `TestUpdateAlertConfig_RejectsNonObjectBody` in
|
||||
`internal/api/alerting/alerts_test.go` prove the transport.
|
||||
|
||||
### Alert intent and UDP availability transport
|
||||
|
||||
`GET /api/alerts/intent-policies` and
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -294,6 +294,14 @@ expose only a raw reason. A same-VM fresh observation withdraws the notice and
|
||||
updates values without remounting the row. Permission, disabled-agent and actual
|
||||
unavailable cases retain their own explanations.
|
||||
|
||||
When a linked Pulse agent's filesystems fill in for a VM with no Proxmox guest
|
||||
filesystems (`disksFromAgent`), the reading follows that agent instead of the
|
||||
Proxmox reason: current while it reports, last known once it is stale, and
|
||||
unavailable once the guest is stopped. `getWorkloadGuestDiskRead` in
|
||||
`workloadGuestPresentation.ts` owns that rule for the workload row's disk cell,
|
||||
the guest drawer's filesystem rows and its History, so the three cannot
|
||||
disagree about one guest; `workloadGuestPresentation.test.ts` pins it.
|
||||
|
||||
Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`,
|
||||
`StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions,
|
||||
and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no
|
||||
@@ -2116,6 +2124,18 @@ the same record; the table renders defaults once, in both desktop table and
|
||||
narrow card layouts, and per-section metric columns must resolve to metric
|
||||
keys the shared column normalizer produces so the defaults editor reads and
|
||||
writes the same record keys the section persists.
|
||||
A global default that another setting decides while it is unset passes a
|
||||
`globalDefaultFallbacks` entry for that metric (label, title, whether the other
|
||||
setting is itself off, and the value switching on stages when it is). The
|
||||
TrueNAS Disks temperature uses it to read `By type`, following Disk
|
||||
temperature by type, with `features/alerts/thresholds/trueNASDiskTemperature.ts`
|
||||
giving each disk row its type's trigger from the unsaved editor state.
|
||||
`resolveAlertResourceGlobalDefaultCell` decides the cell for both layouts:
|
||||
unset follows the fallback, switching on from Off returns to unset unless the
|
||||
fallback is off, and an unset default with no fallback stays Off as the engine
|
||||
reads it. `ResourceTable.tsx` hands its props to the shared table state through
|
||||
getters, so the Custom badge and reset control follow defaults that load after
|
||||
the table mounts.
|
||||
Platform sub-routes that add native provider inventory must stay on the shared
|
||||
platform page and table primitives. The vSphere Networks surface routes through
|
||||
`/vmware/networks`, the shared platform tab model, the command palette
|
||||
@@ -3011,9 +3031,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 chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. 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 page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
|
||||
10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
@@ -3860,22 +3880,19 @@ 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.
|
||||
UI surfaces must not duplicate the `patrolControlStarter` query string or
|
||||
write Patrol control or legacy entry-point starter telemetry from local
|
||||
click handlers.
|
||||
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.
|
||||
19. Keep shared summary-card emphasis coherent. When shared summary primitives enter an `inactive` state, `SummaryMetricCard`, `InteractiveSparkline`, and `DensityMap` must all demote background context together so storage, infrastructure, and workloads read as one interaction model instead of mixing page-local opacity, sticky-shell, or highlight rules.
|
||||
20. Keep density-map summaries overview-first. When a shared summary density map receives row focus or chart-hover emphasis, `frontend-modern/src/components/shared/DensityMap.tsx`, `frontend-modern/src/components/shared/useDensityMapState.ts`, and `frontend-modern/src/components/shared/densityMapModel.ts` must preserve the multi-entity overview rows and keep focused-entity detail in the hover tooltip instead of swapping the card into a single-series chart, dimming the rest of the map into unusable background noise, duplicating cursor-value tooltip copy, or adding persistent card chrome that steals heatmap space. The card body must stay overview-first; the tooltip may carry the active entity identity, current value, and peak, shared tooltip shells must follow semantic surface tokens instead of forcing a dark palette in light mode, the tooltip header must let long entity names consume the available width before truncating rather than clipping against an arbitrary fixed label cap, numeric metric readouts such as `16.9 MB/s` or `37.4 MB/s` must stay single-line instead of wrapping the unit onto a second row, and density-map detail that cannot fit cleanly inside the canonical tooltip shell must be omitted rather than introducing tooltip-specific chrome or a secondary chart inside the hover surface.
|
||||
UI surfaces must not duplicate the `patrolControlStarter` query string or
|
||||
write Patrol control or legacy entry-point starter telemetry from local
|
||||
click handlers.
|
||||
21. Keep retired self-hosted hosted-model and trial acquisition surfaces out of
|
||||
normal v6 GA runtime. Shared shells and helper-driven badges may continue to
|
||||
parse legacy payload fields, but ordinary self-hosted Assistant, Patrol, and
|
||||
settings flows must present provider setup as BYOK/local/self-managed and
|
||||
must not surface hosted-model credits, in-app trial starts, or generic
|
||||
managed-model claims.
|
||||
22. Keep sparkline scrubbing source-local and sibling-sync timestamp-based. The chart a user is actively scrubbing in `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/useInteractiveSparklineState.ts` must keep its dashed hover cursor on the real local mouse `x`, while sibling cards may map the shared hover timestamp onto their own timelines. Shared cursor sync must not snap the source chart back onto the nearest sample timestamp, the rendered SVG/canvas hover cursor must bind to the actual numeric cursor coordinate rather than a boolean guard state, the time cursor must span the chart viewport instead of collapsing to the series height, and the hover tooltip must track the pointer instead of anchoring to the chart top edge while following the active theme rather than a hardcoded dark shell. The hover tooltip must stay side-offset from the active scrub cursor and flip to the available side near viewport edges so it does not cover the highlighted guide or graph point.
|
||||
23. Keep shared contextual focus canonical after adoption. Once a summary or table surface enters route-backed contextual focus, future additions must extend `frontend-modern/src/components/shared/contextualFocus.ts` and its guardrail tests rather than forking another helper for workload IDs, resource IDs, or scroll-preserving same-route selection.
|
||||
24. Keep shared infrastructure/resource selectors on the canonical agent-facet
|
||||
truth. Shared primitives and settings-facing selector helpers must treat
|
||||
@@ -3905,12 +3922,14 @@ production table, router and styles; it does not qualify full-app scrolling.
|
||||
26. Keep the authenticated app root aligned with that same first-session path.
|
||||
That same shared-primitive ownership now includes contextual row focus.
|
||||
`frontend-modern/src/components/shared/contextualFocus.ts` is the canonical
|
||||
owner for interactive-series filtering, focused-label lookup, active-series
|
||||
resolution, and nearest-scrollable-ancestor preservation across page-scoped
|
||||
summary surfaces. Dashboard row focus, infrastructure summary emphasis,
|
||||
storage summary emphasis, and workloads summary emphasis must all route through
|
||||
that helper instead of maintaining page-local copies of the same hover/focus
|
||||
rules.
|
||||
owner for nearest-scrollable-ancestor preservation, off-screen element
|
||||
reveal, and inline-detail lookup and reveal, while page/group/entity scope
|
||||
resolution belongs to
|
||||
`frontend-modern/src/components/shared/summaryCardInteraction.ts` behind
|
||||
`frontend-modern/src/components/shared/summaryTableFocus.ts`. Workloads and
|
||||
Storage row focus and the Proxmox page's node-to-guests reveal must route
|
||||
through those helpers instead of maintaining page-local copies of the same
|
||||
hover/focus rules.
|
||||
`frontend-modern/src/App.tsx` must land authenticated `/` and `/login`
|
||||
handoffs through this subsystem's provider-first platform landing contract:
|
||||
the first visible provider/runtime platform wins, and the Machines surface
|
||||
@@ -4830,6 +4849,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
|
||||
@@ -4902,6 +4928,16 @@ or not fully verified.
|
||||
The same empty-state helper must consume Patrol trust-history evidence so a
|
||||
historical regression reads as history review context, not as a current issue
|
||||
and not as a healthy all-clear.
|
||||
`frontend-modern/scripts/canonical-platform-audit.mjs` carries no Patrol
|
||||
summary-card rule. Its `canonical-patrol/no-local-summary-card-presentation`
|
||||
regex matched only text shaped like the deleted summary card's tint ternaries
|
||||
on `summaryStats().criticalFindings`, `warningFindings` and `fixedCount`. The
|
||||
Patrol state hook's `summaryStats` accessor has had no reader since that card
|
||||
was deleted, so the accessor and the rule are both gone.
|
||||
`frontend-modern/src/utils/patrolSummaryPresentation.ts` is off the audit
|
||||
allowlist too: it now holds only the recency and verification presenters,
|
||||
which no audit rule needs to exempt, so the audit scans it like any other
|
||||
module.
|
||||
The Patrol page renders no summary shell today. If one returns, the same
|
||||
hierarchy applies inside it: once its primary assessment strip states Patrol's
|
||||
current risk and verification basis,
|
||||
@@ -5109,52 +5145,27 @@ Enter/Space work through the native button, and lifecycle actions do not bubble
|
||||
into collapse. Generic `Ask Assistant` and `Copy context` actions do not belong
|
||||
in object drawer headers: Assistant remains available through the global shell,
|
||||
and raw context export must not compete with the operational reading path.
|
||||
The shared interactive sparkline now follows that same split.
|
||||
`frontend-modern/src/components/shared/InteractiveSparkline.tsx` stays the
|
||||
render shell, `frontend-modern/src/components/shared/useInteractiveSparklineState.ts`
|
||||
owns hover state, RAF throttling, canvas draw scheduling, and resize lifecycle,
|
||||
and `frontend-modern/src/components/shared/interactiveSparklineModel.ts` owns
|
||||
sparkline downsampling, gap segmentation, axis-tick math, and hover-selection
|
||||
policy. Future sparkline work should extend those owners instead of pushing
|
||||
canvas scheduling or chart-shape math back into the shared component shell.
|
||||
That same sparkline boundary now also owns floating tooltip shell routing:
|
||||
local hover tooltips must derive viewport anchor coordinates from the shared
|
||||
runtime/model path, keep the tooltip beside rather than on top of the scrub
|
||||
cursor, and render through
|
||||
`frontend-modern/src/components/shared/TooltipPortal.tsx`, not as HTML
|
||||
`foreignObject` shells inside the `preserveAspectRatio="none"` chart SVG where
|
||||
cross-browser scaling can stretch the tooltip surface or drop its semantic
|
||||
shell styling.
|
||||
That same shared sparkline boundary now also owns active-series isolation
|
||||
metadata. The shell may expose `data-active-series-display` and
|
||||
`data-rendered-series-count` for proof and inspection, but only the shared
|
||||
runtime/model owners may decide whether a hovered or focused series is merely
|
||||
emphasized or fully isolated; feature shells must not fork their own row-hover
|
||||
line filtering.
|
||||
`frontend-modern/src/components/Workloads/MetricMiniSparkline.tsx` and
|
||||
`frontend-modern/src/features/proxmox/BackupActivityChart.tsx` render their
|
||||
hover tooltips through `frontend-modern/src/components/shared/TooltipPortal.tsx`
|
||||
rather than as HTML `foreignObject` shells inside the chart SVG; the mini
|
||||
sparkline's SVG uses `preserveAspectRatio="none"`, where cross-browser scaling
|
||||
can stretch an embedded tooltip surface or drop its semantic shell styling.
|
||||
The retired dashboard overview route must not regain feature-local trend,
|
||||
KPI, problem-resource, or card shells. Workload-table and guest-row fallback
|
||||
copy that lives under `frontend-modern/src/components/Workloads/` must keep
|
||||
using `frontend-modern/src/utils/workloadEmptyStatePresentation.ts` and
|
||||
`frontend-modern/src/utils/workloadGuestPresentation.ts`. New route-level empty
|
||||
states, tone mapping, or compact issue copy must extend the shared
|
||||
`emptyStatePresentation`, `semanticTonePresentation`, and
|
||||
`problemResourcePresentation` helpers instead of reviving deleted
|
||||
dashboard-only KPI, metric, storage, recovery, or trend presentation helpers.
|
||||
states or tone mapping must extend the shared `emptyStatePresentation` and
|
||||
`semanticTonePresentation` helpers instead of reviving deleted dashboard-only
|
||||
KPI, metric, storage, recovery, problem-resource, or trend presentation
|
||||
helpers.
|
||||
That shell must also stay passive with respect to data ownership: future
|
||||
overview trend cards may render summary-range controls and operator-facing
|
||||
empty or error copy only after they have a governed owner, and they must not
|
||||
reintroduce route-local metrics-history fetch loops for CPU and memory
|
||||
sparklines; the infrastructure and workloads summary chart routes already own
|
||||
that chart contract.
|
||||
The shared density map now follows that same owner split.
|
||||
`frontend-modern/src/components/shared/DensityMap.tsx` stays the render shell,
|
||||
`frontend-modern/src/components/shared/useDensityMapState.ts` owns hover
|
||||
signals, canvas draw lifecycle, and resize handling, and
|
||||
`frontend-modern/src/components/shared/densityMapModel.ts` owns bucket/window
|
||||
math, hover target selection, focused-series tooltip detail, and density-cell
|
||||
opacity rules. Future density-map work should extend those owners instead of
|
||||
pushing canvas lifecycle, tooltip shaping, or chart math back into the shared
|
||||
shell.
|
||||
The shared trial banner is retired for self-hosted v6 GA. Future commercial
|
||||
notification work must start from the explicit Plans, hosted, activation,
|
||||
recovery, or support surfaces rather than reviving a global authenticated-shell
|
||||
@@ -5272,7 +5283,19 @@ too. Every read of the clock returns the wall clock; the 30-second tick only
|
||||
tells readers to re-read, so a cell that mounts between ticks never measures
|
||||
from a stale time and a timestamp from the last few seconds never reads as a
|
||||
future time.
|
||||
The rule has one deliberate exception. The age of a latest reading (last used,
|
||||
Future times have their own formatter. `formatRelativeTime` is past-only: it
|
||||
reads a time ahead of `now` as "just now" (compact) or "0s ago", which is right
|
||||
for clock skew on something already observed and wrong for an expiry, a
|
||||
reminder or a schedule.
|
||||
Those go through `formatTimeUntil` (`frontend-modern/src/utils/format.ts`),
|
||||
which counts down ("in 3h", "in 1d"), rounds to the nearest unit so a duration
|
||||
just chosen reads as chosen, and returns `dueText` (default "now") once the
|
||||
time arrives. A countdown target does not change while its surface stays open,
|
||||
so the countdown passes `now` from the shared clock, as the Patrol suppression
|
||||
expiry and the Patrol findings reminder and snooze lines do. The replication
|
||||
Next sync column and the Patrol header's next-check `CountdownTimer` keep their
|
||||
own minute- and second-precision countdowns.
|
||||
The relative-age rule has one deliberate exception. The age of a latest reading (last used,
|
||||
last seen, last success, last checked) on data the surface reads once and does
|
||||
not re-read stays the age at read time, because a moving age over a snapshot
|
||||
that never refreshes claims the reading stopped when it may not have. Such a
|
||||
@@ -5787,7 +5810,15 @@ the surface-alt detail row shell locally. The content shell must clip
|
||||
horizontal paint below the large breakpoint without becoming a scroll
|
||||
container, reset the parent table's `whitespace-nowrap` inheritance, and allow
|
||||
its descendants to shrink, then restore visible overflow for the static
|
||||
desktop layout. Long operator-state copy must wrap inside the shared row border
|
||||
desktop layout. Below that breakpoint the default content shell is capped at
|
||||
the table scroll shell's content-box inline size (`max-w-[100cqi]`; the shell
|
||||
is a size container), never a viewport estimate of the page chrome, so a phone
|
||||
drawer does not leave an empty strip beside its content (the retired
|
||||
`100vw-3.5rem` cap left 27px at 390px) and is never wider than the table's
|
||||
visible area. Callers that pass their own `contentClass` own their width.
|
||||
Fixed-layout cells clip overflow, so the sticky offset is inert and horizontal
|
||||
scrolling still moves the drawer with its cell.
|
||||
Long operator-state copy must wrap inside the shared row border
|
||||
instead of painting beneath adjacent controls or disappearing at the clip edge.
|
||||
When focused detail content is removed, `InlineDetailTableRow` restores focus
|
||||
to its current `aria-controls` disclosure with `preventScroll`; live refresh,
|
||||
@@ -6172,14 +6203,6 @@ when local login is hidden behind SSO, and the `/docs/...` public-route contract
|
||||
keeps that destination readable without an authenticated session. The login
|
||||
surface must remain guidance-only: it may not expose secrets, imply an email
|
||||
reset flow, or create a browser-side authentication bypass.
|
||||
The shared summary strip primitives now follow that same owner split.
|
||||
`frontend-modern/src/components/shared/SummaryPanel.tsx` and
|
||||
`frontend-modern/src/components/shared/SummaryMetricCard.tsx` stay the render
|
||||
shells for summary-frame spacing and card density, while monitoring surfaces
|
||||
such as recovery, infrastructure, workloads, and storage only choose from the
|
||||
owned shared density modes instead of forking summary spacing with feature-
|
||||
local padding hacks. Future summary-density work should extend those shared
|
||||
primitives rather than hard-coding compact card chrome inside one surface.
|
||||
The shared tooltip now follows that same owner split.
|
||||
`frontend-modern/src/components/shared/Tooltip.tsx` stays the render shell and
|
||||
singleton API boundary, `frontend-modern/src/components/shared/useTooltipState.ts`
|
||||
@@ -6378,11 +6401,6 @@ 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 shared-boundary rule applies to summary density. The shared compact
|
||||
mode on `SummaryPanel.tsx` and `SummaryMetricCard.tsx` exists for genuinely
|
||||
dense monitoring surfaces, but pages that are trying to align with the normal
|
||||
Pulse monitoring scan path should stay on the default shared density instead of
|
||||
using page-local compact overrides by habit.
|
||||
That same recovery shell boundary now also owns one canonical top-level filter
|
||||
controller in
|
||||
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`. Route-backed
|
||||
@@ -6440,20 +6458,6 @@ 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.
|
||||
`frontend-modern/src/utils/problemResourcePresentation.ts` now also belongs to
|
||||
that same dashboard overview boundary so the problem-resource severity contract
|
||||
stays shared with `ProblemResourcesTable.tsx` instead of floating as an
|
||||
unowned helper.
|
||||
Problem-resource table readability belongs to that same owner. Repeated rows
|
||||
may collapse only when they share the same governed display label, resource
|
||||
type, and problem signal; the header count and Pulse Brief counts must continue
|
||||
to represent the underlying affected resources, and grouped links must route to
|
||||
the broad owning surface rather than inventing a synthetic resource target.
|
||||
Problem Resources and Pulse Brief wording must not amplify generic
|
||||
status-shaped names such as `storage (offline)` into first-viewport prose or
|
||||
grouped-row sublabels; when the resource name is only a type plus status, the
|
||||
surface should summarize the type-level issue in operator language instead of
|
||||
repeating raw backend-shaped labels.
|
||||
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
|
||||
@@ -6663,6 +6667,11 @@ icon/content layout, and action-link chrome. The
|
||||
outdated-sensor notices, and the
|
||||
`platform-inline-notice-local-amber-shell` pattern guard blocks future
|
||||
`platformPage` files from reintroducing page-local amber notice shells.
|
||||
A notice's render predicate reads what its page hydrates on every tab the
|
||||
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.
|
||||
|
||||
Alert incident-event filter containers, labels, and chips must now route
|
||||
through the shared presentation helpers in
|
||||
@@ -7172,6 +7181,12 @@ shell stays in `frontend-modern/src/features/alerts/OverviewTab.tsx`, while
|
||||
alert stats, filtered ordering, and single/bulk acknowledge runtime behavior.
|
||||
Future overview control flow should extend that hook rather than restoring
|
||||
action timers or acknowledge mutations to the tab shell.
|
||||
The overview follows the shared-clock rule for relative ages: the alert card
|
||||
reads `useRelativeTimeNow` for its started age and the live reading's stale
|
||||
cut-off, and the hook reads it for the Last 24 Hours count. A hook-local
|
||||
minute signal used as "now" is a table-local interval under that rule; the
|
||||
hook keeps a minute interval only to refresh delivery diagnoses, a server read
|
||||
whose cadence is not tied to the age clock.
|
||||
Render-heavy overview ownership now lives in
|
||||
`frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`,
|
||||
`frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`,
|
||||
@@ -7894,10 +7909,12 @@ renders above the chart.
|
||||
TrueNAS disk rows follow it too: the drawer's Temperature row reads
|
||||
"(last known)" with the reason as title and no heat tone, and the one-line
|
||||
summary leaves a retained reading out because it has no room for the reason.
|
||||
The Proxmox outdated sensor setup notice (`features/platformPage/sensorSetup.ts`)
|
||||
counts a SATA or SAS disk as having a temperature only when the reading is
|
||||
current, so a retained one cannot hide the notice for a disk Pulse cannot read
|
||||
now.
|
||||
The Proxmox outdated sensor setup notice reads the registry's per-node
|
||||
`proxmox.sensorSetupOutdated` verdict, which counts a SATA or SAS disk as having
|
||||
a temperature only when the reading was collected now
|
||||
(`diskinventory.TemperatureCollected` in
|
||||
`internal/unifiedresources/proxmox_sensor_setup.go`), so a retained one cannot
|
||||
hide the notice for a disk Pulse cannot read now.
|
||||
|
||||
The focused browser proofs are
|
||||
`frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts`,
|
||||
|
||||
@@ -1825,6 +1825,29 @@ boundary around it:
|
||||
matches node names only, so it relies on fixture node names being unique
|
||||
across instances, which they are.
|
||||
|
||||
`checkPhysicalDiskAlerts` also hands each disk's temperature to
|
||||
`CheckProxmoxDiskTemperature`, so a Proxmox disk that only the node's sensors
|
||||
report alerts under the disk temperature policy, as its Running Hot verdict
|
||||
already says it should. It passes the reading only when this poll collected it
|
||||
(`collectedPhysicalDiskTemperature`, `diskinventory.TemperatureCollected`), so
|
||||
a retained last-known value holds the alert rather than judging it, and it
|
||||
passes `AgentSMARTReported`, which `mergeHostAgentSMARTIntoDisks` sets on a
|
||||
disk a still-reporting linked agent lists in its SMART report (disks built
|
||||
from that report when the Proxmox query fails go through the same merge). A
|
||||
disk the agent lists is the agent's: `CheckHost` raises its temperature
|
||||
alert, and the PVE disk check closes its own as moved to the agent, so no
|
||||
disk alerts twice. Once the agent's lease lapses (`ExpireHostTelemetry` marks
|
||||
the host offline) its retained rows still enrich the disk, but ownership
|
||||
returns to the PVE check, which judges the node's own current readings; an
|
||||
agent newly linked to a disk with an open PVE alert takes it over on the next
|
||||
disk poll. An excluded device closes its temperature
|
||||
alert with the health and wearout ones. Fixture disks carry no agent SMART
|
||||
merge, so on a mock estate every hot disk on an online node alerts.
|
||||
`TestMergeHostAgentSMARTIntoDisksMarksDisksTheAgentReports` and
|
||||
`TestCheckPhysicalDiskAlertsRaisesProxmoxDiskTemperatureAlerts` in
|
||||
`internal/monitoring/physical_disk_roundtrip_test.go` pin the marker,
|
||||
the collected reading, agent ownership and exclusion.
|
||||
|
||||
Switching mock mode fences the alert evaluations that read mode-dependent data
|
||||
(`mockModeFence`, `internal/monitoring/mock_mode_fence.go`). `GetState`, the
|
||||
fixture graph, the unified read view, the recovery rollups and the connection
|
||||
@@ -1899,11 +1922,33 @@ a live report or poll already past its mock-mode check when mock mode is
|
||||
switched on can still evaluate after that clear, and its alerts can persist
|
||||
until mock mode is left.
|
||||
|
||||
The fence and the clear are per monitor while the mode is process-wide. A
|
||||
tenant monitor other than the one `SetMockMode` was called on neither drains
|
||||
nor clears, so fixture alerts it raised outlive the switch as they did before
|
||||
the fence; switching mock mode with several tenant monitors running is not
|
||||
covered.
|
||||
The mode is process-wide while each monitor keeps its own fence, alert manager,
|
||||
fixture agents and state, and the server runs one monitor per organization, so
|
||||
`SetMockMode` switches every running monitor, whichever tenant's monitor it is
|
||||
called on (the mock-mode API calls the requesting tenant's, the demo-fixture
|
||||
licence sync the default organization's). `Start` joins a process-wide set of
|
||||
running monitors under `mockModeSwitchMu`, and leaves it when its loop returns.
|
||||
A switch flips the mode once, then ends each monitor's epoch, waits for its
|
||||
admitted calls, clears its alerts, forgets its fixture agents when leaving mock
|
||||
mode and resets its state, and only then restarts the mode-dependent runtime of
|
||||
each monitor in the set. `Start` sets its runtime context before it joins and
|
||||
chooses the mock metrics sampler or discovery while it joins, under the same
|
||||
lock, so no switch lands between reading the mode and starting the runtime for
|
||||
it, and a switch that reaches a monitor not yet joined leaves the runtime to
|
||||
its `Start`. A monitor that missed switches between `New` and `Start` first
|
||||
leaves what it holds: `New` records the mode and the number of switches made so
|
||||
far, and `Start` compares both, so even a round trip that brought the flag back
|
||||
drops the fixture alerts a read path raised meanwhile, as well as the live
|
||||
alerts `New` restored outside mock mode. A monitor that is not running is
|
||||
switched only when the switch is made through it; a call for the mode the flag
|
||||
already holds still moves any reached monitor whose alerts belong to the other
|
||||
mode, and leaves the rest alone.
|
||||
|
||||
Release builds reach several tenant monitors in mock mode only on an instance
|
||||
running `DemoMode` with the `demo_fixtures` entitlement (release
|
||||
`ValidateEnablement` refuses mock fixtures otherwise) and with more than one
|
||||
organization's monitor running; the licence sync switches only for the
|
||||
default organization.
|
||||
|
||||
The proofs park a real evaluation at a fixed point, switch modes and release
|
||||
it. `TestLeavingMockModeRefusesTheRestOfAnInFlightPass` holds a mock pass at its
|
||||
@@ -1933,7 +1978,25 @@ previous-state carry returns fixture nodes or guests. All eight are in
|
||||
`TestStoreRefreshTakesItsMockModeScopeBeforeReadingState` in
|
||||
`internal/monitoring/canonical_guardrails_test.go` rejects a call that reads
|
||||
state in an argument ahead of `m.mockModeFence.begin()` and pins the
|
||||
end-epoch-then-clear order in `SetMockMode`.
|
||||
end-epoch-then-clear order in `endMockModeEpoch`, which `SetMockMode` runs for
|
||||
every monitor it reaches.
|
||||
|
||||
`TestLeavingMockModeClearsEveryRunningTenantMonitor` and
|
||||
`TestEnteringMockModeClearsEveryRunningTenantMonitor` start the default and one
|
||||
other tenant monitor through `MultiTenantMonitor`, wait until both have joined,
|
||||
raise alerts in both, switch through one of them and check both. Against a
|
||||
switch that reached only its own monitor, the default monitor kept its fixture
|
||||
alerts (69 to 104 across runs) and all 69 fixture agents after leaving mock
|
||||
mode through the other tenant, and that tenant kept its live alert after mock
|
||||
mode was entered through the default monitor. `TestMonitorStartedAfterAMockModeSwitchLeavesTheModeItWasBuiltIn`
|
||||
fails if `Start` joins without leaving the mode its monitor was built in, and
|
||||
`TestSetMockModeAlignsAMonitorWhoseModeTheFlagAlreadyHolds` fails if a call for
|
||||
the flag's current mode returns without moving such a monitor, and
|
||||
`TestMockModeSwitchLeavesAnUnjoinedMonitorsRuntimeToItsStart` fails if a switch
|
||||
starts the runtime of a monitor whose `Start` has not joined, and
|
||||
`TestMonitorStartedAfterAMockModeRoundTripDropsWhatItRaisedMeanwhile` fails if
|
||||
`Start` compares only the mode. All six are in
|
||||
`internal/monitoring/monitor_mock_alerts_test.go`.
|
||||
|
||||
The disk evaluation runs on the mock alert tick, not the poller's disk
|
||||
interval (five minutes by default, `PhysicalDiskPollingMinutes` per instance).
|
||||
@@ -2636,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
|
||||
@@ -3175,7 +3249,20 @@ Monitoring retains one current status per target and location, using server
|
||||
receipt time for remote freshness while keeping the agent-authored observation
|
||||
time as evidence metadata. A stale or disconnected agent path derives as
|
||||
indeterminate at read time; slow or fast agent clocks cannot manufacture or
|
||||
conceal a disconnect. Aggregation is conservative: all reachable paths are
|
||||
conceal a disconnect. A local path beside other locations that has not run
|
||||
within the same window derives as indeterminate too ("no recent local check").
|
||||
The target takes its freshness from its newest location, so otherwise a
|
||||
failing remote report kept an old local pass counted as reachable and the
|
||||
check read available with current evidence. For the same reason an available
|
||||
check's evidence lasts only as long as its newest reachable path, not a
|
||||
fresher path that failed. When the external-probe entitlement lapses, a
|
||||
single remote result stands in for the local location until the local poller
|
||||
runs, with the earlier of its agent check time and the server's receipt time
|
||||
as its check time, so a fast agent clock cannot keep it current. An aggregate
|
||||
over several locations never stands in: a target without a retained local
|
||||
observation reads as never checked, and not connected, until the local poller
|
||||
runs.
|
||||
Aggregation is conservative: all reachable paths are
|
||||
healthy, mixed reachable and failed/unknown paths are degraded, all current
|
||||
paths unreachable are unavailable, and no reachable path with incomplete or
|
||||
indeterminate coverage is unknown. Only aggregate unavailability advances the
|
||||
@@ -4051,6 +4138,14 @@ ambiguous. Direct SATA, SAS, and NVMe device fallback IDs retain their legacy
|
||||
shape, while multiple controller members behind one block path add their
|
||||
controller target to the fallback identity. Per-member I/O must never inherit
|
||||
an aggregate controller counter.
|
||||
A linked node's agent with no SMART reading for a device, such as a member's
|
||||
standby row, files that device's I/O under the Proxmox disk's metrics target
|
||||
(`proxmoxPhysicalDiskMatchesForLinkedNode` reads `MetricResourceID`). For a
|
||||
member merged with its Proxmox row that target is the member's own SMART key,
|
||||
scoped to the member once (`PhysicalDiskMetaMetricID`), so its SMART and I/O
|
||||
history share one series. The target used to carry the member topology twice,
|
||||
and I/O already stored under that doubled key is left to age out rather than
|
||||
migrated. Proof: `TestIdentitylessControllerMembersReadTheirWritersHistory`.
|
||||
The same rules apply to SATA and NVMe inventory: direct-disk source IDs keep
|
||||
their historical shape, controller-member IDs add their member target, and
|
||||
cross-source correlation is scoped to the canonical parent node. A successful
|
||||
@@ -4430,6 +4525,13 @@ disk usage when the last VM snapshot is still recent guest-agent truth rather
|
||||
than an already carried-forward fallback. That keeps transient guest-agent or
|
||||
status-call failures from regressing a VM back to misleading allocated-disk
|
||||
data while still avoiding indefinite replay of stale disk summaries.
|
||||
The previous VM that `previousVMFromView` builds from the unified read state
|
||||
carries disk usage only when it is Proxmox's own reading. When a manually
|
||||
linked Pulse agent's disk filled in because Proxmox had no guest filesystems
|
||||
(`VMView.DiskFromLinkedAgent`), carrying it would relabel the agent's value as
|
||||
a Proxmox `prev-` read, and with Proxmox outranking the agent on guests that
|
||||
copy would then freeze over the agent's live disk.
|
||||
`TestPreviousVMFromViewKeepsLinkedAgentDiskOutOfProxmoxCarry` pins both owners.
|
||||
That compatibility boundary also applies to historical snapshot labels that may
|
||||
still exist in tests, live in-memory state, or pre-canonical diagnostic paths:
|
||||
legacy aliases such as `rrd-available`, `rrd-data`, `node-status-available`,
|
||||
@@ -4737,6 +4839,37 @@ disk history model, and mock seeding plus live mock ticks in
|
||||
`internal/monitoring/mock_metrics_history.go` must append to that same disk
|
||||
timeline instead of creating a second drawer-only or mock-only disk history
|
||||
path.
|
||||
The SMART-resolved id is the serial the disk resource carries: when a SMART row
|
||||
reports none, `HostSMARTDiskMetricID` takes the one the host's Unraid inventory
|
||||
reports for that disk, as the unified-resources adapter does, unless the Unraid
|
||||
row does not describe the disk (`unraidDiskDescribesSMARTRow`: a controller
|
||||
member, or several SMART rows on the disk's device path), and
|
||||
`hostDiskIOMetricResourceID` keys
|
||||
a device with no non-standby SMART row by its Unraid serial
|
||||
(`HostUnraidDeviceMetricID`, which refuses a device several Unraid rows name)
|
||||
before the linked Proxmox node's disks and the `<host>:<device>` fallback.
|
||||
Controller members sharing a device path keep distinct keys, so the I/O
|
||||
writer's aggregate-device guard, which compares every row on the path by its
|
||||
own identity (standby rows and legacy `sdc [sat]` labels included), files
|
||||
their counter under no member.
|
||||
Proof:
|
||||
`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports` and
|
||||
`TestHostDiskIOMetricResourceIDFallbacks`.
|
||||
The agent's `smart_temp` series holds the reading the disk resource shows,
|
||||
not only the SMART row's own. `writeHostSMARTMetrics` writes
|
||||
`HostSMARTDiskTemperature`, which falls back to the host's Unraid inventory
|
||||
reading when the row has none, and `writeHostUnraidDiskTemperatures` writes
|
||||
`HostUnraidDiskTemperature` under `HostUnraidDiskMetricID` for each Unraid row
|
||||
whose key is not one of the host's SMART rows' keys, such as a `--disk-exclude`
|
||||
member, so an Unraid reading never overwrites a SMART row's own at the same
|
||||
timestamp.
|
||||
Unraid rows sharing a key are one disk in the registry, which shows the latest
|
||||
of their readings, so the last row with a reading decides that key's single
|
||||
sample. `diskinventory.TemperatureCollected` keeps a spun-down disk's or an
|
||||
expired host's leftover reading out of history.
|
||||
Proof:
|
||||
`TestAgentDiskChartsTheUnraidTemperatureItShows` and
|
||||
`TestApplyHostReportChartsUnraidTemperatureOfDiskWithoutSMART`.
|
||||
That same monitoring-owned disk-health boundary also includes shared storage
|
||||
risk assessment in `internal/storagehealth/`. When providers or host agents
|
||||
emit structured storage topology such as Unraid per-disk state, the shared
|
||||
|
||||
@@ -1512,15 +1512,29 @@ provider/settings affordances.
|
||||
The feature surface now also keeps the same shell/runtime split internally:
|
||||
`frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` owns feature
|
||||
composition, the Patrol-owned section files under
|
||||
`frontend-modern/src/features/patrol/` own the header, banner, summary, and
|
||||
workspace render surfaces, and
|
||||
`frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` owns Patrol
|
||||
state, transport, polling, and effect lifecycle. The shell and section surfaces
|
||||
must not re-accumulate Patrol API calls, timer orchestration, or store refresh
|
||||
semantics, and `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` now
|
||||
`frontend-modern/src/features/patrol/` own the header, banner, workspace, and
|
||||
tab-panel render surfaces (the Inbox attention list, Protection objectives, and
|
||||
the Activity tab's `This week` digest and recent work), and
|
||||
`frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` owns the
|
||||
page's shared Patrol state, transport, polling, and effect lifecycle. The shell
|
||||
and the header, banner, and workspace surfaces must not re-accumulate those
|
||||
Patrol API calls, timer orchestration, or store refresh semantics; the
|
||||
attention list, objectives, recent work, and `This week` panels each own their
|
||||
own typed read and refresh instead, and `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` now
|
||||
owns the canonical summary normalization so Patrol consumers inherit one
|
||||
governed recent-change and policy-posture snapshot instead of reintroducing
|
||||
hook-local fallback logic.
|
||||
The hook no longer returns the summary card's severity and fix tallies. Its
|
||||
`summaryStats` accessor (critical, warning, and total active findings plus
|
||||
fixed outcomes, counted client-side over the Patrol findings list) lost its
|
||||
only reader when the Patrol summary card was deleted and has been removed,
|
||||
together with the `canonical-patrol/no-local-summary-card-presentation` rule in
|
||||
`frontend-modern/scripts/canonical-platform-audit.mjs`, whose regex matched only
|
||||
text shaped like the deleted card's tint ternaries on
|
||||
`summaryStats().criticalFindings`, `warningFindings` and `fixedCount`. The open-issue severity counts the page shows today come from
|
||||
the Activity tab's `This week` card, which reads them from
|
||||
`GET /api/ai/patrol/digest` (see that card's section below); a count surface
|
||||
that returns must not revive a hook-local tally of the findings list.
|
||||
That same Patrol hook boundary now consumes shared AI settings/model truth
|
||||
through `frontend-modern/src/stores/aiRuntimeState.ts` instead of mounting its
|
||||
own `/api/settings/ai` or `/api/ai/models` reads. Patrol-specific state still
|
||||
@@ -2687,6 +2701,36 @@ clock while it stays open. Its `Last seen` and flapping window are latest
|
||||
readings of a record that may have moved since, so they stay the ages at
|
||||
read time, and the queue rows, summary, recent work, run history and digest
|
||||
re-derive on their own 30- or 60-second re-reads.
|
||||
Future times in Patrol count down instead of reading as ages, because the
|
||||
past-only `formatRelativeTime` reads each of them as "just now" for their whole
|
||||
duration. The attention detail's suppression line counts down to the expiry
|
||||
with the exact time on hover and names the state expiry restores in the
|
||||
early-end control's words, from the same
|
||||
`getAttentionSuppressionEndPresentation` split on
|
||||
`operationalRecord.acknowledgement`: an open issue reads `Returns to active
|
||||
attention in 1d.`, one marked reviewed reads `Suppression ends in 1d. It stays
|
||||
reviewed.` (expiry restores Acknowledged, which is not active attention), and a
|
||||
passed expiry reads `Suppression has ended.`
|
||||
(`getAttentionSuppressionExpiryLabel`). The findings list reads `Reminding in
|
||||
6d` for a will_fix_later reminder and `snoozed, returns in 3h` for a snooze,
|
||||
both through `formatTimeUntil` on the shared clock with the exact time on
|
||||
hover. A reminder reads `Reminder overdue` once the panel's overdue id list
|
||||
holds it. That list is sampled on the shared clock and is what the Overdue
|
||||
commitments chip and filter count, so the label, chip and filter agree: a row
|
||||
mounted or re-read between the deadline and the tick that adds it reads
|
||||
`Reminder due`, and a row already on screen keeps its last countdown until that
|
||||
tick. The list only notifies when its ids change, so a clock tick that moves no
|
||||
deadline past now does not rebuild the open Overdue rows. The hourly reminder
|
||||
sweep then brings the finding back whether or not it still trips. An ended
|
||||
snooze reads `snooze ended` until the next findings re-read drops the snoozed
|
||||
status: `GET /api/ai/patrol/findings` sends no status, so the frontend
|
||||
`aiIntelligence` store (`normalizeFindingStatus`) derives `snoozed` on each
|
||||
read only while `snoozed_until` is ahead, and the finding then shows as Active
|
||||
unless it has since resolved or been dismissed.
|
||||
`getFindingReminderPresentation` and `getFindingSnoozePresentation` in
|
||||
`aiFindingPresentation.ts` own the findings copy.
|
||||
`PatrolAttentionWorkbench.test.tsx`, `FindingsPanel.links.test.tsx` and
|
||||
`FindingsPanel.test.ts` pin it.
|
||||
|
||||
### This week card answers what Patrol did for the customer
|
||||
|
||||
|
||||
@@ -1507,7 +1507,6 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
default filter-option labels must flow through the shared all-option
|
||||
presentation helper via the workload filter config model instead of the
|
||||
hot-path shell hard-coding local `All ...` strings.
|
||||
28. Keep summary-card hover emphasis on one bounded rendering budget: when a summary row is active, shared sparkline and density-map primitives must promote the selected series and demote background series through the same active-series ID rather than layering a second page-local highlight pass, so zoom-range and hover scrubbing stay visually coherent without reintroducing multi-series overdraw on the hot summary cards. Density maps on that hot path must stay overview-first under focus: preserve the multi-entity heatmap rows, layer focused-entity detail inside the card, and avoid swapping transient hover into a separate single-series chart path.
|
||||
29. Keep public self-hosted checkout handoff endpoints on the adjacent
|
||||
commercial/router boundary, not the summary-chart hot path. When
|
||||
`internal/api/router.go`, `internal/api/router_routes_cloud.go`, or
|
||||
@@ -1529,7 +1528,8 @@ them with new hot-path fetches. Node rows that draw infrastructure history
|
||||
must read the canonical infrastructure-summary route owned by
|
||||
`internal/api/router_routes_monitoring.go` and `internal/api/router.go`, today
|
||||
through `frontend-modern/src/components/Workloads/useWorkloadTableMetricHistory.ts`;
|
||||
storage summary cards must use the storage summary cache owners. The authenticated
|
||||
the storage pool Growth column must read through the storage summary cache
|
||||
owners (`useStorageSummaryCharts.ts` and `storageSummaryCache.ts`). The authenticated
|
||||
root path in `frontend-modern/src/App.tsx` must not prewarm a deleted
|
||||
dashboard-specific overview, trend, or summary transport.
|
||||
The same hot path must keep mock/demo chart identity on the canonical
|
||||
@@ -2262,22 +2262,11 @@ Route-backed workload `resource` focus on that hot path is contextual state
|
||||
only, not inferred filter state: opening or closing an inline drawer must not
|
||||
invent, retain, or clear `agent` or node-scope filters unless those filters
|
||||
were already explicit in the managed workload URL.
|
||||
That same hot-path ownership now covers top-of-page summary emphasis: infrastructure
|
||||
and workloads summary cards must treat row hover, chart hover, and route focus
|
||||
as one shared active-series contract so time-range switches, row scrubbing, and
|
||||
chart cross-highlighting reuse one existing chart path instead of repainting
|
||||
page-local “selected row” overlays on top of already downsampled summary
|
||||
history. Hovering a sparkline or density map for one entity must promote that
|
||||
entity into the shared active series so sibling cards highlight the same object
|
||||
at once rather than maintaining chart-local hover state, and the synchronized
|
||||
hover timestamp must remain visible across those sibling cards even when the
|
||||
active entity has no samples for one metric in the current range. Those
|
||||
sibling cards should expose the synchronized value through one compact
|
||||
header-level readout, not by spawning duplicate floating tooltips on every
|
||||
chart.
|
||||
Infrastructure cluster-header hover now belongs to that same bounded hot path:
|
||||
hovering a grouped infrastructure header must scope the top cards to that
|
||||
cluster's unified-resource members through the shared group/entity interaction
|
||||
That same hot-path ownership covers summary emphasis. No top-of-page summary
|
||||
cards render on infrastructure or workloads today; if they return, row hover,
|
||||
chart hover, and route focus must stay one shared active-series contract that
|
||||
reuses one existing chart path instead of repainting page-local “selected row”
|
||||
overlays on top of already downsampled summary history.
|
||||
That same hot path now also owns dashboard freshness discipline. Supported
|
||||
unified-resource dashboard reads may hydrate from REST for first paint, but
|
||||
once websocket `state.resources` is available they must consume that canonical
|
||||
@@ -2414,21 +2403,20 @@ must not tighten physical retention until the durable downgrade state reaches
|
||||
its governed day-60 purge timestamp. Re-upgrade clears that ceiling before the
|
||||
next retention run. The ceiling may only reduce configured retention; it must
|
||||
never expand an operator's shorter storage policy.
|
||||
contract instead of inventing an infrastructure-local summary filter branch.
|
||||
For shared line charts on that hot path, the shared sparkline primitive may
|
||||
isolate the selected series inside the existing render budget, but that
|
||||
isolation must still reuse the same summary series set and timeline data rather
|
||||
than triggering a second page-local chart recomputation.
|
||||
That same hot-path rule now covers contextual row focus on those pages.
|
||||
That same hot-path ownership now also forbids hover-driven table movement.
|
||||
When a summary chart promotes one active entity, the matching row may highlight
|
||||
in place if it is already mounted and visible, but the hot path must not
|
||||
auto-scroll the page or rebuild the table into a one-row filtered view on
|
||||
transient hover. Off-screen reveal must stay behind an explicit `Jump to row`
|
||||
action routed through the shared summary-table focus bridge.
|
||||
That same hot-path contract also owns the row-emphasis paint. Dashboard guest
|
||||
rows and infrastructure resource rows must expose summary-linked activity
|
||||
through the shared `data-summary-row-active` marker and let the shared frontend
|
||||
That same hot-path ownership forbids hover-driven table movement on the
|
||||
workloads and storage tables. Row hover may highlight the matching row in
|
||||
place, but the hot path must not auto-scroll the page or rebuild the table
|
||||
into a one-row filtered view on transient hover; only a deliberate row focus
|
||||
may reveal an off-screen row, through the shared summary-table focus bridge.
|
||||
The bridge measures row position only inside that deliberate reveal. It must
|
||||
not keep window-level scroll or resize listeners, or re-measure the hovered
|
||||
row on every hover change and scroll frame, for an off-screen affordance that
|
||||
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
|
||||
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
|
||||
@@ -2436,14 +2424,7 @@ 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, and the governed infrastructure and
|
||||
workloads summary surfaces must keep the summary page-scoped while that focus
|
||||
reuses the shared highlight contract; density maps may retain page-level
|
||||
context, but they must now also surface focused-entity detail inside the same
|
||||
card instead of dimming the rest of the map into unusable background noise or
|
||||
swapping the hot path into a transient single-series chart. Line-card
|
||||
isolation must still flow through the shared sparkline runtime instead of a
|
||||
page-local focus overlay. The shared same-path scheduler must also own cleanup
|
||||
workload does not look like a full page reload. 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.
|
||||
@@ -2452,12 +2433,13 @@ focus may mount drawer-local facet and intelligence fetches, but those
|
||||
requests must retain the mounted page shell through the shared non-suspending
|
||||
query helper instead of bubbling a transient `Loading view...` fallback through
|
||||
`AppLayout.tsx` when an inline infrastructure drawer opens.
|
||||
That same hot-path rule now has one shared runtime boundary. Interactive-series
|
||||
filtering, active-series derivation, focused-label lookup, and local
|
||||
scroll-preserving row focus must extend
|
||||
`frontend-modern/src/components/shared/contextualFocus.ts` instead of
|
||||
rebuilding page-local `Set` scans or scroll repair logic in dashboard,
|
||||
infrastructure, or workloads hot paths.
|
||||
That same hot-path rule now has one shared runtime boundary. Local
|
||||
scroll-preserving row focus and inline-detail reveal must extend
|
||||
`frontend-modern/src/components/shared/contextualFocus.ts`, and active-series
|
||||
derivation must stay in
|
||||
`frontend-modern/src/components/shared/summaryCardInteraction.ts` behind
|
||||
`summaryTableFocus.ts`, instead of rebuilding page-local `Set` scans or scroll
|
||||
repair logic in workloads, storage, or platform-table hot paths.
|
||||
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
|
||||
@@ -2471,8 +2453,9 @@ 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
|
||||
top of the detail visible instead of hard-centering every expansion.
|
||||
That same hot-path ownership now includes summary cache invalidation. A
|
||||
summary chart cache that hydrates charts for fast remounts, today the storage
|
||||
page's module-scoped `frontend-modern/src/utils/storageSummaryCache.ts`, must
|
||||
summary history cache that hydrates fast remounts, today the storage page's
|
||||
module-scoped `frontend-modern/src/utils/storageSummaryCache.ts` behind the
|
||||
pool Growth column and its sort, must
|
||||
carry the chart contract version in its key and advance it when the summary
|
||||
chart timeline contract changes, so a hot-reloaded or long-lived browser tab
|
||||
cannot rehydrate an older series set before the next live fetch completes.
|
||||
@@ -2720,6 +2703,41 @@ must remain distinguishable without hover. Workloads supplies this row metadata,
|
||||
not local table CSS or a duplicate renderer; non-filesystem rows keep the compact
|
||||
default. Verification covers the opt-in at this composition boundary and actual
|
||||
long-path wrapping in the shared renderer's browser proof.
|
||||
A guest's linked Pulse Agent can stop reporting while Proxmox keeps polling the
|
||||
VM. `useWorkloads` copies `resource.agent.stale` onto the row as `agentStale`,
|
||||
and sets `disksFromAgent` when Proxmox reported no guest filesystems and the
|
||||
agent's own `disks` filled in. When that agent is stale, `GuestDrawerOverview.tsx`
|
||||
reads the Guest RAID card as last known through the shared `RaidCard`
|
||||
`lastKnownReason` prop with `HOST_AGENT_STOPPED_REPORTING_REASON`, puts a status
|
||||
line over muted Network rows, reads agent-supplied Filesystems rows through the
|
||||
existing `last-known` disk read state, and marks the Pulse Agent row as stopped
|
||||
reporting. Agent-supplied filesystem rows follow the agent's freshness rather
|
||||
than the Proxmox `diskStatusReason`, which describes Proxmox's own guest reads,
|
||||
so a live agent's rows keep their usage and bars. Those rows read as
|
||||
unavailable once the guest is stopped, whether its status or the Proxmox
|
||||
`vm-stopped` reason says so, and Proxmox's own guest filesystems keep their
|
||||
Proxmox read state. The two flags ride the existing row mapping and
|
||||
add no query or poll; `GuestDrawer.test.tsx` and `useWorkloads.test.ts` pin
|
||||
both sources. The workload table's disk cell (`useGuestRowState.ts`,
|
||||
`GuestRow.tsx`) and the drawer History (`guestDrawerModel.ts`) judge the same
|
||||
guest through the drawer rows' rule, `getWorkloadGuestDiskRead` in
|
||||
`workloadGuestPresentation.ts`: a silent agent's bars carry the Last known
|
||||
label, a live agent's bars stay current under Proxmox `agent-not-running`, and
|
||||
History reads the aggregate disk metric as current, last known or unavailable
|
||||
the same way. The rule covers the aggregate because Proxmox records no guest
|
||||
disk usage without guest filesystems, so a manually linked guest then keeps the
|
||||
agent's disk metric, and the poller no longer carries that agent reading
|
||||
forward as Proxmox's own `prev-` read (see the monitoring contract).
|
||||
`TestResourceRegistry_ManualGuestAgentLinkDiskMetricFollowsProxmoxGuestFilesystems`
|
||||
pins that merge; `GuestRow.test.tsx` and `GuestDrawer.test.tsx` pin the cell and
|
||||
History. On the websocket path (`resourceStateAdapters.ts`) a native Proxmox VM
|
||||
facet's new guest-read outcome drops filesystems it no longer reports, a native
|
||||
agent facet without `disks` drops the agent's, and the agent facet always
|
||||
carries `stale`, so the rows switch source and lose the Last known label when
|
||||
the agent resumes, without a reload. A stale Proxmox source
|
||||
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
|
||||
@@ -2767,7 +2785,7 @@ the filter and presentation rails wrap-capable instead of forcing a single
|
||||
no-wrap row that clips trailing actions. The workload shell leaves Type,
|
||||
Status, dynamic scope, and contextual `Clear filters` actions on
|
||||
the primary filter rail. Durable presentation choices — grouped/list mode,
|
||||
bars/trends, chart visibility, Guest/Host memory basis, and Columns — pass
|
||||
bars/trends, Guest/Host memory basis, and Columns — pass
|
||||
their panel content through `FilterBar.viewOptions` instead of importing or
|
||||
wrapping `ViewOptionsDisclosure` in the workload feature or rendering as
|
||||
equally prominent filter toggles. The shared FilterBar owns the inline View
|
||||
@@ -2790,10 +2808,13 @@ 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. Workload chart
|
||||
visibility remains a display preference: its menu action 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.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -3025,6 +3025,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
|
||||
|
||||
@@ -944,6 +944,15 @@ Patrol coverage, schedule, finding, and approval state belongs on Patrol-owned
|
||||
surfaces or explicit Patrol affordances; Proxmox overview must not treat it as
|
||||
backup coverage, restore readiness, PBS verification/protection proof, or a
|
||||
replacement for the Proxmox Backups tab and workload Backup column.
|
||||
The outdated sensor setup notice above the Proxmox tabs is temperature
|
||||
guidance, not storage or recovery evidence. It reads the registry's per-node
|
||||
`proxmox.sensorSetupOutdated` verdict from the node rows the active tab already
|
||||
loads (Overview, Storage, Replication and Backups load nodes; Ceph and Mail do
|
||||
not, so it does not render there), so the Overview query must not add
|
||||
`physical_disk` rows to feed it; the derived `ProxmoxData` flag opens no storage
|
||||
or recovery path.
|
||||
`ProxmoxPageSurface.contract.test.tsx` pins the notice on Overview with no disk
|
||||
rows and an Overview query without `physical_disk`.
|
||||
The Proxmox inline estate totals are likewise an adjacent frontend-primitives
|
||||
and unified-resource projection, not backup or recovery evidence.
|
||||
`ProxmoxPageSurface` must flow the already-loaded workload inventory into the
|
||||
@@ -1404,6 +1413,12 @@ state only. A restored prompt or restored message count may help an operator
|
||||
continue a protected-item investigation, but it must not become backup coverage
|
||||
evidence, recovery freshness, restore entitlement, storage-owner identity,
|
||||
approval policy, or a provider-local recovery command handoff.
|
||||
The POST-only guard on Assistant session mutations (`/abort`,
|
||||
`/summarize`, `/fork`, `/undo`, `/redo`, `/steer`; `405` with `Allow: POST`
|
||||
for any other method that reaches the handler) is transport hardening in
|
||||
`internal/api/ai_handler.go`.
|
||||
It opens no storage, backup, or recovery read or write path, and a rejected
|
||||
request never reaches session state.
|
||||
Approved Assistant tool execution through `internal/api/router_routes_ai_relay.go`
|
||||
is also adjacent API/AI action plumbing for storage/recovery consumers.
|
||||
`AssistantToolExecutor` / `ApprovedAssistantToolExecutor` may execute an already
|
||||
@@ -2770,11 +2785,9 @@ canonical pre-handler authorization proof covering both transfer routes.
|
||||
top-level GitHub install.sh SERVER installer, so a correct local script
|
||||
(signed or not) is always preferred over a wrong-identity proxied one on the
|
||||
unverified curl-piped-into-bash agent path (issue #1470).
|
||||
15. Keep storage summary chart identity and sticky-shell behavior on the
|
||||
shared storage path. Pool rows, disk rows, storage summary cards, and
|
||||
storage detail charts must all address history through the canonical
|
||||
unified-resource metrics-target IDs, and the storage page must reuse the
|
||||
shared sticky summary primitive instead of a storage-local scroll wrapper.
|
||||
15. Keep storage chart identity on the shared storage path. Pool rows, disk
|
||||
rows, the pool Growth column, and storage detail charts must all address
|
||||
history through the canonical unified-resource metrics-target IDs.
|
||||
Expanded pool and physical-disk details must also follow the canonical
|
||||
object-detail information architecture: current configuration, topology,
|
||||
health, and SMART facts land on `Overview`; stored capacity and I/O charts
|
||||
@@ -2820,10 +2833,9 @@ canonical pre-handler authorization proof covering both transfer routes.
|
||||
likewise native Assistant tool-visibility plumbing, not MCP transport
|
||||
state and not storage/recovery execution authority.
|
||||
When operators hover or focus pools versus physical disks, the storage
|
||||
summary must reuse one resolved active-series ID across card state and
|
||||
chart highlighting so pool-only cards demote cleanly during disk focus and
|
||||
disk-temperature cards demote cleanly during pool focus, instead of
|
||||
leaving stale row-local IDs or storage-local hover branches on the page.
|
||||
page must reuse one resolved active-series ID for row emphasis in both
|
||||
views instead of leaving stale row-local IDs or storage-local hover
|
||||
branches on the page.
|
||||
Any page, group, or entity scope that becomes pinned through storage
|
||||
interaction must stay row-first: the pinned row or group remains the
|
||||
visible scoped state, and explicit clearing belongs to the shared storage
|
||||
@@ -2836,16 +2848,15 @@ canonical pre-handler authorization proof covering both transfer routes.
|
||||
`data-summary-group-member-active="preview|pinned"` state so the grouped
|
||||
block reads as one scoped set without adding storage-local outlines, pill
|
||||
buttons, or heavy full-row fills.
|
||||
18. Keep storage summary remount caches versioned with the chart contract.
|
||||
`frontend-modern/src/components/Storage/StorageSummary.tsx` may keep a
|
||||
bounded in-memory cache for same-tab remounts, but its cache key must carry
|
||||
an explicit summary contract version so long-lived demo sessions do not
|
||||
rehydrate stale pool or disk sparkline shapes after the storage summary
|
||||
chart model changes. The shared storage history owner at
|
||||
`frontend-modern/src/utils/storageSummaryCache.ts` must likewise remain
|
||||
bounded to 20 recent node/range summaries, use LRU replacement, and abort
|
||||
in-flight requests when organization ownership changes so a late response
|
||||
cannot recreate an old-scope cache entry.
|
||||
18. Keep the storage summary history cache versioned with the chart contract.
|
||||
`frontend-modern/src/utils/storageSummaryCache.ts`, which
|
||||
`frontend-modern/src/components/Storage/useStorageSummaryCharts.ts` reads
|
||||
for the pool Growth column, must carry an explicit summary contract version
|
||||
in its cache key so long-lived demo sessions do not rehydrate stale pool or
|
||||
disk series shapes after the storage summary chart model changes. It must
|
||||
also remain bounded to 20 recent node/range summaries, use LRU replacement,
|
||||
and abort in-flight requests when organization ownership changes so a late
|
||||
response cannot recreate an old-scope cache entry.
|
||||
19. Keep cross-surface workload handoffs on canonical IDs too. Shared workload
|
||||
chart transport may look up provider-backed VM history through unified
|
||||
metrics targets, but infrastructure/workloads/storage/recovery navigation
|
||||
@@ -2997,6 +3008,15 @@ recovery evidence. Unremembered overflow deliveries remain auditable with
|
||||
`reason=capacity`; cache expiry, overflow, or restart is not a successful
|
||||
storage operation or a reason to delete historical events.
|
||||
|
||||
### Alert settings saves keep unsent keys without a new storage path
|
||||
|
||||
`PUT /api/alerts/config` now applies only the top-level keys the body carries
|
||||
and keeps stored values for the rest, so a thresholds save no longer turns off
|
||||
alert TTL cleanup. It still writes `alerts.json` through the existing
|
||||
`SaveAlertConfig` path, opens no storage or recovery route, and leaves backup
|
||||
and snapshot alert settings under the same replace-what-was-sent rule as every
|
||||
other key. `internal/api/alerting/alerts_test.go` pins the handler.
|
||||
|
||||
### Container diagnostics shares private bootstrap transport (1 October 2026)
|
||||
|
||||
Recovery-adjacent diagnostics now reuse the canonical complete installer
|
||||
@@ -3686,11 +3706,6 @@ live wire-up and persistence resync so a finding the operator
|
||||
manually closed before a restart still reads as "Resolved by you"
|
||||
afterward, instead of being misattributed to Pulse's auto-detection.
|
||||
|
||||
`StorageSummary.tsx`, `StoragePageSummary.tsx`, and `useStoragePageSummary.ts`
|
||||
now surface `poolsDegraded` and `disksFailing` health indicators alongside
|
||||
pool/disk counts. These additions project from existing websocket pool/disk
|
||||
state; they must not introduce new API polling or widen the storage-fetch
|
||||
boundary.
|
||||
Agentless availability endpoints are adjacent infrastructure context only, not
|
||||
storage or recovery inventory. Storage/recovery consumers may receive
|
||||
`network-endpoint` resources through shared unified-resource snapshots, but
|
||||
@@ -3703,9 +3718,9 @@ not Standalone Machines membership, storage ownership, repository membership,
|
||||
backup coverage, or restore authority.
|
||||
That same owned summary path now also runs through
|
||||
`useStorageSummaryCharts.ts`: the storage page owns one page-scoped summary
|
||||
range and one shared storage-summary history fetch, and both the sticky
|
||||
summary cards and per-pool growth column reuse that payload instead of
|
||||
forking separate row-local history reads or duplicate polling loops.
|
||||
range and one shared storage-summary history fetch, and the per-pool Growth
|
||||
column and its growth sort reuse that payload instead of forking separate
|
||||
row-local history reads or duplicate polling loops.
|
||||
The storage table's default `priority` order is a risk list: records with an
|
||||
open, unacknowledged alert sort first by severity (fed into
|
||||
`sortStorageRecords` through `StorageSortContext.alertWeightByRecordId` from
|
||||
@@ -4343,38 +4358,26 @@ split: storage derives the health semantics through
|
||||
Storage components and `features/storageBackups` presentation helpers must not
|
||||
recreate raw green/yellow rounded-dot classes locally.
|
||||
That same storage page ownership now also includes contextual focus behavior
|
||||
for pools and disks. Expanding a storage row may set a focused metrics-target
|
||||
ID for shared summary emphasis, but `frontend-modern/src/components/Storage/StorageSummary.tsx`
|
||||
must keep the storage summary page-scoped instead of collapsing its sparklines
|
||||
to the single expanded row or replacing the page overview with row-local empty
|
||||
states.
|
||||
That same page-scoped summary contract now also owns canonical hover-isolation
|
||||
behavior. Pool and disk rows must publish the resolved metrics-target ID into
|
||||
the shared summary contract so pool usage, used capacity, and available space
|
||||
cards can isolate the active row through the shared sparkline primitive while
|
||||
non-matching cards such as disk temperature demote to inactive context instead
|
||||
of rebuilding a row-local summary surface.
|
||||
That same shared summary contract now also owns chart-driven emphasis.
|
||||
Hovering one storage summary chart must promote the same canonical metrics
|
||||
target ID through sibling cards, so pool charts cross-highlight the same pool
|
||||
while non-matching cards such as disk temperature demote to inactive context
|
||||
instead of keeping chart-local hover state. When a sibling storage card can map
|
||||
that same entity into its own series set, it must also surface the synchronized
|
||||
value as a compact card-header readout instead of opening a second floating
|
||||
tooltip away from the pointer.
|
||||
That same storage summary contract now uses the shared contextual-focus owner.
|
||||
`frontend-modern/src/components/Storage/StorageSummary.tsx` must route
|
||||
interactive-series filtering, focused-label lookup, and active-series
|
||||
resolution through `frontend-modern/src/components/shared/contextualFocus.ts`
|
||||
so storage keeps the same page-scoped focus semantics as infrastructure and
|
||||
workloads instead of preserving a storage-local hover/focus branch.
|
||||
That same storage ownership now also governs summary-to-table reveal. Hovering
|
||||
pool or disk charts may highlight the matching row when the active view already
|
||||
shows it, but storage hover must not auto-filter or auto-scroll the table.
|
||||
When the active chart entity is off-screen or hidden behind the other storage
|
||||
view, the page must use the shared summary-table focus bridge and reveal the
|
||||
target row only through an explicit `Jump to row` action, switching views or
|
||||
expanding the owning group only for that deliberate reveal path.
|
||||
for pools and disks. Expanding a pool row or selecting a disk row sets the
|
||||
focused summary series ID and row hover sets the hovered one;
|
||||
`frontend-modern/src/components/Storage/useStoragePageModel.ts` must resolve
|
||||
both through the shared `useSummaryPageInteractionState` bridge in
|
||||
`frontend-modern/src/components/shared/summaryTableFocus.ts`, so pool, disk,
|
||||
and pool-group row emphasis keeps the same page/group/entity semantics as
|
||||
workloads instead of preserving a storage-local hover/focus branch. The
|
||||
storage summary history that `useStorageSummaryCharts.ts` loads feeds only the
|
||||
pool Growth column and its sort; no storage summary card strip renders, and
|
||||
one that returns must stay page-scoped instead of collapsing to the expanded
|
||||
row or replacing the page overview with row-local empty states.
|
||||
That same storage ownership now also governs reveal. Row hover may highlight
|
||||
the matching row in place, but storage hover must not auto-filter or
|
||||
auto-scroll the table. Reveal belongs only to deliberate focus: the bridge
|
||||
hands the focused pool or disk series to the `revealActiveSeries` callback,
|
||||
which reopens a focused pool's collapsed owning group, and a pinned pool-group
|
||||
header that sits off-screen scrolls into view through the same bridge. Focus
|
||||
only ever names a row in the active pools or disks view, so the callback never
|
||||
switches views; the view-switching branches that served the retired jump to
|
||||
the active row are gone.
|
||||
That same reveal contract now also owns inline-detail expansion. When a pool or
|
||||
disk row is deliberately focused and its inline detail opens on the storage
|
||||
page, the detail row must publish the same canonical summary series ID through
|
||||
@@ -4397,8 +4400,8 @@ instead of a separate hardcoded recovery cache, so recovery filters, rollups,
|
||||
and shared route handoffs see the same platform set as settings and
|
||||
infrastructure.
|
||||
That same graph-owned mock boundary also owns demo-readiness for storage and
|
||||
recovery surfaces. Mock summary cards, seeded history, and provider-backed
|
||||
storage/recovery counts must come from the same canonical fixture graph so
|
||||
recovery surfaces. Mock seeded history and provider-backed storage/recovery
|
||||
counts must come from the same canonical fixture graph so
|
||||
storage and recovery demos show realistic healthy-versus-attention balance
|
||||
instead of blank history, stale provider context, or page-local fixture drift.
|
||||
That same recovery-facing demo contract also owns subject readability. When
|
||||
@@ -4407,16 +4410,11 @@ subject identity shown to operators must stay human-readable
|
||||
`<cluster>/<namespace>/<pvc>` context from the canonical graph instead of
|
||||
opaque hash-like IDs that break demo trust and cross-surface recognition.
|
||||
That same adjacent chart boundary now also assumes seeded and live mock
|
||||
storage timelines are one continuous series. Disk-temperature, pool-usage,
|
||||
used-capacity, and available-space cards may consume shaped chart payloads for
|
||||
presentation, but those payloads must still reflect one canonical mock metric
|
||||
timeline instead of a seeded seven-day sparkline with a second live tail
|
||||
stitched on afterward.
|
||||
That same chart boundary now also owns row-hover summary filtering. Storage
|
||||
pool and disk rows may focus the summary cards, but the shared storage summary
|
||||
must filter every supported card through the same canonical metrics-target
|
||||
identity rather than letting temperature, capacity, or detail cards drift onto
|
||||
page-local row identifiers.
|
||||
storage timelines are one continuous series. Storage chart consumers such as
|
||||
the pool Growth column and the pool and disk History charts may consume shaped
|
||||
chart payloads for presentation, but those payloads must still reflect one
|
||||
canonical mock metric timeline instead of a seeded seven-day series with a
|
||||
second live tail stitched on afterward.
|
||||
That same shared `internal/api/` dependency also assumes auth-persistence
|
||||
teardown is synchronous when recovery-adjacent runtimes reinitialize. Session,
|
||||
CSRF, and recovery-token workers may not leave stale background goroutines or
|
||||
@@ -4854,8 +4852,8 @@ the cloud-summary decision is derived from scope rather than stored as a
|
||||
separate boolean flag.
|
||||
The same storage-facing runtime paths now also normalize org scope through
|
||||
`frontend-modern/src/utils/orgScope.ts` before building cache keys or
|
||||
multi-tenant fetch state, so Dashboard, StorageSummary, and other storage
|
||||
adjacent consumers do not each keep a local `getOrgID() || 'default'`
|
||||
multi-tenant fetch state, so `storageSummaryCache.ts` and other
|
||||
storage-adjacent consumers do not each keep a local `getOrgID() || 'default'`
|
||||
fallback.
|
||||
|
||||
The frontend storage and recovery surfaces are also first-class embedded
|
||||
@@ -4897,10 +4895,10 @@ small table chrome to return to the primary page. It must not return as an
|
||||
equal-weight subtab or a top-level posture-card action strip without a separate
|
||||
governed product decision.
|
||||
Recovery is also intentionally outside the interactive page/group/entity
|
||||
summary-card contract used by workloads, infrastructure, and storage. The
|
||||
recovery route must not adopt `summaryCardInteraction.ts`, synchronized card
|
||||
hover, row-driven summary scope, or shared `SummaryPanel` card framing simply
|
||||
because adjacent monitoring pages use those primitives. Recovery may still show
|
||||
summary-scope contract that the workloads and storage tables use. The recovery
|
||||
route must not adopt `summaryCardInteraction.ts`, `summaryTableFocus.ts`,
|
||||
row-driven summary scope, or a summary card strip simply because adjacent
|
||||
monitoring pages use those primitives. Recovery may still show
|
||||
coverage breadth and platform context inside the Protection coverage table and
|
||||
event filters, but the page must read item-first so the unified recovery model
|
||||
is not visually anchored to one platform family.
|
||||
@@ -5191,8 +5189,8 @@ recovery-local grid overrides or width hacks. Protected-items controls should
|
||||
also use the same shared `Reset all` page-controls action pattern as storage
|
||||
and workloads when visible filters are active, instead of forcing operators to
|
||||
clear each inventory filter manually.
|
||||
That same handoff should keep Recovery out of shared summary-card density
|
||||
tuning unless a new governed product decision reintroduces a first-viewport
|
||||
That same handoff should keep Recovery free of a first-viewport summary card
|
||||
strip unless a new governed product decision reintroduces a first-viewport
|
||||
summary owner. The current route should spend its top-level density budget on
|
||||
the activity section, compact controls, and one primary data card.
|
||||
That same shell rule applies to the recovery-events workspace.
|
||||
@@ -6267,6 +6265,19 @@ is in `applyLicensedFeatureConfigSnapshot`
|
||||
(`pkg/server/telemetry_licensed_features.go`), pinned by
|
||||
`TestApplyLicensedFeatureConfigSnapshot_CountsScheduledReportingAndProfiles`.
|
||||
|
||||
### Single-resource report AI narration requires POST
|
||||
|
||||
`internal/api/metrics_reporting_handlers.go` now accepts `POST` with a JSON
|
||||
body for `/api/admin/reports/generate` and lets only that method use the
|
||||
tenant's AI narrator; `GET` keeps working with query parameters and the
|
||||
deterministic summary. A narrated PDF records the same AI cost-ledger entry as
|
||||
before, and only a `POST` can now produce one. A `GET` reads Patrol findings
|
||||
only from an AI service that is already running
|
||||
(`AISettingsHandler.ExistingAIService`), so it no longer constructs a tenant AI
|
||||
service, whose construction opens that tenant's SQLite resource export store.
|
||||
Report content, backup and recovery enrichment, and tenant storage are
|
||||
otherwise unchanged.
|
||||
|
||||
### Audit-read activity is a bounded, content-free local history
|
||||
|
||||
`audit_read_activity.json` follows the same shape as the existing external-agent
|
||||
@@ -6886,3 +6897,31 @@ migration or recovery authority is added or moved.
|
||||
The performance report and reporting runtime snapshot handlers apply the same
|
||||
test before tabulating a disk temperature. That changes only which held value a
|
||||
report shows, not any storage or recovery path.
|
||||
|
||||
### A linked agent's stale flag and guest disk owner open no storage path
|
||||
|
||||
`internal/unifiedresources/types.go` now always sends `AgentData.Stale`, so a
|
||||
resumed agent clears its stopped-reporting mark in browsers that merge agent
|
||||
facets field by field, and `VMView.DiskFromLinkedAgent` keeps the poller from
|
||||
carrying a linked agent's guest disk forward as a Proxmox read. Both describe
|
||||
which source's reading is current; no backup, retention, migration or recovery
|
||||
path is added or moved.
|
||||
|
||||
### Demo write guard ignores websocket upgrade headers
|
||||
|
||||
`internal/api/demo_middleware.go` no longer exempts every request carrying
|
||||
`Upgrade: websocket` from the demo read-only guard. On a `DEMO_MODE` instance a
|
||||
`POST`, `PUT`, `PATCH` or `DELETE` with websocket handshake headers is now
|
||||
judged like any other write: outside the login, logout, AI execute and OIDC
|
||||
allowlist it gets the generic demo `403`, or `404` on a hidden route, and that
|
||||
includes config import at `/api/config/import`. HTTP/1.1 websocket handshakes
|
||||
are `GET` requests and still connect. No storage, retention, backup, migration
|
||||
or recovery path is added or moved.
|
||||
|
||||
### Demo mode hides the pprof routes
|
||||
|
||||
`internal/api/demo_mode_operations.go` adds the Go runtime profiling family
|
||||
(`/debug/pprof` and every path below it, every method) to the public-demo
|
||||
hidden routes, so the demo guard on a `DEMO_MODE` instance answers it with
|
||||
`404`. That removes a diagnostic read surface on demo instances only; no
|
||||
storage, retention, backup, migration or recovery path is added or moved.
|
||||
|
||||
@@ -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
|
||||
@@ -2303,6 +2308,16 @@ evidence of what the array was doing when the agent went quiet, but without
|
||||
live status colour. Where a retained rebuild percentage is shown, it reads
|
||||
"Rebuild was at N%" with no speed or progress bar, and a member that was not
|
||||
healthy names its state in its badge.
|
||||
A Pulse agent manually linked into a guest carries the same signal there.
|
||||
`AgentData.Stale` is always on the wire, because browsers merge agent facets
|
||||
field by field (`resourceStateAdapters.ts`) and would keep a resumed agent's
|
||||
omitted false as true; `TestAgentDataAlwaysSendsStale` pins it. That always
|
||||
present `stale` also marks a native agent facet, so one without `disks` has
|
||||
withdrawn them, and a native Proxmox VM facet's new guest-read outcome likewise
|
||||
drops `disks` it no longer reports; either way the other source's filesystems
|
||||
take over without a reload (`resourceStateAdapters.test.ts`). `VMView.DiskFromLinkedAgent` names the agent
|
||||
as the owner of a guest's selected disk metric, which the monitoring poller
|
||||
reads before it carries a Proxmox disk reading forward.
|
||||
For SMART disk
|
||||
temperatures on rows that still render, the provenance travels on
|
||||
`agent.sensors.smart[].collection`: the Machines temperature cell
|
||||
@@ -2803,7 +2818,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
|
||||
@@ -2884,8 +2901,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
|
||||
@@ -2987,6 +3005,36 @@ or force every provider type back into the active route payload. Docker,
|
||||
Kubernetes, TrueNAS, and VMware surface contract tests pin their route query
|
||||
maps, and the shared hook test pins facet normalization.
|
||||
|
||||
### Proxmox node verdicts over disks arrive on the node
|
||||
|
||||
The Proxmox page hydrates one query per workflow, and only its Storage tab
|
||||
loads `physical_disk` rows, so a node verdict that depends on the node's disks
|
||||
is derived in the registry and carried on the node, never rebuilt in the page
|
||||
from disk rows. `ProxmoxData.SensorSetupOutdated` (`proxmox.sensorSetupOutdated`
|
||||
on REST and websocket rows) is that verdict for the outdated sensor setup
|
||||
notice. `refreshProxmoxSensorSetupLocked`
|
||||
(`internal/unifiedresources/proxmox_sensor_setup.go`) runs after
|
||||
`buildChildCounts` on every ingest path and gives every Proxmox node an
|
||||
explicit true or false (other resources omit it), because browser facet merges
|
||||
read an omitted field as a partial snapshot and would keep an earlier true. It
|
||||
is true when the node's temperature payload is available and in the legacy
|
||||
`sensors -j` format (`models.Temperature.LegacySensorsFormat`) and a disk whose
|
||||
canonical parent is the node has no reading collected now
|
||||
(`diskinventory.TemperatureCollected`) and a type whose temperature a PVE node
|
||||
gets only through SMART: `sata`, `sas`, or the `hdd` and `ssd` form factors
|
||||
Proxmox's own inventory reports. Proxmox also types a non-rotational USB device
|
||||
`ssd`, so a USB device without SMART counts. A retained reading does not count
|
||||
as current, including one kept while a host agent reports the disk in standby;
|
||||
a reading a linked host agent collects now clears the disk. NVMe disks never
|
||||
set it, because kernel hwmon reports them even on a legacy setup, and a host
|
||||
agent the registry has not linked to the node contributes no disks. The
|
||||
verdict reflects the registry's node and disk state at each ingest.
|
||||
`TestProxmoxNodeSensorSetupOutdatedFromItsDisks` and
|
||||
`TestProxmoxNodeSensorSetupOutdatedFollowsWhereTheReadingArrives` in
|
||||
`internal/unifiedresources/registry_test.go` pin the rule, the linked-agent
|
||||
case, the explicit false on the wire and the presented JSON;
|
||||
`resourceStateAdapters.test.ts` pins that a false clears a merged true.
|
||||
|
||||
### Canonical REST facets preserve realtime workload evidence
|
||||
|
||||
The frontend REST projection retains the complete source-authored Proxmox
|
||||
@@ -3431,6 +3479,20 @@ It owns probe status, incidents, history, evidence, and the outgoing `checks`
|
||||
relationship. The matched resource carries every correlated check in the canonical
|
||||
`availabilityChecks` facet, keyed by saved target id, while `availability`
|
||||
remains an additive singular compatibility summary selected from that set.
|
||||
The summary is the worst check: a confirmed outage first, then any other
|
||||
observed failure, then an unchecked, ambiguous or unresolved check, then a
|
||||
passing one, ties going to the first saved target id. A confirmed outage is an
|
||||
enabled check whose observed failures reach its failure threshold, with an
|
||||
unavailable aggregate when one is reported and an outcome that is not
|
||||
indeterminate. That is the gate the availability poller applies before it
|
||||
raises `availability_unreachable`, so a probe agent that stops reporting keeps
|
||||
its old failure count but confirms nothing. `availabilityOutageConfirmed` in
|
||||
`internal/unifiedresources/availability.go` holds that rule for both the
|
||||
ranking and `EvaluateResourceHealth`, which reads the summary. A confirmed
|
||||
outage on any attached check therefore makes the resource's health critical
|
||||
even when a check that sorts earlier has failed below its threshold, with the
|
||||
`availability_failed` reason unless a critical alert explains the verdict
|
||||
first.
|
||||
Adding a second explicit or unambiguously correlated check must retain both
|
||||
source-owned endpoint rows, project both facets onto the same resource, and
|
||||
emit one `checks` relationship per target from the check to that resource.
|
||||
@@ -3540,6 +3602,24 @@ remain preferred over device fallback. Direct SATA, SAS, and NVMe fallbacks
|
||||
retain their historical device-scoped shape; controller members sharing one
|
||||
block path add controller/target scope so they cannot collapse into one
|
||||
resource. Topology correlation is parent-scoped and ambiguity fails closed.
|
||||
That scope is added once. A controller member without a usable serial or WWN
|
||||
keeps its history under a source ID that already names the member: the host
|
||||
agent's `HostSMARTDiskMetricID`, and the Proxmox `PhysicalDiskMetricID`, which
|
||||
returns `ProxmoxPhysicalDiskSourceID`. `PhysicalDiskMetaMetricID` therefore
|
||||
returns a fallback that already ends in the disk's own device, controller and
|
||||
target unchanged as the metrics target. It appends that topology only to a
|
||||
fallback without it: a Proxmox source ID from before members were scoped,
|
||||
which `PhysicalDiskMetricID` scopes the same way, or the canonical resource ID
|
||||
`PhysicalDiskView.MetricResourceID` falls back to. The suffix survives a merge
|
||||
because a member target only reaches a disk together with the controller the
|
||||
agent reported beside it (the multiplexed smartctl path sets both), and the
|
||||
Proxmox row copies both from that agent row. A registry rehydrated from
|
||||
persisted resources rebuilds the agent source ID in the same scoped shape
|
||||
(`seedAgentPhysicalDiskSourceIDLocked`). TrueNAS disks carry no controller
|
||||
target and keep their fallback. The reader used to append the topology to an
|
||||
already scoped source ID as well, so such a member's chart read a key no
|
||||
writer used. Proof: `TestPhysicalDiskMetaMetricIDScopesAControllerMemberOnce`,
|
||||
`TestIdentitylessControllerMembersReadTheirWritersHistory`.
|
||||
|
||||
Host reports, monitoring models, registry merges, typed physical-disk views,
|
||||
API resources, websocket/REST snapshot merges, and storage presentation must
|
||||
@@ -5313,7 +5393,8 @@ TrueNAS API and the agent on that box), and an unknown machine never splits a
|
||||
disk. A split (operator exclusion) recorded against any ID the disk can hold,
|
||||
unscoped, current or machine-scoped, keeps the observation on its
|
||||
source-specific ID, keyed to its machine when another machine's disk already
|
||||
holds that ID (agent disk source IDs are the bare serial). Once the identity
|
||||
holds that ID (an agent disk's source-specific ID hashes its bare serial or
|
||||
WWN, below). Once the identity
|
||||
spans machines the unscoped ID no longer names one disk, so a split recorded
|
||||
against it applies to every copy, erring towards an extra row rather than a
|
||||
merge the operator forbade, and a manual link recorded against it stops
|
||||
@@ -5331,14 +5412,113 @@ same-serial disk first appears on another machine and back when it goes away,
|
||||
and alert-history rows owned under the unscoped ID do not follow a re-key; a
|
||||
disk two reporters share is scoped to its canonical parent when the identity
|
||||
first spans machines, which the fixed ingest order (snapshot sources, then
|
||||
supplemental records) and source priority decide; agent SMART and Unraid disk
|
||||
source IDs are the bare serial, so same-serial agent disks on different hosts
|
||||
share one `SourceAgent` mapping and only the last ingested keeps the agent
|
||||
source target; same-serial disks share one metrics history key; and across
|
||||
reporters, hostnames that normalize alike (default TrueNAS names, short names
|
||||
across domains) still join. `IdentityMatcher` keeps one resource per machine
|
||||
ID, so the registry indexes disks by hardware key (`physicalDisksByHardware`)
|
||||
to see every copy.
|
||||
supplemental records) and source priority decide; and across reporters,
|
||||
hostnames that normalize alike (default TrueNAS names, short names across
|
||||
domains) still join. `IdentityMatcher` keeps one resource per machine ID, so
|
||||
the registry indexes disks by hardware key (`physicalDisksByHardware`) to see
|
||||
every copy.
|
||||
An agent disk's source ID carries its host, because the registry keeps one
|
||||
resource per source ID: when it was the bare serial, same-serial agent disks on
|
||||
different hosts shared one `SourceAgent` mapping, every disk but the last
|
||||
ingested lost its agent source target, and a disk only the agent reports lost
|
||||
its metrics target and chart. `HostSMARTDiskSourceID` and
|
||||
`HostUnraidDiskSourceID` key a disk with a usable serial or WWN as
|
||||
`<agent ID>/physical-disk:<serial or WWN>`, which the SMART and Unraid
|
||||
observations of one disk share when both report its serial; a disk without
|
||||
hardware identity keeps its host/device/topology key. A registry seeded from
|
||||
unified resources rebuilds one key from the agent ID the host above the disk
|
||||
retains, directly or above its Unraid array or cache pool, and the merged
|
||||
disk's serial or WWN (`seedAgentPhysicalDiskSourceIDLocked`), so a rehydrated
|
||||
disk, which is what the resource API serves, keeps an agent source target,
|
||||
though not every key its observations ingest under (a SMART row missing the
|
||||
serial its Unraid row reports, or a host several agent IDs report from); with
|
||||
no agent host found, a disk with hardware identity seeds its bare serial or
|
||||
WWN. Canonical IDs do not move: a disk with hardware identity is keyed by it,
|
||||
and `SourceSpecificID` hashes an agent disk's bare serial or WWN without the
|
||||
host (`sourceSpecificIDKey`), so a disk split off onto its source-specific ID,
|
||||
and the exclusion that split it, keep the IDs recorded before the host joined
|
||||
the source ID. Every host's split copy shares that ID, so a split observation
|
||||
takes it keyed to its machine when another machine's disk holds it, and joins
|
||||
the holder on its own machine, whether the split reaches it through
|
||||
`physicalDiskIDForMachineLocked` or `findMatch`. A split therefore cannot
|
||||
separate observations of one disk that one machine reports under several agent
|
||||
IDs: they meet on that ID, as they met on one source key before. Disk history stays keyed by
|
||||
hardware identity, not host: `HostSMARTDiskMetricID`, like the Proxmox and
|
||||
TrueNAS writers and `PhysicalDiskMetaMetricID`, prefers the reported serial or
|
||||
WWN, so a drive keeps one SMART and I/O history across a move between hosts and
|
||||
across sources that report it by the same identifier, and both hosts of a
|
||||
dual-ported SAS disk chart the one drive. A host-scoped history key would
|
||||
orphan the stored history of every agent disk with hardware identity for the
|
||||
rare collision. Residual: distinct drives that share a usable serial on
|
||||
different hosts (cloned VM disks with an explicit serial, fixed-serial USB
|
||||
bridges) interleave their samples in one series, as they already merge into one
|
||||
disk on a single host. Proof:
|
||||
`TestSameSerialAgentDisksOnTwoHostsEachKeepTheirMetricsTarget`,
|
||||
`TestHostSMARTDiskSourceIDScopesHardwareIdentityToItsHost`,
|
||||
`TestRehydratedAgentDisksKeepTheirLiveSourceIDs`,
|
||||
`TestSplitAgentDisksOnTwoMachinesNeverOverwriteEachOther`.
|
||||
A SMART row that reports no serial, smartctl's standby row among them, takes
|
||||
the serial the host's Unraid inventory reports for the same disk
|
||||
(`hostSMARTDiskSerial` over `matchUnraidDisk`). The adapter shows that serial
|
||||
and the disk's metrics target reads it, so `HostSMARTDiskMetricID` resolves the
|
||||
same serial, and the disk I/O writer keys a device with no SMART reading by its
|
||||
Unraid serial (`HostUnraidDeviceMetricID`) before its Proxmox and host/device
|
||||
fallbacks. The writers used to key such a row on its WWN or host/device key,
|
||||
which the chart did not read, so the disk's SMART temperature and I/O history
|
||||
never appeared. The row's source ID stays its own (`HostSMARTDiskSourceID`
|
||||
reads only the row), so its SMART and Unraid observations still ingest under
|
||||
two keys onto one disk. Samples already written under the old key stay there:
|
||||
the chart did not read that key while the Unraid row named the disk (only
|
||||
Unraid OS reports an Unraid inventory, and it is never a Proxmox node whose
|
||||
disk row could replace the serial), and a device-keyed series can hold another
|
||||
disk's samples once device letters move. The key follows the inventory: while the host
|
||||
reports no Unraid row for the disk, the row's own key is both written and read.
|
||||
A row matches its Unraid row by device path alone, so a row on a path several
|
||||
of the host's SMART rows share, controller members behind one block device,
|
||||
takes no serial from it, and the I/O writer still files that device's counter
|
||||
under no member. The I/O writer's inventory fallback also skips a device
|
||||
several Unraid rows name, while a SMART row keeps the adapter's first match.
|
||||
A row's own serial is kept even when it is a placeholder, as the
|
||||
adapter keeps it, and the Unraid row then stays a disk of its own. Proof:
|
||||
`TestHostSMARTDiskMetricIDTakesTheSerialItsUnraidRowReports`,
|
||||
`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports`.
|
||||
The adapters' disk temperature rule is shared with the agent disk history
|
||||
writer. `HostSMARTDiskTemperature` returns the temperature and collection state
|
||||
a SMART row's disk shows: the row's own reading, or, when it has none, its
|
||||
Unraid row's under `unraidDiskTemperatureStatus`. `matchUnraidDisk` finds that
|
||||
Unraid row by the SMART row's usable serial anywhere in the inventory before
|
||||
any device path, and a placeholder serial matches nothing. The fallback, like
|
||||
the serial a row without one takes (`hostSMARTDiskSerial`), comes only from an
|
||||
Unraid row that describes the row's disk (`unraidDiskDescribesSMARTRow`): one
|
||||
carrying the row's usable serial, or one naming the row's device path when the
|
||||
row is that whole kernel block device. A controller member, even the only one
|
||||
reported, and a row sharing its path with other SMART rows are not, because
|
||||
the Unraid row describes the block device as a whole. `HostUnraidDiskTemperature`
|
||||
returns what a disk built from an Unraid row alone shows, and
|
||||
`HostUnraidDiskMetricID` returns the key that disk's metrics target reads: its
|
||||
usable serial, else its `HostUnraidDiskSourceID`, and nothing for a row without
|
||||
a device, which is not ingested. The writer used to take only the SMART row's
|
||||
own reading, so a temperature shown from the Unraid inventory, for a disk whose
|
||||
SMART probe returned none or one excluded from SMART collection, was never
|
||||
charted. Proof: `TestAgentDiskChartsTheUnraidTemperatureItShows` and
|
||||
`TestHostUnraidDiskMetricIDMatchesItsDiskMetricsTarget`.
|
||||
`HostDiskTemperatureReadings` lists the reading each disk of a host agent shows
|
||||
for consumers that judge disk heat, the agent disk temperature alerts in
|
||||
`CheckHost`: one per SMART row the registry ingests (no virtual block device),
|
||||
then one per Unraid row whose disk key (`HostUnraidDiskMetricID`) no SMART row's
|
||||
`HostSMARTDiskMetricID` equals, the history writer's rule, with a row without a
|
||||
device skipped as the registry skips it. Rows with one key are one registry
|
||||
disk whatever device labels they carry, such as a controller member
|
||||
(`0 [megaraid,0]`) and its Unraid device (`sda`). When smartctl and Unraid
|
||||
report different usable serials for one device the registry shows both disks,
|
||||
and both are listed. Each reading is taken from the disk resource
|
||||
`resourceFromHostSMARTDisk` or `resourceFromHostUnraidPhysicalDisk` builds, so
|
||||
its temperature, collection state and disk type are the ones that row's disk
|
||||
shows. Unraid rows on different devices that share a key and no SMART row are
|
||||
one registry disk but keep a reading each, since alerts are keyed by device.
|
||||
Proof: `TestHostDiskTemperatureReadingsMatchTheDisksTheRegistryShows` compares
|
||||
each reading with the ingested disk, including two disks on one device and a
|
||||
controller member Unraid lists under its block device.
|
||||
That same canonical physical-disk view must also expose source-independent host
|
||||
context. When a disk is API-backed rather than node-backed, typed views should
|
||||
fall back to canonical host identity such as `identity.hostnames` instead of
|
||||
@@ -5431,8 +5611,11 @@ should extend these unified-resource owners instead of rebuilding status or
|
||||
badge logic inside PMG, recovery, dashboard, or infrastructure-local views.
|
||||
The shared resource-runtime adapter boundary is also owned here now.
|
||||
`frontend-modern/src/utils/agentResources.ts` owns canonical actionable
|
||||
resource identities, agent-facet detection, cluster-name fallbacks, and
|
||||
resource-derived chart-key candidates. `frontend-modern/src/utils/resourcePlatformData.ts`
|
||||
resource identities, agent-facet detection, and cluster-name fallbacks. It no
|
||||
longer carries a resource-wide chart-key candidate list: workload table
|
||||
sparklines match chart series to rows through the chart-key candidates in
|
||||
`frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts`.
|
||||
`frontend-modern/src/utils/resourcePlatformData.ts`
|
||||
owns the typed extraction of platform-data fragments from unified resources,
|
||||
and `frontend-modern/src/utils/resourceStateAdapters.ts` owns canonical
|
||||
projection from unified resources into node/PBS/PMG runtime view models.
|
||||
@@ -5452,12 +5635,14 @@ wording stays aligned with the drawer's recent-change cards and timeline.
|
||||
Timeline cards in that drawer surface change metadata when it is present, so
|
||||
the history view preserves the richer provenance already carried by the
|
||||
unified-resource model instead of flattening those fields away.
|
||||
The same Infrastructure resource-only links now also default through the
|
||||
shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
|
||||
The shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
|
||||
and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
|
||||
cards from the Patrol page, resource drawer, and problem-resource dashboard
|
||||
panels, so canonical resource-filter path construction stays owned by the
|
||||
shared summary cards rather than being duplicated per surface.
|
||||
cards keep resource links behind an optional `buildResourceHref` input with no
|
||||
default, following the 2026-05-16 cross-resource drilldown retirement above.
|
||||
The resource drawer passes none, so its change and correlation labels render
|
||||
as plain text; a surface that needs resource links must pass a platform-route
|
||||
builder into the shared card rather than rebuilding resource-filter paths per
|
||||
surface.
|
||||
Platform tables supply the resource-label resolver to the resource drawer
|
||||
through `PlatformResourceDetailTableRow`. `createPlatformResourceLabelResolver(...)`
|
||||
in `frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx`
|
||||
@@ -5948,7 +6133,12 @@ the reference and is never retried. An owned row records its alert's
|
||||
reference as `alert_resource_id`. A read by the reference returns the rows
|
||||
journaled under it plus the rows owned away from it, matched through the
|
||||
indexed alert identifier (`ProxmoxPhysicalDiskAlertIdentifiers`) and that
|
||||
recorded reference, and every count uses the same predicate. The Alerts
|
||||
recorded reference, and every count uses the same predicate. Those
|
||||
identifiers cover the health and wearout specs and the disk temperature
|
||||
metric spec (`<reference>::metric-threshold:diskTemperature`), since PVE disks
|
||||
also raise temperature alerts under the same reference
|
||||
(`TestProxmoxDiskTemperatureAlertRowsFollowRecordedHardwareIdentity` in
|
||||
`internal/unifiedresources/store_test.go`). The Alerts
|
||||
history Resource action therefore still lists every occurrence raised under
|
||||
the path, across the disks that held it, and alert-centric reads keep their
|
||||
occurrences (see the AI runtime contract's incident-history queries). Rows
|
||||
@@ -6104,6 +6294,13 @@ presentation. `frontend-modern/src/components/Infrastructure/`
|
||||
placement, signal, and snapshot context through the canonical resource drawer
|
||||
and debug/source sections rather than introducing a VMware-only detail route,
|
||||
drawer tab, or provider-local investigation shell.
|
||||
The TrueNAS physical-disk drawer judges a current temperature reading by the
|
||||
disk temperature policy, like the TrueNAS storage table:
|
||||
`useResourceDetailDrawerDerivedState.ts` passes the alerts store's
|
||||
`getDiskTemperatureThresholds` into `buildTrueNASDetailSections`, and the
|
||||
Temperature row takes a warning tone only when `isPhysicalDiskRunningHot`
|
||||
says the reading reached its disk type's alert trigger. A retained reading
|
||||
keeps its muted last-known row, and without a resolver heat is not judged.
|
||||
That same infrastructure consumer boundary also owns source selection
|
||||
continuity. Settings infrastructure panels and platform/runtime pages must
|
||||
keep canonical sources such as `truenas` and `availability` present in their
|
||||
@@ -6186,9 +6383,20 @@ pass and manual links both apply that function:
|
||||
the best of equal ones. A quiet source drops out of that decision.
|
||||
- Once every source is quiet, an `offline` verdict survives and any other
|
||||
verdict reads `warning`. The best of those wins.
|
||||
- A current facet sighting carries no verdict (the PBS host-agent association,
|
||||
an availability check projected onto its target). It counts as `online` only
|
||||
when no current source has a verdict, and reads `warning` once quiet.
|
||||
- A current facet sighting carries no verdict (the PBS host-agent association).
|
||||
It counts as `online` only when no current source has a verdict, and reads
|
||||
`warning` once quiet.
|
||||
- Availability checks projected onto a monitored resource rank like that facet,
|
||||
but their shared sighting is not their verdict. `availabilityChecksProveOnline`
|
||||
judges each check at the pass's `now` by its own evidence window
|
||||
(`Evidence.ValidUntil`: two poll intervals for a single local check, the
|
||||
probe report window for a remote or multi-location one). An enabled,
|
||||
passing check with current evidence proves the resource answers and reads
|
||||
`online` once one of the resource's own sources has gone quiet and none that
|
||||
is current has a verdict. Every other check abstains, current or quiet: a
|
||||
failing check proves only that one port or service does not answer, and a
|
||||
pass whose evidence lapsed proves nothing now. A check's own row keeps the ordinary
|
||||
rules.
|
||||
|
||||
The stale pass used to read delivery as the verdict. A Proxmox node the cluster
|
||||
reported offline on a live poll came back `online` once its linked agent fell
|
||||
@@ -6212,24 +6420,52 @@ online stays `online` when its linked agent stops reporting. A live agent keeps
|
||||
a node `online` when the Proxmox poll reports it offline, which the Proxmox
|
||||
nodes table reads with `connectionHealth: error` as a stale provider. When a
|
||||
guest's or container's only source goes quiet, a running one stays `warning`
|
||||
and a stopped one stays `offline`.
|
||||
and a stopped one stays `offline`. In each quiet case a projected availability
|
||||
check with current evidence lifts the resource to `online`, as described
|
||||
below.
|
||||
|
||||
The sighting still reads `stale`, so health keeps its `telemetry_stale` reason
|
||||
and monitored-system reasons are unchanged. In-memory clones keep the verdicts,
|
||||
including a facet's missing one. A serialized copy carries none, so
|
||||
`IngestResources` gives each sighting of a copy that lost them all the stored
|
||||
resource status; a merged row's separate source verdicts do not survive that
|
||||
round trip. A manual link that joins two resources reported by one source keeps
|
||||
round trip, and a copy whose status a passing check supplied gives that status
|
||||
to every source. A manual link that joins two resources reported by one source keeps
|
||||
the fresher sighting. `registry_merge_policy_test.go` pins the node,
|
||||
poller-expired, guest, round-trip, copy, agent, Docker, Kubernetes,
|
||||
mixed-source and manual-link cases, and the aggregation table.
|
||||
|
||||
Availability checks still count by delivery. With every source that has a
|
||||
verdict quiet, a current check keeps its target `online` even when the check
|
||||
fails, as it did before. Several checks share one sighting whose freshness
|
||||
comes from whichever check was projected last, so an availability verdict
|
||||
needs each check's own freshness, including local checks that miss their
|
||||
cadence, and has to survive manual links. That is left to a follow-up.
|
||||
Availability checks used to count by delivery too. With every source that
|
||||
has a verdict quiet, a current failing check kept a node `preserveOrExpireNodes`
|
||||
expired `online`, and a quiet one lifted it to `warning`. Several checks share
|
||||
one sighting whose freshness comes from whichever check was projected last, so
|
||||
a failing check that just ran could also vouch for another check's old pass.
|
||||
Each check is now judged by its own evidence. A failing probe no longer holds
|
||||
an expired node `online`, and it never makes a guest read stopped to the
|
||||
consumers that count `offline` as stopped (the workloads summary, the Proxmox
|
||||
page counts). The failure stays on the check's own row, which raises an
|
||||
outage incident once the check confirms one (past its failure threshold, from
|
||||
every location). A check observed from several locations relies on the poller
|
||||
ageing out a location that stopped running and lasting an available check's
|
||||
evidence only as long as its newest reachable path (monitoring.md), so one
|
||||
location's fresh failure cannot lend its freshness to another location's old
|
||||
pass.
|
||||
|
||||
The verdict is read from the checks on the resource when status is
|
||||
aggregated. Projecting, re-running or retargeting a check also re-applies the
|
||||
stale pass's rule to every target it touches whose status that pass owns,
|
||||
because a check is not a delivery from the target's own sources and the
|
||||
resources API replays checks after its stale pass. The pass owns a status only
|
||||
through the target's own sightings: the checks' sighting going quiet neither
|
||||
hands it the target nor, once removed, takes the target away, so a target
|
||||
whose own sources have not gone quiet keeps the status they gave it. A check
|
||||
retargeted away therefore takes its verdict with it at once. A
|
||||
manual link keeps only the primary's own checks, so the merged resource is
|
||||
judged by the checks it shows and never by the linked resource's fresher
|
||||
sighting. `registry_test.go` pins the expired-node and quiet-guest cases, both
|
||||
projection orders, re-judging on check changes, ownership through the
|
||||
target's own sightings and the manual link; the aggregation table pins the
|
||||
check rules.
|
||||
|
||||
### Canonical object drawer hierarchy
|
||||
|
||||
@@ -6368,6 +6604,75 @@ 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 are the one record ingest that never joins links. 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.
|
||||
- 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. An agent-type primary
|
||||
carries the folded resource's machine keys and is pinned with them, and
|
||||
succeeding the folded ID re-keyed the link onto the primary itself, which
|
||||
split the pair on the next rebuild. That already broke snapshot links whose
|
||||
primary is a machine-keyless agent-type row, such as a Proxmox node.
|
||||
- Fold history is registry-local: resource seeding and overlay clones do not
|
||||
carry it. A reference to the folded resource's canonical ID, such as an
|
||||
availability check configured against an agent later linked into its
|
||||
guest, does not resolve to the primary, so the check loses its target in
|
||||
the broadcast as it already did in the resources API.
|
||||
|
||||
`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`).
|
||||
`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.
|
||||
|
||||
### Provider link network corroboration
|
||||
|
||||
An existing reciprocal host/provider link remains authoritative, including a
|
||||
@@ -6436,9 +6741,52 @@ conflict): the agent's row may be the slot's previous occupant, retained by a
|
||||
silent agent, and the join would hand the replacement that disk's serial,
|
||||
readings and canonical resource. A hardware identity match still joins on its
|
||||
own, and without a WWN on either side the path fallback stands. The seeded
|
||||
source mapping reused after a restart is a separate path this does not cover.
|
||||
`TestRegistrySASPathJoinRefusesContradictingWWN` covers the stale row and both
|
||||
same-disk shapes; monitoring's SMART merge applies the matching guard first.
|
||||
source mapping a rehydrated registry reuses applies the same refusal (next
|
||||
section). `TestRegistrySASPathJoinRefusesContradictingWWN` covers the stale
|
||||
row and both same-disk shapes; monitoring's SMART merge applies the matching
|
||||
guard first.
|
||||
|
||||
### Seeded slot mapping refuses a replaced disk
|
||||
|
||||
`ingestRecord` honours a source key's existing mapping, such as one
|
||||
`IngestResources` seeds from persisted unified resources, before any
|
||||
identity matching. Proxmox keys a disk by its slot
|
||||
(`ProxmoxPhysicalDiskSourceID`), so a disk swapped into the slot arrived
|
||||
under the previous disk's key, and `mergeInto` kept that disk's canonical ID
|
||||
and, where an agent report had been merged in, its serial, WWN, temperature
|
||||
and failed health. A Proxmox disk observation now refuses the mapping when
|
||||
the two carry hardware identities naming different disks
|
||||
(`proxmoxDiskSlotHoldsAnotherDisk`: `HardwareIdentityConflict` after
|
||||
`HardwareIdentityMatch`). Only fields reported on both sides count, so a
|
||||
missing serial or WWN, including Proxmox's literal `unknown`, is no evidence.
|
||||
Differing serials are no evidence when either side is SAS, because Proxmox
|
||||
may report the SAS address there (#1595), or when the mapped disk was merged
|
||||
with an agent report, whose serial `mergeInto` may have kept (collection
|
||||
status does not reliably say which serial it holds), unless Proxmox read the
|
||||
drive's own serial too, by the rule monitoring's
|
||||
`hostAgentSMARTSerialComparable` applies: NVMe on both sides, or an agent
|
||||
SATA disk under Proxmox vendor `ATA` that Proxmox does not type `usb`, not a
|
||||
SCSI designator or a USB bridge's serial. A refused key falls through to the linked-disk join,
|
||||
`findMatch` and `chooseNewID` like an unmapped one, except that none of them
|
||||
may merge it into the refused disk: an ID that lands on it is replaced with
|
||||
the key's source-specific ID. Agreement still wins, as it does in identity
|
||||
matching, so drives that share a serial they do not own, such as a USB
|
||||
bridge's, read as one disk: a second swap in the same enclosure joins the
|
||||
first replacement, which reported the same bridge serial. That belongs to
|
||||
identity matching, not this guard. The refused disk keeps its Proxmox
|
||||
facet and is not updated, so until the registry is rebuilt both disks answer
|
||||
to the slot's PVE alert reference, and canonical reference resolution treats
|
||||
that reference as ambiguous rather than handing it to either. A later
|
||||
rehydration may seed the slot key to the old disk again, and the refusal
|
||||
repeats. No current caller ingests Proxmox disk records over a seeded
|
||||
mapping: the monitor rebuilds a fresh registry each poll, and the resource
|
||||
API, host-continuity and Patrol registries seeded from unified resources do
|
||||
not replay Proxmox disk records. This closes the registry contract, not a
|
||||
reported symptom. `TestRegistrySeededSlotMappingRefusesReplacedDisk` covers
|
||||
SATA (with and without a new WWN), SAS, USB-enclosure and Proxmox-only
|
||||
replacements, plus eight same-disk controls (including a SCSI designator
|
||||
from an agent without collection status and a USB bridge serial under
|
||||
Proxmox vendor `ATA`), each through two rehydrations.
|
||||
|
||||
### Drawer tab selection survives a transient snapshot change (#1723)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "48c2d6cdce8d019151e3567873de88e21b76bb22",
|
||||
"verified_at": "2026-10-07T19:21:21Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
|
||||
"frontend-modern/src/components/Workloads/GuestRow.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
|
||||
"frontend-modern/src/components/Workloads/useGuestRowState.ts",
|
||||
"frontend-modern/src/utils/resourceStateAdapters.ts",
|
||||
"frontend-modern/src/utils/workloadGuestPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "1cf03b76a8d8732db0fda9b2ec30c0245c350eadcb36a36909493a9f4d6e7831",
|
||||
"frontend-modern/src/components/Workloads/GuestRow.tsx": "c7b8bb81f158d8d64195be65fa69b8ee18019414e602043c5413693efc06d1d6",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "a30e4435523c0d30f618eb7f0e803cfba9a3a5203851d642f1d5b25c2f33b10d",
|
||||
"frontend-modern/src/components/Workloads/useGuestRowState.ts": "1c41975ec9447f3657cd4e1cf0e9fc6ce187fdca18e7f050f882ac5d483379c9",
|
||||
"frontend-modern/src/utils/resourceStateAdapters.ts": "35e7e01344e2f78cfcf550cc0b277aa06e42c544a110a6c33b054d97d9b16765",
|
||||
"frontend-modern/src/utils/workloadGuestPresentation.ts": "3d1458ee02891047f722d643e74978fac6ec4edae5a24f5eb1808331de2e3b8d"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox?q=checkout-web-01",
|
||||
"/proxmox?q=checkout-web-02",
|
||||
"/proxmox?q=analytics-batch-01",
|
||||
"/proxmox?q=checkout-web-193",
|
||||
"/standalone"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (PULSE_MOCK_MODE, embedded production build of this tree on origin/main dace9295c6, rebased onto 48c2d6cdce which changes none of the verified frontend sources) with a verification-only generator overlay: Proxmox guest filesystems stripped to usage -1 + agent-not-running (or vm-stopped) for checkout-web-01/02/193, and standalone agents Apollo-114 and Meridian-731 held offline from a control file",
|
||||
"Agents linked into VMs through POST /api/resources/{vm}/link: Apollo-114 into checkout-web-01 (silent), Spectrum-709 into checkout-web-02 (live), Meridian-848 into analytics-batch-01 (Proxmox filesystems kept, control), Meridian-731 into stopped checkout-web-193 (silent); /api/resources confirmed agent.stale, agent.disks, absent proxmox.disks and the agent-sourced metrics.disk",
|
||||
"Mock-mode websocket state does not apply manual links (record-ingested resources skip applyManualLinks; chipped separately), so guest passes ran the Proxmox page on the linked REST resources with the websocket held by Playwright routeWebSocket",
|
||||
"Before (main frontend + backend, same data): checkout-web-01 and checkout-web-02 disk cells read '- Unavailable' with 'Guest agent not running' and History deferred the disk on that message at 1440 and 390",
|
||||
"After, live agent (checkout-web-02): agent filesystem bars with no read-status label, nothing deferred in History, drawer filesystem rows current (1440 and 390)",
|
||||
"After, silent agent (checkout-web-01): bars plus 'Last known' (phone 'Prior') with 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.', drawer filesystem rows last known, History disk deferred on the same message (1440 and 390)",
|
||||
"After, stopped guest (checkout-web-193): '- Unavailable' / 'N/A' with the vm-stopped message in cell, drawer and History; analytics-batch-01 with Proxmox filesystems unchanged",
|
||||
"Machines (/standalone at 1920): Apollo-114 held silent read '(stale)' with the stopped-reporting title; after releasing the hold mid-session without a reload, main kept '(stale)' although /api/state had dropped stale, while this build received stale:false and cleared it with metrics resuming",
|
||||
"No page errors or console errors on /proxmox (guest rows, drawers, History) and /standalone, with and without the websocket"
|
||||
],
|
||||
"interactions": [
|
||||
"Playwright 1440x900 and 390x844: open /proxmox?q=<guest> for four guests, read the disk cell text, read-status notice and bar, open the guest drawer (phone: second visible cell), read the filesystem status line, switch to History and read the disk current/last-known/deferred legends; screenshots of rows and History",
|
||||
"Same pass against a before binary built from main's frontend and backend on the same data dir",
|
||||
"Playwright 1920x1000 on /standalone: watch Apollo-114 while the control file releases its silent hold, polling /api/state for the agent's stale flag and re-reading the row without reloading",
|
||||
"API: GET /api/resources/{vm} and /api/state before and after linking and after backend restarts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "e02edd9989c88fbe6e5459afdf488c73538ddd24",
|
||||
"verified_at": "2026-10-07T12:56:24Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/platformPage/sensorSetup.ts",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts",
|
||||
"frontend-modern/src/types/resource.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/platformPage/sensorSetup.ts": "18e4da01eb9206bce83a2e7d2fbb9f97313685c5655d620092a9e0ca51804ef5",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "db074960c69c59bb030814a04028a50ee5b347b7cf16b04b4c6edea708ffd782",
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts": "77cc56a11213f951e051ceabd05bc2bf3161adedadc3586f2badee98bf1056c3",
|
||||
"frontend-modern/src/types/resource.ts": "37e3ddb2e1334472a280e0ef93053292002567acabf84aace9fed1735a763b57"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox",
|
||||
"/proxmox/storage",
|
||||
"/proxmox/replication",
|
||||
"/proxmox/backups"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (8-node estate) built from this tree with a local-only generator overlay: pve2, pve3 and pve5 report the legacy sensors -j payload; pve2 has a Proxmox-inventory hdd disk with no temperature, pve3 a SATA disk whose reading is retained (host agent stopped reporting), pve5 a SATA disk with no temperature.",
|
||||
"Overview, Storage, Replication and Backups each show the warning notice naming pve2, pve3 and pve5 at both viewports. The Overview query is type=agent,vm,system-container,oci-container and no physical_disk request is made on Overview, Replication or Backups; only Storage requests disk rows.",
|
||||
"Before control on the same stack with origin/main's ProxmoxPageSurface.tsx and sensorSetup.ts swapped in: no notice on Overview, Replication or Backups, and Storage names only pve5 (hdd type and retained reading missed).",
|
||||
"Websocket frames carry sensorSetupOutdated true on every tab. Fresh desktop load: no console errors and no 5xx responses. Notice text fits the 390px viewport without overflow; the node table stays below it in reading order.",
|
||||
"Mock data only; not a real pre-rc.6 SSH node, installed upgrade or release acceptance."
|
||||
],
|
||||
"interactions": [
|
||||
"Navigated each tab by URL at 1440x900 and 390x844 and waited 20s on Overview to outlast websocket row merges; the notice stayed.",
|
||||
"Live clearing without reload: switching pve5's disk to a current 37C reading dropped pve5 from the notice (3 nodes to pve2, pve3) on the next websocket update. Control with the earlier omitempty backend kept pve5 for 90s.",
|
||||
"Open Infrastructure settings link targets /settings/infrastructure (contract test)."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "19558f955d2d17c88e97358ac9c78a23387b8ce3",
|
||||
"verified_at": "2026-10-07T13:11:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/charts.ts",
|
||||
"frontend-modern/src/components/shared/contextualFocus.ts",
|
||||
"frontend-modern/src/components/shared/summaryCardInteraction.ts",
|
||||
"frontend-modern/src/utils/agentResources.ts",
|
||||
"frontend-modern/src/utils/resourceIdentity.ts",
|
||||
"frontend-modern/src/utils/status.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/charts.ts": "1d38ecb88f6266d6376ff1299f1ef6ce9e113a88507672a926cfb7475876f77e",
|
||||
"frontend-modern/src/components/shared/contextualFocus.ts": "d86a4bdcb0474a64451b665f2f07517f2537bc5904c6b22fc5c883563b97a9fc",
|
||||
"frontend-modern/src/components/shared/summaryCardInteraction.ts": "f5ff8561513b192f1c5b7dc11d7072f007cc0ddf9ba6467960a0541170b864c7",
|
||||
"frontend-modern/src/utils/agentResources.ts": "bd94b247f8afc025f169bf06c30526681488b15146be6e6debbbd25a4aedf076",
|
||||
"frontend-modern/src/utils/resourceIdentity.ts": "f1ece68877a3a8e2eb7319471e76c02e753b1aff239cefde4db539d80515f189",
|
||||
"frontend-modern/src/utils/status.ts": "a4f2ebf9ee6a3d6ab53438a0222fa23f590be28f00f99e9c823112c6b2bb7c1e"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox",
|
||||
"/proxmox/storage",
|
||||
"/proxmox/storage?group=node"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Production vite build of this tree compared with the build of its parent 19558f955d: after normalising chunk-name hashes, SRI hashes and 1-3 character minified identifiers, every dist file is identical except the shared chunk carrying ChartsAPI, which differs only by the removed never-called getInfrastructureCharts static method (99 bytes); the other removed exports were already tree-shaken. Served embedded by a private DEMO_MODE mock backend on port 7763",
|
||||
"Proxmox overview at 1440: node table plus grouped guests table; clicking the Analytics A node name focuses the \"Guests on Analytics A\" heading inside the viewport and the node row carries data-summary-row-active=true",
|
||||
"Guest row hover sets data-summary-row-active=true; workload group header hover stays inert (Proxmox mounts WorkloadsSurface with groupNodeDrawerMode disabled, unchanged)",
|
||||
"Guest inline detail ([data-inline-detail-for]) opens with its top inside the viewport at 1440 and 390 without moving the page scroll",
|
||||
"Proxmox storage pools at 1440: Growth (24h) column renders values on 72 pool rows from the storage summary history cache; pool row hover sets data-summary-row-active=true; pool inline detail opens in view at 1440 and 390",
|
||||
"Storage grouped by node: pool-group header hover marks 2 member pools data-summary-group-member-active=preview; header click pins the group scope (header stays active after the pointer leaves) and Escape clears it",
|
||||
"No page errors; the only failed request is the Pro-gated /api/usage/workload-history (403 on this community-tier stack), which no file in this change touches"
|
||||
],
|
||||
"interactions": [
|
||||
"Demo auto sign-in, then direct navigation to /proxmox, /proxmox/storage and /proxmox/storage?group=node",
|
||||
"Clicked a node name in the Proxmox node table, hovered and clicked a guest row, hovered a workload group header",
|
||||
"Hovered and clicked a storage pool row, hovered and clicked a pool-group header, moved the pointer away, pressed Escape"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "bdf0f76b7bf61d550b1cb8574497eef75b354acb",
|
||||
"verified_at": "2026-10-07T14:11:27Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx",
|
||||
"frontend-modern/src/components/Alerts/ResourceTable.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx",
|
||||
"frontend-modern/src/components/Alerts/alertResourceTableModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/features/alerts/alertsConfigurationModel.ts",
|
||||
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsPlatformData.ts",
|
||||
"frontend-modern/src/features/alerts/thresholds/trueNASDiskTemperature.ts",
|
||||
"frontend-modern/src/types/alerts.ts",
|
||||
"frontend-modern/src/utils/alertThresholdDefaults.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx": "4d09dd97adc2868889e256455c207b6b9089ea311713878f0d6ff00efb6b7123",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx": "ceaf136da9361a5b1756350626a833da4dfebcb4f211c7269d7d89d37fb7e085",
|
||||
"frontend-modern/src/components/Alerts/ResourceTable.tsx": "7e80a6c789b3069fcadcb86e0a6689e5319e7b0ac5d5bfa1a596520daec2d7e1",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx": "fc4e9f4d05c0bf5799acbbe64c37b901e104869ff120819342432563789ad0e9",
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx": "a22b86c28b65ec18ba81171fb1206a24107a0d97db19d7dd488bdfd0a689a956",
|
||||
"frontend-modern/src/components/Alerts/alertResourceTableModel.ts": "5881aeaf7ee575589997bf67fb713a01784ce7ca16ecb5a5eaea77f36010837a",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts": "b60ba59688d6ea0cabfb2b413b8adcaa85c8d41524c1d1f0df9bdb9e6d85733c",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "63b07de3f354f8328298348a8892ad73fde2f11ec6768e9c0f6fc37eb4c43516",
|
||||
"frontend-modern/src/features/alerts/alertsConfigurationModel.ts": "0e116e65c992a72e21d700835b0cb0db7173be9ec9c4980382958506d4e75976",
|
||||
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsPlatformData.ts": "8525b0b012198ff87e29063908a9907b7fa1080cd84187202b1cd3cf198a3b46",
|
||||
"frontend-modern/src/features/alerts/thresholds/trueNASDiskTemperature.ts": "80433d42dd4658a1449c0f0c378d7fa52cd6dfab117d626195099768e750352f",
|
||||
"frontend-modern/src/types/alerts.ts": "d072175ab98e3785eae643d655be4c2d54cac0eddc8b55803a2841a312062e5a",
|
||||
"frontend-modern/src/utils/alertThresholdDefaults.ts": "077e291d5c224063a373a5e9f5555d9b470f585801b0cfdd9dc1acacf233af28"
|
||||
},
|
||||
"routes": [
|
||||
"/alerts/thresholds/truenas",
|
||||
"/truenas/storage"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1366,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (8 nodes, mock TrueNAS truenas-main) with a local-only generator overlay pinning TrueNAS disk temperatures: nvme0n1 60C, nvme1n1 72C, sda 57C (SATA), sdc 63C (SAS); after = this branch, before = main logic in the same tree",
|
||||
"Thresholds > TrueNAS > Disks with no saved TrueNAS-wide value: header reads 'Defaults: NVMe 70\u00b0C \u00b7 SAS 65\u00b0C \u00b7 SATA 55\u00b0C', note 'Unless set here, each disk alerts at Disk temperature by type, set under Agents.', Global Defaults Temp cell empty with placeholder 'By type' (fits after widening to w-20) and On, disk rows nvme0n1/nvme1n1 70\u00b0C, sda/sdb 55\u00b0C, sdc-sdf 65\u00b0C",
|
||||
"Same section at 375px: Global Defaults card shows TEMP \u00b0C 'By type' with On, disk cards show their type triggers, no horizontal overflow",
|
||||
"Agent Disk Temp default switched off through the API: header 'Defaults: all metric alerts off', Global Defaults cell Off with title 'Off because the agent Disk Temp default under Agents is off...', disk rows Off",
|
||||
"Saved TrueNAS-wide 62: header 'Defaults: Temp 62\u00b0C', every disk row 62\u00b0C, Custom badge and reset control after reload; saved 55 survives save and reload as 55 (stored with truenasDiskTemperatureByType)",
|
||||
"Alerts with factory per-type thresholds: after stack raises nvme1n1 72 (trigger 70) and sda 57 (trigger 55) with diskType metadata, none for nvme0n1 60 or sdc 63; before (main) raised all four at a flat 55",
|
||||
"TrueNAS storage table agrees: Attention 'Disk temperature is 72\u00b0C, at or above its 70\u00b0C alert threshold.' on nvme1n1 and '57\u00b0C ... 55\u00b0C' on sda, none on nvme0n1",
|
||||
"TrueNAS disk drawer (Platform details > Show TrueNAS > Health): nvme0n1 Temperature 60\u00b0C in default text colour (main toned it amber from the fixed 55C); sda 57\u00b0C amber rgb(252,211,77) at 1366 and at 375 with no overflow"
|
||||
],
|
||||
"interactions": [
|
||||
"Typed 62 into the TrueNAS Disks Global Defaults Temp cell, saved, reloaded: API truenasDiskDefaults {trigger 62, clear 57}, cell shows 62 with Custom",
|
||||
"Triple-clicked and typed 55, saved, reloaded: API keeps {55, 50}, cell shows 55 with Custom and the reset control",
|
||||
"Clicked Reset to factory defaults: cell returns to 'By type'; saved: API truenasDiskDefaults {}",
|
||||
"On the 375px card, clicked On to switch the default Off, saved: API stores {trigger 0, clear 0} and the after stack's TrueNAS disk alerts resolve; clicked again: returns to 'By type', saved: {}",
|
||||
"With the agent Disk Temp default off, clicked the Off badge: stages an explicit 55 (Custom, rows 55\u00b0C); discarded",
|
||||
"Loaded an old saved config (flat 55/50, no marker) with the new binary: GET returns truenasDiskDefaults {} and truenasDiskTemperatureByType true",
|
||||
"Expanded nvme0n1 and sda rows on /truenas/storage, opened Platform details and Show TrueNAS, read the Temperature row value and computed colour at 1366 and 375"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "48c2d6cdce8d019151e3567873de88e21b76bb22",
|
||||
"verified_at": "2026-10-07T18:53:57Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts",
|
||||
"frontend-modern/src/components/shared/contextualFocus.ts",
|
||||
"frontend-modern/src/components/shared/summaryCardInteraction.ts",
|
||||
"frontend-modern/src/components/shared/summaryTableFocus.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts": "9f4734cf74cb44b7db9222bd08d25fa2ae52d787e192bd07e36640e2e81cde6d",
|
||||
"frontend-modern/src/components/shared/contextualFocus.ts": "4dc89355f978c9dfee90cb0c780976504ac4e7e158bbe7ddf02d566c57211fff",
|
||||
"frontend-modern/src/components/shared/summaryCardInteraction.ts": "9e4f4f9275cd01f2a19b98c1c795eec5c7f37be8f5a002170a94f400bdae4625",
|
||||
"frontend-modern/src/components/shared/summaryTableFocus.ts": "52c46c227696b5dc9713d0cc3d9b76e434fecafeeda46edb064858b0328732ee"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/storage",
|
||||
"/proxmox/storage?tab=disks",
|
||||
"/proxmox/storage?group=node&resource=ceph-rbd",
|
||||
"/proxmox/storage?resource=ceph-rbd",
|
||||
"/proxmox/storage?group=node",
|
||||
"/proxmox/storage?group=node&summaryGroup=storage:node:West%20Production%20F",
|
||||
"/proxmox",
|
||||
"/proxmox?resource=<guest id>"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private 12-node mock stack (backend :7783, Vite :5323 from the landing clone). One Playwright scenario script ran with the four runtime files swapped back to origin/main (before) and with this change (after) at 1440x900 and 390x844, then again after rebasing onto 71a723abb1 with the backend rebuilt from that tree; the final parent adds only #2677 (Patrol correlation fetch), which touches none of these files; no page errors in any run",
|
||||
"Storage pools: hovering 8 rows marks each data-summary-row-active and never moves the page; clicking a lower pool opens its inline detail and scrolls just enough to show the row header and top of the detail; Escape closes it; identical before and after",
|
||||
"Storage deep link ?resource=ceph-rbd opens the Ceph pool detail in view; with group=node, clicking a group header pins it (summaryGroup in the URL, header marked active); a summaryGroup deep link pins and scrolls toward the last group, landing the header in or just below view with the same timing spread before and after",
|
||||
"Storage Physical Disks: clicking a lower disk opens its detail with the same reveal, and focus stays on the Physical Disks view; pool focus stays on the pools view; identical before and after",
|
||||
"Storage in-app link: with group=node, collapsing the shared group with its disclosure button hides ceph-rbd; a client-side navigation to ?resource=ceph-rbd then reopens the group and lands the Ceph pool detail in view, at both widths, identical before and after",
|
||||
"Workloads (/proxmox): hovering guest rows marks them active; clicking a lower guest opens its inline drawer in view and Escape closes it; a ?resource deep link opens the drawer in view; identical before and after",
|
||||
"Cost: window scroll listeners drop from 2 to 1 and resize by one on both pages; getComputedStyle calls during 8 row hovers fall 128 to 0 (Storage) and 175-193 to 13-39 (Workloads), during 10 scroll steps 320 to 0 and 274-472 to 130",
|
||||
"Pre-existing on main and unchanged here: with a pool open, Storage scrolls itself back to it a few seconds after scrolling away (disks view and Workloads do not), and collapsing the group holding the open pool bounces it open; traced to the reveal effect tracking reads inside revealActiveSeries, left for a follow-up"
|
||||
],
|
||||
"interactions": [
|
||||
"Hovered 8 rows then scrolled the app shell 10 steps on Storage and Workloads with window listener and getComputedStyle counters installed before load",
|
||||
"Clicked a pool row, a physical disk row and a guest row near the bottom, measured the row and detail rectangles, pressed Escape",
|
||||
"Loaded the ceph-rbd, summaryGroup and guest resource deep links, repeating the summaryGroup link four times per width",
|
||||
"Collapsed the shared storage group via its disclosure button, then pushed /proxmox/storage?group=node&resource=ceph-rbd through the router",
|
||||
"Clicked a storage group header to pin it, and clicked the header of the group holding an open pool",
|
||||
"Scrolled the shell to the top with a pool, a disk and a guest open and sampled the scroll position every 500 ms for 12 s"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "7eb32f318100fef045d63414fd63304c62acfdef",
|
||||
"verified_at": "2026-10-07T16:55:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/shared/InlineDetailTableRow.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/shared/InlineDetailTableRow.tsx": "e5b27178cb3268132595943058849b3f619da22fcbd7916dfc301d4e852ef244"
|
||||
},
|
||||
"routes": [
|
||||
"/standalone/availability",
|
||||
"/standalone/machines",
|
||||
"/proxmox",
|
||||
"/docker",
|
||||
"/kubernetes"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 1000,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"height": 1024
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 320,
|
||||
"height": 700
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private stacks served by Vite from this tree (Playwright 1.56.1 headless Chromium): a non-mock stack with one HTTP availability check, then a mock estate (3 Proxmox nodes, 2 Docker hosts, 1 Kubernetes cluster, 2 generic hosts).",
|
||||
"Before (origin/main class max-w-[calc(100vw-3.5rem)]): at 390px every opened drawer (availability check, Proxmox node, Proxmox guest, Docker host, Docker container, Kubernetes node, Machines agent) was 334px inside a 361px visible table, leaving a 27px empty strip; at 768px a table forced wider than its shell got a 712px drawer in a 695px visible area.",
|
||||
"After (max-w-[100cqi]): drawer content equals the scroll shell's visible width at 320 (291px), 390 (361px), 768 (695px) and 1000 (916px) with no right gap, including the forced-wider table; 1440px keeps the static lg layout (max-width none, 1320px). The availability drawer's hostname now shows in full instead of truncating.",
|
||||
"Long unwrapped identity chips (e.g. proxmox-lxc-docker:Production West:pve1:107) were already clipped at the content edge before (Kubernetes 20px, Docker 8px and 83px) and are clipped less after (7px, 70px); chip wrapping is a separate follow-up.",
|
||||
"Sticky positioning is inert in both versions because .table-fixed td sets overflow hidden; not changed here. This is not an installed-build, physical-phone or release acceptance."
|
||||
],
|
||||
"interactions": [
|
||||
"Opened each drawer by its disclosure button (row tap at phone width where the button is hidden), measured the scroll shell, detail cell, content shell and every descendant's right edge, and captured screenshots at 390px before and after.",
|
||||
"Forced a 900px table inside its shell at 390px and 768px and scrolled it 300px sideways to compare drawer width against the visible area for both classes."
|
||||
]
|
||||
}
|
||||
@@ -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,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "bab38a25b92684c978c4273200765152040624ed",
|
||||
"verified_at": "2026-10-07T14:02:51Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx",
|
||||
"frontend-modern/src/features/alerts/useAlertOverviewState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "bb98293f7a7b7b857b40ec71a38e1e32163aa937405278dd07af2a8c9dac1118",
|
||||
"frontend-modern/src/features/alerts/useAlertOverviewState.ts": "3e2c91ba930a1d3faf3965a5fd68f5bb44e27465a88cbb06c23aabe500d25668"
|
||||
},
|
||||
"routes": [
|
||||
"/alerts/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (backend :7697, Vite :5217 serving this tree on base bab38a25b9) with Playwright page.clock installed before load and an init script recording when each setInterval starts, so the shared 30s clock and the overview hook's minute interval have known phases",
|
||||
"Three producer-shaped copies of a live disk threshold alert (metricStatus breaching) sent in one websocket frame 15s after a shared tick and 45s after the hook's minute, then /ws data frames held (data-free pong per step) and /api reads stalled, so only the clock moves",
|
||||
"Late-mounted card (raised 70s ago, reading 10m05s old) reads '1 min. ago' and 'Last reading: Disk 92%, 10 mins ago' at mount; held card (reading goes stale 5s after the hook's next minute) is live at mount and 1s before the cut-off, and reads 'Last reading: ..., 10 mins ago' 31s after it; Triggered (24h) drops 85 -> 84 31s after the third copy turns 24h old; no page errors; same at 1440 and 390",
|
||||
"Same-session control with both runtime files swapped to main under the running Vite: the late card read 'this minute' and led with the old reading as live, the held card was still live 31s after the cut-off, and the 24h count rose 84 -> 85 because the just-raised copy read as future; fix restored and diff compared byte-for-byte",
|
||||
"tsc clean; src/features/alerts, src/components/Alerts and src/utils suites pass except the Mac-only agentInstallCommand lifecycle tests"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded /alerts/overview, injected the alert copies between ticks, held the stream and stepped the page clock past the stale cut-off and the 24h boundary, at both viewports, before and after the fix"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "48c2d6cdce8d019151e3567873de88e21b76bb22",
|
||||
"verified_at": "2026-10-07T19:05:33Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/types/aiIntelligence.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/types/aiIntelligence.ts": "d9287de8171cee10d72c5df0a898c28e47e649be3f9dee8fa791a7406c0adc51"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/kubernetes"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Type-only deletion: TypeScript erases CorrelationsResponse, and npm run build before and after the change emitted the same 187 dist files with identical SHA-256 sums, so the shipped bundle is byte-identical",
|
||||
"Embedded production build in a private demo-mode mock stack (port 7763, fresh data dir)",
|
||||
"Patrol inbox: Patrol heading and 5 attention rows render at 1280x800 and 390x844",
|
||||
"Kubernetes deployment account-portal drawer opens with header, technical summary, runtime context and identity sections at both widths",
|
||||
"No console errors and no page errors on either route at either width; no /api/ai/intelligence/correlations request",
|
||||
"Relationship map (ResourceCorrelationSummary, the surviving ResourceCorrelation consumer) not rendered in this pass: table-row drawers do not fetch per-resource facets or resource intelligence (enableRemoteHistory is off for table-row presentation); its code ships unchanged in the byte-identical bundle"
|
||||
],
|
||||
"interactions": [
|
||||
"Demo sign-in, then navigate to /patrol and wait for the Patrol heading",
|
||||
"Navigate to /kubernetes and click the account-portal deployment row to open its inline resource drawer",
|
||||
"Repeat the same pass at 1280x800 and 390x844"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "7eb32f318100fef045d63414fd63304c62acfdef",
|
||||
"verified_at": "2026-10-07T16:47:38Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
|
||||
"frontend-modern/src/utils/aiFindingPresentation.ts",
|
||||
"frontend-modern/src/utils/format.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx": "6b50ebbf3bbe24e31eebdb3c8be8ac6ca9c08405352facbfcd294a1361d8a2f5",
|
||||
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "9055b69986cd4d320fd2194dba2819c0643e63326724952fd1021325530e9f5b",
|
||||
"frontend-modern/src/utils/aiFindingPresentation.ts": "29e212a8d0aed4c1eb17697bbed86519ed82f64bc2940239db4a79470a23a090",
|
||||
"frontend-modern/src/utils/format.ts": "abaec8a1f40c159e9a58702b726ccaea196bf6b72825c97addbee00af3bd7809"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/patrol/activity"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (backend :7793 built from this tree on origin/main 7eb32f3181, Vite :5303 serving it) with Playwright page.clock installed before load and the data held: the /ws proxy dropped every server frame and answered the client heartbeat with a data-free pong, every GET /api/ read was replayed from its cached body (Patrol findings reads left pending), and the clock was moved with setSystemTime plus a 31-second runFor so only the shared 30-second tick fired; one websocket connection and no attention detail re-read for the whole hold",
|
||||
"Unreviewed suppressed issue (ops-prometheus Docker service health, suppressed through the real form for the default 24 hours; the form's duration read 'Return it to active attention after'): Lifecycle reads 'Returns to active attention in 1d.' with hover 'Suppressed until 8 Oct 17:36' and button 'Return to active attention', then 'in 20h.' at +4h, 'in 29m.' at +23h20m (28m at 390) and 'Suppression has ended.' at +25h, at 1440 and 390",
|
||||
"Reviewed then suppressed issue (esxi-07.lab.local VMware host health, Mark reviewed then suppressed for 24 hours from its detail; the form's duration read 'End suppression after'): Lifecycle reads 'Suppression ends in 1d. It stays reviewed.' with button 'End suppression', then 'in 20h' at +4h, 'in 29m' at +23h20m (28m at 390) and 'Suppression has ended.' at +25h, at 1440 and 390",
|
||||
"Patrol activity, Finding options and history, Resolved: mail-gateway-us dismissed through Finding options, Dismiss: Later reads '(will fix later) \u00b7 Reminding in 7d' with hover 'Reminder due 14 Oct 17:36. Pulse brings this finding back on its first reminder check after that.'; at +7d1m it reads 'Reminder overdue' and the 'Overdue commitments (1)' chip appears on that tick with findings reads still pending, and the chip filters to that finding, at 1440 and 390",
|
||||
"Between ticks: page clock set 5 seconds past that reminder's deadline with no timer fired, then switching from Active to Resolved mounts the row reading 'Reminder due' with no Overdue chip; one 31-second runFor later the row reads 'Reminder overdue' and the 'Overdue commitments (1)' chip appears together, at 1440 and 390",
|
||||
"Same list, dr-vault snoozed for 1 hour through POST /api/ai/patrol/snooze (Patrol rows hide the snooze buttons): reads 'snoozed, returns in 49m' with hover 'Snoozed until 7 Oct 18:36', 'in 9m' at +40m (8m at 390) and 'snooze ended' at +70m; after releasing the hold the next findings read lists dr-vault under Active, at 1440 and 390",
|
||||
"No 'just now' on any future time at either viewport (the only 'just now' left is the attention detail's read-time Last seen age)",
|
||||
"Same-session controls under the running Vite: with FindingsPanel.tsx and aiFindingPresentation.ts swapped to the first draft of this change, the between-ticks row read 'Reminder overdue' with no Overdue chip; with FindingsPanel.tsx, PatrolAttentionWorkbench.tsx, aiFindingPresentation.ts and format.ts swapped back to origin/main, the reviewed issue read 'Returns to active attention just now.' before and after +4h, the reminder 'Reminding just now' before and after +7d with no Overdue chip, and the snooze 'snoozed until just now'"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened a decision by its Patrol attention link, filled the suppression reason and submitted Suppress temporarily with the default 24 hours",
|
||||
"Opened a second decision by its attention link, clicked Mark reviewed, reopened it and suppressed it for 24 hours",
|
||||
"Opened Finding options and history, opened Finding options on a finding, chose Dismiss: Later and Confirm Dismiss",
|
||||
"Opened Reviewed and suppressed, opened each suppressed issue from the list, read the Lifecycle line, its hover title and the early-end button",
|
||||
"Switched the findings filter between Active and Resolved, read reminder and snooze hover titles, stepped the page clock and clicked the Overdue commitments chip",
|
||||
"Released the hold and switched to Active to see the ended snooze re-derived",
|
||||
"Repeated every pass at 1440x900 and 390x844, then reran with the first draft and with origin/main's components as controls"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "bab38a25b92684c978c4273200765152040624ed",
|
||||
"verified_at": "2026-10-07T15:05:37Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Alerts/alertResourceTableModel.ts",
|
||||
"frontend-modern/src/features/alerts/alertsConfigurationModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Alerts/alertResourceTableModel.ts": "9d2cbc37fe80492a67b54b106a1c413ea1cbca66b1cd30e1c100d1c90a408eea",
|
||||
"frontend-modern/src/features/alerts/alertsConfigurationModel.ts": "3070cf7df162eefd5653e5a6eb0a0722e2ffa04b33b12a81ebb2f38a4035c62f"
|
||||
},
|
||||
"routes": [
|
||||
"/alerts/thresholds/proxmox",
|
||||
"/alerts/thresholds/systems",
|
||||
"/alerts/thresholds/kubernetes",
|
||||
"/alerts/thresholds/truenas",
|
||||
"/alerts/thresholds/vsphere",
|
||||
"/alerts/thresholds/docker"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 768
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (PULSE_MOCK_MODE: 3 PVE nodes, 3 agents, 1 Kubernetes cluster, TrueNAS, vSphere) on a backend built from bab38a25b9, Vite on this tree, alerts activated, fresh data dir per run, no injected data",
|
||||
"Before (origin/main payload builder): Proxmox Global Defaults CPU and Temp switched Off and saved; the API stored nodeDefaults.cpu {-1, 0} and nodeDefaults.temperature {80, 75}; after reload Temp read On 80",
|
||||
"Final content, 1280 wide: Machines CPU Off saved as agentDefaults.cpu {0, 0} and reads Off after reload with the Custom badge (factory is 80)",
|
||||
"Final content, 390 wide: Kubernetes Clusters Memory Off saved as kubernetesDefaults.memory {0, 0} and reads Off after reload",
|
||||
"Same payload change, earlier passes: Proxmox Temp Off saved {0, 0} and reads Off after reload (1280); TrueNAS Systems CPU and vSphere Hosts CPU Off saved {0, 0} and read Off (390); Machines CPU at 1% raised 8 agent CPU alerts, switching it Off resolved all 8, Proxmox Temp Off resolved the node temperature alert, nothing re-fired over two minutes, values stayed Off across a backend restart (1024 and 375)",
|
||||
"Custom badge on a fresh install: no Proxmox, Kubernetes, TrueNAS or vSphere Global Defaults group reads Custom (on the payload-only build Kubernetes, TrueNAS Systems and vSphere Hosts/VMs did); after an unrelated save (Proxmox node CPU 81) only Virtualization Hosts reads Custom and VMs & Containers stays clean",
|
||||
"Docker Containers still reads Custom on a fresh install because the backend's no-alerts.json fallback stores Docker CPU/memory/disk as {0, 0}; separate pre-existing defect, not touched here",
|
||||
"No page errors in any Playwright run"
|
||||
],
|
||||
"interactions": [
|
||||
"Playwright: Expand all, clicked a Global Defaults On/Off badge, Save Changes, read GET /api/alerts/config, reloaded and re-read badge states and the Custom badge (Machines CPU at 1280x900, Kubernetes Memory at 390x844)",
|
||||
"Playwright: recorded Custom badges on Proxmox, Kubernetes, TrueNAS, vSphere and Docker on a fresh install, changed Proxmox node CPU to 81, saved, recorded them again",
|
||||
"Browser pane and Playwright (earlier passes on the same payload change): Machines CPU 1% then Off with /api/alerts/active reads, backend restart, Proxmox Temp, TrueNAS and vSphere CPU Off at phone width"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "8e1a564d7e229bda51c021c7e6bde920e0ca0596",
|
||||
"verified_at": "2026-10-07T14:35:42Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx": "3052a190a76e885e98ed6601b3e83c8959e1c656d12270b87e074fffa95860ac"
|
||||
},
|
||||
"routes": [
|
||||
"/alerts/thresholds/systems",
|
||||
"/alerts/overview",
|
||||
"/proxmox/storage?tab=disks"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1366,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (8 PVE nodes) on this branch with a local-only generator overlay pinning PVE disks: pve1 NVMe 63C, pve2 SATA 56C, pve3 NVMe 72C, pve3 SAS 63C, pve3 SATA 66C",
|
||||
"Alerts > Thresholds > Systems (Agents section): 'Disk temperature by type' card reads 'Alert trigger in \u00b0C for each disk type, for agent, TrueNAS and Proxmox disks. ... a TrueNAS Disks value replaces them for TrueNAS disks.' at 1366, and at 375 the card spans 34-341px with no horizontal overflow",
|
||||
"Alerts overview: 'Samsung 870 EVO 1TB (/dev/nvme2) (Temperature) CRITICAL on West Production C \u2014 Temperature 66\u00b0C, above the 55\u00b0C alert level; Clears once it drops to 50\u00b0C or lower and stays there for 5 minutes', Alert level 55\u00b0C, Clear level 50\u00b0C",
|
||||
"Warning-level PVE disk temperature alerts (SATA 56C trigger 55, NVMe 72C trigger 70) appear after the 300s stability window (first breach 15:26:32, firing by 15:31:46); the critical 66C fires at once; none for NVMe 63C or SAS 63C",
|
||||
"Physical Disks filtered to West Production: exactly the three alerting disks read Running Hot (Crucial MX500 56\u00b0C, WD Black SN850X 72\u00b0C, Samsung 870 EVO 66\u00b0C); the 870 EVO drawer reads 'Running Hot \u2014 Disk temperature is 66\u00b0C, at or above its 55\u00b0C alert threshold.'"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened Alerts overview and read the PVE disk temperature alert card",
|
||||
"Filtered Physical Disks by 'West Production C', expanded the Samsung 870 EVO row",
|
||||
"Opened Thresholds > Systems, read the Disk temperature by type card at 1366 and at 375"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "43a80dafd91e92d3079a039cdb08929281812eef",
|
||||
"verified_at": "2026-10-07T13:06:25Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts",
|
||||
"frontend-modern/src/types/workloads.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f984547722c304cd3c80610a3e5ef743b3e6ad268493016ddc649c858e54e6de",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts": "1439f5bc51ad5e362eb21b65b9d95b870f84cdae823770f4ae41dd848905703d",
|
||||
"frontend-modern/src/types/workloads.ts": "4eebb6b801a056900e7fc309679cdca891b48bdd2672e40dbb58eaffce4ca0c6"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox?q=analytics-batch-310",
|
||||
"/proxmox?q=analytics-batch-118",
|
||||
"/proxmox?q=analytics-batch-58",
|
||||
"/proxmox?q=analytics-batch-262"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack built from origin/main 43a80dafd9 plus this change, with a local-only go build -overlay of internal/mock/generator.go: host agents apollo-114 and meridian-731 held past their reporting lease (offline, LastSeen frozen 14 minutes back, readings retained); all four standalone agents seeded with md0 raid1 clean and md1 raid5 'clean, degraded, recovering' at 42.4% / 118M/sec (sdd1 faulty, sde1 spare rebuilding); analytics-batch-310 and analytics-batch-58 forced to report no Proxmox guest filesystems with diskStatusReason agent-not-running",
|
||||
"Agents linked into VMs through the real POST /api/resources/{vm}/link: apollo-114 -> analytics-batch-118, meridian-731 -> analytics-batch-310 (both silent), spectrum-709 -> analytics-batch-262 and meridian-848 -> analytics-batch-58 (both live). /api/resources serves agent.stale true with the agent's raid, disks and networkInterfaces on the two silent VMs and no stale flag on the two live ones",
|
||||
"Before this change (same stack, PR 2652 head, real browser run): silent VMs showed Guest RAID with emerald 'clean', amber 'clean, degraded, recovering', 'Rebuild: 42% \u00b7 118M/sec' and coloured member badges, network rows and 'Pulse 0.2.0-alpha' as current; the live analytics-batch-58 showed its agent filesystems as 'Usage unavailable \u00b7 ?/328 GB' under 'Guest agent not running. Install and start qemu-guest-agent in the VM.'",
|
||||
"After, silent analytics-batch-310 (agent filesystems) at 1440 and 390: Pulse Agent row 'Pulse 0.2.0-alpha \u00b7 stopped reporting' in amber with title 'Pulse Agent 0.2.0-alpha has stopped reporting. Its readings here are its last report.'; Filesystems status 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.' with 'Last known 75%' / 'Last known 42%' rows and no bars; Network status 'Using last known interface details. The Pulse Agent in this guest stopped reporting.' over a muted eth0 row; both RAID arrays data-raid-reading=last-known, '(last known)' states in muted text titled 'Last known reading, not current: host agent stopped reporting', 'Rebuild was at 42%', neutral badges 'sdd1 \u00b7 faulty' and 'sde1 \u00b7 spare rebuilding'",
|
||||
"After, silent analytics-batch-118 with Proxmox guest filesystems (earlier run of the same change): RAID, network and agent row read as above while the Proxmox filesystem rows kept their usage and bars",
|
||||
"After, live analytics-batch-58 (agent filesystems, Proxmox agent-not-running): '/ 65%' and '/data 28%' with progress bars, no status line, no qemu-guest-agent advice; RAID current with emerald/amber states and 'Rebuild: 42% \u00b7 118M/sec'",
|
||||
"After, live control analytics-batch-262 (Proxmox filesystems): unchanged at both widths",
|
||||
"No horizontal page overflow at either width; no page errors. Stopped guests were not exercised in the browser: their agent filesystems read 'Usage unavailable' under the stopped-VM message whether the guest status or the Proxmox vm-stopped reason says stopped, and the stale flip on an open drawer, are covered by GuestDrawer.test.tsx. The final stopped-status gate changes only stopped guests, so the running-guest states above were observed on this code path"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened each guest's inline drawer from the Proxmox workloads table filtered by name (name cell click at 1440, first visible cell tap at 390)",
|
||||
"Scrolled the phone drawer to the Network section and Guest RAID card and captured both",
|
||||
"Read computed text colour, titles and data-raid-reading of every Filesystems, Network, System and RAID row via Playwright"
|
||||
]
|
||||
}
|
||||
@@ -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,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "421d1e5423e5b83b7923ea8c3b133ad520cd5f60",
|
||||
"verified_at": "2026-10-07T15:23:47Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "0fef3795b2a6e2bcb1a979583deceeabd8536868adcb08eb383425dd97bb85ad"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/patrol (Activity tab)",
|
||||
"/patrol (Protection tab)",
|
||||
"/patrol?attention=<first attention row>"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Embedded production build of this tree (vite build into internal/api/frontend-modern/dist, go build) on a private DEMO_MODE mock stack (port 7693, fresh data dir, telemetry off).",
|
||||
"Inbox: tablist Inbox/Protection/Activity with Inbox selected; '50 decisions need you' attention list with 5 rows (for example 'Open clickhouse-2 · Docker container memory limit'); header recency line 'Last check: just now · checked 1377 resources'.",
|
||||
"Activity: 'This week' digest card ('Patrol runs 11', 'New issues 6', '6 still open (2 critical, 4 warning)') above Recent work; Protection panel renders.",
|
||||
"No console errors and no page errors at either width.",
|
||||
"Dist proof against the parent tree (built at 333e51d2de, whose frontend-modern tree is identical to the parent 421d1e5423): 187 files each side; 94 byte-identical, 92 differ only in chunk-hash references and SRI strings; only assets/AIIntelligence-*.js differs in code, and after normalising hashes and 1-3 character identifiers its only residual is the removed summaryStats closure and its summaryStats entry in the returned state object. check-bundle-size.mjs passes."
|
||||
],
|
||||
"interactions": [
|
||||
"Demo auto sign-in, navigate to /patrol, read the header and Inbox attention rows.",
|
||||
"Click the first attention row: URL gains ?attention=<id> and the detail panel opens; Escape, reload /patrol.",
|
||||
"Click Activity, Protection, then Inbox tabs; Inbox re-selects.",
|
||||
"Same script in a 1440x900 desktop context and a 390x844 phone context; screenshots of Inbox, opened row and Activity inspected at both widths."
|
||||
]
|
||||
}
|
||||
@@ -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:
|
||||
@@ -891,7 +894,9 @@ For an existing incident whose notifications should be paused without clearing
|
||||
it, use the snooze and unsnooze endpoints below.
|
||||
|
||||
- `GET /api/alerts/config`
|
||||
- `PUT /api/alerts/config`
|
||||
- `PUT /api/alerts/config` — replaces only the top-level keys the body
|
||||
carries; a key left out keeps its stored value, and the merged config is
|
||||
normalized as before. The body must be a JSON object.
|
||||
- `GET /api/alerts/deadman/config` — returns only whether an external watchdog
|
||||
is configured; `pingUrl` is `***REDACTED***` when present and never returns
|
||||
the credential-bearing URL
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -130,7 +130,6 @@ const ALLOWLIST = new Set([
|
||||
'src/utils/remediationPresentation.ts',
|
||||
'src/utils/patrolEmptyStatePresentation.ts',
|
||||
'src/utils/patrolRunPresentation.ts',
|
||||
'src/utils/patrolSummaryPresentation.ts',
|
||||
'src/utils/rbacPresentation.ts',
|
||||
'src/utils/rbacPermissions.ts',
|
||||
'src/utils/systemLogsPresentation.ts',
|
||||
@@ -581,13 +580,6 @@ const HELPER_RULES = [
|
||||
message:
|
||||
'Do not define local AI quickstart credits badge presentation in page code. Use @/utils/aiQuickstartPresentation instead.',
|
||||
},
|
||||
{
|
||||
rule: 'canonical-patrol/no-local-summary-card-presentation',
|
||||
regex:
|
||||
/\bsummaryStats\(\)\.criticalFindings\s*>\s*0\b[\s\S]{0,320}bg-red-50[\s\S]{0,320}text-red-600|\bsummaryStats\(\)\.warningFindings\s*>\s*0\b[\s\S]{0,320}bg-amber-50[\s\S]{0,320}text-amber-600|\bsummaryStats\(\)\.fixedCount\s*>\s*0\b[\s\S]{0,320}bg-green-50[\s\S]{0,320}text-green-600/g,
|
||||
message:
|
||||
'Do not define local patrol summary card presentation in page code. Use @/utils/patrolSummaryPresentation instead.',
|
||||
},
|
||||
{
|
||||
rule: 'canonical-patrol/no-local-empty-state-copy',
|
||||
regex:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Branch-coverage tests for the currently-uncovered ChartsAPI methods:
|
||||
* - ChartsAPI.getInfrastructureCharts (deprecated delegate -> getInfrastructureSummaryCharts)
|
||||
* - ChartsAPI.getInfrastructureSummaryCharts range/node/signal request shaping
|
||||
* - ChartsAPI.getStorageSummaryTrend
|
||||
*
|
||||
* These tests assert request shaping (final path + query string + signal) and
|
||||
@@ -40,7 +40,7 @@ import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
const ALL_TIME_RANGES: TimeRange[] = ['5m', '15m', '30m', '1h', '4h', '12h', '24h', '7d', '30d'];
|
||||
|
||||
describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
describe('ChartsAPI.getInfrastructureSummaryCharts — branch coverage', () => {
|
||||
const apiFetchJSONMock = vi.mocked(apiFetchJSON);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -50,7 +50,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
it('routes to /charts/infrastructure with default range=1h and signal undefined when called with no args', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts();
|
||||
await ChartsAPI.getInfrastructureSummaryCharts();
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', {
|
||||
@@ -61,7 +61,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
it('passes an explicit range token through to the URL without transformation', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts('24h');
|
||||
await ChartsAPI.getInfrastructureSummaryCharts('24h');
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=24h', {
|
||||
signal: undefined,
|
||||
@@ -73,7 +73,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
async (range) => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts(range);
|
||||
await ChartsAPI.getInfrastructureSummaryCharts(range);
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(`/api/charts/infrastructure?range=${range}`, {
|
||||
signal: undefined,
|
||||
@@ -85,7 +85,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
const controller = new AbortController();
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts('1h', controller.signal);
|
||||
await ChartsAPI.getInfrastructureSummaryCharts('1h', controller.signal);
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', {
|
||||
signal: controller.signal,
|
||||
@@ -95,7 +95,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
it('appends node=<id> when options.nodeId is a non-empty string', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts('1h', undefined, { nodeId: 'cluster-a-node-1' });
|
||||
await ChartsAPI.getInfrastructureSummaryCharts('1h', undefined, { nodeId: 'cluster-a-node-1' });
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
'/api/charts/infrastructure?range=1h&node=cluster-a-node-1',
|
||||
@@ -106,7 +106,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
it('omits the node query param when options.nodeId is explicitly null (falsy branch)', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts('1h', undefined, { nodeId: null });
|
||||
await ChartsAPI.getInfrastructureSummaryCharts('1h', undefined, { nodeId: null });
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', {
|
||||
signal: undefined,
|
||||
@@ -116,7 +116,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
it('omits the node query param when options.nodeId is an empty string (falsy branch)', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts('1h', undefined, { nodeId: '' });
|
||||
await ChartsAPI.getInfrastructureSummaryCharts('1h', undefined, { nodeId: '' });
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', {
|
||||
signal: undefined,
|
||||
@@ -126,7 +126,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
it('omits the node query param when options is undefined entirely', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts('1h', undefined, undefined);
|
||||
await ChartsAPI.getInfrastructureSummaryCharts('1h', undefined, undefined);
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', {
|
||||
signal: undefined,
|
||||
@@ -136,7 +136,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
it('URL-encodes special characters in the node id (URLSearchParams.toString)', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts('1h', undefined, { nodeId: 'node a/b' });
|
||||
await ChartsAPI.getInfrastructureSummaryCharts('1h', undefined, { nodeId: 'node a/b' });
|
||||
|
||||
// space -> '+', '/' -> '%2F'
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
@@ -149,7 +149,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as never);
|
||||
const controller = new AbortController();
|
||||
|
||||
await ChartsAPI.getInfrastructureCharts('4h', controller.signal, { nodeId: 'pve1' });
|
||||
await ChartsAPI.getInfrastructureSummaryCharts('4h', controller.signal, { nodeId: 'pve1' });
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=4h&node=pve1', {
|
||||
signal: controller.signal,
|
||||
@@ -173,7 +173,7 @@ describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => {
|
||||
};
|
||||
apiFetchJSONMock.mockResolvedValueOnce(payload as never);
|
||||
|
||||
const result = await ChartsAPI.getInfrastructureCharts('1h');
|
||||
const result = await ChartsAPI.getInfrastructureSummaryCharts('1h');
|
||||
|
||||
expect(result).toBe(payload);
|
||||
});
|
||||
|
||||
@@ -439,17 +439,6 @@ export class ChartsAPI {
|
||||
return apiFetchJSON(url, { signal });
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getInfrastructureSummaryCharts.
|
||||
*/
|
||||
static async getInfrastructureCharts(
|
||||
range: TimeRange = '1h',
|
||||
signal?: AbortSignal,
|
||||
options?: { nodeId?: string | null },
|
||||
): Promise<InfrastructureChartsResponse> {
|
||||
return this.getInfrastructureSummaryCharts(range, signal, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch persistent metrics history for a specific resource
|
||||
* This uses the SQLite-backed store with longer retention (up to 90 days)
|
||||
|
||||
@@ -37,6 +37,7 @@ import { AIAPI, type RemediationPlan } from '@/api/ai';
|
||||
import { createSuppressionRuleFromFinding, reinvestigateFinding } from '@/api/patrol';
|
||||
import type { PatrolRunRecord, PatrolRuntimeState, PatrolAutonomyLevel } from '@/api/patrol';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import {
|
||||
getFindingAlertIdentifier,
|
||||
hasTriggeringAlert,
|
||||
@@ -82,6 +83,8 @@ import {
|
||||
getFindingSubjectPresentation,
|
||||
getFindingTitlePresentation,
|
||||
getFindingRecencyPresentation,
|
||||
getFindingReminderPresentation,
|
||||
getFindingSnoozePresentation,
|
||||
getFindingEvidencePresentation,
|
||||
hasFindingInvestigationDetails,
|
||||
hasFindingInvestigationHandoffPointer,
|
||||
@@ -192,6 +195,10 @@ function getFindingSuppressionRuleScope(finding: UnifiedFinding) {
|
||||
export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
const location = useLocation();
|
||||
const [filter, setFilter] = createSignal<FindingsPanelFilter>(props.filterOverride ?? 'active');
|
||||
// A reminder or snooze end never changes while its row stays on screen, so
|
||||
// the countdown to it, and whether a reminder is overdue, read the shared
|
||||
// clock.
|
||||
const now = useRelativeTimeNow();
|
||||
const [sortBy, setSortBy] = createSignal<'severity' | 'time'>('severity');
|
||||
const [expandedId, setExpandedId] = createSignal<string | null>(null);
|
||||
let panelRoot: HTMLDivElement | undefined;
|
||||
@@ -336,6 +343,32 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Will_fix_later commitments whose RemindAt deadline has passed. The Go
|
||||
// store's proactive sweep (SweepWillFixLaterReminders) wakes these on a 1h
|
||||
// timer; until then the Overdue commitments chip and filter surface them on
|
||||
// demand. The list is sampled on the shared clock and notifies only when its
|
||||
// ids change, so a tick that moves no deadline past now does not rebuild the
|
||||
// filtered rows. Each row's "Reminder overdue" label reads the same list, so
|
||||
// a row that mounts between ticks agrees with the chip and filter.
|
||||
const overdueFindingIds = createMemo(
|
||||
() => {
|
||||
const nowMs = now();
|
||||
return sourceFindings()
|
||||
.filter((f) => {
|
||||
if (f.dismissedReason !== 'will_fix_later' || !f.remindAt) return false;
|
||||
const due = Date.parse(f.remindAt);
|
||||
return Number.isFinite(due) && due <= nowMs;
|
||||
})
|
||||
.map((f) => f.id);
|
||||
},
|
||||
[],
|
||||
{
|
||||
equals: (prev, next) =>
|
||||
prev.length === next.length && prev.every((id, index) => id === next[index]),
|
||||
},
|
||||
);
|
||||
const overdueFindingIdSet = createMemo(() => new Set(overdueFindingIds()));
|
||||
|
||||
// Filter and sort findings
|
||||
const filteredFindings = createMemo(() => {
|
||||
if (hasUnknownRunSnapshot()) {
|
||||
@@ -363,22 +396,8 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
const approvalFindingIds = new Set(sourceFindingsWithPendingApprovals().map((f) => f.id));
|
||||
findings = findings.filter((f) => approvalFindingIds.has(f.id));
|
||||
} else if (filter() === 'overdue') {
|
||||
// Overdue surfaces will_fix_later commitments whose RemindAt
|
||||
// deadline has already passed. The Go store's proactive sweep
|
||||
// (SweepWillFixLaterReminders) wakes these on a 1h timer, but
|
||||
// this filter lets the operator see them on demand — including
|
||||
// any that the sweep has not yet promoted on this cycle.
|
||||
const nowMs = Date.now();
|
||||
findings = findings.filter((f) => {
|
||||
if (f.dismissedReason !== 'will_fix_later') {
|
||||
return false;
|
||||
}
|
||||
if (!f.remindAt) {
|
||||
return false;
|
||||
}
|
||||
const due = Date.parse(f.remindAt);
|
||||
return Number.isFinite(due) && due <= nowMs;
|
||||
});
|
||||
const overdueIds = overdueFindingIdSet();
|
||||
findings = findings.filter((f) => overdueIds.has(f.id));
|
||||
}
|
||||
|
||||
// Filter by specific finding IDs if provided
|
||||
@@ -466,17 +485,8 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
? scopedPendingApprovalCount()
|
||||
: sourceFindingsWithPendingApprovals().length,
|
||||
}));
|
||||
// Count of will_fix_later commitments whose RemindAt deadline has
|
||||
// already passed. Drives the Overdue commitments chip below.
|
||||
const overdueCount = createMemo(() => {
|
||||
const nowMs = Date.now();
|
||||
return sourceFindings().filter((f) => {
|
||||
if (f.dismissedReason !== 'will_fix_later') return false;
|
||||
if (!f.remindAt) return false;
|
||||
const due = Date.parse(f.remindAt);
|
||||
return Number.isFinite(due) && due <= nowMs;
|
||||
}).length;
|
||||
});
|
||||
// Drives the Overdue commitments chip below.
|
||||
const overdueCount = createMemo(() => overdueFindingIds().length);
|
||||
// Active findings that restate an active alert (backend-stamped mirror).
|
||||
// They are demoted into a collapsed group below the list rather than shown
|
||||
// as separate items: the alert already owns that condition on the Patrol
|
||||
@@ -1277,18 +1287,27 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
{formatOperatorStateDismissCauseLabel(getOperatorStateDismissCause(finding))}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={finding.dismissedReason === 'will_fix_later' && finding.remindAt}>
|
||||
<span
|
||||
class="ml-2 text-amber-600 dark:text-amber-400"
|
||||
title="Pulse will surface this finding again on this date if it is still tripping."
|
||||
>
|
||||
{' · '}Reminding {formatTime(finding.remindAt!)}
|
||||
</span>
|
||||
<Show
|
||||
when={getFindingReminderPresentation(
|
||||
finding,
|
||||
now(),
|
||||
overdueFindingIdSet().has(finding.id),
|
||||
)}
|
||||
>
|
||||
{(reminder) => (
|
||||
<span class="ml-2 text-amber-600 dark:text-amber-400" title={reminder().title}>
|
||||
{' · '}
|
||||
{reminder().label}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={finding.status === 'snoozed' && finding.snoozedUntil}>
|
||||
<span class="ml-2 text-blue-500 dark:text-blue-400">
|
||||
{' · '}snoozed until {formatTime(finding.snoozedUntil!)}
|
||||
</span>
|
||||
<Show when={getFindingSnoozePresentation(finding, now())}>
|
||||
{(snooze) => (
|
||||
<span class="ml-2 text-blue-500 dark:text-blue-400" title={snooze().title}>
|
||||
{' · '}
|
||||
{snooze().label}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={finding.acknowledgedAt && finding.status === 'active'}>
|
||||
<span class="ml-2 text-muted">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { createSignal, type JSX } from 'solid-js';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FindingsPanel } from '../FindingsPanel';
|
||||
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
|
||||
|
||||
const mockState = vi.hoisted(() => {
|
||||
const loadFindings = vi.fn();
|
||||
@@ -771,3 +772,163 @@ describe('FindingsPanel investigate action gating', () => {
|
||||
expect(busyButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FindingsPanel future times', () => {
|
||||
const hour = 60 * 60 * 1000;
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.findings = [...mockState.initialFindings];
|
||||
mockState.loadPatrolFindings.mockClear();
|
||||
mockState.findingsLoading = false;
|
||||
mockState.findingsError = null;
|
||||
mockState.patrolFindingsLoading = false;
|
||||
mockState.patrolFindingsError = null;
|
||||
mockState.patrolPendingApprovals = [];
|
||||
});
|
||||
|
||||
it('counts reminders and snoozes down on the shared clock instead of reading just now', () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: ['Date', 'setInterval', 'clearInterval'],
|
||||
now: Date.parse('2026-03-30T12:00:00Z'),
|
||||
});
|
||||
mockState.patrolFindings = [
|
||||
{
|
||||
...mockState.initialPatrolFindings[0],
|
||||
id: 'finding-later',
|
||||
title: 'Backup job keeps slipping',
|
||||
status: 'dismissed',
|
||||
dismissedReason: 'will_fix_later',
|
||||
remindAt: '2026-04-06T12:00:00Z',
|
||||
},
|
||||
{
|
||||
...mockState.initialPatrolFindings[0],
|
||||
id: 'finding-snoozed',
|
||||
title: 'Swap pressure on vm-100',
|
||||
status: 'snoozed',
|
||||
snoozedUntil: '2026-03-30T15:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
render(() => <FindingsPanel findingsSource="patrol" filterOverride="resolved" />);
|
||||
|
||||
const reminder = screen.getByText(/Reminding in 7d/);
|
||||
const snooze = screen.getByText(/snoozed, returns in 3h/);
|
||||
expect(reminder).toHaveAttribute(
|
||||
'title',
|
||||
expect.stringMatching(
|
||||
/^Reminder due .+\. Pulse brings this finding back on its first reminder check after that\.$/,
|
||||
),
|
||||
);
|
||||
expect(snooze).toHaveAttribute('title', expect.stringMatching(/^Snoozed until /));
|
||||
expect(screen.queryByText(/just now/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Overdue commitments/ })).not.toBeInTheDocument();
|
||||
|
||||
// The findings are not re-read: only the shared clock moves.
|
||||
vi.advanceTimersByTime(2 * hour);
|
||||
expect(snooze).toHaveTextContent('snoozed, returns in 1h');
|
||||
|
||||
vi.advanceTimersByTime(hour);
|
||||
expect(snooze).toHaveTextContent('snooze ended');
|
||||
|
||||
vi.setSystemTime(Date.parse('2026-04-06T12:30:00Z'));
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(reminder).toHaveTextContent('Reminder overdue');
|
||||
// The Overdue commitments chip reads the same clock as the label, with no
|
||||
// findings re-read.
|
||||
expect(screen.getByRole('button', { name: /Overdue commitments \(1\)/ })).toBeInTheDocument();
|
||||
expect(reminder).toHaveAttribute('title', expect.stringMatching(/^Reminder was due /));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps overdue rows mounted across clock ticks and adds a reminder once it falls due', () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: ['Date', 'setInterval', 'clearInterval'],
|
||||
now: Date.parse('2026-03-30T12:00:00Z'),
|
||||
});
|
||||
mockState.patrolFindings = [
|
||||
{
|
||||
...mockState.initialPatrolFindings[0],
|
||||
id: 'finding-overdue',
|
||||
title: 'Certificate renewal keeps slipping',
|
||||
status: 'dismissed',
|
||||
dismissedReason: 'will_fix_later',
|
||||
remindAt: '2026-03-30T11:00:00Z',
|
||||
},
|
||||
{
|
||||
...mockState.initialPatrolFindings[0],
|
||||
id: 'finding-due-soon',
|
||||
title: 'Backup job keeps slipping',
|
||||
status: 'dismissed',
|
||||
dismissedReason: 'will_fix_later',
|
||||
remindAt: '2026-03-30T12:10:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
render(() => <FindingsPanel findingsSource="patrol" filterOverride="resolved" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Overdue commitments \(1\)/ }));
|
||||
|
||||
const review = screen.getByRole('button', {
|
||||
name: 'Review issue for Certificate renewal keeps slipping',
|
||||
});
|
||||
review.focus();
|
||||
expect(screen.queryByText('Backup job keeps slipping')).not.toBeInTheDocument();
|
||||
|
||||
// A tick that moves no deadline past now leaves the rows alone.
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(review.isConnected).toBe(true);
|
||||
expect(document.activeElement).toBe(review);
|
||||
|
||||
// Once the second reminder falls due it joins the open Overdue filter
|
||||
// and the chip count, on the same clock as the row labels.
|
||||
vi.advanceTimersByTime(10 * 60 * 1000);
|
||||
expect(screen.getByText('Backup job keeps slipping')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Overdue commitments \(2\)/ })).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('calls a reminder overdue only once the Overdue commitments list holds it', () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: ['Date', 'setInterval', 'clearInterval'],
|
||||
now: Date.parse('2026-03-30T12:00:00Z'),
|
||||
});
|
||||
mockState.patrolFindings = [
|
||||
{
|
||||
...mockState.initialPatrolFindings[0],
|
||||
id: 'finding-due',
|
||||
title: 'Certificate renewal keeps slipping',
|
||||
status: 'dismissed',
|
||||
dismissedReason: 'will_fix_later',
|
||||
remindAt: '2026-03-30T12:00:05Z',
|
||||
},
|
||||
];
|
||||
const [filter, setFilter] = createSignal<'active' | 'resolved'>('active');
|
||||
|
||||
try {
|
||||
render(() => <FindingsPanel findingsSource="patrol" filterOverride={filter()} />);
|
||||
expect(screen.queryByText('Certificate renewal keeps slipping')).not.toBeInTheDocument();
|
||||
|
||||
// The deadline passes between ticks of the shared clock, and the row
|
||||
// first mounts before the next tick: it must not call itself overdue
|
||||
// while the chip and filter do not count it.
|
||||
vi.setSystemTime(Date.parse('2026-03-30T12:00:10Z'));
|
||||
setFilter('resolved');
|
||||
expect(screen.getByText('Certificate renewal keeps slipping')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Reminder due/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Reminder overdue/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Overdue commitments/ })).not.toBeInTheDocument();
|
||||
|
||||
// The next tick moves the label, the chip and the filter together.
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(screen.getByText(/Reminder overdue/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Overdue commitments \(1\)/ })).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
getFindingSeveritySortOrder,
|
||||
getFindingResolutionReason,
|
||||
getFindingRecencyPresentation,
|
||||
getFindingReminderPresentation,
|
||||
getFindingSnoozePresentation,
|
||||
getFindingLoopStateBadgeClasses,
|
||||
getFindingLoopStateBadgeTone,
|
||||
getFindingSeverityBadgeClasses,
|
||||
@@ -369,11 +371,14 @@ describe('FindingsPanel assistant handoff', () => {
|
||||
// Once a finding is dismissed as will_fix_later, the row must surface the
|
||||
// pending reminder so the operator knows the commitment exists; otherwise
|
||||
// the only place the deadline lives is the lifecycle log. The amber tone
|
||||
// signals "pending operator action" rather than a generic muted note.
|
||||
expect(findingsPanelSource).toContain(
|
||||
"finding.dismissedReason === 'will_fix_later' && finding.remindAt",
|
||||
// signals "pending operator action" rather than a generic muted note. The
|
||||
// deadline is in the future, so it counts down on the shared clock rather
|
||||
// than going through the past-only age formatter, and it reads overdue from
|
||||
// the same list as the Overdue commitments chip.
|
||||
expect(findingsPanelSource).toMatch(
|
||||
/getFindingReminderPresentation\(\s*finding,\s*now\(\),\s*overdueFindingIdSet\(\)\.has\(finding\.id\),?\s*\)/,
|
||||
);
|
||||
expect(findingsPanelSource).toContain('Reminding {formatTime(finding.remindAt!)}');
|
||||
expect(findingsPanelSource).not.toContain('formatTime(finding.remindAt');
|
||||
expect(findingsPanelSource).toContain('text-amber-600 dark:text-amber-400');
|
||||
});
|
||||
|
||||
@@ -652,6 +657,92 @@ describe('aiFindingPresentation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findingReminderAndSnoozePresentation', () => {
|
||||
const now = Date.parse('2026-03-25T12:00:00Z');
|
||||
|
||||
it('counts down to a will_fix_later reminder instead of reading it as just now', () => {
|
||||
const reminder = getFindingReminderPresentation(
|
||||
{ dismissedReason: 'will_fix_later', remindAt: '2026-03-31T12:00:00Z' },
|
||||
now,
|
||||
false,
|
||||
);
|
||||
expect(reminder?.label).toBe('Reminding in 6d');
|
||||
expect(reminder?.title).toMatch(
|
||||
/^Reminder due .+\. Pulse brings this finding back on its first reminder check after that\.$/,
|
||||
);
|
||||
expect(reminder?.title).not.toContain('still tripping');
|
||||
expect(
|
||||
getFindingReminderPresentation(
|
||||
{ dismissedReason: 'will_fix_later', remindAt: '2026-03-25T15:00:00Z' },
|
||||
now,
|
||||
false,
|
||||
)?.label,
|
||||
).toBe('Reminding in 3h');
|
||||
});
|
||||
|
||||
it('calls a reminder overdue once the Overdue commitments list holds it', () => {
|
||||
const reminder = getFindingReminderPresentation(
|
||||
{ dismissedReason: 'will_fix_later', remindAt: '2026-03-25T11:00:00Z' },
|
||||
now,
|
||||
true,
|
||||
);
|
||||
expect(reminder?.label).toBe('Reminder overdue');
|
||||
expect(reminder?.title).toMatch(
|
||||
/^Reminder was due .+\. Pulse brings this finding back on its next reminder check\.$/,
|
||||
);
|
||||
// Past the deadline but not yet on the list (it is sampled on the next
|
||||
// clock tick), so the chip and filter would not count it yet.
|
||||
expect(
|
||||
getFindingReminderPresentation(
|
||||
{ dismissedReason: 'will_fix_later', remindAt: '2026-03-25T11:59:50Z' },
|
||||
now,
|
||||
false,
|
||||
)?.label,
|
||||
).toBe('Reminder due');
|
||||
});
|
||||
|
||||
it('shows no reminder for other dismissals or an unusable remind time', () => {
|
||||
expect(
|
||||
getFindingReminderPresentation(
|
||||
{ dismissedReason: 'not_an_issue', remindAt: '2026-03-31T12:00:00Z' },
|
||||
now,
|
||||
false,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getFindingReminderPresentation({ dismissedReason: 'will_fix_later' }, now, false),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getFindingReminderPresentation(
|
||||
{ dismissedReason: 'will_fix_later', remindAt: 'not a date' },
|
||||
now,
|
||||
true,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('counts down to the end of a snooze and keeps the exact time on hover', () => {
|
||||
const snooze = getFindingSnoozePresentation(
|
||||
{ status: 'snoozed', snoozedUntil: '2026-03-25T15:00:00Z' },
|
||||
now,
|
||||
);
|
||||
expect(snooze?.label).toBe('snoozed, returns in 3h');
|
||||
expect(snooze?.title).toMatch(/^Snoozed until .+/);
|
||||
expect(
|
||||
getFindingSnoozePresentation(
|
||||
{ status: 'snoozed', snoozedUntil: '2026-03-25T11:00:00Z' },
|
||||
now,
|
||||
)?.label,
|
||||
).toBe('snooze ended');
|
||||
expect(
|
||||
getFindingSnoozePresentation(
|
||||
{ status: 'active', snoozedUntil: '2026-03-25T15:00:00Z' },
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('patrolFindingClassification', () => {
|
||||
it('describes grouped Patrol findings as issues instead of internal signals', () => {
|
||||
expect(getPatrolFindingIssueCountLabel(0)).toBe('0 issues');
|
||||
@@ -997,7 +1088,8 @@ describe('aiFindingPresentation', () => {
|
||||
it('uses explicit textual separators in finding metadata instead of relying on visual spacing', () => {
|
||||
expect(findingsPanelSource).toContain("{' · '}acknowledged");
|
||||
expect(findingsPanelSource).toContain("{' · '}last investigated");
|
||||
expect(findingsPanelSource).toContain("{' · '}snoozed until");
|
||||
expect(findingsPanelSource).toMatch(/\{' · '\}\s*\{snooze\(\)\.label\}/);
|
||||
expect(findingsPanelSource).toMatch(/\{' · '\}\s*\{reminder\(\)\.label\}/);
|
||||
});
|
||||
|
||||
it('keeps the Open work heading free of a duplicate count badge', () => {
|
||||
|
||||
@@ -43,12 +43,11 @@ import {
|
||||
ALERT_RESOURCE_METRIC_OFF_VALUE,
|
||||
getAlertResourceColumnKind,
|
||||
getAlertResourceColumnHeaderTooltip,
|
||||
getAlertResourceEnabledDefault,
|
||||
getAlertResourceMetricBounds,
|
||||
getAlertResourceMetricDelayOverride,
|
||||
getAlertResourceMetricStep,
|
||||
isAlertResourceMetricOff,
|
||||
normalizeAlertResourceMetricKey,
|
||||
resolveAlertResourceGlobalDefaultCell,
|
||||
} from './alertResourceTableModel';
|
||||
import type { OfflineState, Resource, ResourceTableProps } from './ResourceTable';
|
||||
|
||||
@@ -210,7 +209,10 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
|
||||
{(column) => (
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind(getAlertResourceColumnKind(column))} whitespace-normal wrap-break-word`}
|
||||
title={getAlertResourceColumnHeaderTooltip(column)}
|
||||
title={
|
||||
props.table.columnTooltips?.[column] ??
|
||||
getAlertResourceColumnHeaderTooltip(column)
|
||||
}
|
||||
>
|
||||
{column}
|
||||
</TableHead>
|
||||
@@ -281,10 +283,14 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
|
||||
{(column) => {
|
||||
const metric = normalizeAlertResourceMetricKey(column);
|
||||
const bounds = getAlertResourceMetricBounds(metric);
|
||||
const value = () => props.table.globalDefaults?.[metric] ?? 0;
|
||||
const value = () => props.table.globalDefaults?.[metric];
|
||||
const fallback = () => props.table.globalDefaultFallbacks?.[metric];
|
||||
// An unset default and a stored 0 are both disabled to the
|
||||
// alert engine, so neither may render as On.
|
||||
const isOff = () => isAlertResourceMetricOff(value());
|
||||
// alert engine, so neither may render as On, unless the
|
||||
// metric's unset default follows another setting.
|
||||
const cell = () =>
|
||||
resolveAlertResourceGlobalDefaultCell(metric, value(), fallback());
|
||||
const isOff = () => cell().isOff;
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
@@ -297,8 +303,12 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
step={getAlertResourceMetricStep(metric)}
|
||||
value={isOff() ? '' : value()}
|
||||
placeholder={getAlertResourceTableMetricPlaceholder(isOff())}
|
||||
value={isOff() || cell().follows ? '' : (value() ?? '')}
|
||||
placeholder={
|
||||
cell().follows && !isOff()
|
||||
? fallback()?.label
|
||||
: getAlertResourceTableMetricPlaceholder(isOff())
|
||||
}
|
||||
disabled={isOff()}
|
||||
onInput={(e) => {
|
||||
const nextValue = parseFloat(e.currentTarget.value);
|
||||
@@ -318,12 +328,16 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
|
||||
}));
|
||||
props.table.setHasUnsavedChanges?.(true);
|
||||
}}
|
||||
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${
|
||||
class={`${cell().follows && !isOff() ? 'w-20' : 'w-16'} px-2 py-0.5 text-sm text-center border rounded ${
|
||||
isOff()
|
||||
? 'border-border bg-surface-alt text-muted italic placeholder: dark:placeholder: placeholder:opacity-60 pointer-events-none'
|
||||
: 'border-border text-base-content focus:border-blue-500 focus:ring-1 focus:ring-blue-500'
|
||||
}`}
|
||||
title={getAlertResourceTableMetricInputTitle(isOff())}
|
||||
title={
|
||||
cell().follows
|
||||
? fallback()?.title
|
||||
: getAlertResourceTableMetricInputTitle(isOff())
|
||||
}
|
||||
/>
|
||||
<Show when={isOff()}>
|
||||
<button
|
||||
@@ -332,7 +346,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
|
||||
onClick={() => {
|
||||
props.table.setGlobalDefaults?.((prev) => ({
|
||||
...prev,
|
||||
[metric]: getAlertResourceEnabledDefault(metric),
|
||||
[metric]: cell().enableValue,
|
||||
}));
|
||||
props.table.setHasUnsavedChanges?.(true);
|
||||
}}
|
||||
@@ -348,7 +362,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
|
||||
props.table.setGlobalDefaults?.((prev) => ({
|
||||
...prev,
|
||||
[metric]: isOff()
|
||||
? getAlertResourceEnabledDefault(metric)
|
||||
? cell().enableValue
|
||||
: ALERT_RESOURCE_METRIC_OFF_VALUE,
|
||||
}));
|
||||
props.table.setHasUnsavedChanges?.(true);
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
ALERT_RESOURCE_METRIC_OFF_VALUE,
|
||||
alertResourceSupportsMetric,
|
||||
buildAlertResourceEditPayload,
|
||||
getAlertResourceEnabledDefault,
|
||||
getAlertResourceLabel,
|
||||
getAlertResourceMetricBounds,
|
||||
getAlertResourceMetricDisplayValue,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
isAlertResourceMetricOff,
|
||||
isAlertResourceMetricOverridden,
|
||||
normalizeAlertResourceMetricKey,
|
||||
resolveAlertResourceGlobalDefaultCell,
|
||||
resolveAlertResourceMetricEnableValue,
|
||||
} from './alertResourceTableModel';
|
||||
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
|
||||
@@ -212,10 +212,14 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
||||
{(column) => {
|
||||
const metric = normalizeAlertResourceMetricKey(column);
|
||||
const bounds = getAlertResourceMetricBounds(metric);
|
||||
const value = () => props.table.globalDefaults?.[metric] ?? 0;
|
||||
const value = () => props.table.globalDefaults?.[metric];
|
||||
const fallback = () => props.table.globalDefaultFallbacks?.[metric];
|
||||
// An unset default and a stored 0 are both disabled to the alert
|
||||
// engine, so neither may render as On.
|
||||
const isOff = () => isAlertResourceMetricOff(value());
|
||||
// engine, so neither may render as On, unless the metric's unset
|
||||
// default follows another setting.
|
||||
const cell = () =>
|
||||
resolveAlertResourceGlobalDefaultCell(metric, value(), fallback());
|
||||
const isOff = () => cell().isOff;
|
||||
|
||||
return (
|
||||
<div class="p-2 bg-surface rounded-sm border border-border-subtle flex flex-col gap-1">
|
||||
@@ -227,8 +231,13 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
step={getAlertResourceMetricStep(metric)}
|
||||
value={isOff() ? '' : value()}
|
||||
placeholder={getAlertResourceTableMetricPlaceholder(isOff())}
|
||||
value={isOff() || cell().follows ? '' : (value() ?? '')}
|
||||
placeholder={
|
||||
cell().follows && !isOff()
|
||||
? fallback()?.label
|
||||
: getAlertResourceTableMetricPlaceholder(isOff())
|
||||
}
|
||||
title={cell().follows ? fallback()?.title : undefined}
|
||||
disabled={isOff()}
|
||||
class={`min-h-11 w-full rounded-sm border p-1 text-center text-sm sm:min-h-0 ${isOff() ? 'bg-surface-hover' : ' border-border'}`}
|
||||
onInput={(e) => {
|
||||
@@ -257,7 +266,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
||||
onClick={() => {
|
||||
props.table.setGlobalDefaults?.((prev) => ({
|
||||
...prev,
|
||||
[metric]: getAlertResourceEnabledDefault(metric),
|
||||
[metric]: cell().enableValue,
|
||||
}));
|
||||
props.table.setHasUnsavedChanges?.(true);
|
||||
}}
|
||||
@@ -271,7 +280,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
||||
props.table.setGlobalDefaults?.((prev) => ({
|
||||
...prev,
|
||||
[metric]: isOff()
|
||||
? getAlertResourceEnabledDefault(metric)
|
||||
? cell().enableValue
|
||||
: ALERT_RESOURCE_METRIC_OFF_VALUE,
|
||||
}));
|
||||
props.table.setHasUnsavedChanges?.(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { ALERT_BULK_EDIT_CLEAR_LABEL } from '@/utils/alertBulkEditPresentation';
|
||||
import resourceTableSource from '@/components/Alerts/ResourceTable.tsx?raw';
|
||||
@@ -1007,6 +1008,144 @@ describe('ResourceTable', () => {
|
||||
expect(updater({ cpu: 0 })).toEqual({ cpu: 80 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['desktop', false],
|
||||
['mobile', true],
|
||||
])('lets an unset %s global default follow its fallback', (_layout, mobile) => {
|
||||
mockIsMobile.mockReturnValue(mobile);
|
||||
const setGlobalDefaults = vi.fn();
|
||||
const props = makeProps({
|
||||
resources: [],
|
||||
columns: ['Temp °C'],
|
||||
globalDefaults: { temperature: undefined },
|
||||
globalDefaultFallbacks: {
|
||||
temperature: { label: 'By type', title: 'Follows Disk temperature by type' },
|
||||
},
|
||||
setGlobalDefaults,
|
||||
setHasUnsavedChanges: vi.fn(),
|
||||
});
|
||||
render(() => <ResourceTable {...props} />);
|
||||
|
||||
// Unset reads On and names where the value comes from.
|
||||
expect(screen.getByTestId('status-badge')).toHaveTextContent('On');
|
||||
const input = screen.getByPlaceholderText('By type') as HTMLInputElement;
|
||||
expect(input.value).toBe('');
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(input.title).toBe('Follows Disk temperature by type');
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-badge'));
|
||||
const updater = setGlobalDefaults.mock.calls[0][0] as (
|
||||
prev: Record<string, number | undefined>,
|
||||
) => Record<string, number | undefined>;
|
||||
expect(updater({ temperature: undefined })).toEqual({ temperature: -1 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['desktop', false],
|
||||
['mobile', true],
|
||||
])('switches an Off %s fallback default back to following', (_layout, mobile) => {
|
||||
mockIsMobile.mockReturnValue(mobile);
|
||||
const setGlobalDefaults = vi.fn();
|
||||
const props = makeProps({
|
||||
resources: [],
|
||||
columns: ['Temp °C'],
|
||||
globalDefaults: { temperature: -1 },
|
||||
globalDefaultFallbacks: {
|
||||
temperature: { label: 'By type', title: 'Follows Disk temperature by type' },
|
||||
},
|
||||
setGlobalDefaults,
|
||||
setHasUnsavedChanges: vi.fn(),
|
||||
});
|
||||
render(() => <ResourceTable {...props} />);
|
||||
|
||||
expect(screen.getByTestId('status-badge')).toHaveTextContent('Off');
|
||||
fireEvent.click(screen.getByTestId('status-badge'));
|
||||
const updater = setGlobalDefaults.mock.calls[0][0] as (
|
||||
prev: Record<string, number | undefined>,
|
||||
) => Record<string, number | undefined>;
|
||||
expect(updater({ temperature: -1 })).toEqual({ temperature: undefined });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['desktop', false],
|
||||
['mobile', true],
|
||||
])('reads an unset %s default Off when its fallback is off', (_layout, mobile) => {
|
||||
mockIsMobile.mockReturnValue(mobile);
|
||||
const setGlobalDefaults = vi.fn();
|
||||
const props = makeProps({
|
||||
resources: [],
|
||||
columns: ['Temp °C'],
|
||||
globalDefaults: { temperature: undefined },
|
||||
globalDefaultFallbacks: {
|
||||
temperature: { label: 'By type', title: 'Off by type', off: true, enableValue: 55 },
|
||||
},
|
||||
setGlobalDefaults,
|
||||
setHasUnsavedChanges: vi.fn(),
|
||||
});
|
||||
render(() => <ResourceTable {...props} />);
|
||||
|
||||
expect(screen.getByTestId('status-badge')).toHaveTextContent('Off');
|
||||
expect(screen.queryByPlaceholderText('By type')).toBeNull();
|
||||
|
||||
// Unset would still be off, so switching on stages an explicit value.
|
||||
fireEvent.click(screen.getByTestId('status-badge'));
|
||||
const updater = setGlobalDefaults.mock.calls[0][0] as (
|
||||
prev: Record<string, number | undefined>,
|
||||
) => Record<string, number | undefined>;
|
||||
expect(updater({ temperature: undefined })).toEqual({ temperature: 55 });
|
||||
});
|
||||
|
||||
it('shows a set fallback global default as its number', () => {
|
||||
const props = makeProps({
|
||||
resources: [],
|
||||
columns: ['Temp °C'],
|
||||
globalDefaults: { temperature: 62 },
|
||||
globalDefaultFallbacks: {
|
||||
temperature: { label: 'By type', title: 'Follows Disk temperature by type' },
|
||||
},
|
||||
setGlobalDefaults: vi.fn(),
|
||||
setHasUnsavedChanges: vi.fn(),
|
||||
});
|
||||
render(() => <ResourceTable {...props} />);
|
||||
|
||||
expect(screen.getByDisplayValue('62')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('By type')).toBeNull();
|
||||
});
|
||||
|
||||
it('follows global defaults that arrive after the table mounts', () => {
|
||||
const [globalDefaults, setGlobalDefaults] = createSignal<Record<string, number | undefined>>({
|
||||
temperature: undefined,
|
||||
});
|
||||
const props = makeProps({
|
||||
resources: [],
|
||||
columns: ['Temp °C'],
|
||||
factoryDefaults: { temperature: undefined },
|
||||
onResetDefaults: vi.fn(),
|
||||
setGlobalDefaults: vi.fn(),
|
||||
setHasUnsavedChanges: vi.fn(),
|
||||
});
|
||||
render(() => <ResourceTable {...props} globalDefaults={globalDefaults()} />);
|
||||
|
||||
expect(screen.queryByText('Custom')).toBeNull();
|
||||
// The saved config loads after mount.
|
||||
setGlobalDefaults({ temperature: 62 });
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses a table column tooltip over the generic one', () => {
|
||||
const props = makeProps({
|
||||
resources: [],
|
||||
columns: ['Temp °C'],
|
||||
columnTooltips: { 'Temp °C': 'Disk temperature that raises an alert.' },
|
||||
});
|
||||
render(() => <ResourceTable {...props} />);
|
||||
|
||||
expect(screen.getByText('Temp °C', { selector: 'th' })).toHaveAttribute(
|
||||
'title',
|
||||
'Disk temperature that raises an alert.',
|
||||
);
|
||||
});
|
||||
|
||||
it('stages an explicit threshold from the mobile row editor', () => {
|
||||
mockIsMobile.mockReturnValue(true);
|
||||
const setEditingThresholds = vi.fn();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ActionIconButton } from '@/components/shared/Button';
|
||||
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
|
||||
import { useAlertResourceTableState } from './useAlertResourceTableState';
|
||||
import type { GroupHeaderMeta, Resource } from '@/features/alerts/thresholds/tableTypes';
|
||||
import type { AlertResourceGlobalDefaultFallback } from './alertResourceTableModel';
|
||||
import { AlertResourceTableDesktop } from './AlertResourceTableDesktop';
|
||||
import { AlertResourceTableMobile } from './AlertResourceTableMobile';
|
||||
|
||||
@@ -49,6 +50,10 @@ export interface ResourceTableProps {
|
||||
formatMetricValue: (metric: string, value: number | undefined) => string;
|
||||
hasActiveAlert: (resourceId: string, metric: string) => boolean;
|
||||
globalDefaults?: Record<string, number | undefined>;
|
||||
/** Metrics whose unset global default follows another setting. */
|
||||
globalDefaultFallbacks?: Record<string, AlertResourceGlobalDefaultFallback>;
|
||||
/** Header tooltips that replace the generic one for a column label. */
|
||||
columnTooltips?: Record<string, string>;
|
||||
setGlobalDefaults?: (
|
||||
value:
|
||||
| Record<string, number | undefined>
|
||||
@@ -97,10 +102,20 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||
someSelected,
|
||||
clearSelectedIds,
|
||||
} = useAlertResourceTableState({
|
||||
resources: props.resources,
|
||||
groupedResources: props.groupedResources,
|
||||
globalDefaults: props.globalDefaults,
|
||||
factoryDefaults: props.factoryDefaults,
|
||||
// Getters keep the state reactive: saved defaults arrive after mount, and
|
||||
// the Custom badge and reset control must follow them.
|
||||
get resources() {
|
||||
return props.resources;
|
||||
},
|
||||
get groupedResources() {
|
||||
return props.groupedResources;
|
||||
},
|
||||
get globalDefaults() {
|
||||
return props.globalDefaults;
|
||||
},
|
||||
get factoryDefaults() {
|
||||
return props.factoryDefaults;
|
||||
},
|
||||
});
|
||||
const totalColumnCount = createMemo(
|
||||
() =>
|
||||
|
||||
@@ -82,9 +82,10 @@ export function ThresholdsTableAgentsResourcesSection(props: ThresholdsTableSect
|
||||
<Card padding="md" tone="card" class="mt-4">
|
||||
<h3 class="text-sm font-semibold text-base-content">Disk temperature by type</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Alert trigger in °C for each disk type. Warning colors start 5°C below the trigger.
|
||||
Setting a Disk Temp override on a host above replaces these for all of that host's
|
||||
disks.
|
||||
Alert trigger in °C for each disk type, for agent, TrueNAS and Proxmox disks. Warning
|
||||
colors start 5°C below the trigger. A Disk Temp override on a host above replaces
|
||||
these for all of that host's disks, and a TrueNAS Disks value replaces them for
|
||||
TrueNAS disks.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<For each={DISK_TEMP_TYPE_FIELDS}>
|
||||
|
||||
@@ -8,6 +8,9 @@ import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
|
||||
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
|
||||
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
|
||||
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
|
||||
import { getDiskTemperatureByTypeItems } from '@/features/alerts/thresholds/trueNASDiskTemperature';
|
||||
import { FACTORY_AGENT_DEFAULTS } from '@/utils/alertThresholdDefaults';
|
||||
import type { AlertResourceGlobalDefaultFallback } from './alertResourceTableModel';
|
||||
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
|
||||
|
||||
const TRUENAS_SYSTEM_COLUMNS = [
|
||||
@@ -22,6 +25,7 @@ const TRUENAS_SYSTEM_COLUMNS = [
|
||||
];
|
||||
const TRUENAS_STORAGE_COLUMNS = ['Usage %'];
|
||||
const TRUENAS_DISK_COLUMNS = ['Temp °C'];
|
||||
const TRUENAS_DISK_COLUMN_TOOLTIPS = { 'Temp °C': 'Disk temperature that raises an alert.' };
|
||||
|
||||
function TrueNASResourceSection(
|
||||
props: ThresholdsTableSectionProps & {
|
||||
@@ -39,6 +43,10 @@ function TrueNASResourceSection(
|
||||
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
||||
) => void;
|
||||
onResetDefaults?: () => void;
|
||||
defaultsSummary?: string;
|
||||
globalDefaultFallbacks?: Record<string, AlertResourceGlobalDefaultFallback>;
|
||||
columnTooltips?: Record<string, string>;
|
||||
note?: string;
|
||||
},
|
||||
) {
|
||||
const { state, tableProps } = props;
|
||||
@@ -48,7 +56,9 @@ function TrueNASResourceSection(
|
||||
<CollapsibleSection
|
||||
id={props.id}
|
||||
title={props.title}
|
||||
defaultsSummary={getAlertThresholdsDefaultsSummary(props.columns, props.defaults)}
|
||||
defaultsSummary={
|
||||
props.defaultsSummary ?? getAlertThresholdsDefaultsSummary(props.columns, props.defaults)
|
||||
}
|
||||
resourceCount={props.resources().length}
|
||||
collapsed={state.isCollapsed(props.id)}
|
||||
onToggle={() => state.toggleSection(props.id)}
|
||||
@@ -57,6 +67,9 @@ function TrueNASResourceSection(
|
||||
emptyMessage="No TrueNAS alert targets match the current filters."
|
||||
>
|
||||
<div ref={state.registerSection(props.id)} class="scroll-mt-24">
|
||||
<Show when={props.note}>
|
||||
<p class="mb-3 text-xs text-muted">{props.note}</p>
|
||||
</Show>
|
||||
<ResourceTable
|
||||
title=""
|
||||
onConfigureResourceIntent={tableProps.onConfigureResourceIntent}
|
||||
@@ -79,6 +92,8 @@ function TrueNASResourceSection(
|
||||
formatMetricValue={formatMetricValue}
|
||||
hasActiveAlert={state.hasActiveAlert}
|
||||
globalDefaults={props.defaults}
|
||||
globalDefaultFallbacks={props.globalDefaultFallbacks}
|
||||
columnTooltips={props.columnTooltips}
|
||||
setGlobalDefaults={props.setGlobalDefaults}
|
||||
setHasUnsavedChanges={tableProps.setHasUnsavedChanges}
|
||||
globalDisableFlag={tableProps.disableAllTrueNAS}
|
||||
@@ -104,6 +119,38 @@ export function ThresholdsTableTrueNASTab(props: ThresholdsTableSectionProps) {
|
||||
const trueNASDefaults = () => props.tableProps.trueNASDefaults ?? {};
|
||||
const trueNASStorageDefaults = () => ({ usage: trueNASDefaults().usage ?? 85 });
|
||||
const trueNASDiskDefaults = () => props.tableProps.trueNASDiskDefaults ?? {};
|
||||
const diskTemperatureByType = () =>
|
||||
getDiskTemperatureByTypeItems({
|
||||
agentDiskTemperature: props.tableProps.agentDefaults.diskTemperature,
|
||||
diskTempByType: props.tableProps.diskTempByType,
|
||||
});
|
||||
// Unset, TrueNAS disks follow Disk temperature by type, so the header names
|
||||
// the per-type triggers rather than one number.
|
||||
const trueNASDiskDefaultsSummary = () => {
|
||||
if (trueNASDiskDefaults().temperature !== undefined) return undefined;
|
||||
return getAlertThresholdsDefaultsSummary(
|
||||
TRUENAS_DISK_COLUMNS,
|
||||
diskTemperatureByType().length > 0 ? {} : { temperature: 0 },
|
||||
{ ruleItems: diskTemperatureByType() },
|
||||
);
|
||||
};
|
||||
// When the agent Disk Temp default is off, Disk temperature by type is off
|
||||
// too, so the unset cell reads Off and switching it on stages a TrueNAS-wide
|
||||
// value at the factory disk temperature trigger.
|
||||
const trueNASDiskTemperatureFallback = (): Record<string, AlertResourceGlobalDefaultFallback> => {
|
||||
const items = diskTemperatureByType();
|
||||
return {
|
||||
temperature: {
|
||||
label: 'By type',
|
||||
title:
|
||||
items.length > 0
|
||||
? `Follows Disk temperature by type under Agents: ${items.join(', ')}. Enter a value to use one temperature for every TrueNAS disk.`
|
||||
: 'Off because the agent Disk Temp default under Agents is off. Turn it on here to alert on TrueNAS disks anyway.',
|
||||
off: items.length === 0,
|
||||
enableValue: FACTORY_AGENT_DEFAULTS.diskTemperature,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -175,6 +222,10 @@ export function ThresholdsTableTrueNASTab(props: ThresholdsTableSectionProps) {
|
||||
icon={<HardDrive class="w-5 h-5" />}
|
||||
typeKey="truenas-disk"
|
||||
defaults={trueNASDiskDefaults()}
|
||||
defaultsSummary={trueNASDiskDefaultsSummary()}
|
||||
globalDefaultFallbacks={trueNASDiskTemperatureFallback()}
|
||||
columnTooltips={TRUENAS_DISK_COLUMN_TOOLTIPS}
|
||||
note="Unless set here, each disk alerts at Disk temperature by type, set under Agents."
|
||||
factoryDefaults={props.tableProps.factoryTrueNASDiskDefaults}
|
||||
setGlobalDefaults={props.tableProps.setTrueNASDiskDefaults}
|
||||
onResetDefaults={props.tableProps.resetTrueNASDiskDefaults}
|
||||
|
||||
@@ -457,6 +457,25 @@ describe('ThresholdsTable navigation and redirection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('says Disk temperature by type covers agent, TrueNAS and Proxmox disks', async () => {
|
||||
setPathname('/alerts/thresholds/systems');
|
||||
const host: Agent = {
|
||||
id: 'legacy-h1',
|
||||
hostname: 'legacy-host',
|
||||
displayName: 'Legacy Host',
|
||||
status: 'online',
|
||||
lastSeen: 123,
|
||||
memory: { total: 100, used: 50, free: 50, usage: 50 },
|
||||
};
|
||||
|
||||
render(() => <ThresholdsTable {...(baseProps() as any)} agents={[host]} />);
|
||||
|
||||
const heading = await screen.findByText('Disk temperature by type');
|
||||
expect(heading.parentElement?.textContent).toContain(
|
||||
'for each disk type, for agent, TrueNAS and Proxmox disks',
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to correct route when tabs are clicked', () => {
|
||||
render(() => <ThresholdsTable {...(baseProps() as any)} />);
|
||||
|
||||
|
||||
+24
@@ -116,6 +116,30 @@ describe('hasCustomAlertResourceGlobalDefaults', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads an Off default saved as 0 as the factory Off of -1', () => {
|
||||
expect(
|
||||
hasCustomAlertResourceGlobalDefaults(
|
||||
{ cpu: 80, diskRead: 0, networkIn: 0 },
|
||||
{ cpu: 80, diskRead: -1, networkIn: -1 },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(hasCustomAlertResourceGlobalDefaults({ diskRead: 100 }, { diskRead: -1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an Off default custom against an unset factory value', () => {
|
||||
// An unset factory value follows another setting (TrueNAS disks follow Disk
|
||||
// temperature by type), so Off is a choice that needs its reset control.
|
||||
expect(
|
||||
hasCustomAlertResourceGlobalDefaults({ temperature: 0 }, { temperature: undefined }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCustomAlertResourceGlobalDefaults({ temperature: -1 }, { temperature: undefined }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCustomAlertResourceGlobalDefaults({ temperature: undefined }, { temperature: undefined }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when a factory key is absent from globalDefaults (current is undefined)', () => {
|
||||
expect(hasCustomAlertResourceGlobalDefaults({ memory: 90 }, { cpu: 80, memory: 90 })).toBe(
|
||||
false,
|
||||
|
||||
@@ -101,7 +101,7 @@ export function hasCustomAlertResourceGlobalDefaults(
|
||||
return Object.keys(factoryDefaults).some((key) => {
|
||||
const current = globalDefaults[key];
|
||||
const factory = factoryDefaults[key];
|
||||
return current !== undefined && current !== factory;
|
||||
return current !== undefined && !alertResourceThresholdsEquivalent(current, factory);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,12 +208,26 @@ export const ALERT_RESOURCE_METRIC_OFF_VALUE = -1;
|
||||
* The alert engine disables a metric whenever its trigger is `<= 0`
|
||||
* (`internal/alerts/canonical_metric.go`), so every editor surface has to read
|
||||
* `0` as Off too: older builds advertised `0` as the disable value and those
|
||||
* overrides are still on disk. New writes always use the canonical `-1`.
|
||||
* overrides are still on disk. Editors always stage the canonical `-1`; the
|
||||
* save payload writes an Off global default as `0`, because the backend reads
|
||||
* a negative default as unset.
|
||||
*/
|
||||
export function isAlertResourceMetricOff(value: number | undefined | null): boolean {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two thresholds alert the same way. Every value `<= 0` is Off, and a
|
||||
* global default saves Off as `0` while the editors and factory defaults use
|
||||
* `-1`, so the two compare equal.
|
||||
*/
|
||||
export function alertResourceThresholdsEquivalent(
|
||||
left: number | undefined,
|
||||
right: number | undefined,
|
||||
): boolean {
|
||||
return left === right || (isAlertResourceMetricOff(left) && isAlertResourceMetricOff(right));
|
||||
}
|
||||
|
||||
/**
|
||||
* Value an override editor must stage when a metric is switched back on.
|
||||
* Clearing the override (`undefined`) only re-inherits the default, so it
|
||||
@@ -230,6 +244,49 @@ export function resolveAlertResourceMetricEnableValue(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A global default that may stay unset because another setting decides it,
|
||||
* such as TrueNAS disk temperature following Disk temperature by type. Unset,
|
||||
* the cell is empty with `label` as its placeholder and `title` explaining
|
||||
* where the value comes from. `off` says the other setting is itself off, so
|
||||
* the unset cell reads Off and switching it on has to stage `enableValue`.
|
||||
*/
|
||||
export interface AlertResourceGlobalDefaultFallback {
|
||||
label: string;
|
||||
title: string;
|
||||
off?: boolean;
|
||||
enableValue?: number;
|
||||
}
|
||||
|
||||
export interface AlertResourceGlobalDefaultCell {
|
||||
/** Unset and decided by the fallback. */
|
||||
follows: boolean;
|
||||
isOff: boolean;
|
||||
/** Value staged when the cell is switched back on from Off. */
|
||||
enableValue: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* State of one Global Defaults metric cell. An unset default is Off, as the
|
||||
* alert engine reads it, unless the metric has a fallback; then it reads as
|
||||
* the fallback does. Switching it on from Off returns it to unset when the
|
||||
* fallback is on, and stages an explicit value when the fallback is off,
|
||||
* since unset would still be off.
|
||||
*/
|
||||
export function resolveAlertResourceGlobalDefaultCell(
|
||||
metric: string,
|
||||
value: number | undefined,
|
||||
fallback: AlertResourceGlobalDefaultFallback | undefined,
|
||||
): AlertResourceGlobalDefaultCell {
|
||||
const follows = value === undefined && fallback !== undefined;
|
||||
const explicitEnableValue = fallback?.enableValue ?? getAlertResourceEnabledDefault(metric);
|
||||
return {
|
||||
follows,
|
||||
isOff: follows ? Boolean(fallback?.off) : isAlertResourceMetricOff(value ?? 0),
|
||||
enableValue: fallback && !fallback.off ? undefined : explicitEnableValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAlertResourceMetricDelayOverride(
|
||||
metricDelaySeconds: Record<string, number> | undefined,
|
||||
metric: string,
|
||||
|
||||
+29
-6
@@ -830,16 +830,39 @@ describe('buildTrueNASVMSections additional branches', () => {
|
||||
// =========================================================================
|
||||
|
||||
describe('buildTrueNASDiskSections additional branches', () => {
|
||||
it('sets Temperature tone to warning at 55 degrees', () => {
|
||||
expect(findRow(diskRes({ temperature: 55 }), 'Temperature')?.tone).toBe('warning');
|
||||
// The drawer judges heat by the disk type's alert trigger, through the
|
||||
// resolver the alerts store provides; these are the factory triggers.
|
||||
const factoryTriggers: Record<string, number> = { nvme: 70, sas: 65, sata: 55 };
|
||||
const resolveThresholds = (diskType: string) => {
|
||||
const critical = factoryTriggers[diskType.toLowerCase()] ?? 55;
|
||||
return { warning: critical - 5, critical };
|
||||
};
|
||||
const temperatureTone = (disk: Partial<ResourcePhysicalDiskMeta>) =>
|
||||
buildTrueNASDetailSections(diskRes(disk), resolveThresholds)
|
||||
.flatMap((section) => section.rows)
|
||||
.find((row) => row.label === 'Temperature')?.tone;
|
||||
|
||||
it('sets Temperature tone to warning from the disk type trigger', () => {
|
||||
expect(temperatureTone({ diskType: 'sata', temperature: 55 })).toBe('warning');
|
||||
expect(temperatureTone({ diskType: 'sas', temperature: 65 })).toBe('warning');
|
||||
expect(temperatureTone({ diskType: 'nvme', temperature: 70 })).toBe('warning');
|
||||
});
|
||||
|
||||
it('sets Temperature tone to warning above 55 degrees', () => {
|
||||
expect(findRow(diskRes({ temperature: 60 }), 'Temperature')?.tone).toBe('warning');
|
||||
it('keeps a reading under its type trigger at default tone', () => {
|
||||
expect(temperatureTone({ diskType: 'nvme', temperature: 60 })).toBe('default');
|
||||
expect(temperatureTone({ diskType: 'sas', temperature: 63 })).toBe('default');
|
||||
expect(temperatureTone({ diskType: 'sata', temperature: 40 })).toBe('default');
|
||||
});
|
||||
|
||||
it('sets Temperature tone to default below 55 degrees', () => {
|
||||
expect(findRow(diskRes({ temperature: 40 }), 'Temperature')?.tone).toBe('default');
|
||||
it('judges no heat without thresholds', () => {
|
||||
expect(findRow(diskRes({ diskType: 'sata', temperature: 80 }), 'Temperature')?.tone).toBe(
|
||||
'default',
|
||||
);
|
||||
expect(
|
||||
buildTrueNASDetailSections(diskRes({ diskType: 'sata', temperature: 80 }), () => null)
|
||||
.flatMap((section) => section.rows)
|
||||
.find((row) => row.label === 'Temperature')?.tone,
|
||||
).toBe('default');
|
||||
});
|
||||
|
||||
it('formats Wearout percentage for a valid value', () => {
|
||||
|
||||
+2
-1
@@ -381,8 +381,9 @@ describe('resourceDetailDrawerTrueNASModel', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const sataTrigger = () => ({ warning: 50, critical: 55 });
|
||||
const temperatureRow = (resource: Resource) =>
|
||||
buildTrueNASDetailSections(resource)
|
||||
buildTrueNASDetailSections(resource, sataTrigger)
|
||||
.find((section) => section.label === 'Health')
|
||||
?.rows.find((row) => row.label === 'Temperature');
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/detailSectionModel';
|
||||
import { getPhysicalDiskTemperaturePresentation } from '@/features/storageBackups/diskTemperaturePresentation';
|
||||
import {
|
||||
getPhysicalDiskTemperaturePresentation,
|
||||
isPhysicalDiskRunningHot,
|
||||
} from '@/features/storageBackups/diskTemperaturePresentation';
|
||||
import type { TrueNASDiskTemperatureThresholdResolver } from '@/features/truenas/truenasPageModel';
|
||||
import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth';
|
||||
|
||||
export type ResourceDetailDrawerTrueNASRowTone = DetailValueTone;
|
||||
@@ -399,9 +403,13 @@ const diskTemperature = (disk: ResourcePhysicalDiskMeta) =>
|
||||
});
|
||||
|
||||
// A retained reading, such as a silent host agent's on a merged disk row, is
|
||||
// not current: it takes no heat tone and its title says why.
|
||||
// not current: it takes no heat tone and its title says why. A current reading
|
||||
// is hot from its disk type's alert trigger, the threshold the TrueNAS storage
|
||||
// table and the disk's temperature alert judge it by. Without a resolver, heat
|
||||
// is not judged.
|
||||
const diskTemperatureRow = (
|
||||
disk: ResourcePhysicalDiskMeta,
|
||||
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
|
||||
): ResourceDetailDrawerTrueNASRow | null => {
|
||||
const reading = diskTemperature(disk);
|
||||
if (!reading) return null;
|
||||
@@ -411,9 +419,12 @@ const diskTemperatureRow = (
|
||||
tone: 'muted',
|
||||
});
|
||||
}
|
||||
return row('Temperature', reading.label, {
|
||||
tone: (disk.temperature ?? 0) >= 55 ? 'warning' : 'default',
|
||||
});
|
||||
const thresholds = resolveDiskTemperatureThresholds?.(disk.diskType ?? '') ?? null;
|
||||
const hot = isPhysicalDiskRunningHot(
|
||||
{ temperature: disk.temperature ?? 0, collection: disk.collection },
|
||||
thresholds,
|
||||
);
|
||||
return row('Temperature', reading.label, { tone: hot ? 'warning' : 'default' });
|
||||
};
|
||||
|
||||
const currentDiskTemperatureLabel = (disk: ResourcePhysicalDiskMeta): string | null => {
|
||||
@@ -424,6 +435,7 @@ const currentDiskTemperatureLabel = (disk: ResourcePhysicalDiskMeta): string | n
|
||||
const buildTrueNASDiskSections = (
|
||||
resource: Resource,
|
||||
disk: ResourcePhysicalDiskMeta,
|
||||
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
|
||||
): ResourceDetailDrawerTrueNASSection[] => {
|
||||
const riskReasons = (disk.risk?.reasons ?? [])
|
||||
.map((reason) => asString(reason.summary))
|
||||
@@ -439,7 +451,7 @@ const buildTrueNASDiskSections = (
|
||||
|
||||
const healthRows = compactRows([
|
||||
row('Health', normalizeDelimitedLabel(disk.health), { tone: diskStateTone(disk) }),
|
||||
diskTemperatureRow(disk),
|
||||
diskTemperatureRow(disk, resolveDiskTemperatureThresholds),
|
||||
row(
|
||||
'Wearout',
|
||||
disk.wearout === undefined || disk.wearout < 0 ? null : formatPercent(disk.wearout),
|
||||
@@ -746,6 +758,7 @@ const buildTrueNASShareSections = (
|
||||
|
||||
export const buildTrueNASDetailSections = (
|
||||
resource: Resource,
|
||||
resolveDiskTemperatureThresholds?: TrueNASDiskTemperatureThresholdResolver,
|
||||
): ResourceDetailDrawerTrueNASSection[] => {
|
||||
if (resource.truenas?.share) return buildTrueNASShareSections(resource.truenas.share);
|
||||
if (resource.truenas?.vm) return buildTrueNASVMSections(resource, resource.truenas.vm);
|
||||
@@ -754,7 +767,11 @@ export const buildTrueNASDetailSections = (
|
||||
return buildTrueNASStorageSections(resource, resource.storage);
|
||||
}
|
||||
if (isTrueNASScopedResource(resource) && resource.physicalDisk) {
|
||||
return buildTrueNASDiskSections(resource, resource.physicalDisk);
|
||||
return buildTrueNASDiskSections(
|
||||
resource,
|
||||
resource.physicalDisk,
|
||||
resolveDiskTemperatureThresholds,
|
||||
);
|
||||
}
|
||||
if (isTrueNASScopedResource(resource) && resource.truenas) {
|
||||
return buildTrueNASSystemSections(resource, resource.truenas);
|
||||
|
||||
+5
-1
@@ -23,6 +23,7 @@ import {
|
||||
} from '@/utils/resourceIdentity';
|
||||
import { isPulseAgentPlatformResource } from '@/utils/agentResources';
|
||||
import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import {
|
||||
getResourcePolicyBadges,
|
||||
getResourcePolicyGovernedSummary,
|
||||
@@ -381,7 +382,10 @@ export const useResourceDetailDrawerDerivedState = (
|
||||
const vmwareDetailsSummary = createMemo(() =>
|
||||
buildVMwareDetailsSummary(resource().type, vmwareData()),
|
||||
);
|
||||
const trueNASDetailSections = createMemo(() => buildTrueNASDetailSections(resource()));
|
||||
const { getDiskTemperatureThresholds } = useAlertsActivation();
|
||||
const trueNASDetailSections = createMemo(() =>
|
||||
buildTrueNASDetailSections(resource(), getDiskTemperatureThresholds),
|
||||
);
|
||||
const hasTrueNASDetails = createMemo(() => trueNASDetailSections().length > 0);
|
||||
const trueNASDetailsSummary = createMemo(() => buildTrueNASDetailsSummary(resource()));
|
||||
const kubernetesDetailSections = createMemo(() => buildKubernetesDetailSections(resource()));
|
||||
|
||||
@@ -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()}`,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChartsAPI } from '@/api/charts';
|
||||
import storageSummarySource from '@/components/Storage/useStorageSummaryCharts.ts?raw';
|
||||
@@ -57,6 +58,9 @@ if (typeof HTMLCanvasElement.prototype.getContext === 'function') {
|
||||
|
||||
let mockLocationSearch = '';
|
||||
let mockLocationPath = '/proxmox/storage';
|
||||
// Most tests read a location snapshot; set liveLocation to follow in-app navigation.
|
||||
let liveLocation = false;
|
||||
const [locationVersion, setLocationVersion] = createSignal(0);
|
||||
const navigateSpy = vi.fn();
|
||||
|
||||
let wsConnected = true;
|
||||
@@ -296,7 +300,19 @@ vi.mock('@solidjs/router', async () => {
|
||||
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
|
||||
return {
|
||||
...actual,
|
||||
useLocation: () => ({ pathname: mockLocationPath, search: mockLocationSearch }),
|
||||
useLocation: () =>
|
||||
liveLocation
|
||||
? {
|
||||
get pathname() {
|
||||
locationVersion();
|
||||
return mockLocationPath;
|
||||
},
|
||||
get search() {
|
||||
locationVersion();
|
||||
return mockLocationSearch;
|
||||
},
|
||||
}
|
||||
: { pathname: mockLocationPath, search: mockLocationSearch },
|
||||
useNavigate: () => navigateSpy,
|
||||
};
|
||||
});
|
||||
@@ -388,6 +404,7 @@ describe('Storage', () => {
|
||||
fetchMock.mockClear();
|
||||
mockLocationPath = '/proxmox/storage';
|
||||
mockLocationSearch = '';
|
||||
liveLocation = false;
|
||||
navigateSpy.mockReset();
|
||||
|
||||
wsConnected = true;
|
||||
@@ -825,6 +842,41 @@ describe('Storage', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reopens the collapsed group of a pool focused by an in-app storage link', async () => {
|
||||
liveLocation = true;
|
||||
hookResources = [
|
||||
buildStorageResource('storage-ceph-link', 'Ceph-Link-Store', 'pve1', {
|
||||
storageType: 'cephfs',
|
||||
}),
|
||||
buildStorageResource('storage-other', 'Other-Store', 'pve2'),
|
||||
];
|
||||
mockLocationSearch = '?group=node';
|
||||
|
||||
render(() => <Storage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Collapse pve1' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Ceph-Link-Store')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockLocationSearch = '?group=node&resource=storage-ceph-link';
|
||||
setLocationVersion((version) => version + 1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Collapse pve1' })).toBeInTheDocument();
|
||||
const poolRow = screen.getByText('Ceph-Link-Store').closest('tr[data-summary-series-id]');
|
||||
expect(
|
||||
document
|
||||
.querySelector('tr[data-inline-detail-for]')
|
||||
?.getAttribute('data-inline-detail-for'),
|
||||
).toBe(poolRow?.getAttribute('data-summary-series-id'));
|
||||
});
|
||||
expect(screen.getAllByRole('tab', { name: 'Storage' })[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears pinned storage group scope from the content-card header action', async () => {
|
||||
hookResources = [
|
||||
buildStorageResource('storage-1', 'Node-Store', 'pve1'),
|
||||
|
||||
@@ -209,13 +209,6 @@ export const useStoragePageModel = (options: UseStoragePageModelOptions = {}) =>
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
const physicalDiskSeriesIds = createMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const disk of physicalDisks()) {
|
||||
ids.add(resolvePhysicalDiskMetricResourceId(disk));
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
const storageSummaryGroupScopes = createMemo<Map<string, SummarySeriesGroupScope>>(() => {
|
||||
if (view() !== 'pools' || groupBy() === 'none') {
|
||||
return new Map<string, SummarySeriesGroupScope>();
|
||||
@@ -409,21 +402,13 @@ export const useStoragePageModel = (options: UseStoragePageModelOptions = {}) =>
|
||||
focusedSeriesId: focusedStorageResourceId,
|
||||
onEscapeClear: clearAllPageStateOnEscape,
|
||||
revealActiveSeries: (seriesId) => {
|
||||
if (physicalDiskSeriesIds().has(seriesId)) {
|
||||
if (view() !== 'disks') {
|
||||
setView('disks');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const groupKey = storageGroupKeyByMetricSeriesId().get(seriesId);
|
||||
if (!groupKey) {
|
||||
return;
|
||||
}
|
||||
// Focus only ever names a row in the active view, so reveal never
|
||||
// switches views; it reopens a focused pool's collapsed group.
|
||||
if (view() !== 'pools') {
|
||||
setView('pools');
|
||||
return;
|
||||
}
|
||||
if (!expandedGroups().has(groupKey)) {
|
||||
const groupKey = storageGroupKeyByMetricSeriesId().get(seriesId);
|
||||
if (groupKey && !expandedGroups().has(groupKey)) {
|
||||
toggleGroup(groupKey);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -440,6 +440,54 @@ describe('GuestDrawer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("judges a linked agent's disk metric in History by that agent, not the Proxmox reason", async () => {
|
||||
chartsApiMocks.getMetricsHistory.mockResolvedValue({
|
||||
resourceType: 'vm',
|
||||
resourceId: 'inst1:node1:100',
|
||||
range: '24h',
|
||||
start: 1,
|
||||
end: 3,
|
||||
metrics: {},
|
||||
source: 'store',
|
||||
});
|
||||
// Proxmox has no guest filesystems, so the agent's filesystems and disk
|
||||
// metric fill in while Proxmox still reports its own agent-not-running.
|
||||
const [guest, setGuest] = createSignal(
|
||||
makeGuest({
|
||||
disk: { total: 100, used: 40, free: 60, usage: 40 },
|
||||
disks: [{ total: 100, used: 40, free: 60, usage: 40, mountpoint: '/' }],
|
||||
disksFromAgent: true,
|
||||
diskStatusReason: 'agent-not-running',
|
||||
}),
|
||||
);
|
||||
render(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
|
||||
);
|
||||
const history = screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
expect(history.querySelector('[data-history-current="disk"]')).toHaveTextContent(
|
||||
'Disk40.0%current',
|
||||
);
|
||||
expect(history.querySelector('[data-history-deferred="disk"]')).toBeNull();
|
||||
|
||||
setGuest({ ...guest(), agentStale: true });
|
||||
expect(history.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
expect(history.querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
|
||||
'Disk40.0%last known',
|
||||
);
|
||||
expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
|
||||
'The Pulse Agent in this guest stopped reporting.',
|
||||
);
|
||||
|
||||
setGuest({ ...guest(), status: 'stopped' });
|
||||
expect(history.querySelector('[data-history-last-known="disk"]')).toBeNull();
|
||||
expect(history.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
|
||||
'Guest filesystem stats unavailable while the VM is stopped.',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the shared discovery loading fallback instead of a drawer-local spinner row', () => {
|
||||
expect(guestDrawerSource).toContain('DiscoveryLoadingFallback');
|
||||
expect(guestDrawerSource).not.toContain(
|
||||
@@ -1602,6 +1650,157 @@ describe('GuestDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── In-guest Pulse Agent readings ──
|
||||
|
||||
describe('In-guest Pulse Agent readings', () => {
|
||||
// Proxmox read no guest filesystems, so these rows came from the agent.
|
||||
const makeAgentGuest = (overrides: Partial<WorkloadGuest> = {}) =>
|
||||
makeGuest({
|
||||
agentVersion: '6.4.5',
|
||||
agentKind: 'pulse',
|
||||
disksFromAgent: true,
|
||||
diskStatusReason: 'agent-not-running',
|
||||
disks: [{ total: 100, used: 75, free: 25, usage: 75, mountpoint: '/' }],
|
||||
networkInterfaces: [{ name: 'eth0', addresses: ['192.168.1.5'], rxBytes: 1024 }],
|
||||
agentRaid: [
|
||||
{
|
||||
device: '/dev/md1',
|
||||
name: 'data',
|
||||
level: 'raid5',
|
||||
state: 'clean, degraded, recovering',
|
||||
totalDevices: 3,
|
||||
activeDevices: 2,
|
||||
workingDevices: 3,
|
||||
failedDevices: 1,
|
||||
spareDevices: 0,
|
||||
devices: [{ device: 'sdd1', state: 'faulty', slot: 1 }],
|
||||
rebuildPercent: 42,
|
||||
rebuildSpeed: '118M/sec',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("reads a silent in-guest agent's RAID, network and filesystems as last known", () => {
|
||||
render(() => <GuestDrawer guest={makeAgentGuest({ agentStale: true })} onClose={vi.fn()} />);
|
||||
|
||||
expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toHaveAttribute(
|
||||
'title',
|
||||
'Pulse Agent 6.4.5 has stopped reporting. Its readings here are its last report.',
|
||||
);
|
||||
expect(
|
||||
technicalDetails().getByText(
|
||||
'Using last known disk stats. The Pulse Agent in this guest stopped reporting.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument();
|
||||
expect(
|
||||
technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
technicalDetails().getByText(
|
||||
'Using last known interface details. The Pulse Agent in this guest stopped reporting.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
technicalDetails()
|
||||
.getByText(/^192\.168\.1\.5/)
|
||||
.closest('td'),
|
||||
).toHaveClass('text-muted');
|
||||
// Proxmox's guest-read reason does not describe rows the agent supplied.
|
||||
expect(technicalDetails().queryByText(/qemu-guest-agent/)).toBeNull();
|
||||
|
||||
const array = screen.getByTestId('raid-card-array');
|
||||
expect(array).toHaveAttribute('data-raid-reading', 'last-known');
|
||||
expect(within(array).getByTestId('raid-card-state')).toHaveAttribute(
|
||||
'title',
|
||||
'Last known reading, not current: host agent stopped reporting',
|
||||
);
|
||||
expect(within(array).getByTestId('raid-card-rebuild')).toHaveTextContent(
|
||||
'Rebuild was at 42%',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a live in-guest agent current when Proxmox has no guest reads', () => {
|
||||
render(() => <GuestDrawer guest={makeAgentGuest()} onClose={vi.fn()} />);
|
||||
|
||||
expect(technicalDetails().getByText('Pulse 6.4.5')).toBeInTheDocument();
|
||||
expect(
|
||||
technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }),
|
||||
).toHaveAttribute('aria-valuenow', '75');
|
||||
expect(technicalDetails().queryByText(/Last known|qemu-guest-agent/)).toBeNull();
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
|
||||
expect(screen.getByTestId('raid-card-rebuild')).toHaveTextContent('Rebuild: 42% · 118M/sec');
|
||||
});
|
||||
|
||||
it("keeps Proxmox's own guest filesystems current under a silent agent", () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeAgentGuest({
|
||||
agentStale: true,
|
||||
disksFromAgent: undefined,
|
||||
diskStatusReason: undefined,
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }),
|
||||
).toHaveAttribute('aria-valuenow', '75');
|
||||
expect(technicalDetails().queryByText(/Using last known disk stats/)).toBeNull();
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute(
|
||||
'data-raid-reading',
|
||||
'last-known',
|
||||
);
|
||||
});
|
||||
|
||||
// Proxmox gives vm-stopped only for a guest with allocated disk, so the
|
||||
// guest's own stopped status must hold the rows too.
|
||||
it.each([
|
||||
['with the Proxmox reason', 'vm-stopped'],
|
||||
['without a Proxmox reason', undefined],
|
||||
])(
|
||||
"reads a stopped guest's agent filesystems as unavailable before the lease lapses, %s",
|
||||
(_label, diskStatusReason) => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeAgentGuest({ status: 'stopped', diskStatusReason })}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
technicalDetails().getByText(
|
||||
'Guest filesystem stats unavailable while the VM is stopped.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText(/^Usage unavailable/)).toBeInTheDocument();
|
||||
expect(
|
||||
technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('follows the agent between silent and reporting while the drawer stays open', () => {
|
||||
const [guest, setGuest] = createSignal(makeAgentGuest());
|
||||
render(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
|
||||
|
||||
setGuest({ ...guest(), agentStale: true });
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute(
|
||||
'data-raid-reading',
|
||||
'last-known',
|
||||
);
|
||||
expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toBeInTheDocument();
|
||||
expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument();
|
||||
|
||||
setGuest({ ...guest(), agentStale: undefined });
|
||||
expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current');
|
||||
expect(technicalDetails().queryByText(/stopped reporting|Last known/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WebInterfaceUrlField ──
|
||||
|
||||
describe('WebInterfaceUrlField', () => {
|
||||
|
||||
@@ -10,11 +10,12 @@ import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDis
|
||||
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
|
||||
import type { Alert } from '@/types/api';
|
||||
import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard';
|
||||
import { HOST_AGENT_STOPPED_REPORTING_REASON } from '@/components/Infrastructure/resourceDetailMappers';
|
||||
import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
import {
|
||||
getWorkloadGuestDiskStatusMessage,
|
||||
getWorkloadGuestDiskRead,
|
||||
getWorkloadsGuestProtectionPresentation,
|
||||
} from '@/utils/workloadGuestPresentation';
|
||||
|
||||
@@ -79,6 +80,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
ageClass: props.backupPresentation?.ageClass,
|
||||
});
|
||||
const guestReadPresentation = () => getGuestDrawerGuestReadPresentation(props.guest);
|
||||
// A linked Pulse agent past its reporting lease leaves its last report on
|
||||
// the guest, so its RAID, network and filesystem rows read as last known.
|
||||
const agentLastKnownReason = () =>
|
||||
props.guest.agentStale ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined;
|
||||
const coverageLabel = () => {
|
||||
if (props.hasWorkloadActionAgent) return WORKLOAD_ACTION_AGENT_LABEL;
|
||||
if (props.hasAgentInfo) return `${props.agentHeading} observed`;
|
||||
@@ -147,23 +152,19 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
props.guest.vmware?.datacenterName ||
|
||||
props.guest.vmware?.clusterName,
|
||||
);
|
||||
const diskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined;
|
||||
// Rows the linked Pulse agent supplied follow that agent's freshness, the
|
||||
// same rule the workload table's disk cell and History read.
|
||||
const diskRead = getWorkloadGuestDiskRead(props.guest, isGuestDrawerVM(props.guest));
|
||||
const diskReadState =
|
||||
props.guest.telemetryAvailability?.disk === false
|
||||
!props.guest.disksFromAgent && props.guest.telemetryAvailability?.disk === false
|
||||
? 'unavailable'
|
||||
: diskReason
|
||||
? diskReason.startsWith('prev-')
|
||||
? 'last-known'
|
||||
: 'unavailable'
|
||||
: 'current';
|
||||
: diskRead.state;
|
||||
const diskRows = (props.guest.disks ?? []).map((disk, index) =>
|
||||
buildWorkloadsDiskPresentation(disk, index, props.diskThresholds, diskReadState),
|
||||
);
|
||||
const diskStatusMessage = diskReason
|
||||
? getWorkloadGuestDiskStatusMessage(diskReason)
|
||||
: diskReadState === 'unavailable'
|
||||
? 'Filesystem usage is unavailable.'
|
||||
: null;
|
||||
const diskStatusMessage =
|
||||
diskRead.message ??
|
||||
(diskReadState === 'unavailable' ? 'Filesystem usage is unavailable.' : null);
|
||||
|
||||
return compactDetailSections([
|
||||
discovery
|
||||
@@ -186,7 +187,13 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
rows: compactDetailRows([
|
||||
props.guest.cpus ? makeDetailRow('CPUs', `${props.guest.cpus}`) : null,
|
||||
props.hasAgentInfo
|
||||
? makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle })
|
||||
? agentLastKnownReason()
|
||||
? makeDetailRow(props.agentHeading, `${props.agentLabel} · stopped reporting`, {
|
||||
title: `${props.agentTitle} has stopped reporting. Its readings here are its last report.`,
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
})
|
||||
: makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle })
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
@@ -272,8 +279,16 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
props.hasNetworkInterfaces
|
||||
? {
|
||||
label: 'Network',
|
||||
rows: compactDetailRows(
|
||||
props.networkInterfaces.slice(0, 4).map((iface, index) => {
|
||||
rows: compactDetailRows([
|
||||
// Only the linked Pulse agent reports guest interfaces.
|
||||
makeDetailRow(
|
||||
'Status',
|
||||
agentLastKnownReason()
|
||||
? 'Using last known interface details. The Pulse Agent in this guest stopped reporting.'
|
||||
: null,
|
||||
{ layout: 'stacked', wrap: true },
|
||||
),
|
||||
...props.networkInterfaces.slice(0, 4).map((iface, index) => {
|
||||
const addresses = iface.addresses ?? [];
|
||||
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
|
||||
const detail = [
|
||||
@@ -291,11 +306,12 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
iface.name || `Interface ${index + 1}`,
|
||||
detail || 'No details',
|
||||
{
|
||||
tone: agentLastKnownReason() ? 'muted' : undefined,
|
||||
wrap: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
]),
|
||||
}
|
||||
: null,
|
||||
]);
|
||||
@@ -338,7 +354,11 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={props.guest.agentRaid?.length}>
|
||||
<RaidCard arrays={props.guest.agentRaid} title="Guest RAID" />
|
||||
<RaidCard
|
||||
arrays={props.guest.agentRaid}
|
||||
title="Guest RAID"
|
||||
lastKnownReason={agentLastKnownReason()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -54,10 +54,6 @@ export { GUEST_COLUMNS, VIEW_MODE_COLUMNS } from './guestRowModel';
|
||||
export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel';
|
||||
import { getGuestColumnStyle } from './guestRowModel';
|
||||
|
||||
// Disk-read reasons that point at something broken the user can fix, as
|
||||
// opposed to a guest that simply has no agent or is not running.
|
||||
const DISK_READ_ACTIONABLE_REASONS = new Set(['permission-denied', 'agent-error']);
|
||||
|
||||
export function GuestRow(props: GuestRowProps) {
|
||||
const [rowActionProps] = splitProps(props, ['onClick']);
|
||||
const selectionGuard = createRowTextSelectionGuard();
|
||||
@@ -82,6 +78,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||
discoveryReadinessPresentation,
|
||||
diskRead,
|
||||
diskThresholds,
|
||||
diskUsageRead,
|
||||
diskWrite,
|
||||
displayId,
|
||||
dockerHostId,
|
||||
@@ -286,7 +283,9 @@ export function GuestRow(props: GuestRowProps) {
|
||||
};
|
||||
|
||||
const getDiskStatusTooltip = () => {
|
||||
if (!isVM(props.guest)) return 'Disk stats unavailable';
|
||||
const message = diskUsageRead().message;
|
||||
if (message) return message;
|
||||
if (!isVM(props.guest) || props.guest.disksFromAgent) return 'Disk stats unavailable';
|
||||
|
||||
const vm = props.guest as VM;
|
||||
return getWorkloadGuestDiskStatusMessage(vm.diskStatusReason);
|
||||
@@ -294,10 +293,9 @@ export function GuestRow(props: GuestRowProps) {
|
||||
// A native title or bar tooltip is not a visible freshness cue on touch,
|
||||
// and disappears from the chart's accessible value when the history lens opens.
|
||||
const diskReadStatus = createMemo(() => {
|
||||
const guest = props.guest;
|
||||
if (!isVM(guest) || !guest.diskStatusReason) return undefined;
|
||||
const reason = guest.diskStatusReason;
|
||||
const retained = reason.startsWith('prev-');
|
||||
const read = diskUsageRead();
|
||||
if (read.state === 'current') return undefined;
|
||||
const retained = read.state === 'last-known';
|
||||
return {
|
||||
label: retained
|
||||
? usesCompactTableLayout()
|
||||
@@ -306,11 +304,11 @@ export function GuestRow(props: GuestRowProps) {
|
||||
: usesCompactTableLayout()
|
||||
? 'N/A'
|
||||
: 'Unavailable',
|
||||
message: getDiskStatusTooltip(),
|
||||
message: read.message,
|
||||
valueLabelContext: retained ? ('last known' as const) : ('current' as const),
|
||||
// Most reasons are a setup gap (no guest agent, VM stopped) rather than a
|
||||
// fault, so they stay muted; only a broken read the user can fix is amber.
|
||||
needsAction: DISK_READ_ACTIONABLE_REASONS.has(reason.replace(/^prev-/, '')),
|
||||
needsAction: read.needsAction,
|
||||
};
|
||||
});
|
||||
const diskValueLabelContext = (): MetricMiniSparklineValueLabelContext =>
|
||||
@@ -632,11 +630,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||
aggregateDisk={props.guest.disk}
|
||||
anomaly={diskAnomaly()}
|
||||
thresholds={diskThresholds()}
|
||||
statusMessage={
|
||||
isVM(props.guest) && props.guest.diskStatusReason
|
||||
? getDiskStatusTooltip()
|
||||
: undefined
|
||||
}
|
||||
statusMessage={diskUsageRead().message ?? undefined}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1076,6 +1076,61 @@ describe('GuestRow', () => {
|
||||
);
|
||||
expect(container.querySelector('[data-workload-disk-read-status]')).toBeNull();
|
||||
});
|
||||
|
||||
it("follows a linked agent's filesystems, not the Proxmox read reason", () => {
|
||||
// Proxmox has no guest filesystems, so the agent's fill in while
|
||||
// Proxmox still reports its own agent-not-running.
|
||||
const agentGuest = (overrides: Partial<WorkloadGuest> = {}) =>
|
||||
makeGuest({
|
||||
diskStatusReason: 'agent-not-running',
|
||||
disksFromAgent: true,
|
||||
disks: [makeDisk({ mountpoint: '/' })],
|
||||
...overrides,
|
||||
});
|
||||
const [guest, setGuest] = createSignal(agentGuest());
|
||||
const { container } = render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<GuestRow guest={guest()} visibleColumnIds={['name', 'disk']} />
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
expect(screen.getByTestId('disk-bar')).not.toHaveAttribute('data-status-message');
|
||||
expect(container.querySelector('[data-workload-disk-read-status]')).toBeNull();
|
||||
|
||||
const lastKnown =
|
||||
'Using last known disk stats. The Pulse Agent in this guest stopped reporting.';
|
||||
setGuest(agentGuest({ agentStale: true }));
|
||||
expect(screen.getByTestId('disk-bar')).toHaveAttribute('data-status-message', lastKnown);
|
||||
let notice = container.querySelector('[data-workload-disk-read-status]');
|
||||
expect(notice?.querySelector('[aria-hidden="true"]')).toHaveTextContent('Last known');
|
||||
expect(notice).toHaveAttribute('title', lastKnown);
|
||||
expect(notice?.className).toContain('text-muted');
|
||||
|
||||
setGuest(agentGuest({ agentStale: true, status: 'stopped', diskStatusReason: 'vm-stopped' }));
|
||||
expect(screen.queryByTestId('disk-bar')).toBeNull();
|
||||
notice = container.querySelector('[data-workload-disk-read-status]');
|
||||
expect(notice?.querySelector('[aria-hidden="true"]')).toHaveTextContent('Unavailable');
|
||||
expect(notice).toHaveAttribute(
|
||||
'title',
|
||||
'Guest filesystem stats unavailable while the VM is stopped.',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not announce a silent linked agent's sparkline reading as current", () => {
|
||||
renderGuestRow({
|
||||
guest: makeGuest({
|
||||
diskStatusReason: 'agent-not-running',
|
||||
disksFromAgent: true,
|
||||
agentStale: true,
|
||||
}),
|
||||
visibleColumnIds: ['name', 'disk'],
|
||||
metricDisplayMode: 'sparklines',
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'test-vm disk usage history, last known 50%' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lock label', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/features/alerts/metricAlertPresentation';
|
||||
import { formatBytes, formatPercent, getBackupInfo, type BackupThresholds } from '@/utils/format';
|
||||
import {
|
||||
getWorkloadGuestDiskRead,
|
||||
getWorkloadGuestDiskStatusMessage,
|
||||
getWorkloadsGuestBackupStatusPresentation,
|
||||
} from '@/utils/workloadGuestPresentation';
|
||||
@@ -153,12 +154,13 @@ export const getGuestDrawerDeferredMetrics = (
|
||||
guest: Guest,
|
||||
): Record<string, GuestDrawerHistoryDeferredMetric> => {
|
||||
const metrics: Record<string, GuestDrawerHistoryDeferredMetric> = {};
|
||||
if (isGuestDrawerVM(guest) && guest.diskStatusReason) {
|
||||
// History judges the aggregate disk metric by the same rule as the
|
||||
// filesystem rows, so a linked agent's reading follows that agent.
|
||||
const disk = getWorkloadGuestDiskRead(guest, isGuestDrawerVM(guest));
|
||||
if (disk.state !== 'current') {
|
||||
metrics.disk = {
|
||||
lastKnownValue: guest.diskStatusReason.startsWith('prev-')
|
||||
? getGuestDrawerDiskUsage(guest)
|
||||
: undefined,
|
||||
message: getWorkloadGuestDiskStatusMessage(guest.diskStatusReason),
|
||||
lastKnownValue: disk.state === 'last-known' ? getGuestDrawerDiskUsage(guest) : undefined,
|
||||
message: disk.message,
|
||||
};
|
||||
}
|
||||
const memory = getGuestDrawerMemoryReading(guest);
|
||||
@@ -188,7 +190,9 @@ export const getGuestDrawerCurrentMetrics = (guest: Guest): Record<string, numbe
|
||||
// A paused filesystem read is not current, even if the snapshot still
|
||||
// carries a numeric summary. Retained evidence has its own labelled path.
|
||||
const diskUsage =
|
||||
isGuestDrawerVM(guest) && guest.diskStatusReason ? undefined : getGuestDrawerDiskUsage(guest);
|
||||
getWorkloadGuestDiskRead(guest, isGuestDrawerVM(guest)).state === 'current'
|
||||
? getGuestDrawerDiskUsage(guest)
|
||||
: undefined;
|
||||
const finite = (value: number | undefined): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildMetricKey } from '@/utils/metricsKeys';
|
||||
import { getGuestHealthIndicator, isGuestRunning } from '@/utils/status';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { getContainerRuntimeBadgeForRuntime } from '@/utils/resourceBadgePresentation';
|
||||
import { getWorkloadGuestDiskRead } from '@/utils/workloadGuestPresentation';
|
||||
import {
|
||||
getCanonicalWorkloadId,
|
||||
getWorkloadPlatformScopes,
|
||||
@@ -225,21 +226,21 @@ export function useGuestRowState(props: GuestRowProps) {
|
||||
return ((disk.used ?? 0) / total) * 100;
|
||||
});
|
||||
|
||||
// The cell's filesystem bars and aggregate follow the drawer's rule: a
|
||||
// linked agent's filesystems follow that agent, Proxmox's its read reason.
|
||||
const diskUsageRead = createMemo(() =>
|
||||
getWorkloadGuestDiskRead(props.guest, workloadType() === 'vm'),
|
||||
);
|
||||
|
||||
const hasDiskUsage = createMemo(() => {
|
||||
const guest = props.guest;
|
||||
if (!guest.disk || guest.telemetryAvailability?.disk === false) return false;
|
||||
const total = guest.disk.total ?? 0;
|
||||
if (!Number.isFinite(total) || total <= 0) return false;
|
||||
// A generic canonical merge can retain the previous numeric metric while
|
||||
// the provider explicitly reports no usable guest reading. Only prev-
|
||||
// reasons allow that number to remain visible as last-known evidence.
|
||||
if (
|
||||
workloadType() === 'vm' &&
|
||||
'diskStatusReason' in guest &&
|
||||
guest.diskStatusReason &&
|
||||
!guest.diskStatusReason.startsWith('prev-')
|
||||
)
|
||||
return false;
|
||||
// its source reports no usable guest reading. Only a last known reading
|
||||
// may keep that number visible.
|
||||
if (diskUsageRead().state === 'unavailable') return false;
|
||||
const percent = diskPercent();
|
||||
return Number.isFinite(percent) && percent >= 0;
|
||||
});
|
||||
@@ -315,6 +316,7 @@ export function useGuestRowState(props: GuestRowProps) {
|
||||
discoveryReadinessPresentation,
|
||||
diskRead,
|
||||
diskThresholds,
|
||||
diskUsageRead,
|
||||
diskWrite,
|
||||
displayId,
|
||||
dockerHostId,
|
||||
|
||||
@@ -5,9 +5,12 @@ import { TableCell, TableRow, type TableRowProps } from './Table';
|
||||
export const INLINE_DETAIL_TABLE_CELL_CLASS = 'p-0 border-b border-border bg-surface-alt';
|
||||
// overflow-x-clip keeps over-wide content from painting outside the row's
|
||||
// border below the lg breakpoint (issue #1622) without creating a scroll
|
||||
// container the way overflow-hidden would.
|
||||
// container the way overflow-hidden would. Below lg the sticky content is as
|
||||
// wide as the table's visible area: 100cqi resolves against the table scroll
|
||||
// shell (a size container), so a guess at the page chrome cannot leave an
|
||||
// empty strip beside the drawer or push it past the visible edge.
|
||||
export const INLINE_DETAIL_TABLE_CONTENT_CLASS =
|
||||
'sticky left-0 min-w-0 max-w-[calc(100vw-3.5rem)] whitespace-normal overflow-x-clip px-2 py-3 sm:px-4 sm:py-4 lg:static lg:max-w-none lg:overflow-x-visible';
|
||||
'sticky left-0 min-w-0 max-w-[100cqi] whitespace-normal overflow-x-clip px-2 py-3 sm:px-4 sm:py-4 lg:static lg:max-w-none lg:overflow-x-visible';
|
||||
|
||||
export interface InlineDetailTableRowProps extends TableRowProps {
|
||||
cellId?: string;
|
||||
|
||||
@@ -1348,13 +1348,8 @@ describe('shared primitive guardrails', () => {
|
||||
);
|
||||
expect(contextualFocusSource).toContain('export const findInlineDetailElement');
|
||||
expect(contextualFocusSource).toContain('export const revealInlineDetailInViewport');
|
||||
expect(contextualFocusSource).toContain('export function useSummaryContextualFocusState');
|
||||
expect(contextualFocusSource).toContain('chartHoveredSeriesId');
|
||||
expect(contextualFocusSource).toContain('hoveredGroupScope');
|
||||
expect(contextualFocusSource).toContain('filterSeriesForActiveScope');
|
||||
expect(contextualFocusSource).toContain('markRouteStateDeliberateScroll');
|
||||
expect(contextualFocusSource).toContain('data-inline-detail-for');
|
||||
expect(summaryCardInteractionSource).toContain('chartHoveredSeriesId');
|
||||
expect(summaryCardInteractionSource).toContain('SummarySeriesGroupScope');
|
||||
expect(summaryCardInteractionSource).toContain('resolveSummaryGroupScope');
|
||||
expect(summaryCardInteractionSource).toContain('resolveSummaryGroupMemberInteractionState');
|
||||
@@ -1368,7 +1363,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(summaryTableFocusSource).toContain('export function useSummaryTableFocusBridge');
|
||||
expect(summaryTableFocusSource).toContain('export function useSummaryPageInteractionState');
|
||||
expect(summaryTableFocusSource).toContain('resolveSummaryActiveSeriesId');
|
||||
expect(summaryTableFocusSource).toContain('activeScopeState');
|
||||
expect(summaryTableFocusSource).toContain('focusedSeriesId');
|
||||
expect(summaryTableFocusSource).toContain('findInlineDetailElement');
|
||||
expect(summaryTableFocusSource).toContain('revealInlineDetailInViewport');
|
||||
@@ -1380,8 +1374,9 @@ describe('shared primitive guardrails', () => {
|
||||
expect(summaryTableFocusSource).toContain("event.key !== 'Escape'");
|
||||
expect(summaryTableFocusSource).toContain('querySelector<HTMLElement>(');
|
||||
expect(summaryTableFocusSource).toContain(
|
||||
"row.scrollIntoView({ behavior: 'smooth', block: 'center' })",
|
||||
"row.scrollIntoView({ behavior: 'smooth', block: 'nearest' })",
|
||||
);
|
||||
expect(summaryTableFocusSource).not.toContain("window.addEventListener('scroll'");
|
||||
expect(summaryTableFocusSource).not.toContain('useNavigate(');
|
||||
});
|
||||
|
||||
@@ -8761,7 +8756,10 @@ describe('shared primitive guardrails', () => {
|
||||
expect(summaryRowActionButtonSource).toContain('h-9 w-9');
|
||||
expect(searchFieldSource).toContain('min-h-11');
|
||||
expect(filterToolbarSource).toContain('min-h-11');
|
||||
expect(inlineDetailTableRowSource).toContain('max-w-[calc(100vw-3.5rem)]');
|
||||
// Phone and tablet drawers fill the table's visible width (the scroll
|
||||
// shell is a size container), not a viewport guess at the page chrome.
|
||||
expect(inlineDetailTableRowSource).toContain('max-w-[100cqi]');
|
||||
expect(inlineDetailTableRowSource).not.toContain('100vw-3.5rem');
|
||||
});
|
||||
|
||||
it('keeps shared settings panels dense without forking their desktop framing', () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('InlineDetailTableRow', () => {
|
||||
expect(detail.parentElement).toHaveClass('left-0');
|
||||
expect(detail.parentElement).toHaveClass('min-w-0');
|
||||
expect(detail.parentElement).toHaveClass('whitespace-normal');
|
||||
expect(detail.parentElement).toHaveClass('max-w-[calc(100vw-3.5rem)]');
|
||||
expect(detail.parentElement).toHaveClass('max-w-[100cqi]');
|
||||
expect(detail.parentElement).toHaveClass('lg:static');
|
||||
expect(detail.parentElement).toHaveClass('lg:max-w-none');
|
||||
});
|
||||
|
||||
+4
-54
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
filterSummarySeriesByGroupScope,
|
||||
isSummarySeriesInGroupScope,
|
||||
normalizeSummarySeriesGroupScope,
|
||||
resolveSummaryGroupMemberInteractionState,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
// summaryCardInteraction.ts. The existing spec (summaryCardInteraction.test.ts)
|
||||
// exercises the precedence/happy paths; this file targets the null/empty/
|
||||
// malformed-input arms that never fire there. A scoped v8 coverage run against
|
||||
// the existing spec alone leaves exactly 8 of 56 branch arms cold (0 functions):
|
||||
// the existing spec alone leaves these branch arms cold:
|
||||
// b6 normalizeSummarySeriesGroupScope: `scope.seriesIds ?? []` nullish-fallback
|
||||
// b8 normalizeSummarySeriesGroupScope: valid id but `seriesIds.length === 0` -> null
|
||||
// b10 normalizeSummarySeriesGroupScope: `typeof scope.label !== 'string'` else arm
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
// b14 isSummarySeriesInGroupScope: valid scope but empty seriesId -> false
|
||||
// b19 resolveSummaryGroupMemberInteractionState: empty seriesId -> 'default'
|
||||
// b37 resolveSummaryScopeState: hoveredGroupScope truthy -> group/preview return
|
||||
// b43 filterSummarySeriesByGroupScope: null/invalid scope -> `[...series]`
|
||||
// Every asserted value below is hand-computed against the source in
|
||||
// src/components/shared/summaryCardInteraction.ts — no snapshots, no
|
||||
// constant-equals-itself tautologies. Deliberately-malformed inputs (missing
|
||||
@@ -187,12 +185,12 @@ describe('summaryCardInteraction.branchcov0724pm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers a valid chartHoveredSeriesId over the hoveredGroupScope group return, confirming b37 is precedence-gated (regression guard)', () => {
|
||||
// chartHoveredSeriesId 'alpha' is in the resolved scope -> entity/preview
|
||||
it('prefers a hovered series inside the scope over the hoveredGroupScope group return, confirming b37 is precedence-gated (regression guard)', () => {
|
||||
// hoveredSeriesId 'alpha' is in the resolved scope -> entity/preview
|
||||
// return wins; the hoveredGroupScope group arm (b37) is NOT taken here.
|
||||
expect(
|
||||
resolveSummaryScopeState({
|
||||
chartHoveredSeriesId: 'alpha',
|
||||
hoveredSeriesId: 'alpha',
|
||||
hoveredGroupScope: validScope,
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -203,52 +201,4 @@ describe('summaryCardInteraction.branchcov0724pm', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// filterSummarySeriesByGroupScope — the no-scope -> shallow-copy arm. The
|
||||
// existing spec only calls this with a valid scope (the .filter path).
|
||||
// -------------------------------------------------------------------------
|
||||
describe('filterSummarySeriesByGroupScope', () => {
|
||||
it('returns a copy of every series when the scope is null/undefined (b43)', () => {
|
||||
const series = [{ id: 'alpha' }, { id: 'beta' }, { id: 'gamma' }];
|
||||
// null scope -> normalizedScope null -> `return [...series]` (b43).
|
||||
const fromNull = filterSummarySeriesByGroupScope(series, null);
|
||||
expect(fromNull.map((s) => s.id)).toEqual(['alpha', 'beta', 'gamma']);
|
||||
// Must be a NEW array reference (shallow copy), not the input identity.
|
||||
expect(fromNull).not.toBe(series);
|
||||
expect(fromNull).toEqual(series);
|
||||
|
||||
const fromUndefined = filterSummarySeriesByGroupScope(series, undefined);
|
||||
expect(fromUndefined.map((s) => s.id)).toEqual(['alpha', 'beta', 'gamma']);
|
||||
expect(fromUndefined).not.toBe(series);
|
||||
});
|
||||
|
||||
it('returns a copy of every series when the scope normalizes to null because its id is blank (b43 via invalid id)', () => {
|
||||
const series = [{ id: 'alpha' }];
|
||||
// id '' -> normalizeSummarySeriesGroupScope returns null -> b43.
|
||||
const result = filterSummarySeriesByGroupScope(series, {
|
||||
id: '',
|
||||
seriesIds: ['alpha'],
|
||||
});
|
||||
expect(result.map((s) => s.id)).toEqual(['alpha']);
|
||||
expect(result).not.toBe(series);
|
||||
});
|
||||
|
||||
it('returns a copy of every series when the scope normalizes to null because its seriesIds are empty (b43 via empty list)', () => {
|
||||
const series = [{ id: 'alpha' }, { id: 'beta' }];
|
||||
// valid id but empty seriesIds -> normalize returns null -> b43.
|
||||
const result = filterSummarySeriesByGroupScope(series, {
|
||||
id: 'cluster-a',
|
||||
seriesIds: [],
|
||||
});
|
||||
expect(result.map((s) => s.id)).toEqual(['alpha', 'beta']);
|
||||
expect(result).not.toBe(series);
|
||||
});
|
||||
|
||||
it('still filters down to in-scope series for a valid scope so the b43 copy path is proven not to over-include', () => {
|
||||
const series = [{ id: 'alpha' }, { id: 'beta' }, { id: 'gamma' }];
|
||||
const result = filterSummarySeriesByGroupScope(series, validScope);
|
||||
expect(result.map((s) => s.id)).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { cleanup, renderHook } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { cleanup } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus';
|
||||
import {
|
||||
preserveScrollableAncestorVerticalOffset,
|
||||
useSummaryContextualFocusState,
|
||||
} from '@/components/shared/contextualFocus';
|
||||
import {
|
||||
filterSummarySeriesByGroupScope,
|
||||
resolveSummaryActiveSeriesId,
|
||||
resolveSummaryCardInteractionState,
|
||||
resolveSummaryGroupMemberInteractionState,
|
||||
resolveSummaryScopeState,
|
||||
type SummarySeriesGroupScope,
|
||||
@@ -29,14 +23,6 @@ describe('summaryCardInteraction', () => {
|
||||
});
|
||||
|
||||
it('prefers hovered series ids and falls back to focused ids', () => {
|
||||
expect(
|
||||
resolveSummaryActiveSeriesId({
|
||||
chartHoveredSeriesId: 'gamma',
|
||||
hoveredSeriesId: 'beta',
|
||||
focusedSeriesId: 'alpha',
|
||||
}),
|
||||
).toBe('gamma');
|
||||
|
||||
expect(
|
||||
resolveSummaryActiveSeriesId({
|
||||
hoveredSeriesId: 'beta',
|
||||
@@ -52,35 +38,6 @@ describe('summaryCardInteraction', () => {
|
||||
).toBe('alpha');
|
||||
});
|
||||
|
||||
it('returns default without an active hover or focus context', () => {
|
||||
expect(
|
||||
resolveSummaryCardInteractionState({
|
||||
series: [{ id: 'alpha' }],
|
||||
}),
|
||||
).toBe('default');
|
||||
});
|
||||
|
||||
it('marks cards active when the hovered series is present', () => {
|
||||
expect(
|
||||
resolveSummaryCardInteractionState({
|
||||
series: [{ id: 'alpha' }, { id: 'beta' }],
|
||||
chartHoveredSeriesId: 'beta',
|
||||
hoveredSeriesId: 'beta',
|
||||
focusedSeriesId: 'alpha',
|
||||
}),
|
||||
).toBe('active');
|
||||
});
|
||||
|
||||
it('marks cards inactive when another summary series owns the current interaction', () => {
|
||||
expect(
|
||||
resolveSummaryCardInteractionState({
|
||||
series: [{ id: 'pool:alpha' }, { id: 'pool:beta' }],
|
||||
hoveredSeriesId: 'SERIAL-123',
|
||||
focusedSeriesId: 'pool:alpha',
|
||||
}),
|
||||
).toBe('inactive');
|
||||
});
|
||||
|
||||
it('keeps group scope separate from active entity focus', () => {
|
||||
const groupScope: SummarySeriesGroupScope = {
|
||||
id: 'cluster-a',
|
||||
@@ -95,27 +52,6 @@ describe('summaryCardInteraction', () => {
|
||||
groupScope,
|
||||
}),
|
||||
).toBe('alpha');
|
||||
|
||||
expect(
|
||||
resolveSummaryCardInteractionState({
|
||||
series: [{ id: 'alpha' }, { id: 'beta' }],
|
||||
hoveredGroupScope: groupScope,
|
||||
}),
|
||||
).toBe('active');
|
||||
|
||||
expect(
|
||||
resolveSummaryCardInteractionState({
|
||||
series: [{ id: 'gamma' }],
|
||||
hoveredGroupScope: groupScope,
|
||||
}),
|
||||
).toBe('inactive');
|
||||
|
||||
expect(
|
||||
filterSummarySeriesByGroupScope(
|
||||
[{ id: 'alpha' }, { id: 'beta' }, { id: 'gamma' }],
|
||||
groupScope,
|
||||
).map((series) => series.id),
|
||||
).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
|
||||
it('resolves preview and pinned scope state through one shared precedence helper', () => {
|
||||
@@ -127,8 +63,7 @@ describe('summaryCardInteraction', () => {
|
||||
|
||||
expect(
|
||||
resolveSummaryScopeState({
|
||||
chartHoveredSeriesId: 'beta',
|
||||
hoveredSeriesId: 'alpha',
|
||||
hoveredSeriesId: 'beta',
|
||||
focusedSeriesId: 'alpha',
|
||||
hoveredGroupScope: groupScope,
|
||||
focusedGroupScope: groupScope,
|
||||
@@ -201,74 +136,6 @@ describe('summaryCardInteraction', () => {
|
||||
).toBe('default');
|
||||
});
|
||||
|
||||
it('filters contextual focus down to interactive series ids through one shared hook', () => {
|
||||
const [hoveredSeriesId] = createSignal<string | null>('gamma');
|
||||
const [focusedSeriesId] = createSignal<string | null>('alpha');
|
||||
const [chartHoveredSeriesId, setChartHoveredSeriesId] = createSignal<string | null>(null);
|
||||
const [hoveredGroupScope, setHoveredGroupScope] = createSignal<SummarySeriesGroupScope | null>({
|
||||
id: 'cluster-a',
|
||||
label: 'Cluster A (2 workloads)',
|
||||
seriesIds: ['alpha', 'beta'],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryContextualFocusState({
|
||||
chartHoveredSeriesId,
|
||||
interactiveSeries: () => [
|
||||
{ id: 'alpha', name: 'Alpha', interactive: true },
|
||||
{ id: 'beta', name: 'Beta', interactive: false },
|
||||
],
|
||||
hoveredGroupScope,
|
||||
hoveredSeriesId,
|
||||
focusedSeriesId,
|
||||
isSeriesInteractive: (series) => series.interactive,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.hasInteractiveSeriesId('alpha')).toBe(true);
|
||||
expect(result.hasInteractiveSeriesId('beta')).toBe(false);
|
||||
expect(result.activeGroupScope()).toEqual({
|
||||
id: 'cluster-a',
|
||||
label: 'Cluster A (2 workloads)',
|
||||
seriesIds: ['alpha'],
|
||||
});
|
||||
expect(result.effectiveHoveredSeriesId()).toBeNull();
|
||||
expect(result.effectiveFocusedSeriesId()).toBe('alpha');
|
||||
expect(result.activeSeriesId()).toBe('alpha');
|
||||
expect(
|
||||
result.getFocusedSeriesName([
|
||||
{ id: 'alpha', name: 'Alpha', interactive: true },
|
||||
{ id: 'beta', name: 'Beta', interactive: false },
|
||||
]),
|
||||
).toBe('Alpha');
|
||||
expect(result.interactionStateFor([{ id: 'alpha' }])).toBe('active');
|
||||
expect(result.interactionStateFor([{ id: 'beta' }])).toBe('inactive');
|
||||
|
||||
setChartHoveredSeriesId('alpha');
|
||||
expect(result.effectiveChartHoveredSeriesId()).toBe('alpha');
|
||||
expect(result.activeSeriesId()).toBe('alpha');
|
||||
expect(result.getActiveSeriesName([{ id: 'alpha', name: 'Alpha', interactive: true }])).toBe(
|
||||
'Alpha',
|
||||
);
|
||||
|
||||
setChartHoveredSeriesId(null);
|
||||
setHoveredGroupScope({
|
||||
id: 'cluster-a',
|
||||
label: 'Cluster A (2 workloads)',
|
||||
seriesIds: ['alpha'],
|
||||
});
|
||||
expect(result.activeGroupScope()?.id).toBe('cluster-a');
|
||||
expect(result.activeSeriesId()).toBe('alpha');
|
||||
expect(
|
||||
result.filterSeriesForActiveScope([
|
||||
{ id: 'alpha', name: 'Alpha', interactive: true },
|
||||
{ id: 'gamma', name: 'Gamma', interactive: true },
|
||||
]),
|
||||
).toEqual([{ id: 'alpha', name: 'Alpha', interactive: true }]);
|
||||
expect(result.isSeriesIdVisibleInActiveScope('alpha')).toBe(true);
|
||||
expect(result.isSeriesIdVisibleInActiveScope('gamma')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves the nearest scrollable ancestor when contextual focus changes locally', () => {
|
||||
const scroller = document.createElement('div');
|
||||
scroller.style.overflowY = 'auto';
|
||||
|
||||
+74
-269
@@ -9,12 +9,9 @@ import {
|
||||
// Branch-coverage tests for the UNCOVERED guard arms of summaryTableFocus.ts.
|
||||
// The existing spec (summaryTableFocus.test.tsx) exercises the happy paths;
|
||||
// this file targets the null/empty/early-return arms that never fire there:
|
||||
// - scheduleViewportRefresh (scroll/resize -> viewportVersion tick) — the
|
||||
// primary measured gap; never invoked by the existing suite.
|
||||
// - activeRow / focusedGroupRow guards: missing container ref, empty id,
|
||||
// querySelector miss, attribute-selector escaping.
|
||||
// - jumpToActiveRow: no-active-id no-op + frame-exhaustion arm.
|
||||
// - shouldShowJumpToActiveRow: empty summary collections + already-visible.
|
||||
// - focusedGroupRow guards: missing container ref, empty id, querySelector
|
||||
// miss, attribute-selector escaping.
|
||||
// - page-default and hover-only active series resolution.
|
||||
// - Escape handler: defaultPrevented, every modifier arm, dialog target.
|
||||
// Every asserted value below is hand-computed against the source in
|
||||
// src/components/shared/summaryTableFocus.ts — no snapshots, no constant-
|
||||
@@ -50,326 +47,140 @@ describe('summaryTableFocus.branchcov0723pm', () => {
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PRIMARY TARGET: scheduleViewportRefresh
|
||||
// (createEffect at summaryTableFocus.ts:226). The existing spec never
|
||||
// dispatches scroll/resize after mounting a table root, so the entire
|
||||
// effect body — listener registration, the rAF callback that ticks
|
||||
// viewportVersion, the dedup guard, and the cancel-on-cleanup arm — is
|
||||
// uncovered.
|
||||
// focusedGroupRow guard arms: missing container ref, empty id, querySelector
|
||||
// miss, attribute-selector escaping.
|
||||
// -------------------------------------------------------------------------
|
||||
describe('scheduleViewportRefresh', () => {
|
||||
it('ticks viewportVersion via the rAF callback after a scroll event, recomputing isActiveRowVisible', () => {
|
||||
// Deferred rAF: capture the callback so we control when viewportVersion
|
||||
// increments (the synchronous default stub would tick instantly and hide
|
||||
// the dedup arm tested below).
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
let nextId = 7;
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
scheduled.push(cb);
|
||||
return nextId++;
|
||||
});
|
||||
|
||||
const root = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
row.setAttribute('data-summary-series-id', 'workload-a');
|
||||
let rowTop = 1200; // below innerHeight(800) -> not visible
|
||||
Object.defineProperty(row, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => buildRect(rowTop),
|
||||
});
|
||||
root.appendChild(row);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const [hoveredSeriesId] = createSignal<string | null>('workload-a');
|
||||
const { result } = renderHook(() => useSummaryPageInteractionState({ hoveredSeriesId }));
|
||||
result.setTableRootRef(root);
|
||||
|
||||
// Row below the viewport -> jump affordance shown.
|
||||
expect(result.shouldShowJumpToActiveRow()).toBe(true);
|
||||
|
||||
// Slide the row into the viewport. The memo must NOT pick this up yet
|
||||
// because viewportVersion has not ticked (no rAF has fired).
|
||||
rowTop = 64;
|
||||
expect(result.shouldShowJumpToActiveRow()).toBe(true);
|
||||
|
||||
// scroll -> scheduleViewportRefresh schedules exactly one rAF.
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
expect(scheduled).toHaveLength(1);
|
||||
|
||||
// Fire the rAF -> viewportVersion++ -> isActiveRowVisible recomputes ->
|
||||
// row is now in-viewport -> affordance hidden.
|
||||
scheduled[0](0);
|
||||
expect(result.shouldShowJumpToActiveRow()).toBe(false);
|
||||
});
|
||||
|
||||
it('coalesces back-to-back scroll events into a single rAF (rafId !== undefined dedup arm)', () => {
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
let nextId = 3;
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
scheduled.push(cb);
|
||||
return nextId++;
|
||||
});
|
||||
|
||||
const root = document.createElement('div');
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({ activeSeriesId: () => null }),
|
||||
);
|
||||
result.setTableRootRef(root);
|
||||
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
|
||||
// Only the first scroll schedules an rAF; subsequent ones hit
|
||||
// `if (rafId !== undefined) return;` while the callback is pending.
|
||||
expect(scheduled).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshes viewportVersion on window resize as well as scroll (resize listener arm)', () => {
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
let nextId = 11;
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
scheduled.push(cb);
|
||||
return nextId++;
|
||||
});
|
||||
|
||||
const root = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
row.setAttribute('data-summary-series-id', 'workload-a');
|
||||
let rowTop = 900;
|
||||
Object.defineProperty(row, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => buildRect(rowTop),
|
||||
});
|
||||
root.appendChild(row);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const [hoveredSeriesId] = createSignal<string | null>('workload-a');
|
||||
const { result } = renderHook(() => useSummaryPageInteractionState({ hoveredSeriesId }));
|
||||
result.setTableRootRef(root);
|
||||
expect(result.shouldShowJumpToActiveRow()).toBe(true);
|
||||
|
||||
rowTop = 50;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
expect(scheduled).toHaveLength(1);
|
||||
scheduled[0](0);
|
||||
expect(result.shouldShowJumpToActiveRow()).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels the pending rAF id when the owner is disposed (cancelAnimationFrame cleanup arm)', () => {
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
const cancelSpy = vi.fn();
|
||||
const pendingId = 42;
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
scheduled.push(cb);
|
||||
return pendingId;
|
||||
});
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelSpy);
|
||||
|
||||
const root = document.createElement('div');
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result, cleanup } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({ activeSeriesId: () => null }),
|
||||
);
|
||||
result.setTableRootRef(root);
|
||||
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
expect(cancelSpy).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
|
||||
// The rAF id returned by the deferred stub (42) is cancelled on dispose.
|
||||
expect(cancelSpy).toHaveBeenCalledWith(pendingId);
|
||||
});
|
||||
|
||||
it('does not attach scroll/resize listeners when tableRoot ref is missing (missing container ref arm)', () => {
|
||||
describe('focusedGroupRow guard arms', () => {
|
||||
it('does not attempt a reveal when setTableRootRef receives undefined (setter `element ?? null` right arm)', () => {
|
||||
const rafSpy = vi.fn((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal('requestAnimationFrame', rafSpy);
|
||||
|
||||
renderHook(() => useSummaryTableFocusBridge({ activeSeriesId: () => null }));
|
||||
const [focusedGroupId] = createSignal<string | null>('group-a');
|
||||
const { result } = renderHook(() => useSummaryTableFocusBridge({ focusedGroupId }));
|
||||
result.setTableRootRef(undefined);
|
||||
|
||||
// No setTableRootRef -> effect hits `if (!root ...) return;` -> no
|
||||
// listeners -> scroll/resize schedule no rAF.
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
// tableRoot stays null -> the reveal effect returns before scheduling.
|
||||
expect(rafSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// activeRow guard arms: missing container ref, empty id, querySelector miss,
|
||||
// attribute-selector escaping.
|
||||
// -------------------------------------------------------------------------
|
||||
describe('activeRow guard arms', () => {
|
||||
it('returns null when the tableRoot ref has never been set (missing container ref)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({ activeSeriesId: () => 'workload-a' }),
|
||||
);
|
||||
expect(result.activeRow()).toBeNull();
|
||||
});
|
||||
it('does not attempt a reveal for a whitespace-only focused group id (normalizeSeriesId guard)', () => {
|
||||
const rafSpy = vi.fn((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal('requestAnimationFrame', rafSpy);
|
||||
|
||||
it('returns null when setTableRootRef receives undefined (setter `element ?? null` right arm)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({ activeSeriesId: () => 'workload-a' }),
|
||||
);
|
||||
result.setTableRootRef(undefined);
|
||||
expect(result.activeRow()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for null / whitespace-only active ids and the row for a real id (normalizedActiveSeriesId guard)', () => {
|
||||
const root = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
row.setAttribute('data-summary-series-id', 'workload-a');
|
||||
root.appendChild(row);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const [activeSeriesId, setActiveSeriesId] = createSignal<string | null>(null);
|
||||
const { result } = renderHook(() => useSummaryTableFocusBridge({ activeSeriesId }));
|
||||
const [focusedGroupId] = createSignal<string | null>(' ');
|
||||
const { result } = renderHook(() => useSummaryTableFocusBridge({ focusedGroupId }));
|
||||
result.setTableRootRef(root);
|
||||
|
||||
expect(result.activeRow()).toBeNull();
|
||||
setActiveSeriesId(' ');
|
||||
expect(result.activeRow()).toBeNull();
|
||||
setActiveSeriesId('workload-a');
|
||||
expect(result.activeRow()).toBe(row);
|
||||
expect(rafSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when no descendant carries the matching data attribute (querySelector miss)', () => {
|
||||
it('does not scroll a decoy row when no group row carries the matching id (querySelector miss)', () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const root = document.createElement('div');
|
||||
const decoy = document.createElement('div');
|
||||
decoy.setAttribute('data-summary-series-id', 'other');
|
||||
decoy.setAttribute('data-summary-group-id', 'other');
|
||||
Object.defineProperty(decoy, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => buildRect(1200, 40),
|
||||
});
|
||||
Object.defineProperty(decoy, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
root.appendChild(decoy);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({ activeSeriesId: () => 'workload-a' }),
|
||||
);
|
||||
const [focusedGroupId] = createSignal<string | null>('group-a');
|
||||
const { result } = renderHook(() => useSummaryTableFocusBridge({ focusedGroupId }));
|
||||
result.setTableRootRef(root);
|
||||
|
||||
expect(result.activeRow()).toBeNull();
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('escapes backslashes and double quotes in the series id before injecting it into the attribute selector', () => {
|
||||
it('escapes backslashes and double quotes in the group id before injecting it into the attribute selector', () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const root = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
// Literal id containing both a double-quote and a backslash — the exact
|
||||
// characters escapeAttributeSelectorValue rewrites.
|
||||
row.setAttribute('data-summary-series-id', 'id"with\\special');
|
||||
row.setAttribute('data-summary-group-id', 'id"with\\special');
|
||||
Object.defineProperty(row, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => buildRect(1200, 40),
|
||||
});
|
||||
Object.defineProperty(row, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
root.appendChild(row);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({ activeSeriesId: () => 'id"with\\special' }),
|
||||
);
|
||||
const [focusedGroupId] = createSignal<string | null>('id"with\\special');
|
||||
const { result } = renderHook(() => useSummaryTableFocusBridge({ focusedGroupId }));
|
||||
result.setTableRootRef(root);
|
||||
|
||||
// If escaping were broken the selector would not match the literal id.
|
||||
expect(result.activeRow()).toBe(row);
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// jumpToActiveRow guard arms
|
||||
// Active series resolution — page default and transient hover
|
||||
// -------------------------------------------------------------------------
|
||||
describe('jumpToActiveRow guard arms', () => {
|
||||
it('is a no-op when there is no active series id (normalizedActiveSeriesId falsy -> early return)', () => {
|
||||
const revealActiveSeries = vi.fn();
|
||||
const rafSpy = vi.fn((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal('requestAnimationFrame', rafSpy);
|
||||
|
||||
const root = document.createElement('div');
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({
|
||||
activeSeriesId: () => null,
|
||||
revealActiveSeries,
|
||||
}),
|
||||
);
|
||||
result.setTableRootRef(root);
|
||||
|
||||
result.jumpToActiveRow();
|
||||
|
||||
expect(revealActiveSeries).not.toHaveBeenCalled();
|
||||
expect(rafSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exhausts the 6-frame retry budget without scrolling when the active row never mounts (no matching row)', () => {
|
||||
const revealActiveSeries = vi.fn();
|
||||
const rafSpy = vi.fn((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal('requestAnimationFrame', rafSpy);
|
||||
|
||||
const root = document.createElement('div');
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({
|
||||
activeSeriesId: () => 'workload-a',
|
||||
revealActiveSeries,
|
||||
}),
|
||||
);
|
||||
result.setTableRootRef(root);
|
||||
|
||||
result.jumpToActiveRow();
|
||||
|
||||
// revealActiveSeries fires once before the scroll retry loop.
|
||||
expect(revealActiveSeries).toHaveBeenCalledWith('workload-a');
|
||||
expect(revealActiveSeries).toHaveBeenCalledTimes(1);
|
||||
// attemptScroll(6) recurses once per synchronous rAF until remainingHits
|
||||
// 0 -> 6 rAF calls total, then the `remainingFrames <= 0` arm returns.
|
||||
expect(rafSpy).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// shouldShowJumpToActiveRow — empty summary collections + already-visible row
|
||||
// -------------------------------------------------------------------------
|
||||
describe('shouldShowJumpToActiveRow arms', () => {
|
||||
it('is false when no hover/focus/group signal resolves an active series (empty summary collections / page-default state)', () => {
|
||||
describe('active series resolution arms', () => {
|
||||
it('resolves no active series or group when no hover/focus/group signal is set (page-default state)', () => {
|
||||
const root = document.createElement('div');
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result } = renderHook(() => useSummaryPageInteractionState({}));
|
||||
result.setTableRootRef(root);
|
||||
|
||||
// resolveSummaryScopeState returns { kind: 'page', seriesId: null }.
|
||||
expect(result.activeSeriesId()).toBeNull();
|
||||
expect(result.activeGroupScope()).toBeNull();
|
||||
// Boolean(null) && !isActiveRowVisible() -> false && ... -> false.
|
||||
expect(result.shouldShowJumpToActiveRow()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the active row is already inside the viewport (already-focused / visible arm)', () => {
|
||||
it('highlights a hovered off-screen row without moving the page (hover is not a reveal)', () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
});
|
||||
const root = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
row.setAttribute('data-summary-series-id', 'workload-a');
|
||||
// top=200, height=40, innerHeight=800 -> 0 < 200 and 240 < 800 -> visible.
|
||||
// top=1200 > innerHeight(800) -> off-screen.
|
||||
Object.defineProperty(row, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => buildRect(200, 40),
|
||||
value: () => buildRect(1200, 40),
|
||||
});
|
||||
Object.defineProperty(row, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
root.appendChild(row);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const revealActiveSeries = vi.fn();
|
||||
const [hoveredSeriesId] = createSignal<string | null>('workload-a');
|
||||
const { result } = renderHook(() => useSummaryPageInteractionState({ hoveredSeriesId }));
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryPageInteractionState({ hoveredSeriesId, revealActiveSeries }),
|
||||
);
|
||||
result.setTableRootRef(root);
|
||||
|
||||
expect(result.activeSeriesId()).toBe('workload-a');
|
||||
// Boolean('workload-a') && !true -> false.
|
||||
expect(result.shouldShowJumpToActiveRow()).toBe(false);
|
||||
expect(revealActiveSeries).not.toHaveBeenCalled();
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,9 +206,7 @@ describe('summaryTableFocus.branchcov0723pm', () => {
|
||||
document.body.appendChild(root);
|
||||
|
||||
const [focusedGroupId] = createSignal<string | null>('group-a');
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({ activeSeriesId: () => null, focusedGroupId }),
|
||||
);
|
||||
const { result } = renderHook(() => useSummaryTableFocusBridge({ focusedGroupId }));
|
||||
result.setTableRootRef(root);
|
||||
|
||||
// `!isElementVisibleWithinViewport(row) && ...` short-circuits on the
|
||||
@@ -422,9 +231,7 @@ describe('summaryTableFocus.branchcov0723pm', () => {
|
||||
document.body.appendChild(root);
|
||||
|
||||
const [focusedGroupId] = createSignal<string | null>('group-a');
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({ activeSeriesId: () => null, focusedGroupId }),
|
||||
);
|
||||
const { result } = renderHook(() => useSummaryTableFocusBridge({ focusedGroupId }));
|
||||
result.setTableRootRef(root);
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
@@ -444,9 +251,7 @@ describe('summaryTableFocus.branchcov0723pm', () => {
|
||||
document.body.appendChild(root);
|
||||
|
||||
const [focusedGroupId] = createSignal<string | null>('group-a');
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryTableFocusBridge({ activeSeriesId: () => null, focusedGroupId }),
|
||||
);
|
||||
const { result } = renderHook(() => useSummaryTableFocusBridge({ focusedGroupId }));
|
||||
result.setTableRootRef(root);
|
||||
|
||||
// Initial rAF + one per decrement: remainingFrames 12->0 = 13 calls.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHook } from '@solidjs/testing-library';
|
||||
import { renderHook, waitFor } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus';
|
||||
@@ -30,8 +30,8 @@ describe('useSummaryPageInteractionState', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('prefers chart hover over row hover and route focus for the active series id', () => {
|
||||
const [hoveredSeriesId] = createSignal<string | null>('row-hovered');
|
||||
it('prefers row hover over route focus for the active series id', () => {
|
||||
const [hoveredSeriesId, setHoveredSeriesId] = createSignal<string | null>('row-hovered');
|
||||
const [focusedSeriesId] = createSignal<string | null>('route-focused');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -43,80 +43,72 @@ describe('useSummaryPageInteractionState', () => {
|
||||
|
||||
expect(result.activeSeriesId()).toBe('row-hovered');
|
||||
|
||||
result.setChartHoverSync({
|
||||
sourceKey: 'cpu',
|
||||
seriesId: 'chart-hovered',
|
||||
timestamp: 123,
|
||||
});
|
||||
setHoveredSeriesId(null);
|
||||
|
||||
expect(result.activeSeriesId()).toBe('chart-hovered');
|
||||
expect(result.activeSeriesId()).toBe('route-focused');
|
||||
});
|
||||
|
||||
it('shows a jump affordance when the active row is mounted but outside the viewport', () => {
|
||||
it('does not subscribe to window scroll or resize', () => {
|
||||
const addEventListener = vi.spyOn(window, 'addEventListener');
|
||||
const [hoveredSeriesId] = createSignal<string | null>('workload-a');
|
||||
const scrollIntoView = vi.fn();
|
||||
const [focusedSeriesId] = createSignal<string | null>('workload-b');
|
||||
const root = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
row.setAttribute('data-summary-series-id', 'workload-a');
|
||||
Object.defineProperty(row, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => buildRect(1200),
|
||||
});
|
||||
Object.defineProperty(row, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
root.appendChild(row);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryPageInteractionState({
|
||||
hoveredSeriesId,
|
||||
focusedSeriesId,
|
||||
}),
|
||||
);
|
||||
|
||||
result.setTableRootRef(root);
|
||||
|
||||
expect(result.shouldShowJumpToActiveRow()).toBe(true);
|
||||
|
||||
result.jumpToActiveRow();
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
|
||||
const subscribed = addEventListener.mock.calls.map(([type]) => type);
|
||||
addEventListener.mockRestore();
|
||||
expect(subscribed).not.toContain('scroll');
|
||||
expect(subscribed).not.toContain('resize');
|
||||
});
|
||||
|
||||
it('reveals an unmapped row before attempting to scroll to it', () => {
|
||||
const [hoveredSeriesId] = createSignal<string | null>('pool-alpha');
|
||||
const scrollIntoView = vi.fn();
|
||||
it('reveals a focused row that only mounts once its owning view opens', async () => {
|
||||
const [focusedSeriesId] = createSignal<string | null>('pool-alpha');
|
||||
const scrollTo = vi.fn();
|
||||
const root = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
row.setAttribute('data-summary-series-id', 'pool-alpha');
|
||||
Object.defineProperty(row, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => buildRect(64),
|
||||
});
|
||||
Object.defineProperty(row, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
const detail = document.createElement('div');
|
||||
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
});
|
||||
vi.stubGlobal('scrollY', 0);
|
||||
|
||||
row.setAttribute('data-summary-series-id', 'pool-alpha');
|
||||
row.getBoundingClientRect = vi.fn(() => buildRect(680, 40));
|
||||
detail.setAttribute('data-inline-detail-for', 'pool-alpha');
|
||||
detail.getBoundingClientRect = vi.fn(() => buildRect(724, 220));
|
||||
document.body.appendChild(root);
|
||||
|
||||
// The owning view renders the row after the reveal request returns, so the
|
||||
// bridge must pick it up through its mutation observer.
|
||||
const revealActiveSeries = vi.fn(() => {
|
||||
root.appendChild(row);
|
||||
window.setTimeout(() => root.append(row, detail), 0);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryPageInteractionState({
|
||||
hoveredSeriesId,
|
||||
focusedSeriesId,
|
||||
revealActiveSeries,
|
||||
}),
|
||||
);
|
||||
|
||||
result.setTableRootRef(root);
|
||||
expect(result.shouldShowJumpToActiveRow()).toBe(true);
|
||||
|
||||
result.jumpToActiveRow();
|
||||
|
||||
expect(revealActiveSeries).toHaveBeenCalledWith('pool-alpha');
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 456, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals focused inline detail without hard-centering the row', () => {
|
||||
|
||||
@@ -1,42 +1,9 @@
|
||||
import { createMemo, type Accessor } from 'solid-js';
|
||||
import { markRouteStateDeliberateScroll } from '@/utils/routeStateNavigation';
|
||||
import {
|
||||
filterSummarySeriesByGroupScope,
|
||||
resolveSummaryActiveSeriesId,
|
||||
resolveSummaryCardInteractionState,
|
||||
resolveSummaryGroupScope,
|
||||
type SummarySeriesGroupScope,
|
||||
type SummaryCardInteractionState,
|
||||
} from './summaryCardInteraction';
|
||||
|
||||
type ContextualFocusSeries = {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const INLINE_DETAIL_TARGET_TOP_RATIO = 0.28;
|
||||
const INLINE_DETAIL_MIN_TOP_MARGIN = 72;
|
||||
const INLINE_DETAIL_MIN_DETAIL_PEEK = 160;
|
||||
|
||||
export interface SummaryChartHoverSync {
|
||||
sourceKey: string;
|
||||
seriesId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface UseSummaryContextualFocusStateOptions<T extends ContextualFocusSeries> {
|
||||
chartHoveredSeriesId?: Accessor<string | null | undefined>;
|
||||
focusedSeriesId?: Accessor<string | null | undefined>;
|
||||
focusedGroupScope?: Accessor<SummarySeriesGroupScope | null | undefined>;
|
||||
getSeriesName?: (series: T) => string | null | undefined;
|
||||
hoveredSeriesId?: Accessor<string | null | undefined>;
|
||||
hoveredGroupScope?: Accessor<SummarySeriesGroupScope | null | undefined>;
|
||||
interactiveSeries: Accessor<readonly T[]>;
|
||||
isSeriesInteractive?: (series: T) => boolean;
|
||||
}
|
||||
|
||||
const normalizeSeriesId = (value: string | null | undefined): string => value?.trim() || '';
|
||||
|
||||
const resolveScrollableAncestor = (element: Element | null | undefined): HTMLElement | null => {
|
||||
let scroller = element instanceof HTMLElement ? element : null;
|
||||
while (scroller) {
|
||||
@@ -217,157 +184,3 @@ export const preserveScrollableAncestorVerticalOffset = (
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export function useSummaryContextualFocusState<T extends ContextualFocusSeries>(
|
||||
options: UseSummaryContextualFocusStateOptions<T>,
|
||||
) {
|
||||
const chartHoveredSeriesId = options.chartHoveredSeriesId ?? (() => null);
|
||||
const hoveredSeriesId = options.hoveredSeriesId ?? (() => null);
|
||||
const focusedSeriesId = options.focusedSeriesId ?? (() => null);
|
||||
const hoveredGroupScope = options.hoveredGroupScope ?? (() => null);
|
||||
const focusedGroupScope = options.focusedGroupScope ?? (() => null);
|
||||
const getSeriesName =
|
||||
options.getSeriesName ??
|
||||
((series: T) => (typeof series.name === 'string' ? series.name : null));
|
||||
const isSeriesInteractive =
|
||||
options.isSeriesInteractive ?? ((series: T) => normalizeSeriesId(series.id) !== '');
|
||||
|
||||
const interactiveSeriesIds = createMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const series of options.interactiveSeries()) {
|
||||
if (!isSeriesInteractive(series)) {
|
||||
continue;
|
||||
}
|
||||
const id = normalizeSeriesId(series.id);
|
||||
if (id) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
|
||||
const hasInteractiveSeriesId = (value: string | null | undefined): value is string => {
|
||||
const normalized = normalizeSeriesId(value);
|
||||
return normalized !== '' && interactiveSeriesIds().has(normalized);
|
||||
};
|
||||
|
||||
const effectiveHoveredSeriesId = createMemo<string | null>(() => {
|
||||
return hasInteractiveSeriesId(hoveredSeriesId()) ? normalizeSeriesId(hoveredSeriesId()) : null;
|
||||
});
|
||||
|
||||
const effectiveChartHoveredSeriesId = createMemo<string | null>(() => {
|
||||
return hasInteractiveSeriesId(chartHoveredSeriesId())
|
||||
? normalizeSeriesId(chartHoveredSeriesId())
|
||||
: null;
|
||||
});
|
||||
|
||||
const effectiveFocusedSeriesId = createMemo<string | null>(() => {
|
||||
return hasInteractiveSeriesId(focusedSeriesId()) ? normalizeSeriesId(focusedSeriesId()) : null;
|
||||
});
|
||||
|
||||
const normalizeGroupScope = (
|
||||
scope: SummarySeriesGroupScope | null | undefined,
|
||||
): SummarySeriesGroupScope | null => {
|
||||
const resolvedScope = resolveSummaryGroupScope({
|
||||
hoveredGroupScope: scope,
|
||||
});
|
||||
if (!resolvedScope) {
|
||||
return null;
|
||||
}
|
||||
const scopedSeriesIds = resolvedScope.seriesIds.filter((id) => interactiveSeriesIds().has(id));
|
||||
if (scopedSeriesIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...resolvedScope,
|
||||
seriesIds: scopedSeriesIds,
|
||||
};
|
||||
};
|
||||
|
||||
const effectiveHoveredGroupScope = createMemo<SummarySeriesGroupScope | null>(() =>
|
||||
normalizeGroupScope(hoveredGroupScope()),
|
||||
);
|
||||
|
||||
const effectiveFocusedGroupScope = createMemo<SummarySeriesGroupScope | null>(() =>
|
||||
normalizeGroupScope(focusedGroupScope()),
|
||||
);
|
||||
|
||||
const activeGroupScope = createMemo<SummarySeriesGroupScope | null>(() =>
|
||||
resolveSummaryGroupScope({
|
||||
hoveredGroupScope: effectiveHoveredGroupScope(),
|
||||
focusedGroupScope: effectiveFocusedGroupScope(),
|
||||
}),
|
||||
);
|
||||
|
||||
const activeSeriesId = createMemo<string | null>(() => {
|
||||
return resolveSummaryActiveSeriesId({
|
||||
chartHoveredSeriesId: effectiveChartHoveredSeriesId(),
|
||||
hoveredSeriesId: effectiveHoveredSeriesId(),
|
||||
focusedSeriesId: effectiveFocusedSeriesId(),
|
||||
groupScope: activeGroupScope(),
|
||||
});
|
||||
});
|
||||
|
||||
const getFocusedSeriesName = (series: readonly T[]): string | null => {
|
||||
const focusedId = effectiveFocusedSeriesId();
|
||||
if (!focusedId) {
|
||||
return null;
|
||||
}
|
||||
const match = series.find((entry) => normalizeSeriesId(entry.id) === focusedId);
|
||||
return match ? getSeriesName(match) || null : null;
|
||||
};
|
||||
|
||||
const getActiveSeriesName = (series: readonly T[]): string | null => {
|
||||
const currentActiveId = activeSeriesId();
|
||||
if (currentActiveId) {
|
||||
const match = series.find((entry) => normalizeSeriesId(entry.id) === currentActiveId);
|
||||
return match ? getSeriesName(match) || null : null;
|
||||
}
|
||||
return activeGroupScope()?.label || null;
|
||||
};
|
||||
|
||||
const interactionStateFor = (
|
||||
series: ReadonlyArray<{ id?: string | null }>,
|
||||
): SummaryCardInteractionState =>
|
||||
resolveSummaryCardInteractionState({
|
||||
series,
|
||||
chartHoveredSeriesId: effectiveChartHoveredSeriesId(),
|
||||
hoveredSeriesId: effectiveHoveredSeriesId(),
|
||||
focusedSeriesId: effectiveFocusedSeriesId(),
|
||||
groupScope: activeGroupScope(),
|
||||
});
|
||||
|
||||
const filterSeriesForActiveScope = <S extends { id?: string | null }>(
|
||||
series: readonly S[],
|
||||
): S[] => {
|
||||
return filterSummarySeriesByGroupScope(series, activeGroupScope());
|
||||
};
|
||||
|
||||
const isSeriesIdVisibleInActiveScope = (value: string | null | undefined): value is string => {
|
||||
const normalized = normalizeSeriesId(value);
|
||||
if (!normalized || !interactiveSeriesIds().has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
const groupScope = activeGroupScope();
|
||||
if (!groupScope) {
|
||||
return true;
|
||||
}
|
||||
return groupScope.seriesIds.includes(normalized);
|
||||
};
|
||||
|
||||
return {
|
||||
activeGroupScope,
|
||||
activeSeriesId,
|
||||
effectiveChartHoveredSeriesId,
|
||||
effectiveFocusedSeriesId,
|
||||
effectiveFocusedGroupScope,
|
||||
effectiveHoveredSeriesId,
|
||||
effectiveHoveredGroupScope,
|
||||
filterSeriesForActiveScope,
|
||||
getActiveSeriesName,
|
||||
getFocusedSeriesName,
|
||||
hasInteractiveSeriesId,
|
||||
isSeriesIdVisibleInActiveScope,
|
||||
interactionStateFor,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
export type SummaryCardInteractionState = 'default' | 'active' | 'inactive';
|
||||
export type SummaryGroupMemberInteractionState = 'default' | 'preview' | 'pinned';
|
||||
export type SummaryScopeKind = 'page' | 'group' | 'entity';
|
||||
export type SummaryScopeSource = 'page' | 'preview' | 'pinned';
|
||||
|
||||
type SummarySeriesIdentity = {
|
||||
id?: string | null;
|
||||
};
|
||||
|
||||
export interface SummarySeriesGroupScope {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
@@ -93,7 +88,6 @@ export const resolveSummaryGroupMemberInteractionState = (options: {
|
||||
};
|
||||
|
||||
export const resolveSummaryScopeState = (options: {
|
||||
chartHoveredSeriesId?: string | null;
|
||||
hoveredSeriesId?: string | null;
|
||||
focusedSeriesId?: string | null;
|
||||
hoveredGroupScope?: SummarySeriesGroupScope | null;
|
||||
@@ -107,19 +101,6 @@ export const resolveSummaryScopeState = (options: {
|
||||
focusedGroupScope: options.focusedGroupScope,
|
||||
});
|
||||
|
||||
const chartHoveredSeriesId = normalizeSeriesId(options.chartHoveredSeriesId);
|
||||
if (
|
||||
chartHoveredSeriesId &&
|
||||
(!groupScope || isSummarySeriesInGroupScope(groupScope, chartHoveredSeriesId))
|
||||
) {
|
||||
return {
|
||||
groupScope,
|
||||
kind: 'entity',
|
||||
seriesId: chartHoveredSeriesId,
|
||||
source: 'preview',
|
||||
};
|
||||
}
|
||||
|
||||
const hoveredSeriesId = normalizeSeriesId(options.hoveredSeriesId);
|
||||
if (
|
||||
hoveredSeriesId &&
|
||||
@@ -175,63 +156,9 @@ export const resolveSummaryScopeState = (options: {
|
||||
};
|
||||
|
||||
export function resolveSummaryActiveSeriesId(options: {
|
||||
chartHoveredSeriesId?: string | null;
|
||||
hoveredSeriesId?: string | null;
|
||||
focusedSeriesId?: string | null;
|
||||
groupScope?: SummarySeriesGroupScope | null;
|
||||
}): string | null {
|
||||
return resolveSummaryScopeState(options).seriesId;
|
||||
}
|
||||
|
||||
export function filterSummarySeriesByGroupScope<T extends SummarySeriesIdentity>(
|
||||
series: readonly T[],
|
||||
groupScope?: SummarySeriesGroupScope | null,
|
||||
): T[] {
|
||||
const normalizedScope = normalizeSummarySeriesGroupScope(groupScope);
|
||||
if (!normalizedScope) {
|
||||
return [...series];
|
||||
}
|
||||
return series.filter((entry) => isSummarySeriesInGroupScope(normalizedScope, entry.id));
|
||||
}
|
||||
|
||||
export function resolveSummaryCardInteractionState(options: {
|
||||
series: readonly SummarySeriesIdentity[];
|
||||
chartHoveredSeriesId?: string | null;
|
||||
hoveredSeriesId?: string | null;
|
||||
focusedSeriesId?: string | null;
|
||||
hoveredGroupScope?: SummarySeriesGroupScope | null;
|
||||
focusedGroupScope?: SummarySeriesGroupScope | null;
|
||||
groupScope?: SummarySeriesGroupScope | null;
|
||||
}): SummaryCardInteractionState {
|
||||
const groupScope =
|
||||
normalizeSummarySeriesGroupScope(options.groupScope) ??
|
||||
resolveSummaryGroupScope({
|
||||
hoveredGroupScope: options.hoveredGroupScope,
|
||||
focusedGroupScope: options.focusedGroupScope,
|
||||
});
|
||||
const activeSeriesId = resolveSummaryActiveSeriesId({
|
||||
chartHoveredSeriesId: options.chartHoveredSeriesId,
|
||||
hoveredSeriesId: options.hoveredSeriesId,
|
||||
focusedSeriesId: options.focusedSeriesId,
|
||||
groupScope,
|
||||
});
|
||||
if (!activeSeriesId) {
|
||||
if (groupScope) {
|
||||
for (const series of options.series) {
|
||||
if (isSummarySeriesInGroupScope(groupScope, series.id)) {
|
||||
return 'active';
|
||||
}
|
||||
}
|
||||
return 'inactive';
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
for (const series of options.series) {
|
||||
if (normalizeSeriesId(series.id) === activeSeriesId) {
|
||||
return 'active';
|
||||
}
|
||||
}
|
||||
|
||||
return 'inactive';
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js';
|
||||
import {
|
||||
findInlineDetailElement,
|
||||
revealInlineDetailInViewport,
|
||||
type SummaryChartHoverSync,
|
||||
} from './contextualFocus';
|
||||
import { findInlineDetailElement, revealInlineDetailInViewport } from './contextualFocus';
|
||||
import {
|
||||
resolveSummaryActiveSeriesId,
|
||||
resolveSummaryScopeState,
|
||||
resolveSummaryGroupScope,
|
||||
type SummarySeriesGroupScope,
|
||||
type SummaryScopeState,
|
||||
} from './summaryCardInteraction';
|
||||
|
||||
const normalizeSeriesId = (value: string | null | undefined): string => value?.trim() || '';
|
||||
@@ -69,7 +63,6 @@ const isElementVisibleWithinViewport = (element: HTMLElement): boolean => {
|
||||
};
|
||||
|
||||
export interface UseSummaryTableFocusBridgeOptions {
|
||||
activeSeriesId: Accessor<string | null | undefined>;
|
||||
focusedSeriesId?: Accessor<string | null | undefined>;
|
||||
focusedGroupId?: Accessor<string | null | undefined>;
|
||||
clearPinnedScope?: () => void;
|
||||
@@ -80,26 +73,9 @@ export interface UseSummaryTableFocusBridgeOptions {
|
||||
export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOptions) {
|
||||
const [tableRoot, setTableRoot] = createSignal<HTMLElement | null>(null);
|
||||
const [clearSurfaceRoot, setClearSurfaceRoot] = createSignal<HTMLElement | null>(null);
|
||||
const [viewportVersion, setViewportVersion] = createSignal(0);
|
||||
const focusedSeriesId = options.focusedSeriesId ?? (() => null);
|
||||
const focusedGroupId = options.focusedGroupId ?? (() => null);
|
||||
|
||||
const normalizedActiveSeriesId = createMemo<string | null>(() => {
|
||||
const normalized = normalizeSeriesId(options.activeSeriesId());
|
||||
return normalized || null;
|
||||
});
|
||||
|
||||
const activeRow = (): HTMLElement | null => {
|
||||
const root = tableRoot();
|
||||
const activeSeriesId = normalizedActiveSeriesId();
|
||||
if (!root || !activeSeriesId) {
|
||||
return null;
|
||||
}
|
||||
return root.querySelector<HTMLElement>(
|
||||
`[data-summary-series-id="${escapeAttributeSelectorValue(activeSeriesId)}"]`,
|
||||
);
|
||||
};
|
||||
|
||||
const focusedRow = (): HTMLElement | null => {
|
||||
const root = tableRoot();
|
||||
const focusedId = normalizeSeriesId(focusedSeriesId());
|
||||
@@ -122,19 +98,6 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp
|
||||
);
|
||||
};
|
||||
|
||||
const isActiveRowVisible = createMemo<boolean>(() => {
|
||||
viewportVersion();
|
||||
const row = activeRow();
|
||||
if (!row) {
|
||||
return false;
|
||||
}
|
||||
return isElementVisibleWithinViewport(row);
|
||||
});
|
||||
|
||||
const shouldShowJumpToActiveRow = createMemo<boolean>(() => {
|
||||
return Boolean(normalizedActiveSeriesId()) && !isActiveRowVisible();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const root = clearSurfaceRoot() ?? tableRoot();
|
||||
const lookupRoot = tableRoot();
|
||||
@@ -200,58 +163,6 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp
|
||||
});
|
||||
});
|
||||
|
||||
const jumpToActiveRow = () => {
|
||||
const activeSeriesId = normalizedActiveSeriesId();
|
||||
if (!activeSeriesId) {
|
||||
return;
|
||||
}
|
||||
|
||||
options.revealActiveSeries?.(activeSeriesId);
|
||||
|
||||
const attemptScroll = (remainingFrames: number) => {
|
||||
const row = activeRow();
|
||||
if (row) {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
if (remainingFrames <= 0 || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(() => attemptScroll(remainingFrames - 1));
|
||||
};
|
||||
|
||||
attemptScroll(6);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const root = tableRoot();
|
||||
if (!root || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | undefined;
|
||||
const scheduleViewportRefresh = () => {
|
||||
if (rafId !== undefined) {
|
||||
return;
|
||||
}
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
rafId = undefined;
|
||||
setViewportVersion((current) => current + 1);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', scheduleViewportRefresh, true);
|
||||
window.addEventListener('resize', scheduleViewportRefresh);
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('scroll', scheduleViewportRefresh, true);
|
||||
window.removeEventListener('resize', scheduleViewportRefresh);
|
||||
if (rafId !== undefined) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const focusedId = normalizeSeriesId(focusedSeriesId());
|
||||
const root = tableRoot();
|
||||
@@ -384,13 +295,9 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp
|
||||
});
|
||||
|
||||
return {
|
||||
activeRow,
|
||||
isActiveRowVisible,
|
||||
jumpToActiveRow,
|
||||
setClearSurfaceRootRef: (element: HTMLElement | undefined) =>
|
||||
setClearSurfaceRoot(element ?? null),
|
||||
setTableRootRef: (element: HTMLElement | undefined) => setTableRoot(element ?? null),
|
||||
shouldShowJumpToActiveRow,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -406,7 +313,6 @@ export interface UseSummaryPageInteractionStateOptions {
|
||||
}
|
||||
|
||||
export function useSummaryPageInteractionState(options: UseSummaryPageInteractionStateOptions) {
|
||||
const [chartHoverSync, setChartHoverSync] = createSignal<SummaryChartHoverSync | null>(null);
|
||||
const hoveredSeriesId = options.hoveredSeriesId ?? (() => null);
|
||||
const focusedSeriesId = options.focusedSeriesId ?? (() => null);
|
||||
const focusedGroupId = options.focusedGroupId ?? (() => null);
|
||||
@@ -420,20 +326,8 @@ export function useSummaryPageInteractionState(options: UseSummaryPageInteractio
|
||||
}),
|
||||
);
|
||||
|
||||
const activeScopeState = createMemo<SummaryScopeState>(() =>
|
||||
resolveSummaryScopeState({
|
||||
chartHoveredSeriesId: chartHoverSync()?.seriesId ?? null,
|
||||
hoveredSeriesId: hoveredSeriesId(),
|
||||
focusedSeriesId: focusedSeriesId(),
|
||||
hoveredGroupScope: hoveredGroupScope(),
|
||||
focusedGroupScope: focusedGroupScope(),
|
||||
groupScope: activeGroupScope(),
|
||||
}),
|
||||
);
|
||||
|
||||
const activeSeriesId = createMemo<string | null>(() =>
|
||||
resolveSummaryActiveSeriesId({
|
||||
chartHoveredSeriesId: chartHoverSync()?.seriesId ?? null,
|
||||
hoveredSeriesId: hoveredSeriesId(),
|
||||
focusedSeriesId: focusedSeriesId(),
|
||||
groupScope: activeGroupScope(),
|
||||
@@ -441,26 +335,17 @@ export function useSummaryPageInteractionState(options: UseSummaryPageInteractio
|
||||
);
|
||||
|
||||
const tableFocus = useSummaryTableFocusBridge({
|
||||
activeSeriesId,
|
||||
clearPinnedScope: options.clearPinnedScope,
|
||||
focusedSeriesId,
|
||||
focusedGroupId,
|
||||
onEscapeClear: () => {
|
||||
setChartHoverSync(null);
|
||||
options.onEscapeClear?.();
|
||||
},
|
||||
onEscapeClear: options.onEscapeClear,
|
||||
revealActiveSeries: options.revealActiveSeries,
|
||||
});
|
||||
|
||||
return {
|
||||
activeGroupScope,
|
||||
activeScopeState,
|
||||
activeSeriesId,
|
||||
chartHoverSync,
|
||||
jumpToActiveRow: tableFocus.jumpToActiveRow,
|
||||
setClearSurfaceRootRef: tableFocus.setClearSurfaceRootRef,
|
||||
setChartHoverSync,
|
||||
setTableRootRef: tableFocus.setTableRootRef,
|
||||
shouldShowJumpToActiveRow: tableFocus.shouldShowJumpToActiveRow,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { AlertSnoozeAction } from './AlertSnoozeAction';
|
||||
import { isAlertSnoozed } from './useAlertSnoozeState';
|
||||
import { isPulseSystemAlert } from '@/utils/alertScope';
|
||||
import { formatTemperature } from '@/utils/temperature';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import {
|
||||
normalizeSourcePlatformQueryValue,
|
||||
resolvePlatformTypeFromSources,
|
||||
@@ -110,6 +111,9 @@ const platformPagePath = (platform: string | undefined): string | undefined => {
|
||||
};
|
||||
|
||||
export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
|
||||
// The started age and the live reading's stale cut-off and ages measure
|
||||
// from the wall clock, so a card that mounts between ticks is current.
|
||||
const now = useRelativeTimeNow();
|
||||
const alertKey = () => getCanonicalAlertId(props.alert);
|
||||
const hasResource = () =>
|
||||
!isPulseSystemAlert(props.alert) && Boolean(props.alert.resourceId?.trim());
|
||||
@@ -138,9 +142,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
|
||||
// A threshold alert can stay open below its trigger, so the card leads with
|
||||
// the reading Pulse is evaluating now; the message keeps the last breach.
|
||||
const metricPresentation = () =>
|
||||
isPulseSystemAlert(props.alert)
|
||||
? null
|
||||
: getMetricAlertPresentation(props.alert, props.state.tick());
|
||||
isPulseSystemAlert(props.alert) ? null : getMetricAlertPresentation(props.alert, now());
|
||||
const alertLevels = (): { alert: string; clear?: string } | null => {
|
||||
const presentation = metricPresentation();
|
||||
if (presentation) {
|
||||
@@ -318,7 +320,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
|
||||
new Date(props.alert.startTime).toLocaleString(),
|
||||
)}
|
||||
>
|
||||
{formatAlertOverviewStartedAgo(props.alert.startTime, props.state.tick())}
|
||||
{formatAlertOverviewStartedAgo(props.alert.startTime, now())}
|
||||
</span>
|
||||
</div>
|
||||
<Show
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n';
|
||||
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
|
||||
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
|
||||
import { METRIC_ALERT_STATUS_STALE_MS } from '../metricAlertPresentation';
|
||||
|
||||
vi.mock('@solidjs/router', () => ({
|
||||
useLocation: () => ({ hash: '', pathname: '/alerts', search: '', query: {} }),
|
||||
A: (props: Record<string, unknown>) => props.children,
|
||||
}));
|
||||
|
||||
const getDeliveryDiagnoses = vi.fn<() => Promise<AlertDeliveryDiagnosis[]>>();
|
||||
|
||||
vi.mock('@/api/alerts', () => ({
|
||||
AlertsAPI: {
|
||||
get getDeliveryDiagnoses() {
|
||||
return getDeliveryDiagnoses;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({
|
||||
InvestigateAlertButton: () => null,
|
||||
}));
|
||||
|
||||
import { OverviewTab } from '../OverviewTab';
|
||||
|
||||
const OPENED_AT = Date.parse('2026-10-07T12:00:00Z');
|
||||
// Between the shared clock's first and second ticks, and before the overview's
|
||||
// own minute.
|
||||
const BETWEEN_TICKS_MS = RELATIVE_TIME_TICK_MS + 15_000;
|
||||
|
||||
function cpuAlert(id: string, startTime: number, observedAt: number): Alert {
|
||||
return {
|
||||
id,
|
||||
resourceId: `vm-${id}`,
|
||||
resourceName: `VM ${id}`,
|
||||
type: 'cpu',
|
||||
level: 'warning',
|
||||
message: `CPU on VM ${id} at 92%`,
|
||||
value: 92,
|
||||
threshold: 80,
|
||||
startTime: new Date(startTime).toISOString(),
|
||||
lastSeen: new Date(observedAt).toISOString(),
|
||||
acknowledged: false,
|
||||
node: 'node1',
|
||||
metricStatus: {
|
||||
phase: 'breaching',
|
||||
value: 91,
|
||||
unit: '%',
|
||||
observedAt: new Date(observedAt).toISOString(),
|
||||
trigger: 80,
|
||||
recovery: 75,
|
||||
},
|
||||
} as Alert;
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const [activeAlerts, setActiveAlerts] = createSignal<Record<string, Alert>>({});
|
||||
render(() => (
|
||||
<OverviewTab
|
||||
overrides={[]}
|
||||
activeAlerts={activeAlerts()}
|
||||
updateAlert={vi.fn()}
|
||||
showQuickTip={() => false}
|
||||
dismissQuickTip={vi.fn()}
|
||||
showAcknowledged={() => true}
|
||||
setShowAcknowledged={vi.fn()}
|
||||
alertsDisabled={() => false}
|
||||
/>
|
||||
));
|
||||
return setActiveAlerts;
|
||||
}
|
||||
|
||||
const triggered24h = () =>
|
||||
screen
|
||||
.getByText('Triggered (24h)')
|
||||
.closest('[data-alert-overview-stat]')
|
||||
?.querySelector('[data-testid="alert-overview-stat-value"]')?.textContent;
|
||||
|
||||
describe('Alert overview card clock', () => {
|
||||
beforeEach(() => {
|
||||
setActiveLocale(DEFAULT_LOCALE);
|
||||
getDeliveryDiagnoses.mockReset();
|
||||
getDeliveryDiagnoses.mockResolvedValue([]);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(OPENED_AT);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('measures a card that mounts between ticks from the wall clock', () => {
|
||||
const setActiveAlerts = renderOverview();
|
||||
vi.advanceTimersByTime(BETWEEN_TICKS_MS);
|
||||
|
||||
// Raised 70 seconds ago; last evaluated just past the stale cut-off. Read
|
||||
// from the time of the last tick, the card would call it "this minute"
|
||||
// and lead with the old reading as the live one.
|
||||
const now = Date.now();
|
||||
setActiveAlerts({
|
||||
late: cpuAlert('late', now - 70_000, now - METRIC_ALERT_STATUS_STALE_MS - 5_000),
|
||||
});
|
||||
|
||||
expect(screen.getByText('1 min. ago')).toBeInTheDocument();
|
||||
expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('turns stale at the next shared tick after the cut-off with no data change', () => {
|
||||
const setActiveAlerts = renderOverview();
|
||||
vi.advanceTimersByTime(BETWEEN_TICKS_MS);
|
||||
|
||||
// The reading goes stale 5 seconds after the shared clock's second tick.
|
||||
const staleAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000;
|
||||
const now = Date.now();
|
||||
setActiveAlerts({ held: cpuAlert('held', now, staleAt - METRIC_ALERT_STATUS_STALE_MS) });
|
||||
expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument();
|
||||
|
||||
vi.advanceTimersByTime(OPENED_AT + 2 * RELATIVE_TIME_TICK_MS - Date.now());
|
||||
expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument();
|
||||
|
||||
// The next tick lands within one shared tick of the cut-off, not on the
|
||||
// overview's next minute.
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(screen.queryByText('CPU 91%, above the 80% alert level')).toBeNull();
|
||||
expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops an alert from the 24h count at the next shared tick after it turns 24h old', () => {
|
||||
const setActiveAlerts = renderOverview();
|
||||
vi.advanceTimersByTime(BETWEEN_TICKS_MS);
|
||||
|
||||
const leavesWindowAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000;
|
||||
setActiveAlerts({ old: cpuAlert('old', leavesWindowAt - 86_400_000, Date.now()) });
|
||||
expect(triggered24h()).toBe('1');
|
||||
|
||||
vi.advanceTimersByTime(OPENED_AT + 3 * RELATIVE_TIME_TICK_MS - Date.now());
|
||||
expect(triggered24h()).toBe('0');
|
||||
});
|
||||
});
|
||||
-1
@@ -26,7 +26,6 @@ import { AlertOverviewAlertCard } from '../AlertOverviewAlertCard';
|
||||
const acknowledge = vi.fn();
|
||||
const toggleTimeline = vi.fn();
|
||||
const state = {
|
||||
tick: () => Date.now(),
|
||||
processingAlerts: () => new Set(),
|
||||
snoozeProcessingAlerts: () => new Set(),
|
||||
deliveryDiagnoses: () => ({}),
|
||||
|
||||
+49
-2
@@ -56,7 +56,8 @@ describe('readAlertsConfigurationSnapshot — absent optional sections', () => {
|
||||
expect(snapshot.trueNASDefaults.cpu).toBe(80);
|
||||
expect(snapshot.trueNASDefaults.usage).toBe(85);
|
||||
expect(snapshot.trueNASDefaults.diskRead).toBe(-1);
|
||||
expect(snapshot.trueNASDiskDefaults.temperature).toBe(55);
|
||||
// Unset: each TrueNAS disk follows Disk temperature by type.
|
||||
expect(snapshot.trueNASDiskDefaults.temperature).toBeUndefined();
|
||||
expect(snapshot.vmwareDefaults.cpu).toBe(80);
|
||||
expect(snapshot.vmwareDefaults.usage).toBe(85);
|
||||
});
|
||||
@@ -232,7 +233,8 @@ describe('readAlertsConfigurationSnapshot — factory fallback (undefined-trigge
|
||||
expect(snapshot.trueNASDefaults.diskWrite).toBe(-1);
|
||||
expect(snapshot.trueNASDefaults.networkIn).toBe(-1);
|
||||
expect(snapshot.trueNASDefaults.networkOut).toBe(-1);
|
||||
expect(snapshot.trueNASDiskDefaults.temperature).toBe(55);
|
||||
// No usable trigger leaves TrueNAS disks on Disk temperature by type.
|
||||
expect(snapshot.trueNASDiskDefaults.temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to factory defaults for vmwareDefaults and agentDefaults', () => {
|
||||
@@ -1526,3 +1528,48 @@ describe('dockerDefaults.updateAlertDelayHours', () => {
|
||||
expect(alertConfig?.dockerDefaults?.updateAlertDelayHours).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TrueNAS disk temperature round trip', () => {
|
||||
const buildTrueNASDiskPayload = (temperature: number | undefined) => {
|
||||
const snapshot = createDefaultAlertsConfigurationSnapshot();
|
||||
snapshot.trueNASDiskDefaults = { temperature };
|
||||
return buildAlertsConfigurationPayload({
|
||||
snapshot,
|
||||
rawOverridesConfig: {},
|
||||
alertsActivationState: null,
|
||||
alertsActivationConfig: null,
|
||||
}).alertConfig;
|
||||
};
|
||||
|
||||
it('omits an unset TrueNAS disk temperature so disks follow Disk temperature by type', () => {
|
||||
const alertConfig = buildTrueNASDiskPayload(undefined);
|
||||
expect(alertConfig?.truenasDiskDefaults).toEqual({});
|
||||
expect(alertConfig?.truenasDiskTemperatureByType).toBe(true);
|
||||
});
|
||||
|
||||
it('writes Off as a zero trigger, which the backend keeps as off', () => {
|
||||
expect(buildTrueNASDiskPayload(-1)?.truenasDiskDefaults).toEqual({
|
||||
temperature: { trigger: 0, clear: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a TrueNAS-wide value, 55 included, as a trigger', () => {
|
||||
expect(buildTrueNASDiskPayload(55)?.truenasDiskDefaults).toEqual({
|
||||
temperature: { trigger: 55, clear: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads an absent temperature as unset and a zero trigger as off', () => {
|
||||
const base = { overrides: {} } as unknown as AlertConfig;
|
||||
expect(
|
||||
readAlertsConfigurationSnapshot({ ...base, truenasDiskDefaults: {} }).trueNASDiskDefaults
|
||||
.temperature,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readAlertsConfigurationSnapshot({
|
||||
...base,
|
||||
truenasDiskDefaults: { temperature: { trigger: 0, clear: 0 } },
|
||||
}).trueNASDiskDefaults.temperature,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ALERT_RESOURCE_METRIC_OFF_VALUE } from '@/components/Alerts/alertResourceTableModel';
|
||||
import type { AlertConfig } from '@/types/alerts';
|
||||
|
||||
import {
|
||||
@@ -224,4 +225,94 @@ describe('alertsConfigurationModel', () => {
|
||||
expect(result.alertConfig).toBeUndefined();
|
||||
expect(result.dockerValidationError).toBe(ALERT_DOCKER_GAP_VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
// The backend reads a negative default trigger as unset and restores the
|
||||
// factory threshold, so every Global Defaults Off must reach it as 0.
|
||||
it('writes an Off global default as trigger 0 in every section and reads it back Off', () => {
|
||||
const thresholdMetrics = {
|
||||
guestDefaults: ['cpu', 'memory', 'disk', 'diskRead', 'diskWrite', 'networkIn', 'networkOut'],
|
||||
nodeDefaults: ['cpu', 'memory', 'disk', 'temperature'],
|
||||
agentDefaults: ['cpu', 'memory', 'disk', 'diskTemperature'],
|
||||
pbsDefaults: ['cpu', 'memory'],
|
||||
kubernetesDefaults: [
|
||||
'cpu',
|
||||
'memory',
|
||||
'disk',
|
||||
'diskRead',
|
||||
'diskWrite',
|
||||
'networkIn',
|
||||
'networkOut',
|
||||
],
|
||||
trueNASDefaults: [
|
||||
'cpu',
|
||||
'memory',
|
||||
'disk',
|
||||
'usage',
|
||||
'temperature',
|
||||
'diskRead',
|
||||
'diskWrite',
|
||||
'networkIn',
|
||||
'networkOut',
|
||||
],
|
||||
trueNASDiskDefaults: ['temperature'],
|
||||
vmwareDefaults: [
|
||||
'cpu',
|
||||
'memory',
|
||||
'disk',
|
||||
'usage',
|
||||
'diskRead',
|
||||
'diskWrite',
|
||||
'networkIn',
|
||||
'networkOut',
|
||||
],
|
||||
dockerDefaults: ['cpu', 'memory', 'disk'],
|
||||
} as const;
|
||||
const payloadSection = {
|
||||
guestDefaults: 'guestDefaults',
|
||||
nodeDefaults: 'nodeDefaults',
|
||||
agentDefaults: 'agentDefaults',
|
||||
pbsDefaults: 'pbsDefaults',
|
||||
kubernetesDefaults: 'kubernetesDefaults',
|
||||
trueNASDefaults: 'truenasDefaults',
|
||||
trueNASDiskDefaults: 'truenasDiskDefaults',
|
||||
vmwareDefaults: 'vmwareDefaults',
|
||||
dockerDefaults: 'dockerDefaults',
|
||||
} as const;
|
||||
|
||||
type Section = keyof typeof thresholdMetrics;
|
||||
|
||||
const snapshot = createDefaultAlertsConfigurationSnapshot();
|
||||
for (const [section, metrics] of Object.entries(thresholdMetrics)) {
|
||||
const defaults: Record<string, unknown> = snapshot[section as Section];
|
||||
for (const metric of metrics) {
|
||||
defaults[metric] = ALERT_RESOURCE_METRIC_OFF_VALUE;
|
||||
}
|
||||
}
|
||||
snapshot.storageDefault = ALERT_RESOURCE_METRIC_OFF_VALUE;
|
||||
|
||||
const { alertConfig } = buildAlertsConfigurationPayload({
|
||||
snapshot,
|
||||
rawOverridesConfig: {},
|
||||
alertsActivationState: null,
|
||||
alertsActivationConfig: null,
|
||||
});
|
||||
expect(alertConfig).toBeTruthy();
|
||||
const payload = alertConfig as unknown as Record<string, Record<string, unknown>>;
|
||||
for (const [section, metrics] of Object.entries(thresholdMetrics)) {
|
||||
const written = payload[payloadSection[section as Section]];
|
||||
for (const metric of metrics) {
|
||||
expect(written[metric], `${section}.${metric}`).toEqual({ trigger: 0, clear: 0 });
|
||||
}
|
||||
}
|
||||
expect(alertConfig?.storageDefault).toEqual({ trigger: 0, clear: 0 });
|
||||
|
||||
const reloaded = readAlertsConfigurationSnapshot(alertConfig as AlertConfig);
|
||||
for (const [section, metrics] of Object.entries(thresholdMetrics)) {
|
||||
const defaults: Record<string, unknown> = reloaded[section as Section];
|
||||
for (const metric of metrics) {
|
||||
expect(defaults[metric], `${section}.${metric}`).toBe(0);
|
||||
}
|
||||
}
|
||||
expect(reloaded.storageDefault).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AlertsAPI } from '@/api/alerts';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
|
||||
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
|
||||
|
||||
import { useAlertOverviewState } from '../useAlertOverviewState';
|
||||
|
||||
@@ -118,6 +119,22 @@ describe('useAlertOverviewState', () => {
|
||||
expect(result.deliveryDiagnoses()).toEqual({ a1: retained });
|
||||
});
|
||||
|
||||
it('refreshes diagnoses once a minute, not on the 30-second age clock', async () => {
|
||||
renderHook(() =>
|
||||
useAlertOverviewState({
|
||||
activeAlerts: () => ({ a1: makeAlert('a1', new Date().toISOString()) }),
|
||||
overrides: () => [],
|
||||
showAcknowledged: () => true,
|
||||
updateAlert: vi.fn(),
|
||||
}),
|
||||
);
|
||||
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(RELATIVE_TIME_TICK_MS);
|
||||
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(60_000 - RELATIVE_TIME_TICK_MS);
|
||||
expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ignores pending responses and stops periodic reads after disposal', async () => {
|
||||
let finish!: (value: AlertDeliveryDiagnosis[]) => void;
|
||||
vi.mocked(AlertsAPI.getDeliveryDiagnoses).mockReturnValueOnce(
|
||||
|
||||
@@ -174,14 +174,26 @@ const cloneBackupDefaults = (backupDefaults: BackupAlertConfig): BackupAlertConf
|
||||
ignoreVMIDs: [...(backupDefaults.ignoreVMIDs ?? [])],
|
||||
});
|
||||
|
||||
// Off is written as 0. The editors stage -1 for Off, but the backend reads a
|
||||
// negative default trigger as unset and restores the factory threshold, so a
|
||||
// saved -1 came back On.
|
||||
const createHysteresisThreshold = (trigger: number | undefined, clearMargin = 5) => {
|
||||
const normalized = typeof trigger === 'number' ? trigger : 0;
|
||||
const normalized = typeof trigger === 'number' && trigger > 0 ? trigger : 0;
|
||||
return {
|
||||
trigger: normalized,
|
||||
clear: Math.max(0, normalized - clearMargin),
|
||||
};
|
||||
};
|
||||
|
||||
// Unset leaves TrueNAS disks on Disk temperature by type, so it is omitted
|
||||
// rather than written as a trigger. Off is written as 0: the backend reads a
|
||||
// negative trigger as unset.
|
||||
const buildTrueNASDiskDefaultsConfig = (temperature: number | undefined) => {
|
||||
if (temperature === undefined) return {};
|
||||
if (temperature <= 0) return { temperature: { trigger: 0, clear: 0 } };
|
||||
return { temperature: createHysteresisThreshold(temperature) };
|
||||
};
|
||||
|
||||
const normalizeGap = (value: unknown, fallback: number) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
@@ -410,10 +422,10 @@ export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConf
|
||||
}
|
||||
|
||||
if (config.truenasDiskDefaults) {
|
||||
// Unset means each TrueNAS disk follows Disk temperature by type.
|
||||
const temperature = config.truenasDiskDefaults.temperature;
|
||||
snapshot.trueNASDiskDefaults = {
|
||||
temperature:
|
||||
getTriggerValue(config.truenasDiskDefaults.temperature) ??
|
||||
FACTORY_TRUENAS_DISK_DEFAULTS.temperature,
|
||||
temperature: temperature === undefined ? undefined : getTriggerValue(temperature),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -844,9 +856,8 @@ export function buildAlertsConfigurationPayload({
|
||||
networkIn: createHysteresisThreshold(snapshot.trueNASDefaults.networkIn),
|
||||
networkOut: createHysteresisThreshold(snapshot.trueNASDefaults.networkOut),
|
||||
},
|
||||
truenasDiskDefaults: {
|
||||
temperature: createHysteresisThreshold(snapshot.trueNASDiskDefaults.temperature),
|
||||
},
|
||||
truenasDiskDefaults: buildTrueNASDiskDefaultsConfig(snapshot.trueNASDiskDefaults.temperature),
|
||||
truenasDiskTemperatureByType: true,
|
||||
vmwareDefaults: {
|
||||
cpu: createHysteresisThreshold(snapshot.vmwareDefaults.cpu),
|
||||
memory: createHysteresisThreshold(snapshot.vmwareDefaults.memory),
|
||||
|
||||
+8
@@ -48,6 +48,14 @@ describe('thresholdsResourceModel branch coverage 0723pm', () => {
|
||||
expect(hasThresholdDiff(override, { cpu: 80, memory: 70 })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an explicit Off override visible against an Off default saved as 0', () => {
|
||||
// The marker drives the row's revert control and override removal, so a
|
||||
// stored -1 must not hide behind an Off default: switching the default
|
||||
// back on would otherwise leave this resource silently off.
|
||||
const override = makeOverride('a', { cpu: -1 });
|
||||
expect(hasThresholdDiff(override, { cpu: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when one threshold diverges while a sibling matches', () => {
|
||||
const override = makeOverride('a', { cpu: 80, memory: 90 });
|
||||
expect(hasThresholdDiff(override, { cpu: 80, memory: 70 })).toBe(true);
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getDiskTemperatureByTypeItems,
|
||||
resolveDiskTemperatureTriggerForType,
|
||||
resolveTrueNASDiskTemperatureDefault,
|
||||
} from '../trueNASDiskTemperature';
|
||||
|
||||
const factoryPolicy = {
|
||||
agentDiskTemperature: 55,
|
||||
diskTempByType: { nvme: 70, sas: 65, sata: 55 },
|
||||
};
|
||||
|
||||
describe('TrueNAS disk temperature in the thresholds editor', () => {
|
||||
it('gives each disk its type trigger, and unknown types the agent default', () => {
|
||||
expect(resolveDiskTemperatureTriggerForType(factoryPolicy, 'NVMe')).toBe(70);
|
||||
expect(resolveDiskTemperatureTriggerForType(factoryPolicy, 'sas')).toBe(65);
|
||||
expect(resolveDiskTemperatureTriggerForType(factoryPolicy, 'sata')).toBe(55);
|
||||
expect(resolveDiskTemperatureTriggerForType(factoryPolicy, 'hdd')).toBe(55);
|
||||
expect(
|
||||
resolveDiskTemperatureTriggerForType({ ...factoryPolicy, agentDiskTemperature: 60 }, ''),
|
||||
).toBe(60);
|
||||
});
|
||||
|
||||
it('switches every type off with the agent Disk Temp default', () => {
|
||||
const off = { ...factoryPolicy, agentDiskTemperature: -1 };
|
||||
expect(resolveDiskTemperatureTriggerForType(off, 'nvme')).toBe(0);
|
||||
expect(getDiskTemperatureByTypeItems(off)).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a TrueNAS-wide value, Off included, replace the type trigger', () => {
|
||||
expect(resolveTrueNASDiskTemperatureDefault(undefined, factoryPolicy, 'nvme')).toBe(70);
|
||||
expect(resolveTrueNASDiskTemperatureDefault(62, factoryPolicy, 'nvme')).toBe(62);
|
||||
expect(resolveTrueNASDiskTemperatureDefault(-1, factoryPolicy, 'nvme')).toBe(-1);
|
||||
});
|
||||
|
||||
it('names the per-type triggers', () => {
|
||||
expect(
|
||||
getDiskTemperatureByTypeItems({
|
||||
...factoryPolicy,
|
||||
diskTempByType: { nvme: 75, sas: 65, sata: 55 },
|
||||
}),
|
||||
).toEqual(['NVMe 75°C', 'SAS 65°C', 'SATA 55°C']);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type { Resource } from '@/types/resource';
|
||||
import type { Resource as TableResource } from '../tableTypes';
|
||||
import type { Override } from '../types';
|
||||
import type { ThresholdsDataInputs } from '../thresholdsResourceModel';
|
||||
import { resolveTrueNASDiskTemperatureDefault } from '../trueNASDiskTemperature';
|
||||
import {
|
||||
createOverridesMap,
|
||||
findOverrideByCandidates,
|
||||
@@ -307,7 +308,18 @@ export function useThresholdsPlatformData(inputs: ThresholdsDataInputs) {
|
||||
resource,
|
||||
type: 'truenasDisk',
|
||||
resourceType: 'TrueNAS Disk',
|
||||
defaults: trueNASDiskDefaults(),
|
||||
// Each disk inherits the trigger saving would apply to it: the
|
||||
// TrueNAS-wide value, else its type's disk temperature trigger.
|
||||
defaults: {
|
||||
temperature: resolveTrueNASDiskTemperatureDefault(
|
||||
trueNASDiskDefaults().temperature,
|
||||
{
|
||||
agentDiskTemperature: props.agentDefaults?.diskTemperature,
|
||||
diskTempByType: props.diskTempByType ?? {},
|
||||
},
|
||||
resource.physicalDisk?.diskType,
|
||||
),
|
||||
},
|
||||
overridesMap,
|
||||
node: trueNASSystemName(resource),
|
||||
instance: readString(resource.physicalDisk?.diskType) || 'TrueNAS',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { formatMetricValue } from './helpers';
|
||||
import { FACTORY_AGENT_DEFAULTS } from '@/utils/alertThresholdDefaults';
|
||||
|
||||
// The thresholds editor's view of the TrueNAS disk temperature trigger. It
|
||||
// mirrors the backend (trueNASDiskTemperatureDefaultNoLock): a TrueNAS-wide
|
||||
// value the user saved applies to every TrueNAS disk; unset, each disk follows
|
||||
// Disk temperature by type, which the agent Disk Temp default switches off for
|
||||
// every type when it is off. It reads the unsaved editor state, so a row shows
|
||||
// what saving would apply.
|
||||
|
||||
export const DISK_TEMPERATURE_TYPE_FIELDS: readonly { key: string; label: string }[] = [
|
||||
{ key: 'nvme', label: 'NVMe' },
|
||||
{ key: 'sas', label: 'SAS' },
|
||||
{ key: 'sata', label: 'SATA' },
|
||||
];
|
||||
|
||||
export interface DiskTemperaturePolicyState {
|
||||
/** The agent Disk Temp default trigger; zero or less is off. */
|
||||
agentDiskTemperature: number | undefined;
|
||||
diskTempByType: Record<string, number>;
|
||||
}
|
||||
|
||||
/** The disk temperature policy's trigger for a disk type; zero or less is off. */
|
||||
export function resolveDiskTemperatureTriggerForType(
|
||||
policy: DiskTemperaturePolicyState,
|
||||
diskType: string | null | undefined,
|
||||
): number {
|
||||
const base = policy.agentDiskTemperature ?? FACTORY_AGENT_DEFAULTS.diskTemperature;
|
||||
if (base <= 0) return 0;
|
||||
const normalizedType = (diskType ?? '').trim().toLowerCase();
|
||||
const byType = normalizedType ? policy.diskTempByType[normalizedType] : undefined;
|
||||
return typeof byType === 'number' && byType > 0 ? byType : base;
|
||||
}
|
||||
|
||||
/** The trigger a TrueNAS disk without its own override inherits. */
|
||||
export function resolveTrueNASDiskTemperatureDefault(
|
||||
trueNASDiskTemperature: number | undefined,
|
||||
policy: DiskTemperaturePolicyState,
|
||||
diskType: string | null | undefined,
|
||||
): number {
|
||||
if (trueNASDiskTemperature !== undefined) return trueNASDiskTemperature;
|
||||
return resolveDiskTemperatureTriggerForType(policy, diskType);
|
||||
}
|
||||
|
||||
/** "NVMe 70°C", one per disk type, or none when the policy is off. */
|
||||
export function getDiskTemperatureByTypeItems(policy: DiskTemperaturePolicyState): string[] {
|
||||
return DISK_TEMPERATURE_TYPE_FIELDS.flatMap((field) => {
|
||||
const trigger = resolveDiskTemperatureTriggerForType(policy, field.key);
|
||||
return trigger > 0 ? [`${field.label} ${formatMetricValue('temperature', trigger)}`] : [];
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Accessor } from 'solid-js';
|
||||
|
||||
import { AlertsAPI } from '@/api/alerts';
|
||||
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import type { Override } from './types';
|
||||
import { useAlertAcknowledgementState } from './useAlertAcknowledgementState';
|
||||
import { useAlertSnoozeState } from './useAlertSnoozeState';
|
||||
@@ -34,8 +35,7 @@ export interface UseAlertOverviewStateProps {
|
||||
}
|
||||
|
||||
export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
|
||||
const [tick, setTick] = createSignal(Date.now());
|
||||
const tickInterval = setInterval(() => setTick(Date.now()), 60_000);
|
||||
const now = useRelativeTimeNow();
|
||||
const activeAlerts = createMemo(() => Object.values(props.activeAlerts()));
|
||||
const {
|
||||
unacknowledgedAlerts,
|
||||
@@ -56,10 +56,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
|
||||
handleUnsnooze,
|
||||
} = useAlertSnoozeState({ alerts: activeAlerts, updateAlert: props.updateAlert });
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(tickInterval);
|
||||
});
|
||||
|
||||
// Delivery diagnoses answer "did/will this alert notify?" per card. One
|
||||
// bulk request covers every active alert; a failed refresh keeps the last
|
||||
// snapshot and the card simply shows no delivery line for unknown alerts.
|
||||
@@ -98,19 +94,24 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
|
||||
.join('\n'),
|
||||
);
|
||||
createEffect(() => {
|
||||
// Refresh when the active alert set changes and on the shared minute
|
||||
// tick, so time-based holds (cooldown, quiet hours) stay current.
|
||||
// Refresh when the active alert set changes.
|
||||
activeAlertIdsKey();
|
||||
tick();
|
||||
void refreshDeliveryDiagnoses();
|
||||
});
|
||||
// And once a minute, so time-based holds (cooldown, quiet hours) stay
|
||||
// current. This is a server read, so it keeps its own minute rather than
|
||||
// following the 30-second clock the ages read.
|
||||
const diagnosisInterval = setInterval(() => void refreshDeliveryDiagnoses(), 60_000);
|
||||
onCleanup(() => {
|
||||
clearInterval(diagnosisInterval);
|
||||
});
|
||||
|
||||
const alertStats = createMemo(() => {
|
||||
const alerts = effectiveAlerts();
|
||||
const recent = alerts.filter((alert) => {
|
||||
const ts = new Date(alert.startTime).getTime();
|
||||
if (Number.isNaN(ts)) return true;
|
||||
const age = tick() - ts;
|
||||
const age = now() - ts;
|
||||
return age >= 0 && age < 86_400_000;
|
||||
});
|
||||
const unacknowledged = alerts.filter((alert) => !alert.acknowledged);
|
||||
@@ -169,7 +170,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) {
|
||||
});
|
||||
|
||||
return {
|
||||
tick,
|
||||
alertStats,
|
||||
filteredAlerts,
|
||||
groupedAlerts,
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
import type { EvidenceEnvelope } from '@/types/operationalTrust';
|
||||
import type { ActionDetailResponse } from '@/types/actionAudit';
|
||||
import { getAlertResourceIncidentAcknowledgedByLabel } from '@/utils/alertIncidentPresentation';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { formatAbsoluteTime, formatRelativeTime, formatTimeUntil } from '@/utils/format';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import type { PatrolAutonomyLevel } from '@/api/patrol';
|
||||
@@ -152,14 +152,31 @@ function getAttentionSuppressionEndPresentation(reviewed: boolean) {
|
||||
durationLabel: 'End suppression after',
|
||||
actionLabel: 'End suppression',
|
||||
successLabel: 'Suppression ended. It stays reviewed',
|
||||
countdownLabel: (remaining: string) => `Suppression ends ${remaining}. It stays reviewed.`,
|
||||
}
|
||||
: {
|
||||
durationLabel: 'Return it to active attention after',
|
||||
actionLabel: 'Return to active attention',
|
||||
successLabel: 'Returned to decision inbox',
|
||||
countdownLabel: (remaining: string) => `Returns to active attention ${remaining}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// The suppression line counts down to the expiry in the same words as the
|
||||
// early-end button, and says so once the expiry has passed.
|
||||
export function getAttentionSuppressionExpiryLabel(
|
||||
expiresAt: string,
|
||||
reviewed: boolean,
|
||||
now: number,
|
||||
): string {
|
||||
const expiry = Date.parse(expiresAt);
|
||||
if (!Number.isFinite(expiry)) return '';
|
||||
if (expiry <= now) return 'Suppression has ended.';
|
||||
return getAttentionSuppressionEndPresentation(reviewed).countdownLabel(
|
||||
formatTimeUntil(expiry, { compact: true, now }),
|
||||
);
|
||||
}
|
||||
|
||||
export function PatrolAttentionWorkbench(
|
||||
props: {
|
||||
autonomyLevel?: PatrolAutonomyLevel;
|
||||
@@ -1284,8 +1301,9 @@ function AttentionLifecycleControls(props: {
|
||||
onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise<void>;
|
||||
onUnsuppress: (itemId: string, reviewed: boolean) => Promise<void>;
|
||||
}) {
|
||||
// The acknowledgement time never changes while the detail stays open, so its
|
||||
// age reads the shared clock.
|
||||
// The acknowledgement and suppression expiry times never change while the
|
||||
// detail stays open, so the acknowledgement age and the countdown to the
|
||||
// expiry read the shared clock.
|
||||
const now = useRelativeTimeNow();
|
||||
const [showSuppression, setShowSuppression] = createSignal(false);
|
||||
const [reason, setReason] = createSignal('');
|
||||
@@ -1328,10 +1346,13 @@ function AttentionLifecycleControls(props: {
|
||||
</p>
|
||||
<Show when={suppression().expiresAt}>
|
||||
{(expiresAt) => (
|
||||
<p>
|
||||
Returns to active attention {formatRelativeTime(expiresAt(), { compact: true })}
|
||||
.
|
||||
</p>
|
||||
<Show when={getAttentionSuppressionExpiryLabel(expiresAt(), reviewed(), now())}>
|
||||
{(label) => (
|
||||
<p title={`Suppressed until ${formatAbsoluteTime(Date.parse(expiresAt()))}`}>
|
||||
{label()}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,7 @@ vi.mock('@/features/actions/ActionReviewDialog', async () => {
|
||||
});
|
||||
|
||||
import {
|
||||
getAttentionSuppressionExpiryLabel,
|
||||
getDistinctPatrolImpact,
|
||||
getPatrolDecisionDisplayTitle,
|
||||
PatrolAttentionWorkbench,
|
||||
@@ -519,6 +520,74 @@ describe('PatrolAttentionWorkbench', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('counts down to when a suppressed issue returns to active attention', async () => {
|
||||
vi.useFakeTimers({
|
||||
toFake: ['Date', 'setInterval', 'clearInterval'],
|
||||
now: Date.parse('2026-07-19T08:10:00Z'),
|
||||
});
|
||||
const suppressed = item({ state: 'suppressed' });
|
||||
const suppressedDetail = detail(suppressed);
|
||||
// A 24-hour suppression set just now.
|
||||
suppressedDetail.operationalRecord.suppression = {
|
||||
at: '2026-07-19T08:10:00Z',
|
||||
by: 'operator',
|
||||
reason: 'Planned storage maintenance',
|
||||
expiresAt: '2026-07-20T08:10:00Z',
|
||||
};
|
||||
const handled = summary({ suppressedCount: 1 });
|
||||
apiMocks.getList.mockImplementation((scope: string) =>
|
||||
Promise.resolve(listResponse(scope === 'all' ? [suppressed] : [], handled)),
|
||||
);
|
||||
apiMocks.getDetail.mockResolvedValue(suppressedDetail);
|
||||
|
||||
try {
|
||||
renderWorkbench();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Reviewed and suppressed (1)' }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: 'Open Database VM · Disk pressure' }),
|
||||
);
|
||||
const detailRegion = await screen.findByRole('complementary', {
|
||||
name: 'Database VM · Disk pressure',
|
||||
});
|
||||
const expiry = await within(detailRegion).findByText(/Returns to active attention/);
|
||||
expect(expiry).toHaveTextContent('Returns to active attention in 1d.');
|
||||
expect(expiry).toHaveAttribute('title', expect.stringMatching(/^Suppressed until /));
|
||||
|
||||
// The detail is not re-read: its suppression never changes and only the
|
||||
// clock moves.
|
||||
vi.advanceTimersByTime(4 * 60 * 60 * 1000);
|
||||
expect(expiry).toHaveTextContent('Returns to active attention in 20h.');
|
||||
|
||||
vi.setSystemTime(Date.parse('2026-07-20T07:30:00Z'));
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(expiry).toHaveTextContent('Returns to active attention in 40m.');
|
||||
|
||||
vi.setSystemTime(Date.parse('2026-07-20T08:11:00Z'));
|
||||
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
|
||||
expect(expiry).toHaveTextContent('Suppression has ended.');
|
||||
expect(detailRegion).not.toHaveTextContent('just now');
|
||||
expect(apiMocks.getDetail).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('says a reviewed issue stays reviewed when its suppression ends', () => {
|
||||
const now = Date.parse('2026-07-19T08:10:00Z');
|
||||
// Expiry restores Acknowledged for a reviewed alert, which is not active
|
||||
// attention, so only an unreviewed issue "returns to active attention".
|
||||
expect(getAttentionSuppressionExpiryLabel('2026-07-20T08:10:00Z', true, now)).toBe(
|
||||
'Suppression ends in 1d. It stays reviewed.',
|
||||
);
|
||||
expect(getAttentionSuppressionExpiryLabel('2026-07-19T11:10:00Z', false, now)).toBe(
|
||||
'Returns to active attention in 3h.',
|
||||
);
|
||||
expect(getAttentionSuppressionExpiryLabel('2026-07-19T08:00:00Z', true, now)).toBe(
|
||||
'Suppression has ended.',
|
||||
);
|
||||
expect(getAttentionSuppressionExpiryLabel('not a date', false, now)).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the detail header and queue controls pinned while a long decision scrolls', async () => {
|
||||
const active = item();
|
||||
apiMocks.getList.mockResolvedValue(
|
||||
|
||||
@@ -691,24 +691,6 @@ export function usePatrolIntelligenceState() {
|
||||
window.history.replaceState(window.history.state, '', nextPath);
|
||||
}
|
||||
|
||||
const summaryStats = () => {
|
||||
const patrolFindings = allPatrolFindings();
|
||||
const activeFindings = patrolFindings.filter((finding) => finding.status === 'active');
|
||||
|
||||
return {
|
||||
criticalFindings: activeFindings.filter((finding) => finding.severity === 'critical').length,
|
||||
warningFindings: activeFindings.filter((finding) => finding.severity === 'warning').length,
|
||||
totalActive: activeFindings.length,
|
||||
fixedCount: patrolFindings.filter(
|
||||
(finding) =>
|
||||
finding.investigationOutcome === 'fix_verified' ||
|
||||
finding.investigationOutcome === 'fix_executed' ||
|
||||
finding.investigationOutcome === 'resolved',
|
||||
).length,
|
||||
hasAnyPatrolFindings: patrolFindings.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const activePatrolFindings = () =>
|
||||
allPatrolFindings().filter((finding) => finding.status === 'active');
|
||||
const shouldShowPatrolSetupOnly = createMemo(() => {
|
||||
@@ -837,7 +819,6 @@ export function usePatrolIntelligenceState() {
|
||||
showBlockedBanner,
|
||||
showReadinessBanner,
|
||||
shouldShowPatrolSetupOnly,
|
||||
summaryStats,
|
||||
triggerPatrolDisabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { collectOutdatedSensorSetupNodes } from '../sensorSetup';
|
||||
|
||||
// Branch-coverage tests for the two UNEXPORTED helpers inside sensorSetup.ts:
|
||||
// - getNodeName (3-way `||` fallback chain + `.trim()`)
|
||||
// - isSMARTOnlyDiskWithoutTemperature (no-meta guard, diskType normalize,
|
||||
// SMART-set membership, `temperature ?? 0`)
|
||||
// Both helpers are private, so every case drives them through the public
|
||||
// `collectOutdatedSensorSetupNodes` and asserts the concrete observable shape.
|
||||
//
|
||||
// Conventions mirror the sibling `sensorSetup.test.ts` fixtures. Disks are
|
||||
// linked to nodes either by `platformData.proxmox.nodeName` (the name-matching
|
||||
// path, used to prove `getNodeName` resolution) or by `parentId` (which makes
|
||||
// `matchesPhysicalDiskNode` short-circuit true, used to isolate the SMART-only
|
||||
// predicate from name resolution).
|
||||
|
||||
type LegacyNodeOver = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
proxmox?: Partial<Resource['proxmox']>;
|
||||
};
|
||||
|
||||
const legacyNode = (over: LegacyNodeOver = {}): Resource =>
|
||||
({
|
||||
id: over.id ?? 'node-1',
|
||||
name: over.name ?? '',
|
||||
displayName: over.name ?? '',
|
||||
type: 'node',
|
||||
proxmox: {
|
||||
temperatureDetails: { available: true, legacySensorsFormat: true },
|
||||
...(over.proxmox ?? {}),
|
||||
},
|
||||
}) as unknown as Resource;
|
||||
|
||||
type DiskOver = {
|
||||
id?: string;
|
||||
parentId?: string;
|
||||
nodeName?: string;
|
||||
diskType?: string;
|
||||
temperature?: number;
|
||||
noMeta?: boolean;
|
||||
};
|
||||
|
||||
const disk = (over: DiskOver = {}): Resource =>
|
||||
({
|
||||
id: over.id ?? 'disk-1',
|
||||
name: 'disk',
|
||||
displayName: 'disk',
|
||||
type: 'physical_disk',
|
||||
parentId: over.parentId,
|
||||
physicalDisk: over.noMeta
|
||||
? undefined
|
||||
: {
|
||||
diskType: over.diskType,
|
||||
temperature: over.temperature,
|
||||
},
|
||||
platformData: over.nodeName ? { proxmox: { nodeName: over.nodeName } } : {},
|
||||
}) as unknown as Resource;
|
||||
|
||||
describe('getNodeName (via collectOutdatedSensorSetupNodes)', () => {
|
||||
it('falls back to proxmox.nodeName when proxmox.node is absent', () => {
|
||||
const node = legacyNode({ id: 'n-nodeName', proxmox: { nodeName: 'bravo' } });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
// Disk resolves to the node by the nodeName-derived identity, proving
|
||||
// getNodeName returned 'bravo' (not '' and not node.name).
|
||||
[disk({ id: 'd1', nodeName: 'bravo', diskType: 'sata', temperature: 0 })],
|
||||
);
|
||||
expect(result).toStrictEqual([{ id: 'n-nodeName', name: 'bravo' }]);
|
||||
});
|
||||
|
||||
it('falls back to the resource name when both proxmox.node and nodeName are absent', () => {
|
||||
const node = legacyNode({ id: 'n-name', name: 'charlie' });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
[disk({ id: 'd2', nodeName: 'charlie', diskType: 'sata', temperature: 0 })],
|
||||
);
|
||||
expect(result).toStrictEqual([{ id: 'n-name', name: 'charlie' }]);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from proxmox.node before using it as the node identity', () => {
|
||||
const node = legacyNode({ id: 'n-trim', proxmox: { node: ' delta ' } });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
// matchesPhysicalDiskNode lowercases/trims both sides, so 'delta' must be
|
||||
// the trimmed getNodeName output for the disk to match.
|
||||
[disk({ id: 'd3', nodeName: 'delta', diskType: 'sata', temperature: 0 })],
|
||||
);
|
||||
expect(result).toStrictEqual([{ id: 'n-trim', name: 'delta' }]);
|
||||
});
|
||||
|
||||
it('returns the empty string when every name source is blank, observable via a parentId-linked disk', () => {
|
||||
// No proxmox.node, no proxmox.nodeName, name is whitespace -> getNodeName
|
||||
// hits the final `|| ''` arm and trims to ''. The disk is matched by
|
||||
// parentId so matchesPhysicalDiskNode short-circuits true regardless of the
|
||||
// (empty) name, letting the flagged node surface with name: ''.
|
||||
const node = legacyNode({ id: 'n-empty', name: ' ' });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
[disk({ id: 'd4', parentId: 'n-empty', diskType: 'sata', temperature: 0 })],
|
||||
);
|
||||
expect(result).toStrictEqual([{ id: 'n-empty', name: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSMARTOnlyDiskWithoutTemperature (via collectOutdatedSensorSetupNodes)', () => {
|
||||
it('returns false for a disk with no physicalDisk metadata, so the node is not flagged', () => {
|
||||
// Disk is linked to the node by parentId (match guaranteed true); the only
|
||||
// reason the node drops out is the `if (!meta) return false` guard.
|
||||
const node = legacyNode({ id: 'n-nometa', proxmox: { node: 'pve' } });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
[disk({ id: 'd-nometa', parentId: 'n-nometa', noMeta: true })],
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns false when diskType is missing, so a matched disk does not flag the node', () => {
|
||||
// diskType undefined -> `(meta.diskType || '')` -> '' not in the SMART-only
|
||||
// set. parentId link isolates the cause to the diskType check.
|
||||
const node = legacyNode({ id: 'n-notype', proxmox: { node: 'pve' } });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
[disk({ id: 'd-notype', parentId: 'n-notype', temperature: 0 })],
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes a whitespace-padded uppercase SAS diskType and flags the node', () => {
|
||||
// Exercises `diskType.trim().toLowerCase()` -> 'sas' (in the SMART-only
|
||||
// set) and temperature 0 -> `(0 ?? 0) <= 0` true.
|
||||
const node = legacyNode({ id: 'n-sas', proxmox: { node: 'pve' } });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
[
|
||||
disk({
|
||||
id: 'd-sas',
|
||||
parentId: 'n-sas',
|
||||
diskType: ' SAS ',
|
||||
temperature: 0,
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result).toStrictEqual([{ id: 'n-sas', name: 'pve' }]);
|
||||
});
|
||||
|
||||
it('uses 0 in place of an undefined temperature and flags the node', () => {
|
||||
// temperature is omitted entirely -> `(meta.temperature ?? 0)` takes the
|
||||
// nullish right-hand side (0) -> `<= 0` true.
|
||||
const node = legacyNode({ id: 'n-undef-temp', proxmox: { node: 'pve' } });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
[disk({ id: 'd-undef-temp', parentId: 'n-undef-temp', diskType: 'sata' })],
|
||||
);
|
||||
expect(result).toStrictEqual([{ id: 'n-undef-temp', name: 'pve' }]);
|
||||
});
|
||||
|
||||
it('uses 0 in place of an explicit null temperature and flags the node', () => {
|
||||
// Deliberately-malformed payload: temperature typed as number | undefined
|
||||
// but carrying null. The `?? 0` coalesces it to 0 -> `<= 0` true.
|
||||
const node = legacyNode({ id: 'n-null-temp', proxmox: { node: 'pve' } });
|
||||
const nullTempDisk = {
|
||||
id: 'd-null-temp',
|
||||
name: 'disk',
|
||||
displayName: 'disk',
|
||||
type: 'physical_disk',
|
||||
parentId: 'n-null-temp',
|
||||
physicalDisk: { diskType: 'sata', temperature: null },
|
||||
} as unknown as Resource;
|
||||
const result = collectOutdatedSensorSetupNodes([node], [nullTempDisk]);
|
||||
expect(result).toStrictEqual([{ id: 'n-null-temp', name: 'pve' }]);
|
||||
});
|
||||
|
||||
it('flags the node for a SATA disk reporting a negative temperature', () => {
|
||||
// Concrete negative value exercises the `<= 0` true arm (not the ?? arm).
|
||||
const node = legacyNode({ id: 'n-neg-temp', proxmox: { node: 'pve' } });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
[disk({ id: 'd-neg-temp', parentId: 'n-neg-temp', diskType: 'sata', temperature: -5 })],
|
||||
);
|
||||
expect(result).toStrictEqual([{ id: 'n-neg-temp', name: 'pve' }]);
|
||||
});
|
||||
|
||||
it('does not flag the node when a SMART-only disk reports a positive temperature', () => {
|
||||
// SAS at 41 degrees -> `(41 ?? 0) <= 0` is false.
|
||||
const node = legacyNode({ id: 'n-warm', proxmox: { node: 'pve' } });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
[disk({ id: 'd-warm', parentId: 'n-warm', diskType: 'sas', temperature: 41 })],
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('evaluates the no-meta guard on the first disk but still flags the node via a second matching disk', () => {
|
||||
// Drives the `!meta` branch on d-bad, then `.some()` advances to d-good
|
||||
// which is SMART-only without a temperature -> node flagged.
|
||||
const node = legacyNode({ id: 'n-mixed', proxmox: { node: 'pve' } });
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[node],
|
||||
[
|
||||
disk({ id: 'd-bad', parentId: 'n-mixed', noMeta: true }),
|
||||
disk({ id: 'd-good', parentId: 'n-mixed', diskType: 'sata', temperature: 0 }),
|
||||
],
|
||||
);
|
||||
expect(result).toStrictEqual([{ id: 'n-mixed', name: 'pve' }]);
|
||||
});
|
||||
});
|
||||
@@ -1,150 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PhysicalDiskCollectionStatus, Resource } from '@/types/resource';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { collectOutdatedSensorSetupNodes } from './sensorSetup';
|
||||
|
||||
const node = (over: Partial<Resource> & { proxmox?: Resource['proxmox'] }): Resource =>
|
||||
({ id: 'node-1', name: 'pve1', type: 'node', ...over }) as Resource;
|
||||
({ id: 'node-1', name: 'pve1', type: 'agent', ...over }) as Resource;
|
||||
|
||||
const disk = (over: {
|
||||
id?: string;
|
||||
parentId?: string;
|
||||
nodeName?: string;
|
||||
diskType?: string;
|
||||
temperature?: number;
|
||||
collection?: PhysicalDiskCollectionStatus;
|
||||
}): Resource =>
|
||||
({
|
||||
id: over.id ?? 'disk-1',
|
||||
name: 'disk',
|
||||
type: 'physical_disk',
|
||||
parentId: over.parentId,
|
||||
physicalDisk: {
|
||||
diskType: over.diskType ?? 'sata',
|
||||
temperature: over.temperature ?? 0,
|
||||
collection: over.collection,
|
||||
},
|
||||
platformData: over.nodeName ? { proxmox: { nodeName: over.nodeName } } : {},
|
||||
}) as unknown as Resource;
|
||||
|
||||
const legacyNode = (over: Partial<Resource> = {}): Resource =>
|
||||
const flaggedNode = (over: Partial<Resource> = {}): Resource =>
|
||||
node({
|
||||
proxmox: {
|
||||
node: 'pve1',
|
||||
temperatureDetails: { available: true, legacySensorsFormat: true },
|
||||
sensorSetupOutdated: true,
|
||||
},
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('collectOutdatedSensorSetupNodes', () => {
|
||||
it('flags a legacy-format node with a SATA disk missing a temperature', () => {
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[legacyNode()],
|
||||
[disk({ nodeName: 'pve1', diskType: 'sata' })],
|
||||
);
|
||||
expect(result).toEqual([{ id: 'node-1', name: 'pve1' }]);
|
||||
it('lists a node carrying the registry verdict without any disk rows', () => {
|
||||
expect(collectOutdatedSensorSetupNodes([flaggedNode()])).toEqual([
|
||||
{ id: 'node-1', name: 'pve1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches disks to nodes by parentId as well as node name', () => {
|
||||
const result = collectOutdatedSensorSetupNodes(
|
||||
[legacyNode()],
|
||||
[disk({ parentId: 'node-1', diskType: 'sas' })],
|
||||
);
|
||||
expect(result).toEqual([{ id: 'node-1', name: 'pve1' }]);
|
||||
});
|
||||
|
||||
it('ignores nodes whose payload is the wrapper format', () => {
|
||||
const wrapperNode = node({
|
||||
proxmox: { node: 'pve1', temperatureDetails: { available: true } },
|
||||
});
|
||||
expect(collectOutdatedSensorSetupNodes([wrapperNode], [disk({ nodeName: 'pve1' })])).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores legacy nodes when temperature collection did not succeed', () => {
|
||||
const unavailableNode = node({
|
||||
it('leaves a legacy-format node the registry did not flag', () => {
|
||||
// The registry clears the verdict when every SATA/SAS disk under the node
|
||||
// has a current temperature, for example from a linked host agent.
|
||||
const reading = node({
|
||||
proxmox: {
|
||||
node: 'pve1',
|
||||
temperatureDetails: { available: false, legacySensorsFormat: true },
|
||||
temperatureDetails: { available: true, legacySensorsFormat: true },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
collectOutdatedSensorSetupNodes([unavailableNode], [disk({ nodeName: 'pve1' })]),
|
||||
).toEqual([]);
|
||||
expect(collectOutdatedSensorSetupNodes([reading])).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores legacy nodes whose SATA/SAS disks already have temperatures', () => {
|
||||
expect(
|
||||
collectOutdatedSensorSetupNodes(
|
||||
[legacyNode()],
|
||||
[disk({ nodeName: 'pve1', diskType: 'sata', temperature: 34 })],
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a retained temperature that is not current as missing', () => {
|
||||
// Normalization keeps a disk's last known temperature when the current
|
||||
// observation did not collect one, such as after a linked host agent
|
||||
// stopped reporting. The disk cannot be read now, so the notice stays.
|
||||
const retained = (state: 'available' | 'unavailable' | 'missing') =>
|
||||
disk({
|
||||
nodeName: 'pve1',
|
||||
diskType: 'sata',
|
||||
temperature: 34,
|
||||
collection: {
|
||||
temperature: { state, source: 'agent', reason: 'host agent stopped reporting' },
|
||||
},
|
||||
});
|
||||
expect(collectOutdatedSensorSetupNodes([legacyNode()], [retained('unavailable')])).toEqual([
|
||||
{ id: 'node-1', name: 'pve1' },
|
||||
it('names a node by its Proxmox node, then node name, then resource name, trimmed', () => {
|
||||
const nodes = [
|
||||
node({ id: 'by-node', proxmox: { node: ' alpha ', sensorSetupOutdated: true } }),
|
||||
node({ id: 'by-node-name', proxmox: { nodeName: 'bravo', sensorSetupOutdated: true } }),
|
||||
node({ id: 'by-name', name: 'charlie', proxmox: { sensorSetupOutdated: true } }),
|
||||
];
|
||||
expect(collectOutdatedSensorSetupNodes(nodes)).toEqual([
|
||||
{ id: 'by-node', name: 'alpha' },
|
||||
{ id: 'by-node-name', name: 'bravo' },
|
||||
{ id: 'by-name', name: 'charlie' },
|
||||
]);
|
||||
expect(collectOutdatedSensorSetupNodes([legacyNode()], [retained('missing')])).toEqual([
|
||||
{ id: 'node-1', name: 'pve1' },
|
||||
]);
|
||||
expect(collectOutdatedSensorSetupNodes([legacyNode()], [retained('available')])).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores legacy nodes with only NVMe disks missing temperatures', () => {
|
||||
expect(
|
||||
collectOutdatedSensorSetupNodes(
|
||||
[legacyNode()],
|
||||
[disk({ nodeName: 'pve1', diskType: 'nvme' })],
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores disks that belong to a different node', () => {
|
||||
expect(collectOutdatedSensorSetupNodes([legacyNode()], [disk({ nodeName: 'pve2' })])).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('sorts multiple affected nodes by name', () => {
|
||||
const nodes = [
|
||||
node({
|
||||
id: 'node-b',
|
||||
name: 'pve-b',
|
||||
proxmox: {
|
||||
node: 'pve-b',
|
||||
temperatureDetails: { available: true, legacySensorsFormat: true },
|
||||
},
|
||||
}),
|
||||
node({
|
||||
id: 'node-a',
|
||||
name: 'pve-a',
|
||||
proxmox: {
|
||||
node: 'pve-a',
|
||||
temperatureDetails: { available: true, legacySensorsFormat: true },
|
||||
},
|
||||
}),
|
||||
flaggedNode({ id: 'node-b', proxmox: { node: 'pve-b', sensorSetupOutdated: true } }),
|
||||
flaggedNode({ id: 'node-a', proxmox: { node: 'pve-a', sensorSetupOutdated: true } }),
|
||||
];
|
||||
const disks = [
|
||||
disk({ id: 'disk-a', nodeName: 'pve-a' }),
|
||||
disk({ id: 'disk-b', nodeName: 'pve-b' }),
|
||||
];
|
||||
expect(collectOutdatedSensorSetupNodes(nodes, disks).map((n) => n.name)).toEqual([
|
||||
'pve-a',
|
||||
'pve-b',
|
||||
]);
|
||||
expect(collectOutdatedSensorSetupNodes(nodes).map((n) => n.name)).toEqual(['pve-a', 'pve-b']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { matchesPhysicalDiskNode } from '@/components/Storage/diskResourceUtils';
|
||||
import { isPhysicalDiskTemperatureCurrent } from '@/features/storageBackups/diskTemperaturePresentation';
|
||||
|
||||
// Disk transports whose temperatures only arrive via SMART (the pulse-sensors
|
||||
// wrapper). NVMe temps come from kernel hwmon and work even on legacy setups.
|
||||
const SMART_ONLY_DISK_TYPES = new Set(['sata', 'sas']);
|
||||
|
||||
export type OutdatedSensorSetupNode = {
|
||||
id: string;
|
||||
@@ -14,44 +8,16 @@ export type OutdatedSensorSetupNode = {
|
||||
const getNodeName = (node: Resource): string =>
|
||||
(node.proxmox?.node || node.proxmox?.nodeName || node.name || '').trim();
|
||||
|
||||
const isSMARTOnlyDiskWithoutTemperature = (disk: Resource): boolean => {
|
||||
const meta = disk.physicalDisk;
|
||||
if (!meta) return false;
|
||||
const diskType = (meta.diskType || '').trim().toLowerCase();
|
||||
if (!SMART_ONLY_DISK_TYPES.has(diskType)) return false;
|
||||
// A last known temperature kept for a disk that could not be read now, such
|
||||
// as after a linked host agent stopped reporting, is not one arriving.
|
||||
return (meta.temperature ?? 0) <= 0 || !isPhysicalDiskTemperatureCurrent(meta.collection);
|
||||
};
|
||||
|
||||
// Flags PVE nodes whose SSH temperature monitoring still runs the pre-rc.6
|
||||
// Lists PVE nodes whose SSH temperature monitoring still runs the pre-rc.6
|
||||
// setup (authorized_keys locked to `sensors -j`). Such a payload parses fine
|
||||
// and delivers CPU/NVMe temps, but SMART (SATA/SAS) disk temperatures can
|
||||
// never arrive, so they silently stay blank. Data-gated three ways: the last
|
||||
// collection succeeded, the payload was legacy-format, and the node actually
|
||||
// has SATA/SAS disks sitting without a temperature. Nodes whose disk temps
|
||||
// arrive elsewhere (e.g. a linked host agent) fall out via the last check.
|
||||
export function collectOutdatedSensorSetupNodes(
|
||||
nodes: Resource[],
|
||||
physicalDisks: Resource[],
|
||||
): OutdatedSensorSetupNode[] {
|
||||
if (!nodes.length || !physicalDisks.length) return [];
|
||||
|
||||
// never arrive, so they silently stay blank. The registry derives the verdict
|
||||
// on the node (`proxmox.sensorSetupOutdated`) from the node's last collection,
|
||||
// its legacy payload and the disks under it, so every Proxmox tab that lists
|
||||
// nodes can show the notice without loading the disk inventory.
|
||||
export function collectOutdatedSensorSetupNodes(nodes: Resource[]): OutdatedSensorSetupNode[] {
|
||||
return nodes
|
||||
.filter((node) => {
|
||||
const details = node.proxmox?.temperatureDetails;
|
||||
if (!details?.available || !details.legacySensorsFormat) return false;
|
||||
const nodeName = getNodeName(node);
|
||||
return physicalDisks.some(
|
||||
(disk) =>
|
||||
isSMARTOnlyDiskWithoutTemperature(disk) &&
|
||||
matchesPhysicalDiskNode(disk, {
|
||||
id: node.id,
|
||||
name: nodeName,
|
||||
instance: node.proxmox?.instance,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.filter((node) => node.proxmox?.sensorSetupOutdated === true)
|
||||
.map((node) => ({ id: node.id, name: getNodeName(node) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
@@ -286,11 +286,10 @@ export function ProxmoxPageSurface() {
|
||||
const serverVersionDisplay = createMemo(() =>
|
||||
formatAgentVersionDisplay(agentUpdateTargetVersion()),
|
||||
);
|
||||
// The node carries the registry's verdict, so the notice reads the same on
|
||||
// every tab that lists nodes; no tab hydrates disks for it.
|
||||
const outdatedSensorSetupNodes = createMemo(() =>
|
||||
collectOutdatedSensorSetupNodes(
|
||||
model().pveNodes,
|
||||
model().resources.filter((resource) => resource.type === 'physical_disk'),
|
||||
),
|
||||
collectOutdatedSensorSetupNodes(model().pveNodes),
|
||||
);
|
||||
|
||||
// The hosts table at the top and the embedded WorkloadsSurface below share
|
||||
|
||||
@@ -375,6 +375,44 @@ describe('ProxmoxPageSurface contract', () => {
|
||||
expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the outdated sensor setup notice on Overview without hydrating disks', () => {
|
||||
const node = makeResource({
|
||||
id: 'agent:pve3',
|
||||
name: 'pve3',
|
||||
displayName: 'pve3',
|
||||
type: 'agent',
|
||||
proxmox: {
|
||||
nodeName: 'pve3',
|
||||
temperatureDetails: { available: true, legacySensorsFormat: true },
|
||||
sensorSetupOutdated: true,
|
||||
},
|
||||
});
|
||||
// Like production, the Overview query returns nodes and guests only: the
|
||||
// verdict has to arrive on the node, because no physical_disk rows do.
|
||||
mockUseUnifiedResources.mockImplementation((options: { cacheKey?: string }) => ({
|
||||
resources: () => (options.cacheKey === 'proxmox-overview' ? [node] : []),
|
||||
loading: () => false,
|
||||
error: () => null,
|
||||
refetch: vi.fn(),
|
||||
}));
|
||||
|
||||
renderSurface();
|
||||
|
||||
const overviewOptions = mockUseUnifiedResources.mock.calls
|
||||
.map(([options]) => options as { cacheKey?: string; query?: string })
|
||||
.find((options) => options.cacheKey === 'proxmox-overview');
|
||||
expect(overviewOptions?.query).toBeDefined();
|
||||
expect(overviewOptions?.query).not.toContain('physical_disk');
|
||||
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview');
|
||||
expect(screen.getByTestId('platform-outdated-sensor-setup-notice')).toHaveTextContent(
|
||||
'pve3 is using an older temperature monitoring setup that cannot read SATA/SAS disk temperatures.',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'Open Infrastructure settings' })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/infrastructure',
|
||||
);
|
||||
});
|
||||
|
||||
it('folds estate topology into the existing nodes table header contract', () => {
|
||||
setResources([
|
||||
makeResource({
|
||||
|
||||
@@ -1943,7 +1943,7 @@ describe('useUnifiedResources', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('passes proxmox temperatureDetails through for the outdated-sensor-setup gate', async () => {
|
||||
it('passes the proxmox temperature payload and sensor setup verdict through', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -1956,6 +1956,7 @@ describe('useUnifiedResources', () => {
|
||||
nodeName: 'pve1',
|
||||
temperature: 58.4,
|
||||
temperatureDetails: { available: true, legacySensorsFormat: true },
|
||||
sensorSetupOutdated: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1974,6 +1975,9 @@ describe('useUnifiedResources', () => {
|
||||
available: true,
|
||||
legacySensorsFormat: true,
|
||||
});
|
||||
// The outdated sensor setup notice reads the registry's verdict on the
|
||||
// node, so it must survive the projection that rows on every tab go through.
|
||||
expect(result!.resources()[0].proxmox?.sensorSetupOutdated).toBe(true);
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -142,6 +142,54 @@ describe('useWorkloads', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('marks a silent linked agent and the guest filesystems it supplied', async () => {
|
||||
const disk = (mountpoint: string) => ({
|
||||
mountpoint,
|
||||
type: 'ext4',
|
||||
total: 100,
|
||||
used: 40,
|
||||
free: 60,
|
||||
usage: 40,
|
||||
});
|
||||
const [snapshot] = createSignal([
|
||||
// Proxmox read no guest filesystems, so the agent's own report fills in.
|
||||
{
|
||||
...sampleResource,
|
||||
id: 'vm-agent-disks',
|
||||
vmid: 102,
|
||||
sources: ['proxmox', 'agent'],
|
||||
agent: { agentVersion: '6.4.5', stale: true, disks: [disk('/agent')] },
|
||||
},
|
||||
// Proxmox guest filesystems win over a live agent's.
|
||||
{
|
||||
...sampleResource,
|
||||
id: 'vm-platform-disks',
|
||||
vmid: 103,
|
||||
sources: ['proxmox', 'agent'],
|
||||
proxmox: { disks: [disk('/platform')] },
|
||||
agent: { agentVersion: '6.4.5', stale: false, disks: [disk('/agent')] },
|
||||
},
|
||||
] as any);
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<UseWorkloadsModule['useWorkloads']> | undefined;
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
result = useWorkloads(() => true, { resourceSnapshot: snapshot });
|
||||
});
|
||||
try {
|
||||
await flushAsync();
|
||||
const byId = (id: string) =>
|
||||
result!.workloads().find((workload) => workload.canonicalResourceId === id);
|
||||
expect(byId('vm-agent-disks')).toMatchObject({ agentStale: true, disksFromAgent: true });
|
||||
expect(byId('vm-agent-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/agent']);
|
||||
expect(byId('vm-platform-disks')?.agentStale).toBeUndefined();
|
||||
expect(byId('vm-platform-disks')?.disksFromAgent).toBeUndefined();
|
||||
expect(byId('vm-platform-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/platform']);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -261,6 +261,7 @@ type APIResource = {
|
||||
available?: boolean;
|
||||
legacySensorsFormat?: boolean;
|
||||
};
|
||||
sensorSetupOutdated?: boolean;
|
||||
template?: boolean;
|
||||
containerType?: string;
|
||||
isOci?: boolean;
|
||||
|
||||
@@ -146,6 +146,7 @@ type APIResource = {
|
||||
networkInterfaces?: APINetworkInterface[];
|
||||
disks?: APIDiskInfo[];
|
||||
raid?: HostRAIDArray[];
|
||||
stale?: boolean;
|
||||
};
|
||||
docker?: {
|
||||
containerId?: string;
|
||||
@@ -592,6 +593,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
|
||||
})(),
|
||||
disk: buildMetric(resource.metrics?.disk),
|
||||
disks: normalizeDiskArray(resource.proxmox?.disks ?? resource.agent?.disks),
|
||||
disksFromAgent: (resource.proxmox?.disks == null && resource.agent?.disks != null) || undefined,
|
||||
// Both the API and owning platform snapshot pass through this mapper.
|
||||
// A retained numeric metric must not lose the provider's read deferral.
|
||||
diskStatusReason: resource.proxmox?.diskStatusReason,
|
||||
@@ -612,6 +614,9 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
|
||||
agentId: resource.agent?.agentId,
|
||||
agentRaid: resource.agent?.raid,
|
||||
networkInterfaces: mapNetworkInterfaces(resource.agent?.networkInterfaces),
|
||||
// A linked agent past its reporting lease keeps its last report on the
|
||||
// resource, so its RAID, network and filesystem rows are not current.
|
||||
agentStale: resource.agent?.stale === true || undefined,
|
||||
networkIn: finiteMetricNumber(resource.metrics?.netIn?.value) ?? 0,
|
||||
networkOut: finiteMetricNumber(resource.metrics?.netOut?.value) ?? 0,
|
||||
diskRead: finiteMetricNumber(resource.metrics?.diskRead?.value) ?? 0,
|
||||
|
||||
@@ -109,14 +109,6 @@ export interface ResourceCorrelation {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CorrelationsResponse {
|
||||
correlations: ResourceCorrelation[];
|
||||
count: number;
|
||||
message?: string;
|
||||
license_required?: boolean;
|
||||
upgrade_url?: string;
|
||||
}
|
||||
|
||||
export interface IntelligenceSummary {
|
||||
timestamp: string;
|
||||
overall_health: IntelligenceHealthScore;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user