Keep quiet hours correct across clock changes and clarify TrueNAS diagnostics (#2377)

## What changed and why

- Quiet-hours alert schedules now follow the configured local clock
through repeated and skipped daylight-saving minutes. Previously,
constructing start/end timestamps could release alerts during a repeated
quiet hour or keep them held after a skipped end. Initial replay uses
the next eligible real minute, without changing selected calendar days
or critical-alert exceptions. Full-day replay and later queued-policy
revalidation remain unchanged.
- The TrueNAS CORE guide now explains how to find an incoming graph
response rather than its outgoing request, including Firefox message
filters, matching request IDs, and CPU usage versus temperature. This
addresses the diagnostic obstacle reported in #2077. Canonical and
shipped guides remain identical; sharing stays limited to sanitized
response details, not credentials or full captures. This is guidance,
not a claim that the appliance's missing readings are fixed.

## Included source

Complete unpublished range:
`6448ea3816e40f79fa89ae6b6d0f16508ac71f8b..680ac756e6320e2e79a72459d1caa35a611bafe7`.

- Reviewed quiet-hours candidate
`0834ec0e14`: runtime
`cecbd5418b`, test alignment
`89a0896959`, and verification-registry
ordering correction `0834ec0e14`.
- Reviewed guide candidate `36066f0406`.
- Integration merges `452ca8ed10` and
`680ac756e6` preserve those identities and
ancestry.

## Validation

- Recorded quiet-hours source proof `source-4dcf07qu`: predetermined
parent failures, six final regression groups/32 subcases, complete
alerts/notifications correctness and race checks across six packages,
affected vet and package builds. Go 1.26.7 was observed; this is not a
whole-application build or installed notification receipt.
- Recorded guide proofs `source-4dykigld` and `source-etia09y0`: five
recipe cases with parent controls and 53 Docs cases. Final candidate
bytes remain unchanged by the integrations.
- Publication-range provenance, per-commit canonical guards, aggregate
contract/registry audits and whitespace checks pass. Browser guard
explicitly skips unchanged frontend runtime source.
- The lightweight guide fixture attempt hit its confinement's
localhost-listener restriction, not a guide assertion failure; its
result is retained separately.

An additional aggregate guide check was requested in the source VM but
is still queued for executor capacity at proposal time, with no executed
result. It is not counted as a pass; the unchanged guide bytes retain
their completed exact-candidate proofs.

Exact-head GitHub checks remain required for landing. Source publication
does not select a release or establish installed delivery.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot] 2026-10-01 22:37:09 +00:00 • committed by GitHub
commit 4317db177e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 387 additions and 83 deletions

View file

@ -175,9 +175,31 @@ Check whether the corresponding graphs work in the TrueNAS web UI. If Pulse
still shows missing readings, report the affected panels, running Pulse and
TrueNAS versions, and the relevant sanitized evidence above. Do not replace
an otherwise working key or clear stored History merely because a graph is
empty. When sharing a requested native graph response, include only its graph
name, `legend`, timing fields and one `data` row or error; omit authentication
messages and identifying details. Do not upload a full browser network capture.
empty.
If a maintainer requests a **CORE 13 native graph response**, use your existing
signed-in **TrueNAS** browser session, not the Pulse page:
1. In Firefox on macOS, open Network with **⌘⌥E**, then reload with Network
recording. Select **WS**, select the WebSocket connection and open its
**Response** pane.
2. Show **All** messages and clear any message search for `reporting.get_data`.
The incoming reply does not need to repeat the method name, so that search
can hide the evidence. Open the CPU or Memory graph on TrueNAS's Reporting
page while recording.
3. Find the outgoing `"msg":"method"` request with `"name":"cpu"` or
`"name":"memory"`; `cputemp` is temperature, not CPU usage. Each request has
a different `id`, which is normal. Find the incoming `"msg":"result"` reply
with the **same `id` as that request**, and expand its `result` (or `error`).
The `legend`, timing fields and `data` belong to the response, not the
outgoing request's `params`.
4. Share only the requested graph name, `legend`, timing fields and one `data`
row, or the matching sanitized error. If no matching incoming reply is
visible after the graph loads, report that instead of sending more requests.
Omit authentication messages, keys, cookies, private hostnames and addresses.
Do not upload a full browser network capture or paste code into the browser
console to collect this evidence.
### Stale TrueNAS data
- If TrueNAS data stops updating, the source status transitions to `stale` after ~120 seconds.

