From d07080e8fb6c76c76be5928c7f5bb2414deeaace Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:58:36 +0100 Subject: [PATCH 1/2] Keep snapshot warnings when creation dates are unknown Distinguish a listed snapshot with an unusable date from removal. Retain the last trusted canonical occurrence and evidence after current policy resolution, including Proxmox's missing-snaptime Unix epoch representation. Add lifecycle, exclusion, identity and real-consumer browser proof fixtures. Contract-Neutral: Repair existing snapshot warning preservation on incomplete evidence; no API, configuration, schema or canonical identity change. Dedicated regression and browser-producer tests accompany the internal contract clarification. Change-source: pulse-maintainer --- .../v6/internal/subsystems/alerts.md | 20 ++ .../build-snapshot-date-observation.mjs | 70 +++++++ .../snapshot-date-observation.html | 12 ++ .../snapshot-date-observation.tsx | 60 ++++++ internal/alerts/backup_snapshot.go | 23 ++- .../alerts/snapshot_date_observation_test.go | 194 ++++++++++++++++++ scripts/check-snapshot-date-observation.cjs | 141 +++++++++++++ 7 files changed, 510 insertions(+), 10 deletions(-) create mode 100644 frontend-modern/browser-tests/build-snapshot-date-observation.mjs create mode 100644 frontend-modern/browser-tests/snapshot-date-observation.html create mode 100644 frontend-modern/browser-tests/snapshot-date-observation.tsx create mode 100644 internal/alerts/snapshot_date_observation_test.go create mode 100644 scripts/check-snapshot-date-observation.cjs diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 0c9cd7247b..58cb0b0b2b 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -15,6 +15,26 @@ ## Purpose +### A listed snapshot with an unknown creation date is not recovered + +Snapshot warning cleanup distinguishes an absent snapshot from a still-listed +snapshot whose creation time is missing, non-positive Unix time or in the future. +After current guest policy and overrides are applied, an unknown date retains +only that snapshot's existing canonical occurrence, including its trusted +age/size evidence, acknowledgement and observation time. It neither creates a +new occurrence nor refreshes old evidence. A trustworthy dated observation, +authoritative removal, exclusion or disablement still follows normal recovery. + +This is deliberately conservative for both age and size warnings: an incomplete +snapshot observation does not independently update either metric. It introduces +no guest, snapshot, backup, filesystem or freeze/thaw operation. +`snapshot_date_observation_test.go` checks repeated gaps, epoch conversion, +exact occurrence/evidence preservation, lifecycle events, independent instances, +policy changes, trustworthy recovery and removal. The real manager's exported +frames feed the production Alerts API adapter and Overview/card browser fixture +at desktop and narrow viewports; this is not installed guest recovery. + + ### Legacy alert-history compaction releases removed-row storage Expiry, duplicate-occurrence compaction and ID removal allocate backing storage diff --git a/frontend-modern/browser-tests/build-snapshot-date-observation.mjs b/frontend-modern/browser-tests/build-snapshot-date-observation.mjs new file mode 100644 index 0000000000..c4ce129e24 --- /dev/null +++ b/frontend-modern/browser-tests/build-snapshot-date-observation.mjs @@ -0,0 +1,70 @@ +// Compile the production consumer outside the bounded browser process tree. +import { build } from 'vite'; +import solid from 'vite-plugin-solid'; +import tailwindcss from '@tailwindcss/vite'; +import { stop } from 'esbuild'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = path.resolve(root, '../tmp/snapshot-date-static'); +const hash = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); +const sources = {}; +const artifacts = {}; + +try { + await build({ + root, + base: '/', + configFile: false, + publicDir: false, + resolve: { alias: { '@': path.join(root, 'src') } }, + plugins: [ + { + name: 'bind-snapshot-date-consumer', + generateBundle() { + for (const id of this.getModuleIds()) { + const file = id.split('?')[0]; + if ( + file.startsWith(root + '/') && + !file.includes('/node_modules/') && + fs.existsSync(file) && + fs.statSync(file).isFile() + ) { + sources[path.relative(root, file)] = hash(fs.readFileSync(file)); + } + } + }, + }, + tailwindcss(), + solid(), + ], + build: { + target: 'esnext', + outDir, + emptyOutDir: true, + rollupOptions: { input: path.join(root, 'browser-tests/snapshot-date-observation.html') }, + }, + }); + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) walk(file); + else artifacts[path.relative(outDir, file)] = hash(fs.readFileSync(file)); + } + }; + walk(outDir); + fs.writeFileSync( + path.join(outDir, 'binding.json'), + JSON.stringify({ + graph: hash(fs.readFileSync(path.join(root, 'package-lock.json'))), + builder: hash(fs.readFileSync(fileURLToPath(import.meta.url))), + sources, + artifacts, + }, null, 2) + '\n', + ); +} finally { + stop(); +} diff --git a/frontend-modern/browser-tests/snapshot-date-observation.html b/frontend-modern/browser-tests/snapshot-date-observation.html new file mode 100644 index 0000000000..c8e8175b5b --- /dev/null +++ b/frontend-modern/browser-tests/snapshot-date-observation.html @@ -0,0 +1,12 @@ + + + + + + Snapshot observation evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/snapshot-date-observation.tsx b/frontend-modern/browser-tests/snapshot-date-observation.tsx new file mode 100644 index 0000000000..a83701f0a7 --- /dev/null +++ b/frontend-modern/browser-tests/snapshot-date-observation.tsx @@ -0,0 +1,60 @@ +// Real active-alert adapter and Overview, fed only by the Go manager's frames. +import { createSignal, onMount } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Route, Router } from '@solidjs/router'; +import { AlertsAPI } from '../src/api/alerts'; +import { OverviewTab } from '../src/features/alerts/OverviewTab'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { aiAvailabilityStore } from '../src/stores/aiAvailability'; +import type { Alert } from '../src/types/api'; +import '../src/index.css'; + +aiAvailabilityStore.setEnabled(false); + +function Fixture() { + const [active, setActive] = createSignal>({}); + const [showAcknowledged, setShowAcknowledged] = createSignal(true); + const [generation, setGeneration] = createSignal(0); + const reload = async () => { + const alerts = await AlertsAPI.getActive(); + setActive(Object.fromEntries(alerts.map((alert) => [alert.id, alert]))); + setGeneration((value) => value + 1); + }; + onMount(() => void reload()); + Object.assign(window, { __snapshotDate: { reload, active, generation } }); + return ( +
+

Snapshot observations

+

Synthetic monitoring evidence. No backup or guest operation.

+ + + setActive((previous) => ({ ...previous, [id]: { ...previous[id], ...updates } })) + } + showQuickTip={() => false} + dismissQuickTip={() => {}} + showAcknowledged={showAcknowledged} + setShowAcknowledged={setShowAcknowledged} + alertsDisabled={() => false} + /> +
+ ); +} + +render( + () => ( + document.documentElement.classList.contains('dark')}> + + + + + ), + document.getElementById('root')!, +); diff --git a/internal/alerts/backup_snapshot.go b/internal/alerts/backup_snapshot.go index c43c9f5714..58adb9ed04 100644 --- a/internal/alerts/backup_snapshot.go +++ b/internal/alerts/backup_snapshot.go @@ -362,16 +362,6 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod if instanceName != "" && snapshot.Instance != "" && snapshot.Instance != instanceName { continue } - if snapshot.Time.IsZero() { - continue - } - - ageHours := now.Sub(snapshot.Time).Hours() - if ageHours < 0 { - continue - } - ageDays := ageHours / 24 - const gib = 1024.0 * 1024 * 1024 sizeGiB := 0.0 if snapshot.SizeBytes > 0 { @@ -422,6 +412,19 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod continue } + // The listing still contains this snapshot. An unusable creation time + // is not evidence of deletion or recovery: keep the last trusted + // occurrence unchanged, without refreshing its evidence or age. Do + // this after policy resolution so exclusions and disablement still win. + // Proxmox's absent snaptime arrives as Unix epoch, not Go's zero time. + if snapshot.Time.IsZero() || snapshot.Time.Unix() <= 0 || snapshot.Time.After(now) { + validAlerts[buildCanonicalStateID(resourceID, resourceID+"/snapshot:"+snapshot.ID)] = struct{}{} + continue + } + + ageHours := now.Sub(snapshot.Time).Hours() + ageDays := ageHours / 24 + var ageLevel AlertLevel var ageThreshold int var sizeLevel AlertLevel diff --git a/internal/alerts/snapshot_date_observation_test.go b/internal/alerts/snapshot_date_observation_test.go new file mode 100644 index 0000000000..28d6cdc08e --- /dev/null +++ b/internal/alerts/snapshot_date_observation_test.go @@ -0,0 +1,194 @@ +package alerts + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts/eventlog" + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func snapshotDateManager(t *testing.T) *Manager { + t.Helper() + m := newEventLogManager(t) + cfg := m.GetConfig() + cfg.SnapshotDefaults = SnapshotAlertConfig{Enabled: true, WarningDays: 7, CriticalDays: 14, WarningSizeGiB: 50, CriticalSizeGiB: 100} + cfg.TimeThresholds = map[string]int{} + m.UpdateConfig(cfg) + return m +} + +func snapshotDateFixture(kind string) (models.GuestSnapshot, map[string]GuestLookup) { + s := models.GuestSnapshot{ID: "east-pve-101-weekly", Name: "weekly", Instance: "east", Node: "pve", VMID: 101, Type: kind, Time: time.Now().Add(-16 * 24 * time.Hour), SizeBytes: 120 << 30} + g := GuestLookup{ResourceID: "east:pve:101", Name: "database", Instance: "east", Node: "pve", VMID: 101, Type: kind} + return s, map[string]GuestLookup{g.ResourceID: g} +} + +func snapshotDateJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestSnapshotDateObservations(t *testing.T) { + invalid := map[string]time.Time{ + "zero": {}, "epoch": time.Unix(0, 0), "negative": time.Unix(-1, 0), "future": time.Now().Add(24 * time.Hour), + } + for _, kind := range []string{"qemu", "lxc"} { + for _, metric := range []string{"age", "size", "both"} { + for label, date := range invalid { + t.Run(kind+"/"+metric+"/"+label, func(t *testing.T) { + m := snapshotDateManager(t) + s, guests := snapshotDateFixture(kind) + if metric == "size" { + s.Time = time.Now().Add(-time.Hour) + } + if metric == "age" { + s.SizeBytes = 1 << 30 + } + m.CheckSnapshotsForInstance("east", []models.GuestSnapshot{s}, guests) + active := m.GetActiveAlerts() + if len(active) != 1 || active[0].Level != AlertLevelCritical { + t.Fatalf("control must establish one critical incident: %+v", active) + } + if err := m.AcknowledgeAlert(active[0].ID, "operator"); err != nil { + t.Fatal(err) + } + before := snapshotDateJSON(t, m.GetActiveAlerts()) + s.Time, s.SizeBytes = date, 0 + for range 4 { + m.CheckSnapshotsForInstance("east", []models.GuestSnapshot{s}, guests) + } + if after := snapshotDateJSON(t, m.GetActiveAlerts()); !bytes.Equal(before, after) { + t.Fatalf("unknown date changed occurrence/evidence/acknowledgement: before=%s after=%s", before, after) + } + if got := len(queryAlertEvents(t, m, eventlog.Filter{Types: []string{eventlog.TypeFired, eventlog.TypeRefired, eventlog.TypeResolved}})); got != 1 { + t.Fatalf("unknown date invented a lifecycle transition: events=%d", got) + } + s.Time, s.SizeBytes = time.Now().Add(-time.Hour), 1<<30 + m.CheckSnapshotsForInstance("east", []models.GuestSnapshot{s}, guests) + m.CheckSnapshotsForInstance("east", []models.GuestSnapshot{s}, guests) + if len(m.GetActiveAlerts()) != 0 || len(queryAlertEvents(t, m, eventlog.Filter{Types: []string{eventlog.TypeResolved}})) != 1 { + t.Fatal("trustworthy normal observation must resolve exactly once") + } + }) + } + } + } + + t.Run("never-observed", func(t *testing.T) { + m := snapshotDateManager(t) + s, guests := snapshotDateFixture("qemu") + for _, date := range invalid { + s.Time = date + m.CheckSnapshotsForInstance("east", []models.GuestSnapshot{s}, guests) + } + if len(m.GetActiveAlerts()) != 0 || len(queryAlertEvents(t, m, eventlog.Filter{Types: []string{eventlog.TypeFired, eventlog.TypeResolved}})) != 0 { + t.Fatal("an unknown date cannot invent snapshot age or a lifecycle event") + } + }) + + for _, policy := range []string{"global-disabled", "guest-disabled", "snapshot-disabled", "override-disabled", "threshold-raised", "no-alerts-tag", "removed"} { + t.Run("policy/"+policy, func(t *testing.T) { + m := snapshotDateManager(t) + s, guests := snapshotDateFixture("qemu") + m.CheckSnapshotsForInstance("east", []models.GuestSnapshot{s}, guests) + if len(m.GetActiveAlerts()) != 1 { + t.Fatal("control must establish an incident") + } + s.Time = time.Time{} + snapshots := []models.GuestSnapshot{s} + cfg := m.GetConfig() + switch policy { + case "global-disabled": + cfg.Enabled = false + case "guest-disabled": + cfg.Overrides = map[string]ThresholdConfig{"east:pve:101": {Disabled: true}} + case "snapshot-disabled": + cfg.SnapshotDefaults.Enabled = false + case "override-disabled": + cfg.Overrides = map[string]ThresholdConfig{"east:pve:101": {Snapshot: &SnapshotAlertConfig{Enabled: false}}} + case "threshold-raised": + cfg.SnapshotDefaults = SnapshotAlertConfig{Enabled: true, WarningDays: 30, CriticalDays: 60, WarningSizeGiB: 200, CriticalSizeGiB: 400} + case "no-alerts-tag": + g := guests["east:pve:101"] + g.Tags = []string{"pulse-no-alerts"} + guests[g.ResourceID] = g + case "removed": + snapshots = nil + } + m.UpdateConfig(cfg) + m.CheckSnapshotsForInstance("east", snapshots, guests) + if len(m.GetActiveAlerts()) != 0 { + t.Fatal("unknown date overrode explicit policy or authoritative removal") + } + }) + } + + t.Run("independent-instances-and-snapshots", func(t *testing.T) { + m := snapshotDateManager(t) + s, guests := snapshotDateFixture("qemu") + other := s + other.ID, other.Name = "east-pve-101-daily", "daily" + west := s + west.ID, west.Instance = "west-pve-101-weekly", "west" + g := guests["east:pve:101"] + g.ResourceID, g.Instance, g.Name = "west:pve:101", "west", "west-database" + guests[g.ResourceID] = g + m.CheckSnapshotsForInstance("", []models.GuestSnapshot{s, other, west}, guests) + if len(m.GetActiveAlerts()) != 3 { + t.Fatal("control must establish three independent incidents") + } + s.Time = time.Time{} + // One same-guest snapshot is removed and the other instance recovers. + m.CheckSnapshotsForInstance("east", []models.GuestSnapshot{s}, guests) + if len(m.GetActiveAlerts()) != 2 { + t.Fatal("unknown date protected a removed sibling or cleared another instance") + } + west.Time, west.SizeBytes = time.Now().Add(-time.Hour), 1<<30 + m.CheckSnapshotsForInstance("west", []models.GuestSnapshot{west}, guests) + active := m.GetActiveAlerts() + if len(active) != 1 || active[0].Instance != "east" || active[0].Metadata["snapshotName"] != "weekly" { + t.Fatalf("unknown date leaked across independent snapshot identity: %+v", active) + } + }) +} + +// Actual manager output is the only alert payload used by the browser fixture. +func TestSnapshotDateBrowserProducer(t *testing.T) { + m := snapshotDateManager(t) + s, guests := snapshotDateFixture("qemu") + frames := map[string]json.RawMessage{} + for _, phase := range []string{"stale", "missing", "future", "recovered"} { + switch phase { + case "missing": + s.Time = time.Unix(0, 0) // real absent-snaptime conversion + case "future": + s.Time = time.Now().Add(24 * time.Hour) + case "recovered": + s.Time, s.SizeBytes = time.Now().Add(-time.Hour), 1<<30 + } + m.CheckSnapshotsForInstance("east", []models.GuestSnapshot{s}, guests) + frames[phase] = snapshotDateJSON(t, m.GetActiveAlerts()) + want := 1 + if phase == "recovered" { + want = 0 + } + if len(m.GetActiveAlerts()) != want { + t.Fatalf("phase %s: want %d active alerts", phase, want) + } + } + if !bytes.Equal(frames["stale"], frames["missing"]) || !bytes.Equal(frames["stale"], frames["future"]) { + t.Fatal("browser frames changed the retained occurrence across a date gap") + } + if os.Getenv("PULSE_SNAPSHOT_DATE_FRAMES") == "1" { + fmt.Printf("SNAPSHOT_DATE_FRAMES=%s\n", snapshotDateJSON(t, frames)) + } +} diff --git a/scripts/check-snapshot-date-observation.cjs b/scripts/check-snapshot-date-observation.cjs new file mode 100644 index 0000000000..1b225fcf51 --- /dev/null +++ b/scripts/check-snapshot-date-observation.cjs @@ -0,0 +1,141 @@ +// Run from the assigned root with pulse-worker-browser. No external services. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +const root = '/workspace'; +const compiled = path.join(root, 'tmp/snapshot-date-static'); +const output = path.join(root, 'tmp/snapshot-date-browser'); +const origin = 'http://127.0.0.1:5371'; +const hash = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); +const binding = JSON.parse(fs.readFileSync(path.join(compiled, 'binding.json'))); +const producer = JSON.parse(fs.readFileSync(path.join(root, 'tmp/snapshot-date-producer.json'))); +const frames = producer.frames; +const result = { + result: 'incomplete', + base: '7374abf19b434c9a275df730d28234bea3943774', + producer, + consumer: binding, + harness: hash(fs.readFileSync(__filename)), + playwright: require('playwright/package.json').version, + cases: [], + requests: [], + captures: [], + cleanup: { browser: false, server: false }, + limits: 'Real CheckSnapshotsForInstance/GetActiveAlerts -> AlertsAPI -> Overview/card; synthetic snapshot listings only. No installed recovery, guest action, backup execution, notification delivery or reporter acceptance.', +}; +let server; +let browser; + +(async () => { + fs.mkdirSync(output, { recursive: true }); + assert.equal(result.playwright, JSON.parse(fs.readFileSync(path.join(root, 'tests/integration/package-lock.json'))).packages['node_modules/@playwright/test'].version); + assert.equal(binding.graph, hash(fs.readFileSync(path.join(root, 'frontend-modern/package-lock.json')))); + assert.equal(binding.builder, hash(fs.readFileSync(path.join(root, 'frontend-modern/browser-tests/build-snapshot-date-observation.mjs')))); + assert.equal(producer.runtime_sha256, hash(fs.readFileSync(path.join(root, 'internal/alerts/backup_snapshot.go')))); + assert.deepEqual(frames.stale, frames.missing); + assert.deepEqual(frames.stale, frames.future); + assert.equal(frames.stale.length, 1); + assert.deepEqual(frames.recovered, []); + for (const [file, digest] of Object.entries(binding.sources)) { + assert.equal(hash(fs.readFileSync(path.join(root, 'frontend-modern', file))), digest, file); + } + for (const file of ['src/api/alerts.ts', 'src/features/alerts/OverviewTab.tsx', 'src/features/alerts/AlertOverviewAlertCard.tsx']) { + assert(binding.sources[file], `real consumer not compiled: ${file}`); + } + const assets = new Map(); + for (const [file, digest] of Object.entries(binding.artifacts)) { + const bytes = fs.readFileSync(path.join(compiled, file)); + assert.equal(hash(bytes), digest, file); + assets.set('/' + file, bytes); + } + server = http.createServer((request, response) => { + const pathname = new URL(request.url, origin).pathname; + const bytes = assets.get(pathname); + if (!bytes || request.method !== 'GET') { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { 'Content-Type': { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }[path.extname(pathname)] || 'application/octet-stream' }); + response.end(bytes); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(5371, '127.0.0.1', resolve); + }); + for (const [name, engine, width, height] of [ + ['desktop', chromium, 1280, 900], + ['narrow', webkit, 320, 812], + ]) { + browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true }); + const page = await browser.newPage({ viewport: { width, height }, isMobile: width < 768, hasTouch: width < 768, locale: 'en-GB', timezoneId: 'UTC' }); + page.setDefaultTimeout(10000); + const record = { name, width, height, browser: browser.version(), errors: [], phases: [] }; + page.on('pageerror', (error) => record.errors.push(error.message)); + let phase = 'stale'; + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + assert.equal(url.origin, origin, 'external request'); + assert.equal(request.method(), 'GET', 'unexpected mutation'); + if (!url.pathname.startsWith('/api/')) return route.continue(); + result.requests.push({ viewport: name, phase, path: url.pathname }); + let body; + if (url.pathname === '/api/alerts/active') body = frames[phase]; + else if (url.pathname === '/api/alerts/delivery-diagnosis') body = []; + else if (url.pathname === '/api/notifications/health') body = { queue: { status: 'unavailable' } }; + else if (url.pathname === '/api/alerts/incidents') body = null; + else throw new Error(`unexpected API read: ${url.pathname}`); + return route.fulfill({ json: body }); + }); + await page.goto(origin + '/browser-tests/snapshot-date-observation.html'); + await page.waitForFunction(() => window.__snapshotDate?.generation() === 1); + for (phase of ['stale', 'missing', 'future', 'recovered']) { + if (phase !== 'stale') { + const previous = await page.evaluate(() => window.__snapshotDate.generation()); + await page.getByRole('button', { name: 'Refresh monitoring evidence', exact: true }).click(); + await page.waitForFunction((generation) => window.__snapshotDate.generation() > generation, previous); + } + const active = await page.evaluate(() => Object.values(window.__snapshotDate.active())); + assert.deepEqual(active, frames[phase], `producer/consumer mismatch in ${name}/${phase}`); + if (phase !== 'recovered') { + await page.getByText(frames.stale[0].message, { exact: true }).waitFor(); + const more = page.getByRole('button', { name: 'More', exact: true }); + if (await more.count()) { + await more.click(); + await page.getByRole('button', { name: 'Less', exact: true }).click(); + } + } else { + assert.equal(await page.getByText(frames.stale[0].message, { exact: true }).count(), 0); + } + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth); + assert.equal(overflow, false, `${name}/${phase} horizontal overflow`); + const capture = `${name}-${phase}.png`; + await page.screenshot({ path: path.join(output, capture), fullPage: true }); + result.captures.push({ file: capture, sha256: hash(fs.readFileSync(path.join(output, capture))) }); + record.phases.push({ phase, active: active.length, overflow }); + } + assert.deepEqual(record.errors, []); + result.cases.push(record); + await browser.close(); + browser = undefined; + } + result.result = 'passed'; +})().catch((error) => { + result.result = 'failed'; + result.error = String(error.stack || error); + process.exitCode = 1; +}).finally(async () => { + if (browser) await browser.close(); + result.cleanup.browser = true; + if (server) await new Promise((resolve) => server.close(resolve)); + result.cleanup.server = true; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); +}); From c3a6b04fb6e691bb2d20bed1ddb749b314c49a39 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:12:43 +0100 Subject: [PATCH 2/2] Keep the global alert pause contract in snapshot regression The focused source proof shows that global alerting disablement pauses evaluation without resolving existing incidents. Assert exact trusted occurrence preservation for that case; retain clearing assertions for snapshot/guest disablement, exclusions, threshold changes and removal. No production source changes. Change-source: pulse-maintainer --- docs/release-control/v6/internal/subsystems/alerts.md | 1 + internal/alerts/snapshot_date_observation_test.go | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 58cb0b0b2b..de838fdf0e 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -24,6 +24,7 @@ only that snapshot's existing canonical occurrence, including its trusted age/size evidence, acknowledgement and observation time. It neither creates a new occurrence nor refreshes old evidence. A trustworthy dated observation, authoritative removal, exclusion or disablement still follows normal recovery. +The global alerting switch remains an evaluation pause, not evidence of recovery. This is deliberately conservative for both age and size warnings: an incomplete snapshot observation does not independently update either metric. It introduces diff --git a/internal/alerts/snapshot_date_observation_test.go b/internal/alerts/snapshot_date_observation_test.go index 28d6cdc08e..79b2a1b3bc 100644 --- a/internal/alerts/snapshot_date_observation_test.go +++ b/internal/alerts/snapshot_date_observation_test.go @@ -103,6 +103,7 @@ func TestSnapshotDateObservations(t *testing.T) { if len(m.GetActiveAlerts()) != 1 { t.Fatal("control must establish an incident") } + before := snapshotDateJSON(t, m.GetActiveAlerts()) s.Time = time.Time{} snapshots := []models.GuestSnapshot{s} cfg := m.GetConfig() @@ -126,6 +127,14 @@ func TestSnapshotDateObservations(t *testing.T) { } m.UpdateConfig(cfg) m.CheckSnapshotsForInstance("east", snapshots, guests) + if policy == "global-disabled" { + // The global switch pauses evaluation; it is not snapshot + // recovery or per-resource disablement. Preserve that contract. + if !bytes.Equal(before, snapshotDateJSON(t, m.GetActiveAlerts())) { + t.Fatal("global evaluation pause changed the trusted occurrence") + } + return + } if len(m.GetActiveAlerts()) != 0 { t.Fatal("unknown date overrode explicit policy or authoritative removal") }