mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Merge origin/main into fix/link-fold-references
PR 2719 added per-side link pins beside the fold bookkeeping this branch rewrote; keep both, and replace the contract bullet that said folded IDs do not resolve.
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,
|
||||
@@ -3678,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
|
||||
@@ -4712,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`,
|
||||
@@ -7639,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
|
||||
@@ -8807,6 +8843,25 @@ in `internal/monitoring/monitor_host_agents_test.go`
|
||||
`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
|
||||
@@ -8935,3 +8990,11 @@ 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
|
||||
@@ -7510,6 +7535,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
|
||||
|
||||
@@ -1114,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
|
||||
@@ -1473,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
|
||||
@@ -1483,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
|
||||
@@ -1851,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
|
||||
@@ -1905,6 +2034,61 @@ alert. `shouldPreserveAlertOutsideNodeCleanup` now keeps any alert carrying
|
||||
`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
|
||||
@@ -3180,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
|
||||
@@ -4025,12 +4039,16 @@ the authoritative analysis outcome.
|
||||
`/api/settings/ai/update` may save a selected Patrol provider/model even
|
||||
when that model is not ready for tool-backed Patrol execution, but it must
|
||||
echo `patrol_readiness` with stable `cause` metadata and execution routes
|
||||
must continue to fail closed before model calls. Frontend Patrol settings
|
||||
must continue to fail closed before model calls. Frontend settings
|
||||
consumers must surface that saved-but-not-ready response as a saved
|
||||
configuration issue with the echoed provider, model, cause, and summary
|
||||
instead of reporting the successful save as a failed save or hiding the
|
||||
readiness blocker behind a generic notification; and the structured
|
||||
investigation-record contract, so unified findings may
|
||||
configuration issue rather than a failed save, and never as inline failure
|
||||
state: every Pulse Intelligence settings page (Provider & Models, Patrol,
|
||||
Assistant, Service context) raises a warning notification naming the echoed
|
||||
summary, provider, and model, while the Patrol page's on/off save raises a
|
||||
fixed not-ready warning and leaves the diagnosis to the page's own readiness
|
||||
surfaces, such as the readiness banner that names the summary, provider,
|
||||
and model while the header offers `Fix setup` when Patrol is active; and
|
||||
the structured investigation-record contract, so unified findings may
|
||||
expose `investigation_record` only through the shared
|
||||
`aicontracts.InvestigationRecord` payload shape, with frontend API types
|
||||
and backend contract tests updated in the same slice as any field change.
|
||||
@@ -4842,6 +4860,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 +7141,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 +7202,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
|
||||
@@ -7635,6 +7659,23 @@ 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
|
||||
@@ -9650,6 +9691,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`,
|
||||
@@ -10275,6 +10342,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
|
||||
@@ -725,8 +733,11 @@ choices compose the shared `ViewOptionsDisclosure` instead of occupying the
|
||||
primary filter rail: layout, metric style, chart visibility, memory basis, and
|
||||
columns remain discoverable behind one `View` trigger. The history range stays
|
||||
inline in both metric modes because bars now expose an intent-driven row
|
||||
history lens and Trends keeps the same charts persistent. That inline range
|
||||
must carry a visible contextual label.
|
||||
history lens and Trends keeps the same charts persistent. In Bars the range
|
||||
renders only at desktop widths while Row hover is set to `History`, the mode
|
||||
that drives that lens; `Details` hover and the phone (`isMobile`) layout hide
|
||||
it. The inline range carries the visible `History` label that
|
||||
`WorkloadsFilter` passes to `MetricHistoryRangeSegmentedControl`.
|
||||
Controls inside the View disclosure must expand in place rather than opening
|
||||
nested absolute panels that can clip or create competing overlay stacks. The
|
||||
Proxmox page owns and persists the `Guest` / `Host` memory basis;
|
||||
@@ -2116,6 +2127,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 +3034,9 @@ Agent`), with the plain-language source phrase available through accessible
|
||||
than receiving an unfiltered copy of the estate. Opaque unified-resource ids
|
||||
are not part of that visible search vocabulary and must not retain a node
|
||||
when the normalized guest table has no corresponding match.
|
||||
8. Keep summary interaction identity on one shared helper. Pages that expose row-hover, group-hover, or route-focus-driven summary emphasis, today the Workloads and Storage tables through `useSummaryPageInteractionState` in `frontend-modern/src/components/shared/summaryTableFocus.ts`, must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` rather than letting each surface read `hovered || focused` from its own page-local ID source. Hovering or pinning a storage pool-group header, or a workload group header on a surface that enables inline group drawers (the Proxmox and VMware surfaces pass `groupNodeDrawerMode="disabled"` today), must scope the matching rows through that same shared contract instead of forking a page-local summary filter path. No summary card or chart strip renders today; one that returns must consume that same resolved scope, and the chart under the pointer must promote its series into the shared active entity through the bridge's `setChartHoverSync` input instead of keeping a chart-local hover island. Recovery is explicitly outside this interaction dialect: its retired posture-card strip must not return with row/group/chart hover behavior without a separate governed product decision.
|
||||
8. Keep summary interaction identity on one shared helper. Pages that expose row-hover, group-hover, or route-focus-driven summary emphasis, today the Workloads and Storage tables through `useSummaryPageInteractionState` in `frontend-modern/src/components/shared/summaryTableFocus.ts`, must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` rather than letting each surface read `hovered || focused` from its own page-local ID source. Hovering or pinning a storage pool-group header, 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.
|
||||
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
|
||||
@@ -3144,33 +3167,30 @@ Agent`), with the plain-language source phrase available through accessible
|
||||
operator action or selected Patrol context, uses distinct monitor labels,
|
||||
and does not become a nested card, generic dashboard strip, trust summary,
|
||||
or duplicate Patrol empty-work list. The
|
||||
Patrol schedule and model drawer is part of that shared
|
||||
feature-presentation boundary: it must stay viewport-bounded, expose an
|
||||
accessible dialog label, keep the four-level control policy on the default
|
||||
Patrol header, and keep provider model, schedule, trigger tuning, and
|
||||
readiness validation inside the secondary disclosure. Backend save rejection reasons must pass
|
||||
through as inline dialog state instead of being replaced with generic toast
|
||||
copy, and that advanced disclosure must open when the inline state exists.
|
||||
When the failure includes
|
||||
Patrol readiness context, the inline state must expose the provider, model,
|
||||
and readiness summary next to a direct provider-settings action instead of
|
||||
hiding that diagnosis behind Assistant alone. The provider-model selector in
|
||||
that popover must stay bound to the shared runtime settings/model catalog
|
||||
even when the popover mounts after async catalog loading, but the full
|
||||
catalog must stay behind an explicit change action so the default advanced
|
||||
drawer leads with the current effective model summary rather than a raw
|
||||
provider route list. A saved direct-provider Patrol model still renders as
|
||||
that model instead of visually falling back to the default selection.
|
||||
Successful provider-model saves that return a not-ready Patrol
|
||||
readiness snapshot must use that same inline surface with `needs attention`
|
||||
wording, and any Assistant handoff from that state describes a saved
|
||||
configuration issue rather than a failed save. When governed fixes are locked, the same Patrol state
|
||||
owner must clear stale full-mode unlock state before persisting the
|
||||
monitor-only autonomy payload, so disabled paid controls cannot leak stale
|
||||
permission into a save. If that inline state opens Assistant, the Patrol
|
||||
feature must hand off
|
||||
a source-named, model-only briefing and close the popover so the shared
|
||||
Assistant drawer is not visually hidden behind feature chrome. When a
|
||||
Patrol page keeps the four-level control policy on its header and has no
|
||||
schedule and model drawer: schedule, trigger tuning, and the Patrol model
|
||||
override live on Settings > Pulse Intelligence > Patrol, and providers and
|
||||
the shared default model on Settings > Provider & Models. Those saves
|
||||
report through the shared notification toasts, not inline state: a
|
||||
rejected save raises an error toast carrying the backend's concrete reason
|
||||
rather than generic fallback copy, and a saved response whose Patrol
|
||||
readiness is not ready or degraded raises a warning toast naming the
|
||||
readiness summary, provider, and model instead of a success toast. On the
|
||||
Patrol page, an on/off save whose response reports Patrol not ready raises
|
||||
a fixed warning toast, and a failed Patrol mode save raises an error toast
|
||||
and restores the previous mode; neither renders inline save-failure state
|
||||
or offers an Assistant handoff. If a Patrol settings popover or inline
|
||||
save-failure state returns, it must stay viewport-bounded, expose an
|
||||
accessible dialog label, bind any model selector to the shared runtime
|
||||
settings and model catalog, show readiness context (provider, model,
|
||||
summary) next to a direct provider-settings action rather than behind
|
||||
Assistant alone, and describe a saved but not ready response as a saved
|
||||
configuration issue rather than a failed save. An Assistant handoff from it
|
||||
must send a source-named, model-only briefing and close the popover so the
|
||||
shared Assistant drawer is not visually hidden behind feature chrome. When
|
||||
governed fixes are locked, the Patrol state owner must clear stale
|
||||
full-mode unlock state before persisting the monitor-only autonomy payload,
|
||||
so disabled paid controls cannot leak stale permission into a save. When a
|
||||
restored Patrol assessment session is attached, the shared Assistant drawer
|
||||
empty state must
|
||||
stay aligned with that source-named briefing and must not render generic
|
||||
@@ -4828,6 +4848,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
|
||||
@@ -5128,10 +5155,10 @@ 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
|
||||
@@ -5255,7 +5282,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
|
||||
@@ -5770,7 +5809,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,
|
||||
@@ -6410,20 +6457,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
|
||||
|
||||
@@ -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
|
||||
@@ -2679,10 +2702,12 @@ truthfulness, not native thaw, containing-release or workload acceptance.
|
||||
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 a link's metric merge judges each side's freshness
|
||||
by them (unified-resources contract, "Operator links reach
|
||||
record-ingested resources"). Regression coverage:
|
||||
`TestMonitorAdapterJoinsLinkedRecordsWithConfiguredStaleThresholds` in
|
||||
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`.
|
||||
@@ -4500,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`,
|
||||
@@ -4809,8 +4841,10 @@ 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 several
|
||||
SMART rows share the disk's device path, and `hostDiskIOMetricResourceID` keys
|
||||
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.
|
||||
@@ -4821,6 +4855,21 @@ 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
|
||||
|
||||
@@ -1581,17 +1581,18 @@ suppress stale healthy summary headlines such as `Health A · 100/100` even if
|
||||
the last summary payload still looks healthy. Legacy `/ai` entry points must
|
||||
redirect into that same Patrol-owned shell rather than preserving a second
|
||||
canonical route.
|
||||
That same browser proof now covers the Patrol control and advanced-settings
|
||||
split. The advanced Patrol settings drawer must stay within the desktop
|
||||
viewport, avoid duplicating the inline Patrol control policy, expose
|
||||
provider/model, schedule, trigger, and user-level model checks directly, and surface
|
||||
the backend's concrete license/validation reason when a settings change is
|
||||
rejected instead of replacing it with a generic `Failed to save advanced
|
||||
settings` toast. That inline failure no longer
|
||||
opens Assistant. If it does again, the handoff is model-only explanation
|
||||
context: raw command, script, credential, and provider-detail payloads stay
|
||||
redacted, Assistant opens with `autonomousMode:false`, and the Patrol control
|
||||
panel closes so the operator is not left behind an overlapping popover.
|
||||
That same browser proof now covers where Patrol settings are saved. The
|
||||
Patrol page has no settings drawer or popover: Patrol schedule, triggers, and
|
||||
the Patrol model override live on Settings > Pulse Intelligence > Patrol
|
||||
(`Save Patrol settings`), and providers and the shared default model on
|
||||
Settings > Provider & Models. A rejected save there raises an error
|
||||
notification carrying the backend's concrete license/validation reason
|
||||
instead of generic fallback copy, and it offers no Assistant handoff. If a
|
||||
Patrol settings popover or an Assistant handoff from a settings failure
|
||||
returns, the handoff is model-only explanation context: raw command, script,
|
||||
credential, and provider-detail payloads stay redacted, Assistant opens with
|
||||
`autonomousMode:false`, and the popover closes so the operator is not left
|
||||
behind it.
|
||||
Patrol-control save-failure sessions saved by earlier builds keep the
|
||||
compatible `handoff_metadata.kind=patrol_configuration_failure` plus only the
|
||||
runtime-failure boolean needed for drawer/session presentation, so they restore
|
||||
@@ -1637,12 +1638,13 @@ ready raises a fixed warning notification, a failed Patrol mode save raises an e
|
||||
notification, and neither offers an Assistant handoff. A provider or model save
|
||||
in Settings > Provider & Models reports the readiness summary, provider and
|
||||
model in its own save message.
|
||||
The Patrol mode selector in that header and configuration dialog must
|
||||
compose the shared `frontend-modern/src/components/shared/FilterButtonGroup.tsx`
|
||||
instead of rebuilding a local active-button group. The wide default Patrol
|
||||
header uses the segmented layout; the constrained configuration dialog may use
|
||||
the shared prominent layout so all four mode labels remain readable without
|
||||
inventing a Patrol-local selector. Patrol owns the default visible four-level
|
||||
The Patrol mode selector in that header must compose the shared
|
||||
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` instead of
|
||||
rebuilding a local active-button group. When the header shows Patrol mode
|
||||
options, it renders the selector in the segmented layout inside its `Mode and
|
||||
automation` disclosure; a constrained surface that hosts it may use the shared
|
||||
prominent layout so all four mode labels remain readable without inventing a
|
||||
Patrol-local selector. Patrol owns the default visible four-level
|
||||
policy presentation (`Watch only`, `Ask first`, `Safe auto-fix`,
|
||||
`Autopilot`), entitlement locks, and the rule that choosing the highest
|
||||
Autopilot level sends `full_mode_unlocked:true` while choosing any lower level clears that
|
||||
@@ -2692,6 +2694,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
|
||||
|
||||
|
||||
@@ -2408,6 +2408,12 @@ 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
|
||||
@@ -2712,9 +2718,26 @@ 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 and the drawer History still read
|
||||
the aggregate disk metric and the Proxmox reason, and the agent's OS, addresses
|
||||
and physical disk health carry no stale cue yet.
|
||||
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
|
||||
@@ -2778,10 +2801,13 @@ counts wrap independently so a narrow toolbar cannot strand View on a line by
|
||||
itself or force page-level horizontal overflow. The compact Add filter control
|
||||
joins that cluster only in the default state where no active menu chip,
|
||||
contextual Clear action, or leading action needs its own filter row; active
|
||||
scope editing keeps Add filter beside the chips. The sparkline range remains
|
||||
inline only while trends are active because it is the frequently changed
|
||||
analytical window, and its compact control must include a visible `Trend range`
|
||||
label so the values are not an orphaned set of durations. Columns must expand
|
||||
scope editing keeps Add filter beside the chips. The sparkline range is a
|
||||
trailing inline control, not a View option, because it is the frequently
|
||||
changed analytical window. `WorkloadsFilter` renders it whenever the metric
|
||||
mode is Trends, and in Bars only at desktop widths while Row hover is `History`
|
||||
(the default); Bars with `Details` hover, or Bars below the 768px `isMobile`
|
||||
breakpoint, hides it. Its compact control carries the visible `History` label
|
||||
so the values are not an orphaned set of durations. Columns must expand
|
||||
as a full-width responsive checkbox row inside the View disclosure instead of
|
||||
opening a nested absolute panel or a tall single-column desktop list. On
|
||||
Proxmox, the non-default Host basis must remain visible in the workload memory
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1413,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
|
||||
@@ -2430,11 +2436,13 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
Storage and recovery UI must keep sourcing those signals from their
|
||||
existing canonical page models instead of polling the connections
|
||||
ledger for per-datastore or per-backup truth.
|
||||
Platform-first top-level pages may embed `StorageSurface` and
|
||||
`RecoverySurface` with `embedded tableOnly` and forced source or
|
||||
platform filters (e.g. `forcedSourceFilter`, `forcedPlatformFilter`)
|
||||
so platform-scoped storage and recovery rows render through the same
|
||||
canonical surfaces rather than a forked per-platform table.
|
||||
Platform-first top-level pages may embed `StorageSurface`
|
||||
(`frontend-modern/src/components/Storage/Storage.tsx`) with a forced
|
||||
source filter (`forcedSourceFilter`) so platform-scoped storage rows
|
||||
render through the same canonical surface rather than a forked
|
||||
per-platform table. The surface has no `embedded` or `tableOnly` mode
|
||||
and no `forcedPlatformFilter`, and no `RecoverySurface` component
|
||||
exists to embed alongside it.
|
||||
`frontend-modern/src/App.tsx` may carry the platform-page route
|
||||
registrations that mount those embedded canonical surfaces, but the
|
||||
routes themselves must derive their paths from the canonical builders
|
||||
@@ -2444,22 +2452,30 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
canonical surface that actually populates. The canonical TrueNAS
|
||||
adapter already emits the top-level TrueNAS system as a unified
|
||||
`agent` row tagged with the `truenas` platform, so the platform
|
||||
page defaults to `/truenas/overview` (the Systems sub-tab) and the
|
||||
embedded `StorageSurface` lives at `/truenas/storage`. The Source
|
||||
filter chip in `StoragePageControls` is also suppressed when a
|
||||
platform page locks source scope through `forcedSourceFilter` (via
|
||||
`suppressSourceFilter`, auto-applied whenever `forcedSourceFilter`
|
||||
is set), so the user never sees the platform's name pinned as a
|
||||
removable filter chip inside the embedded surface.
|
||||
Platform pages that embed `StorageSurface` reuse the canonical
|
||||
`StoragePageControls` toolbar through the `showFilterToolbar` prop on
|
||||
`StorageProps`. The page keeps `tableOnly` to hide the storage summary
|
||||
section but opts in to the shared search, status, grouping, sort,
|
||||
node, view, and chart-collapse controls so platform operators get
|
||||
dense-table storage controls on every embedded storage tab without
|
||||
forking the toolbar. The source scope flows through
|
||||
`forcedSourceFilter` as a typed page input; the source filter remains
|
||||
available in the toolbar only when not forced. The seven-state Storage
|
||||
page defaults to `/truenas/overview`, whose Overview sub-tab lists
|
||||
those systems. `/truenas/storage` renders the TrueNAS-owned
|
||||
`TrueNASStorageTopologyTable`, not `StorageSurface`; the Proxmox
|
||||
Storage tab in `frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx`
|
||||
is the only page that embeds `StorageSurface` today.
|
||||
An embedding page gets the canonical `StoragePageControls` toolbar
|
||||
without opting in: `Storage.tsx` always renders it (only kiosk mode
|
||||
hides it), there is no `showFilterToolbar` prop, and there is no storage
|
||||
summary section to hide. The Proxmox embed passes `forcedSourceFilter`,
|
||||
its own `resourceSource`, `suppressNodeFilter`, `filterAriaLabel`, and
|
||||
Proxmox search placeholder and empty-history copy. It passes no
|
||||
`forcedView`, so the Storage / Physical Disks view tabs stay.
|
||||
`forcedSourceFilter` is a typed page input that the route state reads
|
||||
as the source value and never writes back to the URL, and `Storage.tsx`
|
||||
sets `suppressSourceFilter` whenever it is present. `StoragePageControls`
|
||||
then drops the Source chip, so the user never sees the platform's name
|
||||
pinned as a removable filter chip, and the locked source does not count
|
||||
as an active filter that surfaces the toolbar's clear action.
|
||||
`suppressNodeFilter` drops the Node chip so search carries host and
|
||||
node scoping, and `Storage.tsx` resets any selected node to all nodes
|
||||
while it is set. The embedded toolbar otherwise keeps search, the
|
||||
Status filter on the Storage view (Role and Group filters on Physical
|
||||
Disks once there is more than one choice), and the View popover's
|
||||
grouping and sort; it has no chart-collapse control. The seven-state Storage
|
||||
status catalog stays in the shared Add filter menu instead of occupying a
|
||||
permanent segmented rail; a non-default status surfaces through the
|
||||
canonical FilterBar chip. Grouping and sort remain durable presentation
|
||||
@@ -3002,6 +3018,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
|
||||
@@ -4356,10 +4381,13 @@ 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: a focused
|
||||
pool or disk row may switch to the owning view or expand the owning group
|
||||
through the bridge's `revealActiveSeries` callback, and a pinned pool-group
|
||||
header that sits off-screen scrolls into view through the same bridge.
|
||||
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
|
||||
@@ -6247,6 +6275,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
|
||||
@@ -6879,6 +6920,15 @@ 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
|
||||
@@ -6889,3 +6939,11 @@ 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
|
||||
@@ -2752,7 +2767,7 @@ application resource-provider or WebSocket lifecycle.
|
||||
(`canonical_id` ↔ machine ID / DMI UUID / cluster / hostname) in the
|
||||
`resource_identities` table (`ResourceStore.UpsertResourceIdentityPins`
|
||||
/ `ListResourceIdentityPins`, written diff-aware after monitor-adapter
|
||||
rebuilds) and completes weak incoming identities from those pins
|
||||
rebuilds; manual-link sides write through `ReplaceResourceIdentityPins`) and completes weak incoming identities from those pins
|
||||
before matching and ID derivation
|
||||
(`internal/unifiedresources/canonical_id_pins.go`). Persisted primary
|
||||
hostnames preserve their normalized dotted form. The pin index resolves
|
||||
@@ -2768,7 +2783,15 @@ application resource-provider or WebSocket lifecycle.
|
||||
must not mint host canonical IDs from snapshot-content-dependent
|
||||
identity subsets without consulting the pins, and pin writes stay on
|
||||
the durable store-backed registry (ephemeral per-request registries
|
||||
consult, never write). Pinned hostnames preserve the full dotted name
|
||||
consult, never write). A pin records the identity a resource derives
|
||||
from its own sources rather than a manual link's merged projection:
|
||||
each side of a link writes the pin captured before the link merged
|
||||
them, keeping its row's other fields unless another resource holds that
|
||||
key, and a pinnable folded side keeps its pin under its own canonical
|
||||
ID, less any strong key a live resource or an earlier folded side
|
||||
holds. Capture follows pin completion, so a key an earlier release
|
||||
borrowed into a side's row can still reach the capture (see "Operator
|
||||
links reach record-ingested resources"). Pinned hostnames preserve the full dotted name
|
||||
(`NormalizeFullHostname`): distinct machines that share a short
|
||||
hostname (`cloud.rnd-lax1` vs `cloud.gce-or1`) must keep distinct
|
||||
pins, distinct pin-index buckets, and distinct presentation host rows.
|
||||
@@ -5467,6 +5490,43 @@ 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
|
||||
@@ -5583,12 +5643,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`
|
||||
@@ -6079,7 +6141,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
|
||||
@@ -6235,6 +6302,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
|
||||
@@ -6556,8 +6630,21 @@ 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 still
|
||||
use the default thresholds on every ingest path.
|
||||
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
|
||||
@@ -6567,11 +6654,50 @@ separate.
|
||||
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.
|
||||
record-declared succession may succeed its ID: that re-keyed the link onto
|
||||
the primary itself, which split the pair on the next rebuild, and already
|
||||
broke snapshot links whose primary is a machine-keyless agent-type row,
|
||||
such as a Proxmox node.
|
||||
- Each side of a link is pinned only from its own identity, captured when
|
||||
the link first joins it in a rebuild (`recordLinkOwnPin`). A side with no
|
||||
pinnable identity (not agent-shaped, or no strong key) writes no pin, and
|
||||
a pinnable folded side keeps its pin under its own canonical ID. The merged
|
||||
primary used to be pinned with both sides' keys, and the store's
|
||||
one-owner-per-key rule then deleted the folded side's pin. A Proxmox node's
|
||||
pin records its qualified endpoint as the hostname, so the node's next
|
||||
rebuild completed the linked agent's machine key from that merged pin,
|
||||
minted the agent's machine-derived ID and merged with the agent by ID
|
||||
before the link applied. With the node as primary, succession then
|
||||
rewrote the link into a self-link on the agent's ID; in either direction,
|
||||
removing the link left the pair merged.
|
||||
- A link side rewrites its whole row (`ReplaceResourceIdentityPins`)
|
||||
instead of upserting it. Like an upsert it keeps the stored fields its
|
||||
own pin leaves empty, so evidence from an earlier observation survives a
|
||||
weaker one, but it never keeps a machine, DMI or cluster-and-hostname key
|
||||
another resource holds this rebuild, including a cluster key a kept
|
||||
hostname would complete. That is how a key an earlier release borrowed
|
||||
into the row from the merged identity leaves it; a key a resource reports
|
||||
now also beats one a link side's row only kept, as the store already
|
||||
hands a moved key to the new pin. The store gives each strong key one
|
||||
owner and deletes any other row holding a key a written pin carries, so a
|
||||
folded side drops each own key held by a live non-link resource's
|
||||
upserted row (an upsert cannot drop a field), a live link side's own pin,
|
||||
or a folded side earlier in canonical-ID order. It writes no pin when no
|
||||
strong key is left, or when it would lose the machine or DMI key it
|
||||
reports: a cluster-only row reads as a machine-keyless host's, which
|
||||
cluster-slot succession lets a later machine in that slot absorb. A row kept for a side that writes no pin goes
|
||||
when another written pin carries one of its keys, such as the folded
|
||||
agent's own pin carrying the machine key an earlier release borrowed into
|
||||
a Proxmox node's row. That hand-back only reaches a row the node cannot
|
||||
complete from: while the node's qualified endpoint, or its cluster and
|
||||
name, still resolve to that row, completion supplies the borrowed key
|
||||
before any link applies, so the pair merges by ID and succession rewrites
|
||||
the link into a self-link, as it did before this change. A link side whose
|
||||
row loses a borrowed machine key becomes a machine-keyless row, open to
|
||||
the cluster-slot succession any machine-keyless host gets. Two live resources that share a strong key
|
||||
still take it from each other on alternate rebuilds, as before. Evidence
|
||||
a later record pass of the same rebuild merges into an already-joined row
|
||||
reaches neither side's pin.
|
||||
- A folded resource's canonical ID resolves to the primary holding it. The
|
||||
merged resource carries its folded IDs in memory, including IDs folded
|
||||
into those along a chain of links, and resource seeding indexes them. The
|
||||
@@ -6614,6 +6740,25 @@ resources API seeds, lists the same rows
|
||||
pins the node case (`TestManualLinkKeepsItsEndpointsThroughIdentityPinPersistence`),
|
||||
record-declared succession (`TestRecordDeclaredSuccessionSparesALinkFoldedResource`)
|
||||
and both payloads (`TestManualLinkKeepsProviderPayloadsThePrimaryLacks`).
|
||||
It also pins each side's own pin for a qualified-endpoint node linked to an
|
||||
agent, standalone and clustered, in both directions, through three rebuilds
|
||||
with no steady-state pin writes and a separate pair once the link is removed
|
||||
(`TestManualLinkPinsEachSideFromItsOwnIdentity`), an unclaimed stored field
|
||||
kept without a rewrite every rebuild
|
||||
(`TestManualLinkSideKeepsAnUnclaimedStoredField`), a shared
|
||||
cluster-and-hostname key kept by the live primary while the folded side keeps
|
||||
its machine key, and handed to the folded side when the primary's row only
|
||||
kept it (`TestManualLinkFoldedSideNeverTakesALiveResourcesKey`), a kept
|
||||
hostname refused where it would complete another resource's cluster key
|
||||
(`TestManualLinkSideKeepsNoHostnameThatCompletesAHeldClusterKey`), no pin for a
|
||||
folded side whose machine key another host holds
|
||||
(`TestManualLinkFoldedSideWritesNoPinWithoutItsMachineKey`), a folded side
|
||||
re-pinned after the primary's new pin deletes its widened row
|
||||
(`TestManualLinkFoldedSideKeepsItsPinWhenAPrimaryClaimsItsWiderRow`), and the
|
||||
agent's own pin taking back the machine key an earlier release borrowed into
|
||||
a node's row (`TestManualLinkReturnsABorrowedMachineKeyToItsAgent`).
|
||||
`store_test.go` (`TestResourceIdentityPinReplaceClearsFieldsUpsertKeeps`) pins
|
||||
replace against upsert on both stores.
|
||||
`monitor_adapter_read_state_test.go`
|
||||
(`TestMonitorAdapterJoinsLinkedRecordsWithConfiguredStaleThresholds`) pins the
|
||||
thresholds on the rebuild, supplemental and overlay paths, and
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "421d1e5423e5b83b7923ea8c3b133ad520cd5f60",
|
||||
"verified_at": "2026-10-07T15:20:38Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "f1e249c36f0f0f9810330a37cc9a80cb43baa1cd123db1defd3f27720d39912a",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "cb0a6962ed929238a974a3bbe5f69f4c89961ff30c88c2c94398073b9ef99a1c"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/patrol (Mode and automation disclosure)",
|
||||
"/settings/pulse-intelligence/patrol",
|
||||
"/settings/pulse-intelligence"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent tree (333e51d2de; rebased onto 421d1e5423, which changes only contract docs) and this tree, each on a private DEMO_MODE mock stack with a fresh data dir; the same Playwright script ran against both and produced identical results on all 31 observed fields at both widths.",
|
||||
"Patrol on/off save whose PUT /api/settings/ai/update response (page.route) reports patrol_readiness not_ready: switching off and back on each raise only the warning toast 'Patrol setting was saved, but Patrol is not ready to run.' (role=alert inside the Notifications region, close button only); no role=alert outside the toast region, no 'needs attention' text, no readiness summary rendered inline, no Assistant action.",
|
||||
"Failed Patrol mode save (Pro mode selector unlocked by adding ai_autofix to runtime-capabilities via page.route; PUT /api/ai/patrol/autonomy answered 500 {error}): only the error toast with the server reason; mode badge returns to Watch only; no inline error or 'needs attention' state.",
|
||||
"Successful Patrol mode save (PUT 200, reconcile GET returns approval): Ask first becomes pressed and the badge reads Ask first; no new toast and no inline alert.",
|
||||
"Settings > Pulse Intelligence > Patrol save (Schedule -> Every hour) and Settings > Provider & Models save with a not_ready response: only the warning toast '<saved label>, but Patrol is not ready: The selected model cannot run Patrol tools. \u00b7 Provider: OpenRouter \u00b7 Model: openrouter:deepseek/deepseek-r1'; no inline alert.",
|
||||
"Patrol status with readiness not_ready (page.route on /api/ai/patrol/status): the page's own 'Patrol model issue' banner names summary, provider and model, and the header shows Fix setup, at both widths.",
|
||||
"Dist proof: 187 files each side; after normalising chunk hashes and SRI strings only assets/AIIntelligence.js differs (935 bytes smaller): the removed failure builder, details helper and surface callback replaced by one status check, toast string byte-identical; check-bundle-size.mjs passes."
|
||||
],
|
||||
"interactions": [
|
||||
"Demo sign-in; on /patrol click Toggle Patrol twice (off, then on) with a not-ready settings response; open 'Mode and automation' and click Ask first with a rejected save, then again with an accepted save; desktop mouse and phone touch (isMobile) contexts.",
|
||||
"On /settings/pulse-intelligence/patrol select Schedule 'Every hour' and click Save Patrol settings; on /settings/pulse-intelligence click Save provider settings; both with a not-ready response.",
|
||||
"Screenshots of each toast state captured and inspected at both widths."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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,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,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,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,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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)} />);
|
||||
|
||||
|
||||
@@ -244,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;
|
||||
@@ -464,6 +481,65 @@ describe('Storage', () => {
|
||||
expect(screen.queryByText('Local-LVM-PVE2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives a platform embed the canonical toolbar with source and node scope locked', async () => {
|
||||
mockLocationSearch = '?node=node-2&source=truenas';
|
||||
hookResources = [
|
||||
buildStorageResource('storage-1', 'Local-LVM-PVE1', 'pve1', {
|
||||
parentId: 'node-1',
|
||||
parentName: 'pve1',
|
||||
includePlatformNode: false,
|
||||
}),
|
||||
buildStorageResource('storage-2', 'Local-LVM-PVE2', 'pve2', {
|
||||
parentId: 'node-2',
|
||||
parentName: 'pve2',
|
||||
includePlatformNode: false,
|
||||
}),
|
||||
buildStorageResource('storage-3', 'Tank-TrueNAS', 'truenas-1', {
|
||||
platformId: 'truenas-main',
|
||||
platformType: 'truenas',
|
||||
}),
|
||||
];
|
||||
|
||||
render(() => (
|
||||
<Storage
|
||||
forcedSourceFilter="proxmox-all"
|
||||
suppressNodeFilter
|
||||
filterAriaLabel="Proxmox storage filters"
|
||||
filterSearchPlaceholder="Search Proxmox storage by pool, datastore, node, or device"
|
||||
/>
|
||||
));
|
||||
|
||||
// No prop opts the embed into the toolbar or keeps it out of the view tabs.
|
||||
expect(screen.getByRole('group', { name: 'Proxmox storage filters' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search Proxmox storage by pool, datastore, node, or device'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('tablist', { name: 'Storage view' })).toBeInTheDocument();
|
||||
|
||||
// The forced source outranks the URL source, and the URL node resets to all.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Local-LVM-PVE1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Local-LVM-PVE2')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Tank-TrueNAS')).not.toBeInTheDocument();
|
||||
expect(navigateSpy.mock.calls.some(([path]) => String(path).includes('source='))).toBe(false);
|
||||
|
||||
// Neither lock surfaces as a chip, an Add filter option, or a clear action.
|
||||
expect(queryStorageChip('Source')).toBeNull();
|
||||
expect(queryStorageChip('Node')).toBeNull();
|
||||
const addFilter = screen.getByRole('combobox', { name: 'Filter' });
|
||||
expect(within(addFilter).getByRole('option', { name: 'Status: Warning' })).toBeInTheDocument();
|
||||
expect(
|
||||
within(addFilter).queryByRole('option', { name: /^(Source|Node):/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /charts/i })).not.toBeInTheDocument();
|
||||
|
||||
const viewOptions = openStorageViewOptions();
|
||||
expect(within(viewOptions).getByLabelText('Group by')).toBeInTheDocument();
|
||||
expect(within(viewOptions).getByLabelText('Sort by')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders per-pool growth from the shared storage summary history contract', async () => {
|
||||
const gib = 1024 * 1024 * 1024;
|
||||
const storageSummarySpy = vi.spyOn(ChartsAPI, 'getStorageSummaryCharts').mockResolvedValue({
|
||||
@@ -825,6 +901,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'),
|
||||
@@ -1343,7 +1454,7 @@ describe('Storage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the storage view selector available in table-only embedding', async () => {
|
||||
it('keeps the storage view selector available in a platform-page embed', async () => {
|
||||
mockLocationPath = '/proxmox/storage';
|
||||
hookResources = [
|
||||
buildPhysicalDiskResource('sda', 'node-1', 'pve1'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
import {
|
||||
getWorkloadGuestDiskStatusMessage,
|
||||
getWorkloadGuestDiskRead,
|
||||
getWorkloadsGuestProtectionPresentation,
|
||||
} from '@/utils/workloadGuestPresentation';
|
||||
|
||||
@@ -152,44 +152,19 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
props.guest.vmware?.datacenterName ||
|
||||
props.guest.vmware?.clusterName,
|
||||
);
|
||||
// Rows the linked Pulse agent supplied follow that agent's freshness, not
|
||||
// the Proxmox reason for its own guest reads. A stopped guest has no
|
||||
// current filesystems from either source, with or without the Proxmox
|
||||
// vm-stopped reason, which it gives only for a guest with allocated disk.
|
||||
const vmDiskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined;
|
||||
const agentDisks = props.guest.disksFromAgent === true;
|
||||
const agentDisksStopped =
|
||||
agentDisks && (props.guest.status === 'stopped' || vmDiskReason === 'vm-stopped');
|
||||
const diskReason = agentDisks ? undefined : vmDiskReason;
|
||||
const diskReadState = agentDisks
|
||||
? agentDisksStopped
|
||||
// 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.disksFromAgent && props.guest.telemetryAvailability?.disk === false
|
||||
? 'unavailable'
|
||||
: agentLastKnownReason()
|
||||
? 'last-known'
|
||||
: 'current'
|
||||
: 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 = agentDisks
|
||||
? agentDisksStopped
|
||||
? isGuestDrawerVM(props.guest)
|
||||
? getWorkloadGuestDiskStatusMessage('vm-stopped')
|
||||
: 'Filesystem usage is unavailable.'
|
||||
: agentLastKnownReason()
|
||||
? 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.'
|
||||
: null
|
||||
: diskReason
|
||||
? getWorkloadGuestDiskStatusMessage(diskReason)
|
||||
: diskReadState === 'unavailable'
|
||||
? 'Filesystem usage is unavailable.'
|
||||
: null;
|
||||
const diskStatusMessage =
|
||||
diskRead.message ??
|
||||
(diskReadState === 'unavailable' ? 'Filesystem usage is unavailable.' : null);
|
||||
|
||||
return compactDetailSections([
|
||||
discovery
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1350,7 +1350,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(contextualFocusSource).toContain('export const revealInlineDetailInViewport');
|
||||
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');
|
||||
@@ -1364,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');
|
||||
@@ -1376,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(');
|
||||
});
|
||||
|
||||
@@ -8757,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');
|
||||
});
|
||||
|
||||
+3
-3
@@ -185,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({
|
||||
|
||||
@@ -23,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',
|
||||
@@ -71,8 +63,7 @@ describe('summaryCardInteraction', () => {
|
||||
|
||||
expect(
|
||||
resolveSummaryScopeState({
|
||||
chartHoveredSeriesId: 'beta',
|
||||
hoveredSeriesId: 'alpha',
|
||||
hoveredSeriesId: 'beta',
|
||||
focusedSeriesId: 'alpha',
|
||||
hoveredGroupScope: groupScope,
|
||||
focusedGroupScope: groupScope,
|
||||
|
||||
+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', () => {
|
||||
|
||||
@@ -4,12 +4,6 @@ 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;
|
||||
}
|
||||
|
||||
const resolveScrollableAncestor = (element: Element | null | undefined): HTMLElement | null => {
|
||||
let scroller = element instanceof HTMLElement ? element : null;
|
||||
while (scroller) {
|
||||
|
||||
@@ -88,7 +88,6 @@ export const resolveSummaryGroupMemberInteractionState = (options: {
|
||||
};
|
||||
|
||||
export const resolveSummaryScopeState = (options: {
|
||||
chartHoveredSeriesId?: string | null;
|
||||
hoveredSeriesId?: string | null;
|
||||
focusedSeriesId?: string | null;
|
||||
hoveredGroupScope?: SummarySeriesGroupScope | null;
|
||||
@@ -102,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 &&
|
||||
@@ -170,7 +156,6 @@ export const resolveSummaryScopeState = (options: {
|
||||
};
|
||||
|
||||
export function resolveSummaryActiveSeriesId(options: {
|
||||
chartHoveredSeriesId?: string | null;
|
||||
hoveredSeriesId?: string | null;
|
||||
focusedSeriesId?: string | null;
|
||||
groupScope?: SummarySeriesGroupScope | null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +185,15 @@ const createHysteresisThreshold = (trigger: number | undefined, clearMargin = 5)
|
||||
};
|
||||
};
|
||||
|
||||
// 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)) {
|
||||
@@ -413,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -847,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),
|
||||
|
||||
+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)}`] : [];
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
PATROL_MANUAL_SYNC_TIMEOUT_MS,
|
||||
PATROL_REFRESH_TIMEOUT_MS,
|
||||
buildPatrolSettingsReadinessFailure,
|
||||
getPatrolSavedReadinessWarning,
|
||||
openPatrolAssistantWorkflowHandoff,
|
||||
patrolStartFailureMessage,
|
||||
recordPatrolControlStarterActivity,
|
||||
@@ -338,24 +338,25 @@ describe('usePatrolIntelligenceState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPatrolSettingsReadinessFailure', () => {
|
||||
it('ignores settings snapshots that do not block Patrol readiness', () => {
|
||||
describe('getPatrolSavedReadinessWarning', () => {
|
||||
it('stays quiet when the saved settings do not block Patrol readiness', () => {
|
||||
expect(getPatrolSavedReadinessWarning(null)).toBeNull();
|
||||
expect(
|
||||
buildPatrolSettingsReadinessFailure({
|
||||
settings: settingsWithReadiness({
|
||||
getPatrolSavedReadinessWarning(
|
||||
settingsWithReadiness({
|
||||
status: 'warning',
|
||||
ready: true,
|
||||
summary: 'Patrol can run with reduced confidence.',
|
||||
checks: [],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a saved configuration issue from a not-ready settings response', () => {
|
||||
it('warns that a not-ready Patrol setting was still saved', () => {
|
||||
expect(
|
||||
buildPatrolSettingsReadinessFailure({
|
||||
settings: settingsWithReadiness({
|
||||
getPatrolSavedReadinessWarning(
|
||||
settingsWithReadiness({
|
||||
status: 'not_ready',
|
||||
ready: false,
|
||||
cause: 'model_unsupported_tools',
|
||||
@@ -364,40 +365,8 @@ describe('usePatrolIntelligenceState', () => {
|
||||
model: 'ollama:deepseek-r1:7b',
|
||||
checks: [],
|
||||
}),
|
||||
autonomyLevel: 'monitor',
|
||||
fullModeUnlocked: false,
|
||||
investigationBudget: 15,
|
||||
investigationTimeoutSec: 300,
|
||||
runtimeState: 'blocked',
|
||||
blockedReason: 'Connect a tool-capable Patrol model.',
|
||||
}),
|
||||
).toEqual({
|
||||
message: 'The selected model cannot run Patrol tools.',
|
||||
code: 'patrol_readiness_not_ready',
|
||||
status: 409,
|
||||
saved: true,
|
||||
details: {
|
||||
status: 'not_ready',
|
||||
cause: 'model_unsupported_tools',
|
||||
summary: 'The selected model cannot run Patrol tools.',
|
||||
provider: 'ollama',
|
||||
model: 'ollama:deepseek-r1:7b',
|
||||
},
|
||||
autonomyLevel: 'monitor',
|
||||
fullModeUnlocked: false,
|
||||
investigationBudget: 15,
|
||||
investigationTimeoutSec: 300,
|
||||
readiness: {
|
||||
status: 'not_ready',
|
||||
cause: 'model_unsupported_tools',
|
||||
summary: 'The selected model cannot run Patrol tools.',
|
||||
provider: 'ollama',
|
||||
model: 'ollama:deepseek-r1:7b',
|
||||
},
|
||||
runtimeState: 'blocked',
|
||||
blockedReason: 'Connect a tool-capable Patrol model.',
|
||||
blockedCause: undefined,
|
||||
});
|
||||
),
|
||||
).toBe('Patrol setting was saved, but Patrol is not ready to run.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -410,6 +379,8 @@ describe('usePatrolIntelligenceState', () => {
|
||||
'provider_not_configured',
|
||||
);
|
||||
expect(resolvePatrolBlockedActionCause('', '')).toBeUndefined();
|
||||
expect(patrolIntelligenceStateSource).toContain('blockedCause: patrolStatus()?.blocked_cause');
|
||||
expect(patrolIntelligenceStateSource).toContain(
|
||||
'const blockedCause = createMemo(() => patrolStatus()?.blocked_cause);',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,27 +182,6 @@ export interface PatrolRunAssistantHandoff {
|
||||
context: AIChatContext;
|
||||
}
|
||||
|
||||
export interface PatrolConfigurationFailureInput {
|
||||
message: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
saved?: boolean;
|
||||
details?: Record<string, string>;
|
||||
autonomyLevel?: string;
|
||||
fullModeUnlocked?: boolean;
|
||||
investigationBudget?: number;
|
||||
investigationTimeoutSec?: number;
|
||||
readiness?: {
|
||||
status?: string;
|
||||
cause?: string;
|
||||
summary?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
} | null;
|
||||
runtimeState?: string;
|
||||
blockedReason?: string;
|
||||
blockedCause?: string;
|
||||
}
|
||||
const MAX_PATROL_RUN_HANDOFF_RESOURCES = 8;
|
||||
|
||||
export function buildPatrolInvestigationRecordPresentation(
|
||||
|
||||
@@ -62,7 +62,6 @@ import {
|
||||
patrolAssistantFindingHandoffRequiresApprovalMode,
|
||||
type PatrolAssistantApprovalBriefingInput,
|
||||
type PatrolAssistantFindingHandoff,
|
||||
type PatrolConfigurationFailureInput,
|
||||
} from './patrolInvestigationContextModel';
|
||||
import { schedulePatrolRunAcceptanceReconciliation } from './patrolRunAcceptance';
|
||||
|
||||
@@ -141,19 +140,6 @@ export const patrolStartFailureMessage = (error: unknown): string =>
|
||||
? `Could not reach Pulse to start Patrol: ${patrolErrorMessage(error, 'network request failed')}`
|
||||
: patrolErrorMessage(error, 'Pulse rejected the Patrol run');
|
||||
|
||||
const buildReadinessDetails = (
|
||||
readiness: NonNullable<AISettings['patrol_readiness']>,
|
||||
): Record<string, string> => {
|
||||
const details: Record<string, string> = {
|
||||
status: readiness.status,
|
||||
};
|
||||
if (readiness.cause?.trim()) details.cause = readiness.cause.trim();
|
||||
if (readiness.summary?.trim()) details.summary = readiness.summary.trim();
|
||||
if (readiness.provider?.trim()) details.provider = readiness.provider.trim();
|
||||
if (readiness.model?.trim()) details.model = readiness.model.trim();
|
||||
return details;
|
||||
};
|
||||
|
||||
export function resolvePatrolAutonomyLevelForSave(
|
||||
level: PatrolAutonomyLevel,
|
||||
fullModeUnlocked: boolean,
|
||||
@@ -184,52 +170,16 @@ export function resolvePatrolAutonomySettingsForSave({
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPatrolSettingsReadinessFailure({
|
||||
settings,
|
||||
message,
|
||||
autonomyLevel,
|
||||
fullModeUnlocked,
|
||||
investigationBudget,
|
||||
investigationTimeoutSec,
|
||||
runtimeState,
|
||||
blockedReason,
|
||||
blockedCause,
|
||||
}: {
|
||||
settings: AISettings | null | undefined;
|
||||
message?: string;
|
||||
autonomyLevel?: string;
|
||||
fullModeUnlocked?: boolean;
|
||||
investigationBudget?: number;
|
||||
investigationTimeoutSec?: number;
|
||||
runtimeState?: string;
|
||||
blockedReason?: string;
|
||||
blockedCause?: string;
|
||||
}): PatrolConfigurationFailureInput | null {
|
||||
const readiness = settings?.patrol_readiness;
|
||||
if (!readiness || readiness.status !== 'not_ready') return null;
|
||||
|
||||
return {
|
||||
message:
|
||||
message || readiness.summary || 'Patrol settings were saved, but Patrol is not ready to run.',
|
||||
code: 'patrol_readiness_not_ready',
|
||||
status: 409,
|
||||
saved: true,
|
||||
details: buildReadinessDetails(readiness),
|
||||
autonomyLevel,
|
||||
fullModeUnlocked,
|
||||
investigationBudget,
|
||||
investigationTimeoutSec,
|
||||
readiness: {
|
||||
status: readiness.status,
|
||||
cause: readiness.cause,
|
||||
summary: readiness.summary,
|
||||
provider: readiness.provider,
|
||||
model: readiness.model,
|
||||
},
|
||||
runtimeState,
|
||||
blockedReason,
|
||||
blockedCause,
|
||||
};
|
||||
/**
|
||||
* A Patrol on/off save whose response reports Patrol not ready was still
|
||||
* saved, so it raises a warning rather than an error.
|
||||
*/
|
||||
export function getPatrolSavedReadinessWarning(
|
||||
settings: AISettings | null | undefined,
|
||||
): string | null {
|
||||
return settings?.patrol_readiness?.status === 'not_ready'
|
||||
? 'Patrol setting was saved, but Patrol is not ready to run.'
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,26 +330,6 @@ export function usePatrolIntelligenceState() {
|
||||
applyPatrolAISettings(aiRuntimeSettings());
|
||||
});
|
||||
|
||||
const surfaceSavedPatrolReadinessIssue = (
|
||||
settings: AISettings | null | undefined,
|
||||
message?: string,
|
||||
) => {
|
||||
const failure = buildPatrolSettingsReadinessFailure({
|
||||
settings,
|
||||
message,
|
||||
autonomyLevel: autonomyLevel(),
|
||||
fullModeUnlocked: fullModeUnlocked(),
|
||||
investigationBudget: investigationBudget(),
|
||||
investigationTimeoutSec: investigationTimeout(),
|
||||
runtimeState: runtimeState(),
|
||||
blockedReason: blockedReason(),
|
||||
blockedCause: patrolStatus()?.blocked_cause,
|
||||
});
|
||||
if (failure) {
|
||||
notificationStore.warning(failure.message);
|
||||
}
|
||||
};
|
||||
|
||||
async function handleTogglePatrol() {
|
||||
if (isTogglingPatrol()) return;
|
||||
setIsTogglingPatrol(true);
|
||||
@@ -419,10 +349,10 @@ export function usePatrolIntelligenceState() {
|
||||
} else {
|
||||
setPatrolEnabledLocal(newValue);
|
||||
}
|
||||
surfaceSavedPatrolReadinessIssue(
|
||||
data,
|
||||
'Patrol setting was saved, but Patrol is not ready to run.',
|
||||
);
|
||||
const readinessWarning = getPatrolSavedReadinessWarning(data);
|
||||
if (readinessWarning) {
|
||||
notificationStore.warning(readinessWarning);
|
||||
}
|
||||
if (refetchPatrolStatus) {
|
||||
refetchPatrolStatus();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -147,6 +147,9 @@ export interface AlertConfig {
|
||||
kubernetesDefaults?: AlertThresholds;
|
||||
truenasDefaults?: AlertThresholds;
|
||||
truenasDiskDefaults?: AlertThresholds;
|
||||
// Set on every config written since an unset truenasDiskDefaults
|
||||
// temperature means each TrueNAS disk follows Disk temperature by type.
|
||||
truenasDiskTemperatureByType?: boolean;
|
||||
vmwareDefaults?: AlertThresholds;
|
||||
snapshotDefaults?: SnapshotAlertConfig;
|
||||
backupDefaults?: BackupAlertConfig;
|
||||
|
||||
@@ -96,7 +96,8 @@ describe('alertThresholdDefaults', () => {
|
||||
|
||||
describe('FACTORY_TRUENAS_DISK_DEFAULTS', () => {
|
||||
it('only carries a disk temperature trigger', () => {
|
||||
expect(FACTORY_TRUENAS_DISK_DEFAULTS).toEqual({ temperature: 55 });
|
||||
// Unset: each TrueNAS disk follows Disk temperature by type.
|
||||
expect(FACTORY_TRUENAS_DISK_DEFAULTS).toEqual({ temperature: undefined });
|
||||
expect(Object.keys(FACTORY_TRUENAS_DISK_DEFAULTS)).toEqual(['temperature']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,6 +175,17 @@ describe('docsLinks', () => {
|
||||
expect(handler).toContain('agentID != "" && len(*compatibility.ObservationLocationIDs) == 1');
|
||||
});
|
||||
|
||||
it('documents alert configuration saves as replacing only the keys they send', () => {
|
||||
const api = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
|
||||
expect(api).toContain('`PUT /api/alerts/config` — replaces only the top-level keys the body');
|
||||
expect(api).toContain('a key left out keeps its stored value');
|
||||
const handler = readFileSync(
|
||||
path.join(repoRoot, 'internal', 'api', 'alerting', 'alerts.go'),
|
||||
'utf8',
|
||||
);
|
||||
expect(handler).toContain('GetAlertManager().ApplyConfigUpdate(body)');
|
||||
});
|
||||
|
||||
it('separates server removal from persistent-data erasure', () => {
|
||||
const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8');
|
||||
const removal = installation.split('## 🗑️ Uninstall')[1];
|
||||
@@ -566,6 +577,27 @@ describe('docsLinks', () => {
|
||||
expect(apiReference).not.toContain('Mobile Remote Access');
|
||||
});
|
||||
|
||||
it('documents POST as the only report transport that uses Pulse Assistant', () => {
|
||||
const handler = readFileSync(
|
||||
path.join(repoRoot, 'internal', 'api', 'metrics_reporting_handlers.go'),
|
||||
'utf8',
|
||||
);
|
||||
expect(handler).toContain(
|
||||
'if r.Method == http.MethodPost {\n\t\tnarrator, _, findings = h.resolveNarrator(r.Context())',
|
||||
);
|
||||
expect(handler).toContain('req.Narrator = getReportNarrator{}');
|
||||
for (const guide of ['API.md', 'MSP.md']) {
|
||||
const rootDoc = readFileSync(path.join(repoRoot, 'docs', guide), 'utf8');
|
||||
expect(readFileSync(path.join(frontendRoot, 'public', 'docs', guide), 'utf8')).toBe(rootDoc);
|
||||
expect(rootDoc).toContain('`POST /api/admin/reports/generate` (');
|
||||
}
|
||||
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
|
||||
expect(apiReference).toContain('it never calls an AI provider');
|
||||
expect(apiReference).toContain('`405` with `Allow: GET, POST`');
|
||||
const msp = readFileSync(path.join(repoRoot, 'docs', 'MSP.md'), 'utf8');
|
||||
expect(msp).not.toContain('`GET /api/admin/reports/generate` (single resource)');
|
||||
});
|
||||
|
||||
it('ships credential-free Proxmox setup instructions and a separate token prompt', () => {
|
||||
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
|
||||
const shipped = readFileSync(path.join(frontendRoot, 'public', 'docs', 'API.md'), 'utf8');
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
formatUptime,
|
||||
formatAbsoluteTime,
|
||||
formatRelativeTime,
|
||||
formatTimeUntil,
|
||||
getBackupInfo,
|
||||
} from '@/utils/format';
|
||||
|
||||
@@ -263,6 +264,67 @@ describe('formatRelativeTime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTimeUntil', () => {
|
||||
const now = Date.parse('2024-03-15T12:00:00Z');
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
|
||||
it('returns empty text for falsy or unparseable input', () => {
|
||||
expect(formatTimeUntil(undefined, { now })).toBe('');
|
||||
expect(formatTimeUntil(0, { now, emptyText: '-' })).toBe('-');
|
||||
expect(formatTimeUntil('not a date', { now, emptyText: '-' })).toBe('-');
|
||||
});
|
||||
|
||||
it('counts down to a future time instead of reading it as just now', () => {
|
||||
expect(formatTimeUntil(now + 30 * 1000, { now })).toBe('in under a minute');
|
||||
expect(formatTimeUntil(now + 5 * minute, { now, compact: true })).toBe('in 5m');
|
||||
expect(formatTimeUntil(now + 3 * hour, { now, compact: true })).toBe('in 3h');
|
||||
expect(formatTimeUntil(now + 6 * day, { now, compact: true })).toBe('in 6d');
|
||||
expect(formatTimeUntil(now + 45 * day, { now, compact: true })).toBe('in 45d');
|
||||
});
|
||||
|
||||
it('spells units out in the long form', () => {
|
||||
expect(formatTimeUntil(now + minute, { now })).toBe('in 1 min');
|
||||
expect(formatTimeUntil(now + 5 * minute, { now })).toBe('in 5 mins');
|
||||
expect(formatTimeUntil(now + hour, { now })).toBe('in 1 hour');
|
||||
expect(formatTimeUntil(now + 5 * hour, { now })).toBe('in 5 hours');
|
||||
expect(formatTimeUntil(now + day, { now })).toBe('in 1 day');
|
||||
expect(formatTimeUntil(now + 7 * day, { now })).toBe('in 7 days');
|
||||
expect(formatTimeUntil(now + 60 * day, { now })).toBe('in 2 months');
|
||||
expect(formatTimeUntil(now + 365 * day, { now })).toBe('in 1 year');
|
||||
});
|
||||
|
||||
it('rounds to the nearest unit, so a duration just set reads as chosen', () => {
|
||||
// A 24-hour, 1-hour and 7-day suppression set a few seconds ago.
|
||||
expect(formatTimeUntil(now + day - 5000, { now, compact: true })).toBe('in 1d');
|
||||
expect(formatTimeUntil(now + hour - 5000, { now, compact: true })).toBe('in 1h');
|
||||
expect(formatTimeUntil(now + 7 * day - 5000, { now, compact: true })).toBe('in 7d');
|
||||
expect(formatTimeUntil(now + 2 * hour + 10 * minute, { now, compact: true })).toBe('in 2h');
|
||||
expect(formatTimeUntil(now + 2 * hour + 50 * minute, { now, compact: true })).toBe('in 3h');
|
||||
// Months round from the remaining time itself, not from rounded days.
|
||||
expect(formatTimeUntil(now + 44 * day + 12 * hour, { now })).toBe('in 1 month');
|
||||
expect(formatTimeUntil(now + 45 * day + hour, { now })).toBe('in 2 months');
|
||||
});
|
||||
|
||||
it('reads as due once the time has arrived or passed', () => {
|
||||
expect(formatTimeUntil(now, { now })).toBe('now');
|
||||
expect(formatTimeUntil(now - hour, { now, compact: true })).toBe('now');
|
||||
expect(formatTimeUntil(now - hour, { now, dueText: 'overdue' })).toBe('overdue');
|
||||
});
|
||||
|
||||
it('accepts ISO strings and Date objects and defaults now to the current time', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
expect(formatTimeUntil('2024-03-15T15:00:00Z', { compact: true })).toBe('in 3h');
|
||||
expect(formatTimeUntil(new Date(now + 10 * minute), { compact: true })).toBe('in 10m');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBackupInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -3609,7 +3609,7 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(patrolIntelligenceStateSource).toMatch(
|
||||
/import\s*\{[^}]*\bbuildPatrolAssistantFindingHandoffFromUnifiedFinding\b[^}]*\}\s*from '\.\/patrolInvestigationContextModel';/,
|
||||
);
|
||||
expect(patrolIntelligenceStateSource).toContain('buildPatrolSettingsReadinessFailure');
|
||||
expect(patrolIntelligenceStateSource).toContain('getPatrolSavedReadinessWarning');
|
||||
expect(patrolIntelligenceStateSource).not.toContain('recent_changes?.length');
|
||||
expect(patrolIntelligenceStateSource).not.toContain('governed resource${');
|
||||
expect(patrolInvestigationContextModelSource).toContain(
|
||||
|
||||
@@ -507,7 +507,7 @@ describe('metricThresholds', () => {
|
||||
usage: 85,
|
||||
temperature: 80,
|
||||
});
|
||||
expect(FACTORY_TRUENAS_DISK_DEFAULTS).toEqual({ temperature: 55 });
|
||||
expect(FACTORY_TRUENAS_DISK_DEFAULTS).toEqual({ temperature: undefined });
|
||||
expect(FACTORY_VMWARE_DEFAULTS).toEqual({
|
||||
cpu: 80,
|
||||
memory: 85,
|
||||
|
||||
@@ -1706,6 +1706,82 @@ describe('fast merge path for metrics-only delta patches', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a linked agent's filesystems and freshness replace withdrawn ones", () => {
|
||||
const raw = createPveGuestRaw();
|
||||
raw.sources = ['proxmox-pve', 'agent'] as Resource['sources'];
|
||||
raw.platformData = { ...raw.platformData, sources: ['proxmox-pve', 'agent'] };
|
||||
raw.proxmox = {
|
||||
...raw.proxmox,
|
||||
runtimeStatus: 'running',
|
||||
guestAgentStatus: 'available',
|
||||
disks: [{ mountpoint: '/', total: 2048, used: 512 }],
|
||||
};
|
||||
raw.agent = {
|
||||
agentId: 'agent-in-vm',
|
||||
stale: true,
|
||||
disks: [{ mountpoint: '/', total: 4096, used: 1024 }],
|
||||
} as Resource['agent'];
|
||||
raw.platformData = { ...raw.platformData, agent: raw.agent };
|
||||
const previous = seedDisplayRows([raw]);
|
||||
// The guest agent stopped, so Proxmox withdraws its filesystems, and the
|
||||
// linked Pulse agent reports again with an explicit stale=false.
|
||||
const incoming = structuredClone(raw);
|
||||
incoming.proxmox = {
|
||||
vmid: 100,
|
||||
nodeName: 'pve-node-1',
|
||||
runtimeStatus: 'running',
|
||||
guestAgentStatus: 'expected-unreachable',
|
||||
diskStatusReason: 'agent-not-running',
|
||||
};
|
||||
incoming.agent = { ...raw.agent, stale: false } as Resource['agent'];
|
||||
incoming.platformData = { ...incoming.platformData, agent: incoming.agent };
|
||||
const changed = new Set([raw.id]);
|
||||
const full = mergeCanonicalResourceSnapshot([incoming], previous);
|
||||
const delta = mergeCanonicalResourceDeltaSnapshot([incoming], previous, changed);
|
||||
const fast = mergeCanonicalResourceDeltaSnapshot(
|
||||
[incoming],
|
||||
previous,
|
||||
changed,
|
||||
new Map([[raw.id, ['proxmox', 'agent']]]),
|
||||
);
|
||||
for (const [merged] of [full, delta, fast]) {
|
||||
expect(merged.proxmox?.disks).toBeUndefined();
|
||||
expect(merged.proxmox?.diskStatusReason).toBe('agent-not-running');
|
||||
expect(merged.agent?.stale).toBe(false);
|
||||
expect(merged.agent?.disks).toEqual(raw.agent?.disks);
|
||||
}
|
||||
|
||||
// A partial facet without a native outcome keeps the prior filesystems.
|
||||
const partial = { ...raw, proxmox: { vmid: 100, uptime: 1001 } } as Resource;
|
||||
const [kept] = mergeCanonicalResourceSnapshot([partial], previous);
|
||||
expect(kept.proxmox?.disks).toEqual(raw.proxmox?.disks);
|
||||
|
||||
// A native agent report (it always carries stale) without disks has
|
||||
// withdrawn them; a legacy facet without stale keeps the prior list.
|
||||
const withdrawn = structuredClone(raw);
|
||||
withdrawn.agent = { agentId: 'agent-in-vm', stale: false } as Resource['agent'];
|
||||
withdrawn.platformData = { ...withdrawn.platformData, agent: withdrawn.agent };
|
||||
for (const [merged] of [
|
||||
mergeCanonicalResourceSnapshot([withdrawn], previous),
|
||||
mergeCanonicalResourceDeltaSnapshot([withdrawn], previous, changed),
|
||||
mergeCanonicalResourceDeltaSnapshot(
|
||||
[withdrawn],
|
||||
previous,
|
||||
changed,
|
||||
new Map([[raw.id, ['agent']]]),
|
||||
),
|
||||
]) {
|
||||
expect(merged.agent?.disks).toBeUndefined();
|
||||
expect(merged.agent?.stale).toBe(false);
|
||||
// The compatibility mirror that host threshold rows read drops them too.
|
||||
const mirror = (merged.platformData as { agent?: { disks?: unknown } } | undefined)?.agent;
|
||||
expect(mirror?.disks).toBeUndefined();
|
||||
}
|
||||
const legacy = { ...raw, agent: { agentId: 'agent-in-vm' } } as Resource;
|
||||
const [legacyKept] = mergeCanonicalResourceSnapshot([legacy], previous);
|
||||
expect(legacyKept.agent?.disks).toEqual(raw.agent?.disks);
|
||||
});
|
||||
|
||||
it('keeps explicit lock and deferral evidence on a new outcome, including false expected', () => {
|
||||
const raw = createPveGuestRaw();
|
||||
raw.proxmox = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getWorkloadsGuestBackupStatusPresentation,
|
||||
getWorkloadsGuestBackupTooltip,
|
||||
getWorkloadsGuestProtectionPresentation,
|
||||
getWorkloadGuestDiskRead,
|
||||
getWorkloadGuestDiskStatusMessage,
|
||||
getWorkloadsGuestNetworkEmptyState,
|
||||
} from '@/utils/workloadGuestPresentation';
|
||||
@@ -104,4 +105,63 @@ describe('workloadGuestPresentation', () => {
|
||||
'Using last known disk stats. No filesystems found. VM may be booting or using a Live ISO.',
|
||||
);
|
||||
});
|
||||
|
||||
it("judges a linked agent's filesystems by that agent, not the Proxmox read reason", () => {
|
||||
const agent = { diskStatusReason: 'agent-not-running', disksFromAgent: true };
|
||||
expect(getWorkloadGuestDiskRead({ ...agent, status: 'running' }, true)).toEqual({
|
||||
state: 'current',
|
||||
message: null,
|
||||
needsAction: false,
|
||||
});
|
||||
expect(
|
||||
getWorkloadGuestDiskRead({ ...agent, status: 'running', agentStale: true }, true),
|
||||
).toEqual({
|
||||
state: 'last-known',
|
||||
message: 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.',
|
||||
needsAction: false,
|
||||
});
|
||||
// A stopped guest has no current filesystems from either source, with or
|
||||
// without the Proxmox vm-stopped reason.
|
||||
for (const guest of [
|
||||
{ ...agent, status: 'stopped', agentStale: true },
|
||||
{ ...agent, status: 'running', diskStatusReason: 'vm-stopped' },
|
||||
]) {
|
||||
expect(getWorkloadGuestDiskRead(guest, true)).toEqual({
|
||||
state: 'unavailable',
|
||||
message: 'Guest filesystem stats unavailable while the VM is stopped.',
|
||||
needsAction: false,
|
||||
});
|
||||
}
|
||||
expect(getWorkloadGuestDiskRead({ disksFromAgent: true, status: 'stopped' }, false)).toEqual({
|
||||
state: 'unavailable',
|
||||
message: 'Filesystem usage is unavailable.',
|
||||
needsAction: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Proxmox read reasons for its own guest filesystems', () => {
|
||||
expect(getWorkloadGuestDiskRead({ status: 'running' }, true).state).toBe('current');
|
||||
expect(getWorkloadGuestDiskRead({ diskStatusReason: 'prev-vm-locked' }, true)).toEqual({
|
||||
state: 'last-known',
|
||||
message: getWorkloadGuestDiskStatusMessage('prev-vm-locked'),
|
||||
needsAction: false,
|
||||
});
|
||||
expect(getWorkloadGuestDiskRead({ diskStatusReason: 'agent-not-running' }, true)).toEqual({
|
||||
state: 'unavailable',
|
||||
message: getWorkloadGuestDiskStatusMessage('agent-not-running'),
|
||||
needsAction: false,
|
||||
});
|
||||
expect(
|
||||
getWorkloadGuestDiskRead({ diskStatusReason: 'prev-agent-error' }, true).needsAction,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getWorkloadGuestDiskRead({ diskStatusReason: 'permission-denied' }, true).needsAction,
|
||||
).toBe(true);
|
||||
// Only a VM carries a Proxmox guest read reason.
|
||||
expect(getWorkloadGuestDiskRead({ diskStatusReason: 'prev-vm-locked' }, false).state).toBe(
|
||||
'current',
|
||||
);
|
||||
// An agent's staleness says nothing about Proxmox's own filesystems.
|
||||
expect(getWorkloadGuestDiskRead({ agentStale: true }, true).state).toBe('current');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { MetadataBadgeTone } from '@/components/shared/MetadataBadge';
|
||||
import { isLivePendingApproval } from '@/utils/approvalState';
|
||||
import { getPatrolProviderSettingsAction } from '@/utils/patrolRuntimeActions';
|
||||
import { formatIdentifierLabel, titleCaseDelimitedLabel } from '@/utils/textPresentation';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { formatAbsoluteTime, formatBytes, formatTimeUntil } from '@/utils/format';
|
||||
|
||||
const DEFAULT_BADGE_CLASSES = 'border-border bg-surface-alt text-muted';
|
||||
const DEFAULT_LOOP_STATE_CLASSES = 'border-border bg-surface-alt text-muted';
|
||||
@@ -275,6 +275,11 @@ export interface FindingRecencyPresentation {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface FindingCountdownPresentation {
|
||||
label: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface PatrolFindingClassification {
|
||||
kind: 'runtime' | 'infrastructure';
|
||||
label: string;
|
||||
@@ -913,6 +918,49 @@ export const getFindingRecencyPresentation = (
|
||||
};
|
||||
};
|
||||
|
||||
// A will_fix_later reminder and the end of a snooze are future times, so the
|
||||
// row counts down to them ("Reminding in 6d") from the `now` the panel passes
|
||||
// from the shared relative-time clock, and keeps the exact time on hover. A
|
||||
// reminder reads overdue when `overdue` says the panel's overdue list holds it,
|
||||
// the same list the Overdue commitments chip and filter count, so the three
|
||||
// agree; read between the deadline and the clock tick that adds it there, it
|
||||
// says "Reminder due". It stays overdue until the hourly reminder sweep brings
|
||||
// the finding back, whether or not it still trips. A passed snooze has ended,
|
||||
// so neither keeps counting.
|
||||
export const getFindingReminderPresentation = (
|
||||
finding: Pick<UnifiedFinding, 'dismissedReason' | 'remindAt'>,
|
||||
now: number,
|
||||
overdue: boolean,
|
||||
): FindingCountdownPresentation | null => {
|
||||
if (finding.dismissedReason !== 'will_fix_later' || !finding.remindAt) return null;
|
||||
const due = Date.parse(finding.remindAt);
|
||||
if (!Number.isFinite(due)) return null;
|
||||
const at = formatAbsoluteTime(due);
|
||||
if (overdue) {
|
||||
return {
|
||||
label: 'Reminder overdue',
|
||||
title: `Reminder was due ${at}. Pulse brings this finding back on its next reminder check.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label:
|
||||
due <= now ? 'Reminder due' : `Reminding ${formatTimeUntil(due, { compact: true, now })}`,
|
||||
title: `Reminder due ${at}. Pulse brings this finding back on its first reminder check after that.`,
|
||||
};
|
||||
};
|
||||
|
||||
export const getFindingSnoozePresentation = (
|
||||
finding: Pick<UnifiedFinding, 'status' | 'snoozedUntil'>,
|
||||
now: number,
|
||||
): FindingCountdownPresentation | null => {
|
||||
if (finding.status !== 'snoozed' || !finding.snoozedUntil) return null;
|
||||
const until = Date.parse(finding.snoozedUntil);
|
||||
if (!Number.isFinite(until)) return null;
|
||||
const title = `Snoozed until ${formatAbsoluteTime(until)}`;
|
||||
if (until <= now) return { label: 'snooze ended', title };
|
||||
return { label: `snoozed, returns ${formatTimeUntil(until, { compact: true, now })}`, title };
|
||||
};
|
||||
|
||||
export const getInvestigationStatusBadgeClasses = (status: InvestigationStatus): string =>
|
||||
INVESTIGATION_STATUS_CLASSES[status] || DEFAULT_BADGE_CLASSES;
|
||||
|
||||
|
||||
@@ -44,8 +44,12 @@ export const FACTORY_TRUENAS_DEFAULTS = {
|
||||
networkOut: -1,
|
||||
};
|
||||
|
||||
export const FACTORY_TRUENAS_DISK_DEFAULTS = {
|
||||
temperature: 55,
|
||||
// TrueNAS disk temperature has no flat factory value. Unset, each TrueNAS disk
|
||||
// follows Disk temperature by type, as the backend resolves it
|
||||
// (trueNASDiskTemperatureDefaultNoLock); a saved value applies to every
|
||||
// TrueNAS disk.
|
||||
export const FACTORY_TRUENAS_DISK_DEFAULTS: Record<string, number | undefined> = {
|
||||
temperature: undefined,
|
||||
};
|
||||
|
||||
export const FACTORY_VMWARE_DEFAULTS = {
|
||||
|
||||
@@ -169,9 +169,18 @@ export function formatAbsoluteTime(timestamp: number | undefined): string {
|
||||
return `${day} ${month} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function relativeTimeInputMs(timestamp: number | string | Date): number {
|
||||
if (typeof timestamp === 'number') return timestamp;
|
||||
if (typeof timestamp === 'string') return new Date(timestamp).getTime();
|
||||
return timestamp.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp as a human-readable relative time string.
|
||||
* ALL relative time formatting MUST use this function.
|
||||
* Format a timestamp as a human-readable relative age ("5m ago").
|
||||
* ALL relative ages MUST use this function. It is past-only: a time ahead of
|
||||
* `now` is clock skew on something already observed and reads as "just now".
|
||||
* A time meant to be in the future (an expiry, a reminder, a schedule) uses
|
||||
* formatTimeUntil instead.
|
||||
*
|
||||
* @param timestamp - Unix ms number, ISO date string, Date object, or undefined
|
||||
* @param options.compact - Use short format: "5m ago" instead of "5 mins ago"
|
||||
@@ -185,19 +194,51 @@ export function formatRelativeTime(
|
||||
): string {
|
||||
if (!timestamp) return options?.emptyText ?? '';
|
||||
|
||||
let ms: number;
|
||||
if (typeof timestamp === 'number') {
|
||||
ms = timestamp;
|
||||
} else if (typeof timestamp === 'string') {
|
||||
ms = new Date(timestamp).getTime();
|
||||
} else {
|
||||
ms = timestamp.getTime();
|
||||
}
|
||||
|
||||
const diffMs = (options?.now ?? Date.now()) - ms;
|
||||
const diffMs = (options?.now ?? Date.now()) - relativeTimeInputMs(timestamp);
|
||||
return formatTimeDiff(diffMs, options?.compact);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a future timestamp as a countdown: "in 3h" (compact) or "in 3 hours".
|
||||
* ALL relative future times (expiries, reminders, schedules) MUST use this
|
||||
* function, because formatRelativeTime reads any future time as "just now".
|
||||
*
|
||||
* Unlike an age, a countdown rounds to the nearest unit, so a 24-hour
|
||||
* suppression set a moment ago reads "in 1d" rather than "in 23h".
|
||||
*
|
||||
* @param timestamp - Unix ms number, ISO date string, Date object, or undefined
|
||||
* @param options.compact - Use short format: "in 5m" instead of "in 5 mins"
|
||||
* @param options.emptyText - Text for falsy or unparseable input (default: '')
|
||||
* @param options.dueText - Text once the time has arrived or passed (default: 'now')
|
||||
* @param options.now - The time to count down from (default: Date.now()). A
|
||||
* countdown that must keep moving passes the shared relative-time clock.
|
||||
*/
|
||||
export function formatTimeUntil(
|
||||
timestamp: number | string | Date | undefined,
|
||||
options?: { compact?: boolean; emptyText?: string; dueText?: string; now?: number },
|
||||
): string {
|
||||
if (!timestamp) return options?.emptyText ?? '';
|
||||
|
||||
const remainingMs = relativeTimeInputMs(timestamp) - (options?.now ?? Date.now());
|
||||
if (!Number.isFinite(remainingMs)) return options?.emptyText ?? '';
|
||||
if (remainingMs <= 0) return options?.dueText ?? 'now';
|
||||
if (remainingMs < 60_000) return 'in under a minute';
|
||||
|
||||
const compact = options?.compact;
|
||||
const inUnits = (value: number, short: string, singular: string, plural: string) =>
|
||||
compact ? `in ${value}${short}` : `in ${value} ${value === 1 ? singular : plural}`;
|
||||
|
||||
const minutes = Math.round(remainingMs / 60_000);
|
||||
if (minutes < 60) return inUnits(minutes, 'm', 'min', 'mins');
|
||||
const hours = Math.round(remainingMs / 3_600_000);
|
||||
if (hours < 24) return inUnits(hours, 'h', 'hour', 'hours');
|
||||
const days = Math.round(remainingMs / 86_400_000);
|
||||
if (compact || days < 30) return inUnits(days, 'd', 'day', 'days');
|
||||
const months = Math.round(remainingMs / (30 * 86_400_000));
|
||||
if (months < 12) return inUnits(months, '', 'month', 'months');
|
||||
return inUnits(Math.round(remainingMs / (365 * 86_400_000)), '', 'year', 'years');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a time difference in milliseconds to a human-readable string.
|
||||
* Internal helper used by formatRelativeTime and formatBackupAge.
|
||||
|
||||
@@ -124,8 +124,9 @@ const mergeRecord = <T extends JsonRecord>(incoming?: T, existing?: T): T | unde
|
||||
};
|
||||
|
||||
// Native VM facets carry a runtime state and an explicit guest-agent outcome.
|
||||
// Their optional read reason, lock and false expected flag are omitempty on
|
||||
// wire. A new outcome supersedes those fields, not unrelated richer metadata.
|
||||
// Their optional read reason, lock, false expected flag and guest filesystems
|
||||
// are omitempty on wire. A new outcome supersedes those fields, not unrelated
|
||||
// richer metadata; withdrawn filesystems must not hide a linked agent's.
|
||||
// Partial/legacy facets without that evidence must retain the prior facts.
|
||||
const mergeProxmoxFacet = <T extends JsonRecord>(incoming?: T, existing?: T): T | undefined => {
|
||||
const merged = mergeRecord(incoming, existing);
|
||||
@@ -142,12 +143,30 @@ const mergeProxmoxFacet = <T extends JsonRecord>(incoming?: T, existing?: T): T
|
||||
)
|
||||
return merged;
|
||||
const next = { ...merged };
|
||||
for (const key of ['diskStatusReason', 'guestAgentExpected', 'lock']) {
|
||||
for (const key of ['diskStatusReason', 'guestAgentExpected', 'lock', 'disks']) {
|
||||
if (!Object.prototype.hasOwnProperty.call(incoming, key)) delete next[key];
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
// Native agent facets always carry `stale`; facets synthesized from legacy
|
||||
// platform data never do. Their filesystem list is omitempty on wire, so a
|
||||
// native report without `disks` has withdrawn them, while a partial or legacy
|
||||
// facet keeps the prior list.
|
||||
const mergeAgentFacet = <T extends JsonRecord>(incoming?: T, existing?: T): T | undefined => {
|
||||
const merged = mergeRecord(incoming, existing);
|
||||
if (
|
||||
!incoming ||
|
||||
!merged ||
|
||||
typeof incoming.stale !== 'boolean' ||
|
||||
Object.prototype.hasOwnProperty.call(incoming, 'disks')
|
||||
)
|
||||
return merged;
|
||||
const next = { ...merged };
|
||||
delete next.disks;
|
||||
return next;
|
||||
};
|
||||
|
||||
const mergePlatformData = (
|
||||
incomingValue: Resource['platformData'],
|
||||
existingValue: Resource['platformData'],
|
||||
@@ -180,7 +199,9 @@ const mergePlatformData = (
|
||||
const nested =
|
||||
key === 'proxmox'
|
||||
? mergeProxmoxFacet(asRecord(incoming[key]), asRecord(existing[key]))
|
||||
: mergeRecord(asRecord(incoming[key]), asRecord(existing[key]));
|
||||
: key === 'agent'
|
||||
? mergeAgentFacet(asRecord(incoming[key]), asRecord(existing[key]))
|
||||
: mergeRecord(asRecord(incoming[key]), asRecord(existing[key]));
|
||||
if (key === 'docker') {
|
||||
if (hasDockerFacetEvidence(nested)) {
|
||||
merged[key] = nested;
|
||||
@@ -895,7 +916,9 @@ const mergeCanonicalSourceFacet = <T extends JsonRecord>(
|
||||
shouldKeepSourceFacet(incomingSources, ...sourceCandidates)
|
||||
? sourceCandidates.includes('proxmox-pve')
|
||||
? mergeProxmoxFacet(incomingFacet, existingFacet)
|
||||
: mergeRecord(incomingFacet, existingFacet)
|
||||
: sourceCandidates.includes('agent')
|
||||
? mergeAgentFacet(incomingFacet, existingFacet)
|
||||
: mergeRecord(incomingFacet, existingFacet)
|
||||
: incomingFacet;
|
||||
|
||||
// A missing field alone can be a partial snapshot. Explicit unavailable raw
|
||||
|
||||
@@ -146,3 +146,61 @@ export function getWorkloadGuestDiskStatusMessage(reason?: string): string {
|
||||
|
||||
return carriedForward ? `Using last known disk stats. ${message}` : message;
|
||||
}
|
||||
|
||||
export interface WorkloadGuestDiskReadSource {
|
||||
status?: string;
|
||||
/** Proxmox's reason for its own guest filesystem read. */
|
||||
diskStatusReason?: string;
|
||||
/** `disks` came from the linked Pulse agent because Proxmox reported none. */
|
||||
disksFromAgent?: boolean;
|
||||
agentStale?: boolean;
|
||||
}
|
||||
|
||||
export type WorkloadGuestDiskRead =
|
||||
| { state: 'current'; message: null; needsAction: false }
|
||||
| {
|
||||
state: 'last-known' | 'unavailable';
|
||||
/** Why the reading is not current. */
|
||||
message: string;
|
||||
/** A broken read the user can fix, rather than a setup gap. */
|
||||
needsAction: boolean;
|
||||
};
|
||||
|
||||
// Disk-read reasons that point at something broken the user can fix.
|
||||
const DISK_READ_ACTIONABLE_REASONS = new Set(['permission-denied', 'agent-error']);
|
||||
|
||||
// One freshness rule for a guest's filesystem rows and aggregate disk metric,
|
||||
// shared by the workload table cell, the drawer and its History. Proxmox
|
||||
// records no guest disk usage without guest filesystems, so when the linked
|
||||
// Pulse agent's filesystems fill in, the registry keeps the agent's disk
|
||||
// metric too and the reading follows that agent, not the Proxmox reason.
|
||||
export function getWorkloadGuestDiskRead(
|
||||
guest: WorkloadGuestDiskReadSource,
|
||||
isVM: boolean,
|
||||
): WorkloadGuestDiskRead {
|
||||
const reason = isVM ? guest.diskStatusReason : undefined;
|
||||
if (guest.disksFromAgent) {
|
||||
if (guest.status === 'stopped' || reason === 'vm-stopped') {
|
||||
return {
|
||||
state: 'unavailable',
|
||||
message: isVM
|
||||
? getWorkloadGuestDiskStatusMessage('vm-stopped')
|
||||
: 'Filesystem usage is unavailable.',
|
||||
needsAction: false,
|
||||
};
|
||||
}
|
||||
return guest.agentStale
|
||||
? {
|
||||
state: 'last-known',
|
||||
message: 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.',
|
||||
needsAction: false,
|
||||
}
|
||||
: { state: 'current', message: null, needsAction: false };
|
||||
}
|
||||
if (!reason) return { state: 'current', message: null, needsAction: false };
|
||||
return {
|
||||
state: reason.startsWith('prev-') ? 'last-known' : 'unavailable',
|
||||
message: getWorkloadGuestDiskStatusMessage(reason),
|
||||
needsAction: DISK_READ_ACTIONABLE_REASONS.has(reason.replace(/^prev-/, '')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,17 +131,3 @@ func (d *ChangeDetector) loadFromDisk() error {
|
||||
d.trimChanges()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func intToString(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var result string
|
||||
for n > 0 {
|
||||
result = string(rune('0'+n%10)) + result
|
||||
n /= 10
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -711,18 +711,15 @@ func (s *ContextStore) ForceSave() error {
|
||||
var memoryCounter, incidentMemCounter, patternMemCounter atomic.Int64
|
||||
|
||||
func generateMemoryID() string {
|
||||
n := memoryCounter.Add(1)
|
||||
return fmt.Sprintf("mem-%s-%d", time.Now().Format("20060102150405"), n%1000)
|
||||
return nextSequencedID("mem-", &memoryCounter)
|
||||
}
|
||||
|
||||
func generateIncidentMemoryID() string {
|
||||
n := incidentMemCounter.Add(1)
|
||||
return fmt.Sprintf("inc-mem-%s-%d", time.Now().Format("20060102150405"), n%1000)
|
||||
return nextSequencedID("inc-mem-", &incidentMemCounter)
|
||||
}
|
||||
|
||||
func generatePatternMemoryID() string {
|
||||
n := patternMemCounter.Add(1)
|
||||
return fmt.Sprintf("pat-mem-%s-%d", time.Now().Format("20060102150405"), n%1000)
|
||||
return nextSequencedID("pat-mem-", &patternMemCounter)
|
||||
}
|
||||
|
||||
func calculatePatternConfidence(occurrences int) float64 {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nextSequencedID mints prefix + local wall-clock second + "-" + the next value
|
||||
// of seq. Each sequence is package-level and shared by every store in the
|
||||
// process, including one store per tenant monitor, so it must stay atomic.
|
||||
// The suffix never wraps, so one sequence never repeats an ID within a process
|
||||
// however many IDs share a second.
|
||||
func nextSequencedID(prefix string, seq *atomic.Int64) string {
|
||||
return prefix + time.Now().Format("20060102150405") + "-" + strconv.FormatInt(seq.Add(1), 10)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
)
|
||||
|
||||
// TestSequencedIDsStayUniqueUnderConcurrentMinting mints from every
|
||||
// package-level ID sequence on many goroutines at once. Under -race it proves
|
||||
// the sequences are synchronized. The uniqueness check proves the suffix does
|
||||
// not wrap: more than a thousand IDs from one generator land in the same
|
||||
// wall-clock second, which the old counter%1000 suffix turned into duplicates.
|
||||
func TestSequencedIDsStayUniqueUnderConcurrentMinting(t *testing.T) {
|
||||
generators := []struct {
|
||||
prefix string
|
||||
mint func() string
|
||||
}{
|
||||
{"inc-", generateIncidentID},
|
||||
{"inc-evt-", generateIncidentEventID},
|
||||
{"rem-", generateRecordID},
|
||||
{"mem-", generateMemoryID},
|
||||
{"inc-mem-", generateIncidentMemoryID},
|
||||
{"pat-mem-", generatePatternMemoryID},
|
||||
}
|
||||
const goroutines, perGoroutine = 16, 150
|
||||
|
||||
for _, gen := range generators {
|
||||
t.Run(strings.TrimSuffix(gen.prefix, "-"), func(t *testing.T) {
|
||||
shape := regexp.MustCompile("^" + regexp.QuoteMeta(gen.prefix) + `\d{14}-[1-9]\d*$`)
|
||||
minted := make([][]string, goroutines)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for g := range goroutines {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
batch := make([]string, perGoroutine)
|
||||
for i := range batch {
|
||||
batch[i] = gen.mint()
|
||||
}
|
||||
minted[g] = batch
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
seen := make(map[string]bool, goroutines*perGoroutine)
|
||||
for _, batch := range minted {
|
||||
for _, id := range batch {
|
||||
if !shape.MatchString(id) {
|
||||
t.Fatalf("ID %q does not match %s", id, shape)
|
||||
}
|
||||
if seen[id] {
|
||||
t.Fatalf("duplicate ID %q among %d concurrent mints", id, goroutines*perGoroutine)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIncidentStoresMintIDsConcurrentlyAcrossTenants mirrors multi-tenant
|
||||
// monitoring: each tenant monitor owns its own IncidentStore, and their alert
|
||||
// checks call RecordAlertFired from separate goroutines. Each store's mutex
|
||||
// covers only its own state, so the shared ID sequence must be synchronized on
|
||||
// its own. Run under -race.
|
||||
func TestIncidentStoresMintIDsConcurrentlyAcrossTenants(t *testing.T) {
|
||||
const tenants, checkersPerTenant, alertsPerChecker = 2, 4, 50
|
||||
stores := make([]*IncidentStore, tenants)
|
||||
for i := range stores {
|
||||
stores[i] = NewIncidentStore(IncidentStoreConfig{MaxIncidents: checkersPerTenant * alertsPerChecker})
|
||||
}
|
||||
|
||||
startedAt := time.Now().Add(-time.Minute)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for tenant, store := range stores {
|
||||
for checker := range checkersPerTenant {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
for n := range alertsPerChecker {
|
||||
store.RecordAlertFired(&alerts.Alert{
|
||||
ID: fmt.Sprintf("tenant-%d-checker-%d-alert-%d", tenant, checker, n),
|
||||
Type: "cpu",
|
||||
Level: alerts.AlertLevelWarning,
|
||||
ResourceID: fmt.Sprintf("tenant-%d-vm-%d-%d", tenant, checker, n),
|
||||
ResourceName: "vm",
|
||||
StartTime: startedAt,
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
seen := make(map[string]bool, tenants*checkersPerTenant*alertsPerChecker)
|
||||
for tenant, store := range stores {
|
||||
store.mu.RLock()
|
||||
incidents := len(store.incidents)
|
||||
for _, shell := range store.incidents {
|
||||
if seen[shell.ID] {
|
||||
t.Errorf("tenant %d incident ID %q was already minted", tenant, shell.ID)
|
||||
}
|
||||
seen[shell.ID] = true
|
||||
}
|
||||
store.mu.RUnlock()
|
||||
if incidents != checkersPerTenant*alertsPerChecker {
|
||||
t.Errorf("tenant %d recorded %d incidents, want %d", tenant, incidents, checkersPerTenant*alertsPerChecker)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1747,18 +1747,14 @@ func cloneTime(value time.Time) *time.Time {
|
||||
return &cloned
|
||||
}
|
||||
|
||||
var incidentCounter int64
|
||||
var incidentCounter, incidentEventCounter atomic.Int64
|
||||
|
||||
func generateIncidentID() string {
|
||||
incidentCounter++
|
||||
return "inc-" + time.Now().Format("20060102150405") + "-" + intToString(int(incidentCounter%1000))
|
||||
return nextSequencedID("inc-", &incidentCounter)
|
||||
}
|
||||
|
||||
var incidentEventCounter int64
|
||||
|
||||
func generateIncidentEventID() string {
|
||||
incidentEventCounter++
|
||||
return "inc-evt-" + time.Now().Format("20060102150405") + "-" + intToString(int(incidentEventCounter%1000))
|
||||
return nextSequencedID("inc-evt-", &incidentEventCounter)
|
||||
}
|
||||
|
||||
func formatAlertSummary(alert *alerts.Alert) string {
|
||||
|
||||
@@ -11,18 +11,11 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
)
|
||||
|
||||
func TestChangeDetector_DefaultsAndHelpers(t *testing.T) {
|
||||
func TestChangeDetector_Defaults(t *testing.T) {
|
||||
detector := NewChangeDetector(ChangeDetectorConfig{})
|
||||
if detector.maxChanges != 1000 {
|
||||
t.Fatalf("expected default maxChanges=1000, got %d", detector.maxChanges)
|
||||
}
|
||||
|
||||
if got := intToString(0); got != "0" {
|
||||
t.Errorf("intToString(0) = %q", got)
|
||||
}
|
||||
if got := intToString(42); got != "42" {
|
||||
t.Errorf("intToString(42) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewChangeDetector_LoadsFromDisk(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
@@ -349,11 +350,10 @@ func (r *RemediationLog) loadFromDisk() error {
|
||||
|
||||
// Helper functions
|
||||
|
||||
var recordCounter int64
|
||||
var recordCounter atomic.Int64
|
||||
|
||||
func generateRecordID() string {
|
||||
recordCounter++
|
||||
return "rem-" + time.Now().Format("20060102150405") + "-" + intToString(int(recordCounter%1000))
|
||||
return nextSequencedID("rem-", &recordCounter)
|
||||
}
|
||||
|
||||
func truncateOutput(output string, maxLen int) string {
|
||||
|
||||
@@ -19,6 +19,8 @@ package alerts
|
||||
// 4. the per-resource override, through the identity-aware lookup for
|
||||
// the kind (guest stable keys, storage aliases, canonical registry
|
||||
// translation)
|
||||
// 5. a guest's pulse-relaxed tag, which lifts its CPU/memory/disk
|
||||
// triggers to a 95/92/95 floor
|
||||
|
||||
// alertPolicyQuery identifies one resource asking for its effective
|
||||
// alert policy.
|
||||
@@ -28,13 +30,20 @@ type alertPolicyQuery struct {
|
||||
TypeKey string
|
||||
ResourceID string
|
||||
// Guest carries the live guest model when the query is for a guest
|
||||
// kind: filter-scoped custom rules and the clustered override
|
||||
// identity need it. Nil is valid — filter rules then never match and
|
||||
// override lookup falls back to the raw resource ID.
|
||||
// kind: filter-scoped custom rules, the clustered override identity and
|
||||
// the pulse-relaxed tag need it. Nil is valid — filter rules then never
|
||||
// match, override lookup falls back to the raw resource ID, and no tag
|
||||
// relaxes the thresholds.
|
||||
Guest any
|
||||
// StorageAliases are the storage resource's alias IDs, honored by the
|
||||
// storage override lookup.
|
||||
StorageAliases []string
|
||||
// DiskType is a physical disk's type ("nvme", "sas", "sata"), which
|
||||
// picks its disk temperature threshold. DiskTypeUnknown marks a disk
|
||||
// whose type was never recorded, such as an alert persisted before it
|
||||
// carried one; it is judged against the lowest per-type trigger.
|
||||
DiskType string
|
||||
DiskTypeUnknown bool
|
||||
}
|
||||
|
||||
// EffectiveAlertPolicy is the resolved policy for one resource.
|
||||
@@ -47,7 +56,8 @@ type EffectiveAlertPolicy struct {
|
||||
// connectivity opt-outs are Thresholds.DisableConnectivity.
|
||||
OfflineDisabled bool
|
||||
// Thresholds is the folded threshold block: type defaults, then
|
||||
// custom rules, then the per-resource override.
|
||||
// custom rules, then the per-resource override, then a guest's
|
||||
// pulse-relaxed floor.
|
||||
Thresholds ThresholdConfig
|
||||
}
|
||||
|
||||
@@ -116,10 +126,19 @@ func (m *Manager) effectiveAlertPolicyNoLock(q alertPolicyQuery) EffectiveAlertP
|
||||
policy.AllDisabled, policy.OfflineDisabled = m.alertPolicyTypeSwitchesNoLock(q.TypeKey)
|
||||
|
||||
thresholds := m.defaultThresholdsForResourceType(q.TypeKey)
|
||||
if q.TypeKey == "truenas-disk" {
|
||||
thresholds.Temperature = m.trueNASDiskTemperatureDefaultNoLock(q.DiskType, q.DiskTypeUnknown)
|
||||
}
|
||||
switch {
|
||||
case isGuestThresholdResourceType(q.TypeKey):
|
||||
thresholds = m.customRuleThresholdsNoLock(thresholds, q.Guest)
|
||||
thresholds = m.resolveGuestThresholdOverride(thresholds, q.Guest, q.ResourceID)
|
||||
// Polling and config-save reevaluation both resolve through here,
|
||||
// so a relaxed guest's alert is judged by one threshold. Applied
|
||||
// only by CheckGuest, a save re-judged it by the unrelaxed one.
|
||||
if guest, ok := extractGuestSnapshot(q.Guest); ok && parsePulseTags(guest.Tags).Relaxed {
|
||||
thresholds = applyRelaxedGuestThresholds(thresholds)
|
||||
}
|
||||
case q.TypeKey == "storage":
|
||||
thresholds = m.resolveStorageThresholdOverride(thresholds, q.ResourceID, q.StorageAliases)
|
||||
default:
|
||||
|
||||
@@ -784,6 +784,137 @@ func TestPulseRelaxedThresholdsIncreaseCpuTrigger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// pulse-relaxed lifted an Off trigger to its floor like any low trigger, so a
|
||||
// relaxed guest with disk alerts off raised one at 96%. A config save then
|
||||
// re-judged it by the Off threshold and resolved it, and the next poll raised
|
||||
// it again. Relaxing a guest must not raise an alert its config turned off.
|
||||
func TestPulseRelaxedKeepsOffGuestThresholdOff(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
m.mu.Lock()
|
||||
m.config.Enabled = true
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.config.GuestDefaults = ThresholdConfig{
|
||||
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
|
||||
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
Disk: &HysteresisThreshold{Trigger: 0, Clear: 0},
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
vm := models.VM{
|
||||
ID: "inst/qemu/105",
|
||||
Name: "relaxed-vm",
|
||||
Node: "node1",
|
||||
Instance: "inst",
|
||||
Status: "running",
|
||||
Memory: models.Memory{Usage: 40},
|
||||
Disk: models.Disk{Usage: 96},
|
||||
Tags: []string{"pulse-relaxed"},
|
||||
}
|
||||
|
||||
m.CheckGuest(vm, "inst")
|
||||
if testHasActiveAlert(t, m, canonicalMetricStateID(vm.ID, "disk")) {
|
||||
t.Fatalf("expected no disk alert on a relaxed guest whose disk threshold is off")
|
||||
}
|
||||
}
|
||||
|
||||
// A config save re-judges a relaxed guest's alert by the thresholds CheckGuest
|
||||
// raised it under. Reevaluation rebuilt the guest from alert metadata without
|
||||
// its tags, so an unset memory threshold, which pulse-relaxed sets to 92, read
|
||||
// as no threshold there: the save resolved the alert with a recovery
|
||||
// notification and the next poll raised it again. The tags are read back in
|
||||
// the shape a restart restores them.
|
||||
func TestConfigSaveJudgesRelaxedGuestAlertByRelaxedThresholds(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
m.mu.Lock()
|
||||
m.config.Enabled = true
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.config.GuestDefaults = ThresholdConfig{}
|
||||
m.mu.Unlock()
|
||||
|
||||
vm := models.VM{
|
||||
ID: "inst/qemu/106",
|
||||
Name: "relaxed-vm",
|
||||
Node: "node1",
|
||||
Instance: "inst",
|
||||
Status: "running",
|
||||
Memory: models.Memory{Usage: 95},
|
||||
Tags: []string{"pulse-relaxed"},
|
||||
}
|
||||
memoryAlertID := canonicalMetricStateID(vm.ID, "memory")
|
||||
|
||||
m.CheckGuest(vm, "inst")
|
||||
alert := testRequireActiveAlert(t, m, memoryAlertID)
|
||||
if alert.Threshold != 92 {
|
||||
t.Fatalf("memory alert threshold = %v, want the relaxed 92", alert.Threshold)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
raw, err := json.Marshal(alert.Metadata)
|
||||
if err != nil {
|
||||
m.mu.Unlock()
|
||||
t.Fatalf("marshal metadata: %v", err)
|
||||
}
|
||||
var restored map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &restored); err != nil {
|
||||
m.mu.Unlock()
|
||||
t.Fatalf("unmarshal metadata: %v", err)
|
||||
}
|
||||
alert.Metadata = restored
|
||||
m.mu.Unlock()
|
||||
if _, ok := restored["tags"].([]interface{}); !ok {
|
||||
t.Fatalf("restored tags = %T, want []interface{}", restored["tags"])
|
||||
}
|
||||
|
||||
m.UpdateConfig(m.GetConfig())
|
||||
if !testHasActiveAlert(t, m, memoryAlertID) {
|
||||
t.Fatalf("expected a config save to keep a relaxed guest's memory alert over its relaxed trigger")
|
||||
}
|
||||
}
|
||||
|
||||
// pulse-relaxed lifts a guest's disk threshold, but a filesystem's own
|
||||
// override still decides that filesystem, while polling and on a config save.
|
||||
// Judged by the guest's relaxed floor on save, a filesystem alert over its
|
||||
// lower override resolved and the next poll raised it again.
|
||||
func TestConfigSaveKeepsRelaxedGuestFilesystemOverride(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
guestID := BuildGuestKey("pve1", "node1", 108)
|
||||
m.mu.Lock()
|
||||
m.config.Enabled = true
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.config.GuestDefaults = ThresholdConfig{
|
||||
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
|
||||
}
|
||||
m.config.Overrides = map[string]ThresholdConfig{
|
||||
"guest-disk:guest:pve1:108/disk:boot-dev-vda1": {
|
||||
Disk: &HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
},
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
vm := models.VM{
|
||||
ID: guestID,
|
||||
VMID: 108,
|
||||
Name: "relaxed-fs",
|
||||
Node: "node1",
|
||||
Instance: "pve1",
|
||||
Status: "running",
|
||||
Disks: []models.Disk{
|
||||
{Mountpoint: "/boot", Device: "/dev/vda1", Usage: 92, Total: 100, Used: 92, Free: 8},
|
||||
},
|
||||
Tags: []string{"pulse-relaxed"},
|
||||
}
|
||||
bootAlertID := canonicalMetricStateID(guestID+"-disk-boot-dev-vda1", "disk")
|
||||
|
||||
m.CheckGuest(vm, "pve1")
|
||||
if !testHasActiveAlert(t, m, bootAlertID) {
|
||||
t.Fatalf("expected a /boot alert over its 85%% override on a relaxed guest")
|
||||
}
|
||||
m.UpdateConfig(m.GetConfig())
|
||||
if !testHasActiveAlert(t, m, bootAlertID) {
|
||||
t.Fatalf("expected a config save to keep a relaxed guest's /boot alert over its own 85%% override")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAlertMarksResolutionAndReturnsStatus(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
m.ClearActiveAlerts()
|
||||
@@ -2756,6 +2887,101 @@ func TestCheckBackupsIgnoresVMIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Host and guest evaluation used to fill in the Clear of a disk override's
|
||||
// threshold after dropping m.mu, writing through the live config's pointer
|
||||
// while GetConfig cloned it under the read lock, and Docker evaluation read
|
||||
// m.config.DockerDefaults with no lock while UpdateConfig replaced m.config.
|
||||
// Evaluation now copies what it needs under m.mu. Run with -race.
|
||||
func TestAlertEvaluationLeavesTheLiveConfigToTheLock(t *testing.T) {
|
||||
originalLogLevel := zerolog.GlobalLevel()
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
t.Cleanup(func() {
|
||||
zerolog.SetGlobalLevel(originalLogLevel)
|
||||
})
|
||||
|
||||
m := newTestManager(t)
|
||||
|
||||
host := models.Host{
|
||||
ID: "eval-lock",
|
||||
Hostname: "eval-lock",
|
||||
Status: "online",
|
||||
Disks: []models.Disk{{Mountpoint: "/", Device: "/dev/sda1", Usage: 50, Total: 100, Used: 50, Free: 50}},
|
||||
}
|
||||
hostDiskOverrideID, _ := hostDiskResourceID(host, host.Disks[0])
|
||||
vm := models.VM{
|
||||
ID: BuildGuestKey("pve1", "node1", 101),
|
||||
VMID: 101,
|
||||
Name: "eval-lock",
|
||||
Node: "node1",
|
||||
Instance: "pve1",
|
||||
Status: "running",
|
||||
Disks: []models.Disk{{Mountpoint: "/boot", Device: "/dev/vda1", Usage: 50, Total: 100, Used: 50, Free: 50}},
|
||||
}
|
||||
guestDiskOverrideID := "guest-disk:guest:pve1:101/disk:boot-dev-vda1"
|
||||
dockerHost := models.DockerHost{
|
||||
ID: "eval-lock-docker",
|
||||
Hostname: "eval-lock-docker",
|
||||
DisplayName: "eval-lock-docker",
|
||||
Containers: []models.DockerContainer{{
|
||||
ID: "abcdef123456",
|
||||
Name: "web",
|
||||
State: "running",
|
||||
CPUPercent: 10,
|
||||
MemoryPercent: 10,
|
||||
MemoryUsage: 100 << 20,
|
||||
MemoryLimit: 1 << 30,
|
||||
}},
|
||||
}
|
||||
|
||||
// A disk override saved with only a trigger leaves Clear for evaluation
|
||||
// to default.
|
||||
withTriggerOnlyDiskOverrides := func(cfg AlertConfig) AlertConfig {
|
||||
cfg.Enabled = true
|
||||
cfg.ActivationState = ActivationActive
|
||||
cfg.Overrides = map[string]ThresholdConfig{
|
||||
hostDiskOverrideID: {Disk: &HysteresisThreshold{Trigger: 90}},
|
||||
guestDiskOverrideID: {Disk: &HysteresisThreshold{Trigger: 90}},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
m.UpdateConfig(withTriggerOnlyDiskOverrides(m.GetConfig()))
|
||||
|
||||
m.CheckHost(host)
|
||||
m.CheckGuest(vm, "pve1")
|
||||
live := m.GetConfig()
|
||||
for _, id := range []string{hostDiskOverrideID, guestDiskOverrideID} {
|
||||
if got := live.Overrides[id].Disk.Clear; got != 0 {
|
||||
t.Fatalf("evaluating %s wrote clear %v into the live override", id, got)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
// Resetting the overrides gives evaluation a Clear to fill again.
|
||||
m.UpdateConfig(withTriggerOnlyDiskOverrides(m.GetConfig()))
|
||||
m.UpdateConfig(m.GetConfig())
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
_ = m.GetConfig()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
m.CheckHost(host)
|
||||
m.CheckGuest(vm, "pve1")
|
||||
m.CheckDockerHost(dockerHost)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestCheckDockerHostIgnoresContainersByPrefix(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
|
||||
@@ -6254,6 +6480,22 @@ func TestApplyRelaxedGuestThresholds(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("off thresholds stay off", func(t *testing.T) {
|
||||
cfg := ThresholdConfig{
|
||||
CPU: &HysteresisThreshold{Trigger: 0, Clear: 0},
|
||||
Memory: &HysteresisThreshold{Trigger: -1, Clear: -1},
|
||||
Disk: &HysteresisThreshold{Trigger: 0, Clear: 0},
|
||||
}
|
||||
|
||||
result := applyRelaxedGuestThresholds(cfg)
|
||||
|
||||
for name, th := range map[string]*HysteresisThreshold{"CPU": result.CPU, "Memory": result.Memory, "Disk": result.Disk} {
|
||||
if th == nil || th.Trigger > 0 {
|
||||
t.Errorf("%s = %+v, want it to stay off", name, th)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("original config unchanged", func(t *testing.T) {
|
||||
// t.Parallel()
|
||||
original := ThresholdConfig{
|
||||
@@ -12300,6 +12542,65 @@ func TestCheckEscalations(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// A config save re-judges open TrueNAS disk temperature alerts against the
|
||||
// threshold the next evaluation applies to each disk's type.
|
||||
func TestTrueNASDiskTemperatureAlertsReevaluatePerDiskType(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
cfg := unifiedEvalBaseConfig()
|
||||
cfg.TrueNASDiskDefaults = ThresholdConfig{}
|
||||
configureUnifiedEvalManager(t, m, cfg)
|
||||
|
||||
nvme := trueNASTemperatureDisk("nvme0n1", "nvme", 72)
|
||||
sata := trueNASTemperatureDisk("sda", "sata", 57)
|
||||
checkTrueNASTemperatureDisk(t, m, nvme)
|
||||
checkTrueNASTemperatureDisk(t, m, sata)
|
||||
for _, disk := range []unifiedresources.Resource{nvme, sata} {
|
||||
if _, firing := trueNASDiskTemperatureAlert(t, m, disk); !firing {
|
||||
t.Fatalf("%s did not fire before the save: %v", disk.ID, alertKeys(m))
|
||||
}
|
||||
}
|
||||
|
||||
// Raising only the NVMe trigger resolves the NVMe alert and leaves the
|
||||
// SATA alert, still over its own trigger, open.
|
||||
m.mu.Lock()
|
||||
m.config.DiskTempByType["nvme"] = HysteresisThreshold{Trigger: 75, Clear: 70}
|
||||
m.reevaluateActiveAlertsLocked()
|
||||
m.mu.Unlock()
|
||||
if _, firing := trueNASDiskTemperatureAlert(t, m, nvme); firing {
|
||||
t.Fatalf("NVMe alert at 72C stayed open under a 75C NVMe trigger")
|
||||
}
|
||||
if _, firing := trueNASDiskTemperatureAlert(t, m, sata); !firing {
|
||||
t.Fatalf("SATA alert at 57C resolved under its unchanged 55C trigger")
|
||||
}
|
||||
}
|
||||
|
||||
// An alert raised before it recorded its disk type is held to the lowest
|
||||
// per-type trigger, so a save never resolves an alert the next evaluation
|
||||
// raises again.
|
||||
func TestTrueNASDiskTemperatureAlertWithoutDiskTypeHeldAtLowestTrigger(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
cfg := unifiedEvalBaseConfig()
|
||||
cfg.TrueNASDiskDefaults = ThresholdConfig{}
|
||||
cfg.AgentDefaults.DiskTemperature = &HysteresisThreshold{Trigger: 70, Clear: 65}
|
||||
cfg.DiskTempByType = map[string]HysteresisThreshold{"nvme": {Trigger: 60, Clear: 55}}
|
||||
configureUnifiedEvalManager(t, m, cfg)
|
||||
|
||||
nvme := trueNASTemperatureDisk("nvme0n1", "nvme", 66)
|
||||
checkTrueNASTemperatureDisk(t, m, nvme)
|
||||
alert, firing := trueNASDiskTemperatureAlert(t, m, nvme)
|
||||
if !firing {
|
||||
t.Fatalf("NVMe at 66C did not fire under a 60C NVMe trigger: %v", alertKeys(m))
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
delete(alert.Metadata, "diskType")
|
||||
m.reevaluateActiveAlertsLocked()
|
||||
m.mu.Unlock()
|
||||
if _, firing := trueNASDiskTemperatureAlert(t, m, nvme); !firing {
|
||||
t.Fatalf("an alert without a disk type was resolved by the 70C agent default although its NVMe trigger is 60C")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupAlertsForNodes(t *testing.T) {
|
||||
// t.Parallel()
|
||||
|
||||
@@ -22190,14 +22491,20 @@ func TestStaleCleanupKeepsThresholdAlertStillBeingEvaluated(t *testing.T) {
|
||||
|
||||
// Resource history owns each PVE disk alert row by the hardware identity the
|
||||
// alert records, and reads by the path reference find those rows by alert
|
||||
// identifier. Both must match what CheckDiskHealth produces.
|
||||
// identifier. Both must match what CheckDiskHealth and
|
||||
// CheckProxmoxDiskTemperature produce.
|
||||
func TestCheckDiskHealthAlertsCarryHistoryOwnershipIdentity(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
m.ClearActiveAlerts()
|
||||
m.mu.Lock()
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.config.MetricTimeThresholds = nil
|
||||
m.mu.Unlock()
|
||||
disk := proxmox.Disk{DevPath: "/dev/sdb", Model: "Crucial MX500", Serial: "2117E59AB123", WWN: "0x5002538f12345678",
|
||||
Type: "ssd", Health: "FAILED", Wearout: 3}
|
||||
for i := 0; i < 3; i++ {
|
||||
m.CheckDiskHealth("lab", "pve1", disk)
|
||||
m.CheckProxmoxDiskTemperature("lab", "pve1", ProxmoxDiskTemperatureReading{Disk: disk, Celsius: 80})
|
||||
}
|
||||
|
||||
ref := unifiedresources.ProxmoxPhysicalDiskAlertResourceID("lab", "pve1", disk.DevPath)
|
||||
@@ -22218,3 +22525,187 @@ func TestCheckDiskHealthAlertsCarryHistoryOwnershipIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func proxmoxTemperatureDisk(devPath, diskType string) proxmox.Disk {
|
||||
return proxmox.Disk{DevPath: devPath, Model: "Test Disk", Serial: "SER-" + devPath, Type: diskType, Health: "PASSED"}
|
||||
}
|
||||
|
||||
func proxmoxDiskTemperatureAlert(t *testing.T, m *Manager, devPath string) (*Alert, bool) {
|
||||
t.Helper()
|
||||
ref := unifiedresources.ProxmoxPhysicalDiskAlertResourceID("lab", "pve1", devPath)
|
||||
return testLookupActiveAlert(t, m, canonicalMetricStateID(ref, proxmoxDiskTemperatureMetric))
|
||||
}
|
||||
|
||||
func proxmoxDiskTemperatureResolution(m *Manager, devPath string) (*models.AlertResolution, bool) {
|
||||
ref := unifiedresources.ProxmoxPhysicalDiskAlertResourceID("lab", "pve1", devPath)
|
||||
id := canonicalMetricStateID(ref, proxmoxDiskTemperatureMetric)
|
||||
for _, resolved := range m.GetRecentlyResolved() {
|
||||
if resolved.ID == id {
|
||||
return resolved.Resolution, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TestProxmoxDiskTemperatureAlertsFollowDiskTemperaturePolicy pins Proxmox disk
|
||||
// temperature alerts to the disk temperature policy that judges an agent's
|
||||
// disks, TrueNAS disks and the Physical Disks verdict.
|
||||
func TestProxmoxDiskTemperatureAlertsFollowDiskTemperaturePolicy(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
|
||||
|
||||
check := func(devPath, diskType string, celsius int) {
|
||||
m.CheckProxmoxDiskTemperature("lab", "pve1", ProxmoxDiskTemperatureReading{Disk: proxmoxTemperatureDisk(devPath, diskType), Celsius: celsius})
|
||||
}
|
||||
check("/dev/nvme0n1", "nvme", 63)
|
||||
check("/dev/nvme1n1", "nvme", 72)
|
||||
check("/dev/sda", "sata", 56)
|
||||
check("/dev/sdb", "sas", 63)
|
||||
|
||||
if _, firing := proxmoxDiskTemperatureAlert(t, m, "/dev/nvme0n1"); firing {
|
||||
t.Fatalf("NVMe at 63C fired under its 70C trigger: %v", alertKeys(m))
|
||||
}
|
||||
if _, firing := proxmoxDiskTemperatureAlert(t, m, "/dev/sdb"); firing {
|
||||
t.Fatalf("SAS at 63C fired under its 65C trigger: %v", alertKeys(m))
|
||||
}
|
||||
for devPath, trigger := range map[string]float64{"/dev/nvme1n1": 70, "/dev/sda": 55} {
|
||||
alert, firing := proxmoxDiskTemperatureAlert(t, m, devPath)
|
||||
if !firing {
|
||||
t.Fatalf("%s did not fire: %v", devPath, alertKeys(m))
|
||||
}
|
||||
if alert.Threshold != trigger {
|
||||
t.Fatalf("%s trigger = %v, want %v", devPath, alert.Threshold, trigger)
|
||||
}
|
||||
if alert.Type != proxmoxDiskTemperatureMetric || alert.Metadata["resourceType"] != proxmoxDiskResourceType {
|
||||
t.Fatalf("%s alert type %q, resourceType %v", devPath, alert.Type, alert.Metadata["resourceType"])
|
||||
}
|
||||
if alert.Metadata["diskType"] == nil || alert.Metadata[unifiedresources.MetadataDiskSerial] != "SER-"+devPath {
|
||||
t.Fatalf("%s metadata = %v", devPath, alert.Metadata)
|
||||
}
|
||||
}
|
||||
if alert, _ := proxmoxDiskTemperatureAlert(t, m, "/dev/sda"); alert.Message != "Disk temperature at 56.0°C" {
|
||||
t.Fatalf("message = %q", alert.Message)
|
||||
}
|
||||
|
||||
// A raised NVMe trigger moves NVMe disks with it.
|
||||
m.mu.Lock()
|
||||
m.config.DiskTempByType["nvme"] = HysteresisThreshold{Trigger: 75, Clear: 70}
|
||||
m.mu.Unlock()
|
||||
check("/dev/nvme2n1", "nvme", 72)
|
||||
if _, firing := proxmoxDiskTemperatureAlert(t, m, "/dev/nvme2n1"); firing {
|
||||
t.Fatalf("NVMe at 72C fired under a 75C trigger")
|
||||
}
|
||||
}
|
||||
|
||||
// A disk the linked agent reports is the agent's: its open alert closes as
|
||||
// moved to the agent, and the disk never alerts twice. An excluded disk, or a
|
||||
// policy the agent Disk Temp default switched off, closes it as a plain clear.
|
||||
// A disk with no current reading keeps its alert.
|
||||
func TestProxmoxDiskTemperatureAlertOwnershipAndHolds(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
|
||||
disk := proxmoxTemperatureDisk("/dev/sda", "sata")
|
||||
read := func(reading ProxmoxDiskTemperatureReading) {
|
||||
reading.Disk = disk
|
||||
m.CheckProxmoxDiskTemperature("lab", "pve1", reading)
|
||||
}
|
||||
firing := func() bool {
|
||||
_, ok := proxmoxDiskTemperatureAlert(t, m, "/dev/sda")
|
||||
return ok
|
||||
}
|
||||
|
||||
read(ProxmoxDiskTemperatureReading{Celsius: 60})
|
||||
if !firing() {
|
||||
t.Fatalf("SATA at 60C did not fire: %v", alertKeys(m))
|
||||
}
|
||||
read(ProxmoxDiskTemperatureReading{Celsius: 0})
|
||||
if !firing() {
|
||||
t.Fatalf("a poll without a current reading resolved the alert")
|
||||
}
|
||||
|
||||
read(ProxmoxDiskTemperatureReading{Celsius: 60, AgentOwned: true})
|
||||
if firing() {
|
||||
t.Fatalf("an agent-owned disk kept its Proxmox temperature alert")
|
||||
}
|
||||
if resolution, ok := proxmoxDiskTemperatureResolution(m, "/dev/sda"); !ok || resolution == nil || resolution.Reason != string(AlertResolutionMovedToAgent) {
|
||||
t.Fatalf("handover resolution = %+v (resolved %v), want moved_to_agent", resolution, ok)
|
||||
}
|
||||
read(ProxmoxDiskTemperatureReading{Celsius: 80, AgentOwned: true})
|
||||
if firing() {
|
||||
t.Fatalf("an agent-owned disk raised a Proxmox temperature alert")
|
||||
}
|
||||
|
||||
read(ProxmoxDiskTemperatureReading{Celsius: 60})
|
||||
if !firing() {
|
||||
t.Fatalf("the disk did not alert again once the agent stopped listing it")
|
||||
}
|
||||
read(ProxmoxDiskTemperatureReading{Celsius: 60, Excluded: true})
|
||||
if firing() {
|
||||
t.Fatalf("an excluded disk kept its temperature alert")
|
||||
}
|
||||
|
||||
read(ProxmoxDiskTemperatureReading{Celsius: 60})
|
||||
m.mu.Lock()
|
||||
m.config.AgentDefaults.DiskTemperature = &HysteresisThreshold{Trigger: 0, Clear: 0}
|
||||
m.mu.Unlock()
|
||||
read(ProxmoxDiskTemperatureReading{Celsius: 60})
|
||||
if firing() {
|
||||
t.Fatalf("a switched-off agent Disk Temp default kept the Proxmox disk alert")
|
||||
}
|
||||
}
|
||||
|
||||
// A Proxmox disk temperature warning waits out the factory stability window for
|
||||
// a noisy gauge, as agent and TrueNAS disk temperature warnings do; a critical
|
||||
// reading bypasses it.
|
||||
func TestProxmoxDiskTemperatureWarningWaitsOutStabilityWindow(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
cfg := unifiedEvalBaseConfig()
|
||||
m.UpdateConfig(cfg)
|
||||
if delay := m.GetConfig().TimeThresholds[proxmoxDiskResourceType]; delay != 5 {
|
||||
t.Fatalf("proxmox-disk delay = %d, want the 5s factory default", delay)
|
||||
}
|
||||
m.mu.RLock()
|
||||
grace := m.getTimeThreshold("lab:pve1:disk:dev-sda", proxmoxDiskResourceType, proxmoxDiskTemperatureMetric)
|
||||
m.mu.RUnlock()
|
||||
if grace != defaultNoisyGaugeStabilitySeconds {
|
||||
t.Fatalf("Proxmox disk temperature delay = %ds, want the %ds stability window", grace, defaultNoisyGaugeStabilitySeconds)
|
||||
}
|
||||
|
||||
m.CheckProxmoxDiskTemperature("lab", "pve1", ProxmoxDiskTemperatureReading{Disk: proxmoxTemperatureDisk("/dev/sda", "sata"), Celsius: 57})
|
||||
if _, firing := proxmoxDiskTemperatureAlert(t, m, "/dev/sda"); firing {
|
||||
t.Fatalf("a 57C SATA warning fired before its stability window")
|
||||
}
|
||||
m.CheckProxmoxDiskTemperature("lab", "pve1", ProxmoxDiskTemperatureReading{Disk: proxmoxTemperatureDisk("/dev/sdb", "sata"), Celsius: 70})
|
||||
if _, firing := proxmoxDiskTemperatureAlert(t, m, "/dev/sdb"); !firing {
|
||||
t.Fatalf("a 70C SATA critical reading waited out the stability window")
|
||||
}
|
||||
}
|
||||
|
||||
// A config save re-judges an open Proxmox disk temperature alert against its
|
||||
// disk type's trigger, as the next poll does; one without a recorded disk type
|
||||
// is held to the lowest per-type trigger.
|
||||
func TestProxmoxDiskTemperatureAlertsReevaluatePerDiskType(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
cfg := unifiedEvalBaseConfig()
|
||||
cfg.AgentDefaults.DiskTemperature = &HysteresisThreshold{Trigger: 70, Clear: 65}
|
||||
configureUnifiedEvalManager(t, m, cfg)
|
||||
m.CheckProxmoxDiskTemperature("lab", "pve1", ProxmoxDiskTemperatureReading{Disk: proxmoxTemperatureDisk("/dev/nvme0n1", "nvme"), Celsius: 72})
|
||||
m.CheckProxmoxDiskTemperature("lab", "pve1", ProxmoxDiskTemperatureReading{Disk: proxmoxTemperatureDisk("/dev/sda", "sata"), Celsius: 57})
|
||||
sata, ok := proxmoxDiskTemperatureAlert(t, m, "/dev/sda")
|
||||
if !ok {
|
||||
t.Fatalf("SATA at 57C did not fire: %v", alertKeys(m))
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.config.DiskTempByType["nvme"] = HysteresisThreshold{Trigger: 75, Clear: 70}
|
||||
delete(sata.Metadata, "diskType")
|
||||
m.reevaluateActiveAlertsLocked()
|
||||
m.mu.Unlock()
|
||||
|
||||
if _, firing := proxmoxDiskTemperatureAlert(t, m, "/dev/nvme0n1"); firing {
|
||||
t.Fatalf("NVMe alert at 72C survived a save raising its trigger to 75C")
|
||||
}
|
||||
if _, firing := proxmoxDiskTemperatureAlert(t, m, "/dev/sda"); !firing {
|
||||
t.Fatalf("a SATA alert without a disk type was resolved by the 70C agent default although its trigger is 55C")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,7 +794,6 @@ func TestBranchCovNormalizeTrueNASDefaults(t *testing.T) {
|
||||
{"DiskWrite", t1.DiskWrite, alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0}},
|
||||
{"NetworkIn", t1.NetworkIn, alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0}},
|
||||
{"NetworkOut", t1.NetworkOut, alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0}},
|
||||
{"Disk.Temperature", cfg.TrueNASDiskDefaults.Temperature, alertconfig.HysteresisThreshold{Trigger: 55, Clear: 50}},
|
||||
} {
|
||||
if f.got == nil {
|
||||
t.Fatalf("%s is nil, want non-nil", f.name)
|
||||
@@ -803,6 +802,11 @@ func TestBranchCovNormalizeTrueNASDefaults(t *testing.T) {
|
||||
t.Fatalf("%s = %+v, want %+v", f.name, *f.got, f.want)
|
||||
}
|
||||
}
|
||||
// TrueNAS disks follow the disk temperature policy until the user
|
||||
// saves a TrueNAS-wide value.
|
||||
if cfg.TrueNASDiskDefaults.Temperature != nil {
|
||||
t.Fatalf("Disk.Temperature = %+v, want unset", *cfg.TrueNASDiskDefaults.Temperature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("derivation and operator-enabled IO metric via CPU/DiskRead", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Clone returns a deep copy of the configuration that shares no maps, slices
|
||||
// or pointers with c, so the copy can be changed, normalized or encoded
|
||||
// without touching c. Nil and empty maps and slices keep their distinction.
|
||||
// Custom rule filter values are copied as the JSON values the API and
|
||||
// alerts.json decode them to; other Go values stored there stay shared.
|
||||
//
|
||||
// The alert manager hands out its live configuration only through Clone;
|
||||
// callers persist, encode and edit what it returns outside the manager lock.
|
||||
func (c AlertConfig) Clone() AlertConfig {
|
||||
clone := c
|
||||
clone.ActivationTime = clonePointer(c.ActivationTime)
|
||||
clone.GuestDefaults = c.GuestDefaults.Clone()
|
||||
clone.NodeDefaults = c.NodeDefaults.Clone()
|
||||
clone.AgentDefaults = c.AgentDefaults.Clone()
|
||||
clone.DiskFillByType = maps.Clone(c.DiskFillByType)
|
||||
clone.DiskTempByType = maps.Clone(c.DiskTempByType)
|
||||
clone.DockerIgnoredContainerPrefixes = slices.Clone(c.DockerIgnoredContainerPrefixes)
|
||||
clone.IgnoredGuestPrefixes = slices.Clone(c.IgnoredGuestPrefixes)
|
||||
clone.GuestTagWhitelist = slices.Clone(c.GuestTagWhitelist)
|
||||
clone.GuestTagBlacklist = slices.Clone(c.GuestTagBlacklist)
|
||||
clone.PBSDefaults = c.PBSDefaults.Clone()
|
||||
clone.KubernetesDefaults = c.KubernetesDefaults.Clone()
|
||||
clone.TrueNASDefaults = c.TrueNASDefaults.Clone()
|
||||
clone.TrueNASDiskDefaults = c.TrueNASDiskDefaults.Clone()
|
||||
clone.VMwareDefaults = c.VMwareDefaults.Clone()
|
||||
clone.BackupDefaults = c.BackupDefaults.clone()
|
||||
if c.Overrides != nil {
|
||||
clone.Overrides = make(map[string]ThresholdConfig, len(c.Overrides))
|
||||
for id, override := range c.Overrides {
|
||||
clone.Overrides[id] = override.Clone()
|
||||
}
|
||||
}
|
||||
if c.CustomRules != nil {
|
||||
clone.CustomRules = make([]CustomAlertRule, len(c.CustomRules))
|
||||
for i, rule := range c.CustomRules {
|
||||
clone.CustomRules[i] = rule.clone()
|
||||
}
|
||||
}
|
||||
clone.Schedule.QuietHours.Days = maps.Clone(c.Schedule.QuietHours.Days)
|
||||
if c.Schedule.Escalation.Levels != nil {
|
||||
clone.Schedule.Escalation.Levels = make([]EscalationLevel, len(c.Schedule.Escalation.Levels))
|
||||
for i, level := range c.Schedule.Escalation.Levels {
|
||||
level.DestinationIDs = slices.Clone(level.DestinationIDs)
|
||||
clone.Schedule.Escalation.Levels[i] = level
|
||||
}
|
||||
}
|
||||
clone.TimeThresholds = maps.Clone(c.TimeThresholds)
|
||||
clone.MetricTimeThresholds = cloneNestedIntMap(c.MetricTimeThresholds)
|
||||
clone.MetricEvaluationWindows = cloneNestedIntMap(c.MetricEvaluationWindows)
|
||||
return clone
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the thresholds that shares no pointers with t.
|
||||
func (t ThresholdConfig) Clone() ThresholdConfig {
|
||||
clone := t
|
||||
clone.CPU = clonePointer(t.CPU)
|
||||
clone.Memory = clonePointer(t.Memory)
|
||||
clone.Disk = clonePointer(t.Disk)
|
||||
clone.DiskRead = clonePointer(t.DiskRead)
|
||||
clone.DiskWrite = clonePointer(t.DiskWrite)
|
||||
clone.NetworkIn = clonePointer(t.NetworkIn)
|
||||
clone.NetworkOut = clonePointer(t.NetworkOut)
|
||||
clone.Usage = clonePointer(t.Usage)
|
||||
clone.Temperature = clonePointer(t.Temperature)
|
||||
clone.DiskTemperature = clonePointer(t.DiskTemperature)
|
||||
clone.SMARTHealthFailure = clonePointer(t.SMARTHealthFailure)
|
||||
clone.SMARTReallocated = clonePointer(t.SMARTReallocated)
|
||||
clone.SMARTPending = clonePointer(t.SMARTPending)
|
||||
clone.SMARTUncorrectable = clonePointer(t.SMARTUncorrectable)
|
||||
clone.SMARTMediaErrors = clonePointer(t.SMARTMediaErrors)
|
||||
clone.SMARTCRCErrorDelta = clonePointer(t.SMARTCRCErrorDelta)
|
||||
clone.SMARTLifeWarning = clonePointer(t.SMARTLifeWarning)
|
||||
clone.SMARTLifeCritical = clonePointer(t.SMARTLifeCritical)
|
||||
clone.SMARTSpareWarning = clonePointer(t.SMARTSpareWarning)
|
||||
clone.SMARTSpareCritical = clonePointer(t.SMARTSpareCritical)
|
||||
if t.Backup != nil {
|
||||
backup := t.Backup.clone()
|
||||
clone.Backup = &backup
|
||||
}
|
||||
clone.Snapshot = clonePointer(t.Snapshot)
|
||||
clone.Note = clonePointer(t.Note)
|
||||
return clone
|
||||
}
|
||||
|
||||
func (b BackupAlertConfig) clone() BackupAlertConfig {
|
||||
clone := b
|
||||
clone.AlertOrphaned = clonePointer(b.AlertOrphaned)
|
||||
clone.IgnoreVMIDs = slices.Clone(b.IgnoreVMIDs)
|
||||
return clone
|
||||
}
|
||||
|
||||
func (r CustomAlertRule) clone() CustomAlertRule {
|
||||
clone := r
|
||||
if r.FilterConditions.Filters != nil {
|
||||
clone.FilterConditions.Filters = make([]FilterCondition, len(r.FilterConditions.Filters))
|
||||
for i, filter := range r.FilterConditions.Filters {
|
||||
filter.Value = cloneJSONValue(filter.Value)
|
||||
clone.FilterConditions.Filters[i] = filter
|
||||
}
|
||||
}
|
||||
clone.Thresholds = r.Thresholds.Clone()
|
||||
if r.Notifications.Email != nil {
|
||||
email := *r.Notifications.Email
|
||||
email.Recipients = slices.Clone(email.Recipients)
|
||||
clone.Notifications.Email = &email
|
||||
}
|
||||
clone.Notifications.Webhook = clonePointer(r.Notifications.Webhook)
|
||||
return clone
|
||||
}
|
||||
|
||||
// clonePointer copies the value p points at. Only use it for types that hold
|
||||
// no maps, slices or pointers of their own.
|
||||
func clonePointer[T any](p *T) *T {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *p
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cloneNestedIntMap(input map[string]map[string]int) map[string]map[string]int {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]map[string]int, len(input))
|
||||
for key, inner := range input {
|
||||
clone[key] = maps.Clone(inner)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// cloneJSONValue copies a filter value as encoding/json decodes one: arrays
|
||||
// and objects are copied, scalars are values already.
|
||||
func cloneJSONValue(value interface{}) interface{} {
|
||||
switch typed := value.(type) {
|
||||
case []interface{}:
|
||||
if typed == nil {
|
||||
return typed
|
||||
}
|
||||
clone := make([]interface{}, len(typed))
|
||||
for i, item := range typed {
|
||||
clone[i] = cloneJSONValue(item)
|
||||
}
|
||||
return clone
|
||||
case map[string]interface{}:
|
||||
if typed == nil {
|
||||
return typed
|
||||
}
|
||||
clone := make(map[string]interface{}, len(typed))
|
||||
for key, item := range typed {
|
||||
clone[key] = cloneJSONValue(item)
|
||||
}
|
||||
return clone
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user