View file

@ -1982,6 +1982,20 @@ keep schedules such as `00:00` to `23:59` active through the full final
minute instead of expiring at `23:59:00`. Alert quiet-hours proofs should
control time through the alert manager clock hook instead of depending on wall
clock execution at whatever second the test runner happens to hit.
The schedule compares local civil clock minutes on the selected calendar day,
not `time.Date` instants. Both occurrences of a repeated minute have the same
quiet-hours policy; nonexistent clock minutes do not shift the configured
start or end. Initial queued replay uses the first real minute outside the
current non-full-day window, including an unselected day reached at midnight.
This does not reinterpret the day toggles as the previous evening's ownership.
Full-day windows retain the existing daily replay boundary; this clock repair
is not current-policy revalidation of already queued work or external receipt.
`TestQuietHoursDaylightSavingClockAndReplay` in
`internal/alerts/quiet_hours_test.go` checks London, New York and Lord Howe
(one-hour and half-hour changes), inclusive boundaries and exact UTC replay.
`TestQuietHoursOvernightSelectedCalendarDays` checks overnight day selection.
Quiet-hours location fallback is read-only; configuration updates own the
location cache, including when public suppression helpers run concurrently.
Quiet-hours suppression also applies to alert delivery lifecycle, not only the
initial raised notification. Resolved notifications must not fan out when the
alert was never notified or was already acknowledged, and monitoring-driven

View file

@ -2778,6 +2778,7 @@
"internal/alerts/incident_synthesis_test.go",
"internal/alerts/intent_policy_test.go",
"internal/alerts/operational_contract_test.go",
"internal/alerts/quiet_hours_test.go",
"internal/alerts/reducer/ack_test.go",
"internal/alerts/reducer/discrete_test.go",
"internal/alerts/reducer/intent_test.go",

View file

@ -175,9 +175,31 @@ Check whether the corresponding graphs work in the TrueNAS web UI. If Pulse
still shows missing readings, report the affected panels, running Pulse and
TrueNAS versions, and the relevant sanitized evidence above. Do not replace
an otherwise working key or clear stored History merely because a graph is
empty. When sharing a requested native graph response, include only its graph
name, `legend`, timing fields and one `data` row or error; omit authentication
messages and identifying details. Do not upload a full browser network capture.
empty.
If a maintainer requests a **CORE 13 native graph response**, use your existing
signed-in **TrueNAS** browser session, not the Pulse page:
1. In Firefox on macOS, open Network with **⌘⌥E**, then reload with Network
recording. Select **WS**, select the WebSocket connection and open its
**Response** pane.
2. Show **All** messages and clear any message search for `reporting.get_data`.
The incoming reply does not need to repeat the method name, so that search
can hide the evidence. Open the CPU or Memory graph on TrueNAS's Reporting
page while recording.
3. Find the outgoing `"msg":"method"` request with `"name":"cpu"` or
`"name":"memory"`; `cputemp` is temperature, not CPU usage. Each request has
a different `id`, which is normal. Find the incoming `"msg":"result"` reply
with the **same `id` as that request**, and expand its `result` (or `error`).
The `legend`, timing fields and `data` belong to the response, not the
outgoing request's `params`.
4. Share only the requested graph name, `legend`, timing fields and one `data`
row, or the matching sanitized error. If no matching incoming reply is
visible after the graph loads, report that instead of sending more requests.
Omit authentication messages, keys, cookies, private hostnames and addresses.
Do not upload a full browser network capture or paste code into the browser
console to collect this evidence.
### Stale TrueNAS data
- If TrueNAS data stops updating, the source status transitions to `stale` after ~120 seconds.

View file

