diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 9d7946179e..6bf31e686c 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -2745,6 +2745,22 @@ derived alert read-model and Last 24 Hours stat refresh for shared acknowledgement owner instead of keeping its own alert mutation fork. Future overview action behavior should extend that shared acknowledgement hook instead of putting acknowledge mutations back into render shells. +Overview ages and bands read the shared clock. `AlertOverviewAlertCard.tsx` +passes its own `useRelativeTimeNow` reading as `now` to +`formatAlertOverviewStartedAgo` and `getMetricAlertPresentation`, and the Last +24 Hours count in `useAlertOverviewState.ts` reads the same clock, so all three +measure from the wall clock. The hook no longer exposes a minute `tick`: read +as "now", that tick measured a card mounted between ticks from up to a minute +earlier, so an alert raised 70 seconds ago read "this minute", an alert +raised since the last tick stayed out of the 24h count until the next one, a +reading already past the 10-minute stale cut-off led the card as live, and the +stale flip and 24h drop landed up to a minute late. A mounted card now turns stale, +and an alert leaves the 24h count, within one 30-second tick with no data +change. The delivery diagnoses refresh is a server read and keeps its own +minute interval rather than following the 30-second clock. +`AlertOverviewAlertCard.clock.test.tsx` pins the mid-tick mount, the stale flip +and the 24h drop under fake timers, and `useAlertOverviewState.test.tsx` pins +the diagnoses cadence. Render-heavy alert overview ownership now routes through `frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`, `frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a5fa98dd52..f6598abcd8 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7177,6 +7177,12 @@ shell stays in `frontend-modern/src/features/alerts/OverviewTab.tsx`, while alert stats, filtered ordering, and single/bulk acknowledge runtime behavior. Future overview control flow should extend that hook rather than restoring action timers or acknowledge mutations to the tab shell. +The overview follows the shared-clock rule for relative ages: the alert card +reads `useRelativeTimeNow` for its started age and the live reading's stale +cut-off, and the hook reads it for the Last 24 Hours count. A hook-local +minute signal used as "now" is a table-local interval under that rule; the +hook keeps a minute interval only to refresh delivery diagnoses, a server read +whose cadence is not tied to the age clock. Render-heavy overview ownership now lives in `frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`, `frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`, diff --git a/frontend-modern/browser-verification/6324f927820d6ea5.json b/frontend-modern/browser-verification/6324f927820d6ea5.json new file mode 100644 index 0000000000..2170db9557 --- /dev/null +++ b/frontend-modern/browser-verification/6324f927820d6ea5.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "base_sha": "bab38a25b92684c978c4273200765152040624ed", + "verified_at": "2026-10-07T14:02:51Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx", + "frontend-modern/src/features/alerts/useAlertOverviewState.ts" + ], + "content_sha256": { + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "bb98293f7a7b7b857b40ec71a38e1e32163aa937405278dd07af2a8c9dac1118", + "frontend-modern/src/features/alerts/useAlertOverviewState.ts": "3e2c91ba930a1d3faf3965a5fd68f5bb44e27465a88cbb06c23aabe500d25668" + }, + "routes": [ + "/alerts/overview" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (backend :7697, Vite :5217 serving this tree on base bab38a25b9) with Playwright page.clock installed before load and an init script recording when each setInterval starts, so the shared 30s clock and the overview hook's minute interval have known phases", + "Three producer-shaped copies of a live disk threshold alert (metricStatus breaching) sent in one websocket frame 15s after a shared tick and 45s after the hook's minute, then /ws data frames held (data-free pong per step) and /api reads stalled, so only the clock moves", + "Late-mounted card (raised 70s ago, reading 10m05s old) reads '1 min. ago' and 'Last reading: Disk 92%, 10 mins ago' at mount; held card (reading goes stale 5s after the hook's next minute) is live at mount and 1s before the cut-off, and reads 'Last reading: ..., 10 mins ago' 31s after it; Triggered (24h) drops 85 -> 84 31s after the third copy turns 24h old; no page errors; same at 1440 and 390", + "Same-session control with both runtime files swapped to main under the running Vite: the late card read 'this minute' and led with the old reading as live, the held card was still live 31s after the cut-off, and the 24h count rose 84 -> 85 because the just-raised copy read as future; fix restored and diff compared byte-for-byte", + "tsc clean; src/features/alerts, src/components/Alerts and src/utils suites pass except the Mac-only agentInstallCommand lifecycle tests" + ], + "interactions": [ + "Loaded /alerts/overview, injected the alert copies between ticks, held the stream and stepped the page clock past the stale cut-off and the 24h boundary, at both viewports, before and after the fix" + ] +} diff --git a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx index 870d095c84..cd8487f0c7 100644 --- a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx +++ b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx @@ -36,6 +36,7 @@ import { AlertSnoozeAction } from './AlertSnoozeAction'; import { isAlertSnoozed } from './useAlertSnoozeState'; import { isPulseSystemAlert } from '@/utils/alertScope'; import { formatTemperature } from '@/utils/temperature'; +import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import { normalizeSourcePlatformQueryValue, resolvePlatformTypeFromSources, @@ -110,6 +111,9 @@ const platformPagePath = (platform: string | undefined): string | undefined => { }; export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { + // The started age and the live reading's stale cut-off and ages measure + // from the wall clock, so a card that mounts between ticks is current. + const now = useRelativeTimeNow(); const alertKey = () => getCanonicalAlertId(props.alert); const hasResource = () => !isPulseSystemAlert(props.alert) && Boolean(props.alert.resourceId?.trim()); @@ -138,9 +142,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { // A threshold alert can stay open below its trigger, so the card leads with // the reading Pulse is evaluating now; the message keeps the last breach. const metricPresentation = () => - isPulseSystemAlert(props.alert) - ? null - : getMetricAlertPresentation(props.alert, props.state.tick()); + isPulseSystemAlert(props.alert) ? null : getMetricAlertPresentation(props.alert, now()); const alertLevels = (): { alert: string; clear?: string } | null => { const presentation = metricPresentation(); if (presentation) { @@ -318,7 +320,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { new Date(props.alert.startTime).toLocaleString(), )} > - {formatAlertOverviewStartedAgo(props.alert.startTime, props.state.tick())} + {formatAlertOverviewStartedAgo(props.alert.startTime, now())} ({ + useLocation: () => ({ hash: '', pathname: '/alerts', search: '', query: {} }), + A: (props: Record) => props.children, +})); + +const getDeliveryDiagnoses = vi.fn<() => Promise>(); + +vi.mock('@/api/alerts', () => ({ + AlertsAPI: { + get getDeliveryDiagnoses() { + return getDeliveryDiagnoses; + }, + }, +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@/utils/logger', () => ({ + logger: { error: vi.fn() }, +})); + +vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({ + InvestigateAlertButton: () => null, +})); + +import { OverviewTab } from '../OverviewTab'; + +const OPENED_AT = Date.parse('2026-10-07T12:00:00Z'); +// Between the shared clock's first and second ticks, and before the overview's +// own minute. +const BETWEEN_TICKS_MS = RELATIVE_TIME_TICK_MS + 15_000; + +function cpuAlert(id: string, startTime: number, observedAt: number): Alert { + return { + id, + resourceId: `vm-${id}`, + resourceName: `VM ${id}`, + type: 'cpu', + level: 'warning', + message: `CPU on VM ${id} at 92%`, + value: 92, + threshold: 80, + startTime: new Date(startTime).toISOString(), + lastSeen: new Date(observedAt).toISOString(), + acknowledged: false, + node: 'node1', + metricStatus: { + phase: 'breaching', + value: 91, + unit: '%', + observedAt: new Date(observedAt).toISOString(), + trigger: 80, + recovery: 75, + }, + } as Alert; +} + +function renderOverview() { + const [activeAlerts, setActiveAlerts] = createSignal>({}); + render(() => ( + false} + dismissQuickTip={vi.fn()} + showAcknowledged={() => true} + setShowAcknowledged={vi.fn()} + alertsDisabled={() => false} + /> + )); + return setActiveAlerts; +} + +const triggered24h = () => + screen + .getByText('Triggered (24h)') + .closest('[data-alert-overview-stat]') + ?.querySelector('[data-testid="alert-overview-stat-value"]')?.textContent; + +describe('Alert overview card clock', () => { + beforeEach(() => { + setActiveLocale(DEFAULT_LOCALE); + getDeliveryDiagnoses.mockReset(); + getDeliveryDiagnoses.mockResolvedValue([]); + vi.useFakeTimers(); + vi.setSystemTime(OPENED_AT); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('measures a card that mounts between ticks from the wall clock', () => { + const setActiveAlerts = renderOverview(); + vi.advanceTimersByTime(BETWEEN_TICKS_MS); + + // Raised 70 seconds ago; last evaluated just past the stale cut-off. Read + // from the time of the last tick, the card would call it "this minute" + // and lead with the old reading as the live one. + const now = Date.now(); + setActiveAlerts({ + late: cpuAlert('late', now - 70_000, now - METRIC_ALERT_STATUS_STALE_MS - 5_000), + }); + + expect(screen.getByText('1 min. ago')).toBeInTheDocument(); + expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument(); + }); + + it('turns stale at the next shared tick after the cut-off with no data change', () => { + const setActiveAlerts = renderOverview(); + vi.advanceTimersByTime(BETWEEN_TICKS_MS); + + // The reading goes stale 5 seconds after the shared clock's second tick. + const staleAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000; + const now = Date.now(); + setActiveAlerts({ held: cpuAlert('held', now, staleAt - METRIC_ALERT_STATUS_STALE_MS) }); + expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument(); + + vi.advanceTimersByTime(OPENED_AT + 2 * RELATIVE_TIME_TICK_MS - Date.now()); + expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument(); + + // The next tick lands within one shared tick of the cut-off, not on the + // overview's next minute. + vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS); + expect(screen.queryByText('CPU 91%, above the 80% alert level')).toBeNull(); + expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument(); + }); + + it('drops an alert from the 24h count at the next shared tick after it turns 24h old', () => { + const setActiveAlerts = renderOverview(); + vi.advanceTimersByTime(BETWEEN_TICKS_MS); + + const leavesWindowAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000; + setActiveAlerts({ old: cpuAlert('old', leavesWindowAt - 86_400_000, Date.now()) }); + expect(triggered24h()).toBe('1'); + + vi.advanceTimersByTime(OPENED_AT + 3 * RELATIVE_TIME_TICK_MS - Date.now()); + expect(triggered24h()).toBe('0'); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx b/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx index a3dddd8e8f..aae6143db3 100644 --- a/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx @@ -26,7 +26,6 @@ import { AlertOverviewAlertCard } from '../AlertOverviewAlertCard'; const acknowledge = vi.fn(); const toggleTimeline = vi.fn(); const state = { - tick: () => Date.now(), processingAlerts: () => new Set(), snoozeProcessingAlerts: () => new Set(), deliveryDiagnoses: () => ({}), diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx index 80e0cd2034..6c854746b4 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AlertsAPI } from '@/api/alerts'; import { notificationStore } from '@/stores/notifications'; import type { Alert, AlertDeliveryDiagnosis } from '@/types/api'; +import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock'; import { useAlertOverviewState } from '../useAlertOverviewState'; @@ -118,6 +119,22 @@ describe('useAlertOverviewState', () => { expect(result.deliveryDiagnoses()).toEqual({ a1: retained }); }); + it('refreshes diagnoses once a minute, not on the 30-second age clock', async () => { + renderHook(() => + useAlertOverviewState({ + activeAlerts: () => ({ a1: makeAlert('a1', new Date().toISOString()) }), + overrides: () => [], + showAcknowledged: () => true, + updateAlert: vi.fn(), + }), + ); + expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(RELATIVE_TIME_TICK_MS); + expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(60_000 - RELATIVE_TIME_TICK_MS); + expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledTimes(2); + }); + it('ignores pending responses and stops periodic reads after disposal', async () => { let finish!: (value: AlertDeliveryDiagnosis[]) => void; vi.mocked(AlertsAPI.getDeliveryDiagnoses).mockReturnValueOnce( diff --git a/frontend-modern/src/features/alerts/useAlertOverviewState.ts b/frontend-modern/src/features/alerts/useAlertOverviewState.ts index 583bb9163b..0424aba4da 100644 --- a/frontend-modern/src/features/alerts/useAlertOverviewState.ts +++ b/frontend-modern/src/features/alerts/useAlertOverviewState.ts @@ -3,6 +3,7 @@ import type { Accessor } from 'solid-js'; import { AlertsAPI } from '@/api/alerts'; import type { Alert, AlertDeliveryDiagnosis } from '@/types/api'; +import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import type { Override } from './types'; import { useAlertAcknowledgementState } from './useAlertAcknowledgementState'; import { useAlertSnoozeState } from './useAlertSnoozeState'; @@ -34,8 +35,7 @@ export interface UseAlertOverviewStateProps { } export function useAlertOverviewState(props: UseAlertOverviewStateProps) { - const [tick, setTick] = createSignal(Date.now()); - const tickInterval = setInterval(() => setTick(Date.now()), 60_000); + const now = useRelativeTimeNow(); const activeAlerts = createMemo(() => Object.values(props.activeAlerts())); const { unacknowledgedAlerts, @@ -56,10 +56,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { handleUnsnooze, } = useAlertSnoozeState({ alerts: activeAlerts, updateAlert: props.updateAlert }); - onCleanup(() => { - clearInterval(tickInterval); - }); - // Delivery diagnoses answer "did/will this alert notify?" per card. One // bulk request covers every active alert; a failed refresh keeps the last // snapshot and the card simply shows no delivery line for unknown alerts. @@ -98,19 +94,24 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { .join('\n'), ); createEffect(() => { - // Refresh when the active alert set changes and on the shared minute - // tick, so time-based holds (cooldown, quiet hours) stay current. + // Refresh when the active alert set changes. activeAlertIdsKey(); - tick(); void refreshDeliveryDiagnoses(); }); + // And once a minute, so time-based holds (cooldown, quiet hours) stay + // current. This is a server read, so it keeps its own minute rather than + // following the 30-second clock the ages read. + const diagnosisInterval = setInterval(() => void refreshDeliveryDiagnoses(), 60_000); + onCleanup(() => { + clearInterval(diagnosisInterval); + }); const alertStats = createMemo(() => { const alerts = effectiveAlerts(); const recent = alerts.filter((alert) => { const ts = new Date(alert.startTime).getTime(); if (Number.isNaN(ts)) return true; - const age = tick() - ts; + const age = now() - ts; return age >= 0 && age < 86_400_000; }); const unacknowledged = alerts.filter((alert) => !alert.acknowledged); @@ -169,7 +170,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { }); return { - tick, alertStats, filteredAlerts, groupedAlerts,