From 231471be93fb3cd76a48e78598ae43e6deebe575 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:01:03 +0100 Subject: [PATCH 01/26] Require POST for Assistant session mutation routes The session router dispatches /api/ai/sessions/{id}/abort, summarize, fork, undo and redo by path alone, and none of those handlers checked the method, so a GET or HEAD cancelled a running turn, compacted or rewound a transcript, or persisted a fork. The demo-mode guard admits every GET and HEAD as a read, so a public-demo visitor could mutate sessions despite the read-only mode. The CSRF check also skips GET and HEAD, and SameSite=Lax session cookies ride cross-site top-level GET navigations, so a link carrying a session ID could do the same to a signed-in user. Each mutating handler now admits only POST and answers anything else with 405 and Allow: POST before resolving the Assistant service. Steer already refused non-POST but sent no Allow header; it now shares the same guard. The checks sit in the handlers, where steer, chat and the approval handlers already own their method contracts. messages (GET) and the unsupported diff/revert/unrevert routes are unchanged. Every caller (aiChat.ts, the mobile app and its OTA abort probe) already sends POST. --- .../v6/internal/subsystems/agent-lifecycle.md | 6 + .../v6/internal/subsystems/ai-runtime.md | 6 + .../v6/internal/subsystems/api-contracts.md | 14 ++ .../internal/subsystems/storage-recovery.md | 6 + internal/api/ai_handler.go | 32 ++- internal/api/ai_handlers_more_test.go | 204 ++++++++++++++++++ 6 files changed, 266 insertions(+), 2 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b8ed3f012b..2705524f45 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -4703,6 +4703,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`, diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 48b93c4c1a..31337fb6a3 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -3556,6 +3556,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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 978b841439..d7a3f245b5 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -2088,6 +2088,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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 53b6bd3c34..7f2506f6ab 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1404,6 +1404,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 diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index aff2273af3..d494e86374 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -2971,8 +2971,25 @@ func (h *AIHandler) HandleMessages(w http.ResponseWriter, r *http.Request, sessi json.NewEncoder(w).Encode(messages) } +// requireAssistantSessionMutationMethod admits only POST to an Assistant +// session mutation (abort, summarize, fork, undo, redo, steer). The session +// router dispatches these sub-resources by path alone, and GET/HEAD pass both +// the demo-mode read-only guard and the CSRF check while SameSite=Lax cookies +// ride cross-site top-level GET navigations, so each mutation owns its method. +func requireAssistantSessionMutationMethod(w http.ResponseWriter, r *http.Request) bool { + if r.Method == http.MethodPost { + return true + } + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return false +} + // HandleAbort handles POST /api/ai/sessions/{id}/abort func (h *AIHandler) HandleAbort(w http.ResponseWriter, r *http.Request, sessionID string) { + if !requireAssistantSessionMutationMethod(w, r) { + return + } ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) @@ -3187,6 +3204,9 @@ func (h *AIHandler) recordFirstPartyWorkflowPromptActivity(ctx context.Context, // HandleSummarize handles POST /api/ai/sessions/{id}/summarize // Compresses context when nearing model limits func (h *AIHandler) HandleSummarize(w http.ResponseWriter, r *http.Request, sessionID string) { + if !requireAssistantSessionMutationMethod(w, r) { + return + } ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) @@ -3218,6 +3238,9 @@ func (h *AIHandler) HandleDiff(w http.ResponseWriter, r *http.Request, sessionID // HandleFork handles POST /api/ai/sessions/{id}/fork // Creates a branch point in the conversation func (h *AIHandler) HandleFork(w http.ResponseWriter, r *http.Request, sessionID string) { + if !requireAssistantSessionMutationMethod(w, r) { + return + } ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) @@ -3244,6 +3267,9 @@ func (h *AIHandler) HandleFork(w http.ResponseWriter, r *http.Request, sessionID // The body is optional; retry/regenerate sends SessionTurnUndoOptions with the // prompt being re-run so a stale retry can never remove a different turn. func (h *AIHandler) HandleUndoLastTurn(w http.ResponseWriter, r *http.Request, sessionID string) { + if !requireAssistantSessionMutationMethod(w, r) { + return + } ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) @@ -3276,6 +3302,9 @@ func (h *AIHandler) HandleUndoLastTurn(w http.ResponseWriter, r *http.Request, s // HandleRedoLastTurn handles POST /api/ai/sessions/{id}/redo. func (h *AIHandler) HandleRedoLastTurn(w http.ResponseWriter, r *http.Request, sessionID string) { + if !requireAssistantSessionMutationMethod(w, r) { + return + } ctx := r.Context() if !h.IsRunning(ctx) { http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) @@ -3303,8 +3332,7 @@ func (h *AIHandler) HandleRedoLastTurn(w http.ResponseWriter, r *http.Request, s // loop; accepted=false with a reason is a normal outcome and the client // falls back to the ordinary follow-up queue. func (h *AIHandler) HandleSteerSession(w http.ResponseWriter, r *http.Request, sessionID string) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + if !requireAssistantSessionMutationMethod(w, r) { return } ctx := r.Context() diff --git a/internal/api/ai_handlers_more_test.go b/internal/api/ai_handlers_more_test.go index 52339f4893..3e81a3e649 100644 --- a/internal/api/ai_handlers_more_test.go +++ b/internal/api/ai_handlers_more_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "reflect" "strings" + "sync" "testing" "time" "unsafe" @@ -14,6 +15,7 @@ import ( "github.com/gorilla/websocket" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" @@ -22,6 +24,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) type stubMetadataProvider struct{} @@ -795,3 +798,204 @@ func TestHandleExportAICostHistoryCarriesPromptCacheBuckets(t *testing.T) { t.Fatalf("csv row missing cache buckets or cache-rate pricing: %s", lines[1]) } } + +// sessionMutationRecordingAIService records every Assistant session mutation +// and every running check that reaches the service, so a rejected request can +// be proven to stop before the handler touches the service at all. +type sessionMutationRecordingAIService struct { + capturingAIService + mu sync.Mutex + mutations []string + runningCalls int +} + +func newSessionMutationRecordingAIService() *sessionMutationRecordingAIService { + return &sessionMutationRecordingAIService{capturingAIService: capturingAIService{running: true}} +} + +func (s *sessionMutationRecordingAIService) record(op, sessionID string) { + s.mu.Lock() + defer s.mu.Unlock() + s.mutations = append(s.mutations, op+":"+sessionID) +} + +func (s *sessionMutationRecordingAIService) recorded() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.mutations...) +} + +func (s *sessionMutationRecordingAIService) IsRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + s.runningCalls++ + return s.capturingAIService.running +} + +func (s *sessionMutationRecordingAIService) runningChecks() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.runningCalls +} + +func (s *sessionMutationRecordingAIService) AbortSession(ctx context.Context, sessionID string) error { + s.record("abort", sessionID) + return nil +} + +func (s *sessionMutationRecordingAIService) SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) { + s.record("summarize", sessionID) + return map[string]interface{}{"success": true}, nil +} + +func (s *sessionMutationRecordingAIService) ForkSession(ctx context.Context, sessionID string) (*chat.Session, error) { + s.record("fork", sessionID) + return &chat.Session{ID: sessionID + "-fork"}, nil +} + +func (s *sessionMutationRecordingAIService) UndoLastTurn(ctx context.Context, sessionID string, opts chat.SessionTurnUndoOptions) (*chat.SessionTurnUndoResult, error) { + s.record("undo", sessionID) + return &chat.SessionTurnUndoResult{Success: true, SessionID: sessionID}, nil +} + +func (s *sessionMutationRecordingAIService) RedoLastTurn(ctx context.Context, sessionID string) (*chat.SessionTurnRedoResult, error) { + s.record("redo", sessionID) + return &chat.SessionTurnRedoResult{Success: true, SessionID: sessionID}, nil +} + +func (s *sessionMutationRecordingAIService) SteerSession(ctx context.Context, sessionID string, req chat.SessionSteerRequest) (*chat.SessionSteerResult, error) { + s.record("steer", sessionID) + return &chat.SessionSteerResult{Accepted: true, SessionID: sessionID}, nil +} + +var assistantSessionMutationSubresources = []string{"abort", "summarize", "fork", "undo", "redo", "steer"} + +func TestAssistantSessionMutationsRequirePost(t *testing.T) { + for _, sub := range assistantSessionMutationSubresources { + for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodDelete} { + t.Run(sub+"/"+method, func(t *testing.T) { + svc := newSessionMutationRecordingAIService() + handler := &AIHandler{} + setUnexportedField(t, handler, "defaultService", svc) + router := &Router{aiHandler: handler} + + req := httptest.NewRequest(method, "/api/ai/sessions/session-1/"+sub, nil) + rec := httptest.NewRecorder() + router.routeAISessions(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("%s %s status = %d, want %d", method, sub, rec.Code, http.StatusMethodNotAllowed) + } + if allow := rec.Header().Get("Allow"); allow != http.MethodPost { + t.Fatalf("%s %s Allow = %q, want %q", method, sub, allow, http.MethodPost) + } + if got := svc.recorded(); len(got) != 0 { + t.Fatalf("%s %s reached the service: %v", method, sub, got) + } + if checks := svc.runningChecks(); checks != 0 { + t.Fatalf("%s %s resolved the service (%d running checks) before rejecting the method", method, sub, checks) + } + }) + } + + t.Run(sub+"/POST", func(t *testing.T) { + svc := newSessionMutationRecordingAIService() + handler := &AIHandler{} + setUnexportedField(t, handler, "defaultService", svc) + router := &Router{aiHandler: handler} + + body := "" + if sub == "steer" { + body = `{"prompt":"check the backup job too"}` + } + req := httptest.NewRequest(http.MethodPost, "/api/ai/sessions/session-1/"+sub, strings.NewReader(body)) + rec := httptest.NewRecorder() + router.routeAISessions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST %s status = %d, want %d: %s", sub, rec.Code, http.StatusOK, rec.Body.String()) + } + if got := svc.recorded(); !reflect.DeepEqual(got, []string{sub + ":session-1"}) { + t.Fatalf("POST %s mutations = %v, want [%s:session-1]", sub, got, sub) + } + }) + } +} + +func TestAssistantSessionReadSubresourcesKeepTheirMethods(t *testing.T) { + svc := newSessionMutationRecordingAIService() + handler := &AIHandler{} + setUnexportedField(t, handler, "defaultService", svc) + router := &Router{aiHandler: handler} + + messagesReq := httptest.NewRequest(http.MethodGet, "/api/ai/sessions/session-1/messages", nil) + messagesRec := httptest.NewRecorder() + router.routeAISessions(messagesRec, messagesReq) + if messagesRec.Code != http.StatusOK { + t.Fatalf("GET messages status = %d, want %d: %s", messagesRec.Code, http.StatusOK, messagesRec.Body.String()) + } + + diffReq := httptest.NewRequest(http.MethodGet, "/api/ai/sessions/session-1/diff", nil) + diffRec := httptest.NewRecorder() + router.routeAISessions(diffRec, diffReq) + if diffRec.Code != http.StatusNotImplemented { + t.Fatalf("GET diff status = %d, want %d: %s", diffRec.Code, http.StatusNotImplemented, diffRec.Body.String()) + } + + if got := svc.recorded(); len(got) != 0 { + t.Fatalf("read sub-resources mutated the session: %v", got) + } +} + +// TestDemoModeRouterRejectsAssistantSessionMutationsOverSafeMethods drives the +// production handler chain: the demo-mode guard admits GET and HEAD as reads, +// so the session mutation itself must refuse them. +func TestDemoModeRouterRejectsAssistantSessionMutationsOverSafeMethods(t *testing.T) { + tempDir := t.TempDir() + hashed, err := internalauth.HashPassword("Password!1") + if err != nil { + t.Fatalf("hash password: %v", err) + } + cfg := &config.Config{ + DataPath: tempDir, + ConfigPath: tempDir, + AuthUser: "admin", + AuthPass: hashed, + DemoMode: true, + } + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + t.Cleanup(router.shutdownBackgroundWorkers) + svc := newSessionMutationRecordingAIService() + setUnexportedField(t, router.aiHandler, "defaultService", svc) + + serve := func(method, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, nil) + req.SetBasicAuth("admin", "Password!1") + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + return rec + } + + for _, sub := range assistantSessionMutationSubresources { + for _, method := range []string{http.MethodGet, http.MethodHead} { + rec := serve(method, "/api/ai/sessions/session-1/"+sub) + if rec.Header().Get("X-Demo-Mode") != "true" { + t.Fatalf("%s %s did not pass through the demo-mode guard", method, sub) + } + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("demo %s %s status = %d, want %d: %s", method, sub, rec.Code, http.StatusMethodNotAllowed, rec.Body.String()) + } + } + if rec := serve(http.MethodPost, "/api/ai/sessions/session-1/"+sub); rec.Code != http.StatusForbidden { + t.Fatalf("demo POST %s status = %d, want %d: %s", sub, rec.Code, http.StatusForbidden, rec.Body.String()) + } + } + if got := svc.recorded(); len(got) != 0 { + t.Fatalf("demo-mode requests mutated Assistant sessions: %v", got) + } + + if rec := serve(http.MethodGet, "/api/ai/sessions/session-1/messages"); rec.Code != http.StatusOK { + t.Fatalf("demo GET messages status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } +} From 8e1a564d7e229bda51c021c7e6bde920e0ca0596 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:11:44 +0100 Subject: [PATCH 02/26] Judge TrueNAS disk temperature alerts by disk type Every disk heat judge follows the per-type disk temperature policy (NVMe 70C, SAS 65C, SATA 55C, under the agent Disk Temp default) except TrueNAS disk temperature alerts, which used a flat 55/50 TrueNAS Disks default. A TrueNAS NVMe at 60C raised a warning while the TrueNAS storage table, Physical Disks and Patrol called it fine, the false NVMe alert #1388 removed for agent disks. TrueNAS disks now resolve their trigger per disk type unless the user saved a TrueNAS-wide value, which still applies to every TrueNAS disk; a per-disk override beats both. Saved configs keep a chosen value: a stored 55/50 is dropped only from configs written before the new truenasDiskTemperatureByType marker, because the thresholds page wrote that factory value back on every save. Alerts record their disk type so a config save re-judges them per type, and a retained reading no longer raises one. The TrueNAS Disks Global Defaults cell reads "By type" while unset (Off when the agent default switches the policy off), each disk row shows its type's trigger, and Off is written as trigger 0. The shared alert table now reads its props reactively, so the Custom badge and reset control follow a config that loads after the table mounts. The TrueNAS disk drawer tones a current reading by the same per-type trigger instead of a fixed 55C. --- .../v6/internal/subsystems/alerts.md | 44 ++++- .../subsystems/frontend-primitives.md | 12 ++ .../internal/subsystems/unified-resources.md | 7 + .../26f085dd13bc21bf.json | 69 ++++++++ .../Alerts/AlertResourceTableDesktop.tsx | 38 ++-- .../Alerts/AlertResourceTableMobile.tsx | 25 ++- .../components/Alerts/ResourceTable.test.tsx | 139 +++++++++++++++ .../src/components/Alerts/ResourceTable.tsx | 23 ++- .../ThresholdsTableAgentsResourcesSection.tsx | 6 +- .../Alerts/ThresholdsTableTrueNASTab.tsx | 53 +++++- .../Alerts/alertResourceTableModel.ts | 43 +++++ ...DetailDrawerTrueNASModel.coverage2.test.ts | 35 +++- .../resourceDetailDrawerTrueNASModel.test.ts | 3 +- .../resourceDetailDrawerTrueNASModel.ts | 31 +++- .../useResourceDetailDrawerDerivedState.ts | 6 +- .../alertsConfigurationModel.snapshot.test.ts | 51 +++++- .../alerts/alertsConfigurationModel.ts | 20 ++- .../__tests__/trueNASDiskTemperature.test.ts | 44 +++++ .../hooks/useThresholdsPlatformData.ts | 14 +- .../thresholds/trueNASDiskTemperature.ts | 51 ++++++ frontend-modern/src/types/alerts.ts | 3 + .../__tests__/alertThresholdDefaults.test.ts | 3 +- .../utils/__tests__/metricThresholds.test.ts | 2 +- .../src/utils/alertThresholdDefaults.ts | 8 +- internal/alerts/alert_policy.go | 9 + internal/alerts/alerts_test.go | 59 +++++++ ...g_normalize_defaults_branchcov0716_test.go | 6 +- internal/alerts/config/normalize.go | 28 ++- .../config/normalize_truenas_disk_test.go | 58 ++++++ internal/alerts/config/types.go | 7 + internal/alerts/config_runtime.go | 12 +- internal/alerts/default_config.go | 8 +- internal/alerts/truenas_disk_temperature.go | 18 ++ internal/alerts/unified_eval.go | 18 +- internal/alerts/unified_eval_test.go | 165 ++++++++++++++++++ 35 files changed, 1051 insertions(+), 67 deletions(-) create mode 100644 frontend-modern/browser-verification/26f085dd13bc21bf.json create mode 100644 frontend-modern/src/features/alerts/thresholds/__tests__/trueNASDiskTemperature.test.ts create mode 100644 frontend-modern/src/features/alerts/thresholds/trueNASDiskTemperature.ts create mode 100644 internal/alerts/config/normalize_truenas_disk_test.go create mode 100644 internal/alerts/truenas_disk_temperature.go diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 9d7946179e..c7ab193445 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -1830,14 +1830,52 @@ 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. + ### Agent disk temperature alerts clear when their disk leaves the report `CheckHost` also clears a disk temperature alert, and drops its pending diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 7b7e4abd89..c74b2588c0 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2116,6 +2116,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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 3b9f065d08..63fd2077e1 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -6102,6 +6102,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 diff --git a/frontend-modern/browser-verification/26f085dd13bc21bf.json b/frontend-modern/browser-verification/26f085dd13bc21bf.json new file mode 100644 index 0000000000..fa004d8f83 --- /dev/null +++ b/frontend-modern/browser-verification/26f085dd13bc21bf.json @@ -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" + ] +} diff --git a/frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx b/frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx index 92593af32f..92232f4ee8 100644 --- a/frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx +++ b/frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx @@ -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) => ( {column} @@ -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 ( { 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()) + } />