@ -385,70 +385,66 @@ func isSupportedInfrastructureSymptom(alert *Alert) bool {
strings.TrimSpace(alert.Correlation.PrimaryAlertID) != ""
}
// isInQuietHours checks if the current time is within quiet hours
func (m *Manager) isInQuietHours() bool {
if !m.config.Schedule.QuietHours.Enabled {
return false
}
// quietHoursClock compares civil clock minutes, not date-constructed instants.
// time.Date chooses only one side of a repeated hour and normalizes missing
// minutes to a different hour, neither of which is the user's daily schedule.
type quietHoursClock struct {
location *time.Location
start int
end int
days [7]bool
}
// Use cached location if available
func (m *Manager) quietHoursClock() (quietHoursClock, bool) {
quiet := m.config.Schedule.QuietHours
if !quiet.Enabled {
return quietHoursClock{}, false
}
loc := m.quietHoursLoc
if loc == nil {
// Fallback to loading if not cached yet (shouldn't happen with UpdateConfig)
var err error
loc, err = time.LoadLocation(m.config.Schedule.QuietHours.Timezone)
loc, err = time.LoadLocation(quiet.Timezone)
if err != nil {
log.Warn().Err(err).Str("timezone", m.config.Schedule.QuietHours.Timezone).Msg("failed to load timezone, using local time")
log.Warn().Err(err).Str("timezone", quiet.Timezone).Msg("failed to load timezone, using local time")
loc = time.Local
}
m.quietHoursLoc = loc
// Do not populate the cache here: public suppression helpers can call
// this path while holding only a read lock. UpdateConfig owns the cache.
}
nowFn := m.now
if nowFn == nil {
nowFn = time.Now
}
now := nowFn().In(loc).Truncate(time.Minute)
dayName := strings.ToLower(now.Format("Monday"))
// Check if today is enabled for quiet hours
if enabled, ok := m.config.Schedule.QuietHours.Days[dayName]; !ok || !enabled {
return false
}
// Parse start and end times
startTime, err := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.Start, loc)
start, err := time.Parse("15:04", quiet.Start)
if err != nil {
log.Warn().Err(err).Str("start", m.config.Schedule.QuietHours.Start).Msg("failed to parse quiet hours start time")
return false
log.Warn().Err(err).Str("start", quiet.Start).Msg("failed to parse quiet hours start time")
return quietHoursClock{}, false
}
endTime, err := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.End, loc)
end, err := time.Parse("15:04", quiet.End)
if err != nil {
log.Warn().Err(err).Str("end", m.config.Schedule.QuietHours.End).Msg("failed to parse quiet hours end time")
log.Warn().Err(err).Str("end", quiet.End).Msg("failed to parse quiet hours end time")
return quietHoursClock{}, false
}
clock := quietHoursClock{location: loc, start: start.Hour()*60 + start.Minute(), end: end.Hour()*60 + end.Minute()}
for i, day := range [...]string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"} {
clock.days[i] = quiet.Days[day]
}
return clock, true
}
func (clock quietHoursClock) contains(at time.Time) bool {
local := at.In(clock.location)
if !clock.days[local.Weekday()] {
return false
}
// Set to today's date
startTime = time.Date(now.Year(), now.Month(), now.Day(), startTime.Hour(), startTime.Minute(), 0, 0, loc)
endTime = time.Date(now.Year(), now.Month(), now.Day(), endTime.Hour(), endTime.Minute(), 0, 0, loc)
// Quiet hours are configured with minute precision, so treat the start and
// end minute as inclusive for user-facing schedules such as 00:00-23:59.
endExclusive := endTime.Add(time.Minute)
// Handle overnight quiet hours (e.g., 22:00 to 08:00)
if endTime.Before(startTime) {
if !now.Before(startTime) || now.Before(endExclusive) {
return true
}
} else {
if !now.Before(startTime) && now.Before(endExclusive) {
return true
}
minute := local.Hour()*60 + local.Minute()
if clock.start > clock.end {
return minute >= clock.start || minute <= clock.end
}
return minute >= clock.start && minute <= clock.end
}
return false
// isInQuietHours checks the selected local calendar day and includes the whole
// configured end minute, including both copies of it during a clock rollback.
func (m *Manager) isInQuietHours() bool {
clock, valid := m.quietHoursClock()
return valid && clock.contains(m.policyNow())
}
func quietHoursCategoryForAlert(alert *Alert) string {
@ -518,41 +514,42 @@ func (m *Manager) shouldSuppressNotification(alert *Alert) (bool, string) {
}
func (m *Manager) quietHoursReplayAt() time.Time {
nowFn := m.now
if nowFn == nil {
nowFn = time.Now
now := m.policyNow()
clock, valid := m.quietHoursClock()
if !valid || !clock.contains(now) {
return now.Add(time.Minute).UTC()
}
now := nowFn()
loc := m.quietHoursLoc
if loc == nil {
var err error
loc, err = time.LoadLocation(m.config.Schedule.QuietHours.Timezone)
if err != nil {
log.Warn().Err(err).Str("timezone", m.config.Schedule.QuietHours.Timezone).Msg("failed to load timezone for quiet-hours replay, using local time")
loc = time.Local
// A full-day window has no non-quiet clock minute. Preserve its existing
// daily replay boundary; queued-policy revalidation is a separate delivery
// concern, not permission to invent an end to a continuous schedule.
if (clock.end-clock.start+24*60)%(24*60) == 24*60-1 {
local := now.In(clock.location)
endExclusive := time.Date(local.Year(), local.Month(), local.Day(), clock.end/60, clock.end%60, 0, 0, clock.location).Add(time.Minute)
if clock.start > clock.end && local.Hour()*60+local.Minute() >= clock.start {
endExclusive = endExclusive.AddDate(0, 0, 1)
}
if endExclusive.After(now) {
return endExclusive.UTC()
}
m.quietHoursLoc = loc
}
localNow := now.In(loc).Truncate(time.Minute)
startTime, startErr := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.Start, loc)
endTime, endErr := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.End, loc)
if startErr != nil || endErr != nil {
return now.Add(time.Minute).UTC()
}
startTime = time.Date(localNow.Year(), localNow.Month(), localNow.Day(), startTime.Hour(), startTime.Minute(), 0, 0, loc)
endTime = time.Date(localNow.Year(), localNow.Month(), localNow.Day(), endTime.Hour(), endTime.Minute(), 0, 0, loc)
endExclusive := endTime.Add(time.Minute)
if endTime.Before(startTime) && !localNow.Before(startTime) {
endExclusive = endExclusive.AddDate(0, 0, 1)
// Walk real minutes through the current window, rather than constructing
// its end on a local date. This handles repeated ends, skipped ends, half-
// hour DST transitions and a midnight leading into an unselected day. A
// non-full-day window has an exit within three real days even when a
// forward clock change skips its entire gap on the first day.
candidate := now.Truncate(time.Minute).Add(time.Minute)
limit := candidate.Add(72 * time.Hour)
for candidate.Before(limit) {
if !clock.contains(candidate) {
return candidate.UTC()
}
candidate = candidate.Add(time.Minute)
}
if !endExclusive.After(localNow) {
return now.Add(time.Minute).UTC()
}
return endExclusive.UTC()
log.Warn().Msg("could not find quiet-hours window end within three days")
return now.Add(time.Minute).UTC()
}
func markQuietHoursNotificationReplay(alert *Alert, reason string, replayAt time.Time) {

View file

@ -1,6 +1,7 @@
package alerts
import (
"sync"
"testing"
"time"
@ -391,3 +392,231 @@ func TestIsInQuietHours(t *testing.T) {
}
})
}
// Local-clock schedules apply to both copies of a repeated minute. Missing
// minutes do not move a configured start or end into some other wall-clock hour.
func TestQuietHoursDaylightSavingClockAndReplay(t *testing.T) {
for _, tc := range []struct {
name, zone, start, end, now, replay string
quiet bool
}{
{"london-first-repeated-hour", "Europe/London", "01:00", "02:00", "2026-10-25T00:30:00Z", "2026-10-25T02:01:00Z", true},
{"london-second-repeated-hour", "Europe/London", "01:00", "02:00", "2026-10-25T01:30:00Z", "2026-10-25T02:01:00Z", true},
{"london-first-repeated-end", "Europe/London", "00:00", "01:30", "2026-10-25T00:15:00Z", "2026-10-25T00:31:00Z", true},
{"london-second-repeated-end", "Europe/London", "00:00", "01:30", "2026-10-25T01:15:00Z", "2026-10-25T01:31:00Z", true},
{"london-after-first-end", "Europe/London", "00:00", "01:30", "2026-10-25T00:45:00Z", "", false},
{"london-after-second-end", "Europe/London", "00:00", "01:30", "2026-10-25T01:45:00Z", "", false},
{"london-missing-start", "Europe/London", "01:30", "02:30", "2026-03-29T01:15:00Z", "2026-03-29T01:31:00Z", true},
{"london-missing-end-replay", "Europe/London", "00:00", "01:30", "2026-03-29T00:55:30Z", "2026-03-29T01:00:00Z", true},
{"london-after-missing-end", "Europe/London", "00:00", "01:30", "2026-03-29T01:15:00Z", "", false},
{"london-overnight-missing-end", "Europe/London", "22:00", "01:30", "2026-03-28T23:00:00Z", "2026-03-29T01:00:00Z", true},
{"london-overnight-repeated-end", "Europe/London", "22:00", "01:30", "2026-10-24T23:15:00Z", "2026-10-25T00:31:00Z", true},
{"london-end-minute-before-jump", "Europe/London", "00:00", "00:59", "2026-03-29T00:59:59Z", "2026-03-29T01:00:00Z", true},
{"new-york-first-repeated-end", "America/New_York", "00:00", "01:30", "2026-11-01T05:15:00Z", "2026-11-01T05:31:00Z", true},
{"new-york-second-repeated-end", "America/New_York", "00:00", "01:30", "2026-11-01T06:15:00Z", "2026-11-01T06:31:00Z", true},
{"new-york-after-first-end", "America/New_York", "00:00", "01:30", "2026-11-01T05:45:00Z", "", false},
{"new-york-after-second-end", "America/New_York", "00:00", "01:30", "2026-11-01T06:45:00Z", "", false},
{"new-york-missing-start", "America/New_York", "02:30", "03:30", "2026-03-08T07:15:00Z", "2026-03-08T07:31:00Z", true},
{"new-york-missing-end", "America/New_York", "00:00", "02:30", "2026-03-08T06:55:00Z", "2026-03-08T07:00:00Z", true},
{"new-york-after-missing-end", "America/New_York", "00:00", "02:30", "2026-03-08T07:15:00Z", "", false},
{"lord-howe-first-repeated-half-hour", "Australia/Lord_Howe", "01:30", "02:00", "2026-04-04T14:40:00Z", "2026-04-04T15:31:00Z", true},
{"lord-howe-second-repeated-half-hour", "Australia/Lord_Howe", "01:30", "02:00", "2026-04-04T15:10:00Z", "2026-04-04T15:31:00Z", true},
{"lord-howe-missing-start", "Australia/Lord_Howe", "02:00", "02:45", "2026-10-03T15:35:00Z", "2026-10-03T15:46:00Z", true},
{"lord-howe-after-missing-end", "Australia/Lord_Howe", "00:00", "02:15", "2026-10-03T15:40:00Z", "", false},
} {
t.Run(tc.name, func(t *testing.T) {
now, err := time.Parse(time.RFC3339, tc.now)
if err != nil {
t.Fatal(err)
}
m := newTestManager(t)
m.now = func() time.Time { return now }
cfg := m.GetConfig()
cfg.Schedule.QuietHours = QuietHours{Enabled: true, Start: tc.start, End: tc.end, Timezone: tc.zone,
Days: map[string]bool{"monday": true, "tuesday": true, "wednesday": true, "thursday": true, "friday": true, "saturday": true, "sunday": true}}
m.UpdateConfig(cfg)
m.mu.Lock()
defer m.mu.Unlock()
if got := m.isInQuietHours(); got != tc.quiet {
t.Errorf("quiet at %s for %s %s-%s = %v, want %v", now, tc.zone, tc.start, tc.end, got, tc.quiet)
}
if tc.replay == "" {
return
}
want, err := time.Parse(time.RFC3339, tc.replay)
if err != nil {
t.Fatal(err)
}
got := m.quietHoursReplayAt()
if !got.Equal(want) {
t.Errorf("replay = %s, want first eligible real minute %s", got, want)
}
if !got.After(now) {
t.Error("replay must be in the future")
}
now = want.Add(-time.Nanosecond)
if !m.isInQuietHours() {
t.Error("quiet hours ended before the final included minute finished")
}
now = want
if m.isInQuietHours() {
t.Error("computed replay minute is still quiet")
}
})
}
}
func TestQuietHoursOvernightSelectedCalendarDays(t *testing.T) {
m := newTestManager(t)
now := time.Date(2026, 10, 2, 23, 30, 0, 0, time.UTC) // Friday
m.now = func() time.Time { return now }
cfg := m.GetConfig()
cfg.Schedule.QuietHours = QuietHours{Enabled: true, Start: "22:00", End: "06:00", Timezone: "UTC", Days: map[string]bool{"friday": true}}
m.UpdateConfig(cfg)
m.mu.Lock()
defer m.mu.Unlock()
if !m.isInQuietHours() {
t.Fatal("selected Friday evening must be quiet")
}
want := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
if got := m.quietHoursReplayAt(); !got.Equal(want) {
t.Fatalf("unselected Saturday must end suppression at midnight: got %s, want %s", got, want)
}
now = want
if m.isInQuietHours() {
t.Fatal("overnight settings must not silently enable an unselected day")
}
now = now.AddDate(0, 0, 6).Add(6*time.Hour + 59*time.Second) // Friday's inclusive 06:00 minute
if !m.isInQuietHours() || !m.quietHoursReplayAt().Equal(now.Truncate(time.Minute).Add(time.Minute)) {
t.Fatal("selected morning must keep the inclusive end minute")
}
}
func TestQuietHoursReplaySkipsNonexistentGap(t *testing.T) {
// This overnight window has a one-minute daily gap, 01:29. London's
// spring jump skips that entire gap, so the next real exit is Monday.
now := time.Date(2026, 3, 28, 23, 0, 0, 0, time.UTC)
m := fixedQuietHoursTestManager(now, QuietHours{Enabled: true, Start: "01:30", End: "01:28", Timezone: "Europe/London",
Days: map[string]bool{"saturday": true, "sunday": true, "monday": true}})
want := time.Date(2026, 3, 30, 0, 29, 0, 0, time.UTC)
if !m.isInQuietHours() || !m.quietHoursReplayAt().Equal(want) {
t.Fatalf("skipped gap must not release a notification into quiet hours: replay=%s want=%s", m.quietHoursReplayAt(), want)
}
}
func TestQuietHoursFullDayRetainsDailyReplayBoundary(t *testing.T) {
for _, tc := range []struct{ start, end, now, replay string }{
{"00:00", "23:59", "2026-10-01T12:30:00Z", "2026-10-02T00:00:00Z"},
{"22:00", "21:59", "2026-10-01T23:30:00Z", "2026-10-02T22:00:00Z"},
{"22:00", "21:59", "2026-10-01T12:30:00Z", "2026-10-01T22:00:00Z"},
} {
t.Run(tc.start+"/"+tc.now, func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, tc.now)
want, _ := time.Parse(time.RFC3339, tc.replay)
m := fixedQuietHoursTestManager(now, QuietHours{Enabled: true, Start: tc.start, End: tc.end, Timezone: "UTC", Days: map[string]bool{"thursday": true, "friday": true}})
if !m.isInQuietHours() || !m.quietHoursReplayAt().Equal(want) {
t.Fatalf("daily replay changed: %s, want %s", m.quietHoursReplayAt(), want)
}
})
}
}
func TestQuietHoursClockDispatchAndDiagnosis(t *testing.T) {
for _, tc := range []struct {
name, now, start, end, replay string
quiet bool
}{
{"repeated-hour", "2026-10-25T00:30:00Z", "01:00", "02:00", "2026-10-25T02:01:00Z", true},
{"skipped-end", "2026-03-29T00:55:00Z", "00:00", "01:30", "2026-03-29T01:00:00Z", true},
{"after-skipped-end", "2026-03-29T01:15:00Z", "00:00", "01:30", "", false},
} {
for _, critical := range []bool{false, true} {
name := tc.name + "/warning"
if critical {
name = tc.name + "/unsuppressed-critical"
}
t.Run(name, func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, tc.now)
m := newTestManager(t)
m.now = func() time.Time { return now }
cfg := m.GetConfig()
cfg.Enabled = true
cfg.ActivationState = ActivationActive
cfg.FlappingEnabled = false
cfg.Schedule.QuietHours = QuietHours{Enabled: true, Start: tc.start, End: tc.end, Timezone: "Europe/London", Days: map[string]bool{"sunday": true}}
m.UpdateConfig(cfg)
_, alert := testNewCanonicalAlert("vm-100", "vm-100-cpu", "vm", "cpu")
alert.Level = AlertLevelWarning
if critical {
alert.Level = AlertLevelCritical
}
alert.StartTime = now
alert.LastSeen = now
// Old metadata must be cleared when quiet hours no longer apply.
alert.Metadata = map[string]interface{}{MetadataQuietHoursSuppressed: true, MetadataQuietHoursReplayAt: "2099-01-01T00:00:00Z"}
m.mu.Lock()
m.setActiveAlertNoLock(alert.ID, alert)
m.mu.Unlock()
deferred := tc.quiet && !critical
diagnosis, found := m.DiagnoseAlertDelivery(alert.ID)
if !found {
t.Fatal("active occurrence unavailable")
}
if deferred {
want, _ := time.Parse(time.RFC3339, tc.replay)
if diagnosis.Status != AlertDeliveryStatusDeferred || diagnosis.Reason != AlertDeliveryReasonQuietHours+":non-critical" || diagnosis.QuietHoursReplayAt == nil || !diagnosis.QuietHoursReplayAt.Equal(want) {
t.Fatalf("diagnosis disagrees with local-clock policy: %+v", diagnosis)
}
} else if diagnosis.Status != AlertDeliveryStatusWouldSend || diagnosis.Reason != AlertDeliveryReasonReady {
t.Fatalf("eligible alert was diagnosed as held: %+v", diagnosis)
}
var dispatched *Alert
m.SetAlertCallback(func(a *Alert) { dispatched = a })
m.mu.Lock()
admitted := m.dispatchAlert(alert, false)
m.mu.Unlock()
if !admitted || dispatched == nil {
t.Fatal("quiet-hours replay must enter the existing delivery pipeline, not be dropped")
}
if deferred {
if dispatched.Metadata[MetadataQuietHoursSuppressed] != true || dispatched.Metadata[MetadataQuietHoursReplayAt] != tc.replay {
t.Fatalf("pipeline replay timestamp = %#v, want %s", dispatched.Metadata, tc.replay)
}
} else if hasQuietHoursNotificationReplay(dispatched) {
t.Fatalf("eligible alert kept stale deferral metadata: %#v", dispatched.Metadata)
}
// Escalation/bypass sends consult this same public helper.
bypass := cloneAlertForOutput(dispatched)
if m.ShouldSuppressNotification(bypass) || hasQuietHoursNotificationReplay(bypass) != deferred {
t.Fatal("public bypass helper did not apply the same clock policy")
}
})
}
}
}
func TestQuietHoursConcurrentUncachedReaders(t *testing.T) {
now := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC)
m := fixedQuietHoursTestManager(now, QuietHours{Enabled: true, Start: "01:00", End: "02:00", Timezone: "Europe/London", Days: map[string]bool{"sunday": true}})
start := make(chan struct{})
var wg sync.WaitGroup
for worker := 0; worker < 32; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < 25; i++ {
alert := &Alert{ID: "uncached-reader", Type: "cpu", Level: AlertLevelWarning}
if m.ShouldSuppressNotification(alert) || alert.Metadata[MetadataQuietHoursReplayAt] != "2026-10-25T02:01:00Z" {
t.Error("concurrent reader disagrees with quiet-hours replay")
return
}
}
}()
}
close(start)
wg.Wait()
if m.quietHoursLoc != nil {
t.Fatal("read-locked evaluation mutated the configuration-owned location cache")
}
}

