refactor(sidebar): rebuild footer as priority-driven Ops Pulse strip (#1178)

* refactor(sidebar): rebuild footer as priority-driven Ops Pulse strip

Replace the simple notification ticker with a derived activity summary that
picks one of six states (active-op, failure, automation, recent-event,
quiet-live, disconnected) and routes per-state clicks to logs, schedules,
or activity. The hook owns the cascade; the component is pure presentation;
EditorLayout owns wiring.

Failure detection covers unread errors in the last 24h; recent-event is
limited to non-error stack notifications in the last hour; automation reads
the next /scheduled-tasks?action=update run and a debounced state-invalidate
listener; the deploy-panel composite key is used for elapsed-time tracking
so close-then-immediately-reopen counts as a new session.

* refactor(sidebar): apply Ops Pulse audit fixes

- countEnabledAutoUpdates now defaults missing autoUpdateSettings entries to
  enabled, matching the backend's getStackAutoUpdateSettingsForNode contract.
  Previously the automation state could not render even with the documented
  per-row default-true.
- findFailure now requires a stack_name so the sidebar does not select a
  system-level error whose click would no-op through navigateToNotification.
  System errors continue to surface via the top-bar NotificationPanel.
- DeployPanelState gains a monotonic sessionId sourced from the existing
  internal counter, and the new usePanelSessionStartedAt hook keys the
  elapsed-time tracker off it so a same-stack rerun always resets even when
  isOpen stays true across succeeded then preparing.
- buildConfig splits quiet-live out of the default and adds an exhaustiveness
  guard so future SidebarActivitySummary variants fail to compile.
- New unit tests cover the default-true aggregation, the same-stack session
  reset, the non-stack failure guard, and the useNextAutoUpdateRun debounce
  and cleanup paths. Frontend suite: 276 / 276 pass.
This commit is contained in:
Anso
2026-05-23 15:43:06 -04:00
committed by GitHub
parent 21ec5e7e0a
commit bb44db0cb1
11 changed files with 901 additions and 83 deletions
@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
import { apiFetch } from '@/lib/api';
import type { ScheduledTask } from '@/types/scheduling';
const POLL_INTERVAL_MS = 60_000;
const INVALIDATE_DEBOUNCE_MS = 250;
async function fetchNextRun(signal: AbortSignal): Promise<number | null> {
const res = await apiFetch('/scheduled-tasks?action=update', { localOnly: true, signal });
if (!res.ok) return null;
const tasks = (await res.json()) as ScheduledTask[];
let earliest: number | null = null;
for (const t of tasks) {
if (!t.enabled) continue;
if (t.next_run_at == null) continue;
if (earliest == null || t.next_run_at < earliest) earliest = t.next_run_at;
}
return earliest;
}
export function useNextAutoUpdateRun(): number | null {
const [nextRunAt, setNextRunAt] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
let active = true;
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
const run = () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
fetchNextRun(ctrl.signal)
.then((v) => { if (active) setNextRunAt(v); })
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === 'AbortError') return;
console.error('[useNextAutoUpdateRun] fetch failed:', err);
});
};
run();
const onInvalidate = (e: Event) => {
const detail = (e as CustomEvent<{ action?: string; scope?: string }>).detail;
if (detail?.action !== 'auto-update-settings-changed' && detail?.scope !== 'scheduled-tasks') return;
if (invalidateTimer) clearTimeout(invalidateTimer);
invalidateTimer = setTimeout(() => { invalidateTimer = null; run(); }, INVALIDATE_DEBOUNCE_MS);
};
window.addEventListener('sencho:state-invalidate', onInvalidate);
const interval = setInterval(run, POLL_INTERVAL_MS);
return () => {
active = false;
window.removeEventListener('sencho:state-invalidate', onInvalidate);
if (invalidateTimer) clearTimeout(invalidateTimer);
clearInterval(interval);
abortRef.current?.abort();
};
}, []);
return nextRunAt;
}