View file

@ -70,6 +70,25 @@ class TrueNASDocsTest(unittest.TestCase):
self.assertIn("does not establish a live reading", text)
self.assertIn("Do not upload a full browser network capture", text)
def test_core_graph_guidance_distinguishes_request_from_reply(self):
text = DOC.read_text().split("### Inventory works but CPU, memory or History is missing", 1)[1]
text = text.split("### Stale TrueNAS data", 1)[0]
for required in (
"signed-in **TrueNAS** browser session, not the Pulse page",
"In Firefox on macOS", "**WS**", "**Response** pane",
"Show **All** messages", "clear any message search for `reporting.get_data`",
'outgoing `"msg":"method"`', 'incoming `"msg":"result"`',
"**same `id` as that request**", "expand its `result` (or `error`)",
"`cputemp` is temperature, not CPU usage",
"response, not the\n outgoing request's `params`",
"one `data`\n row", "no matching incoming reply",
"Omit authentication messages, keys, cookies, private hostnames and addresses",
"Do not upload a full browser network capture or paste code into the browser",
):
with self.subTest(required=required):
self.assertIn(required, text)
self.assertNotIn("```", text)
def test_preparation_protects_new_and_existing_payload(self):
preparation, _ = commands()
with tempfile.TemporaryDirectory() as temporary: