mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
bb44db0cb1
* 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.
134 lines
4.9 KiB
TypeScript
134 lines
4.9 KiB
TypeScript
import { useEffect, useMemo, useReducer } from 'react';
|
|
import type { ActionVerb, DeployPanelState } from '@/context/DeployFeedbackContext';
|
|
import type { NotificationItem } from '@/components/dashboard/types';
|
|
|
|
const NOW_TICK_MS = 10_000;
|
|
const FAILURE_WINDOW_SECS = 24 * 60 * 60;
|
|
const RECENT_WINDOW_SECS = 60 * 60;
|
|
|
|
export type SidebarActivitySummary =
|
|
| { kind: 'active-op'; stackName: string; action: ActionVerb; startedAt: number }
|
|
| { kind: 'failure'; notif: NotificationItem }
|
|
| { kind: 'automation'; enabledCount: number; totalCount: number; nextRunAt: number }
|
|
| { kind: 'recent-event'; notif: NotificationItem }
|
|
| { kind: 'quiet-live' }
|
|
| { kind: 'disconnected' };
|
|
|
|
interface SummaryInputs {
|
|
notifications: NotificationItem[];
|
|
tickerConnected: boolean;
|
|
panelState: DeployPanelState;
|
|
panelStartedAt: number | null;
|
|
/** Pre-aggregated by the caller so the memo dep list stays scalar; see EditorLayout. */
|
|
autoUpdateEnabledCount: number;
|
|
totalStackCount: number;
|
|
nextAutoUpdateRunAt: number | null;
|
|
}
|
|
|
|
function findFailure(notifications: NotificationItem[], nowSecs: number): NotificationItem | null {
|
|
for (const n of notifications) {
|
|
if (n.level !== 'error') continue;
|
|
if (n.is_read) continue;
|
|
// System-level errors with no stack_name cannot be routed via
|
|
// navigateToNotification; let the top-bar NotificationPanel surface them
|
|
// instead so the sidebar footer's "view logs" click always lands somewhere.
|
|
if (!n.stack_name) continue;
|
|
if (nowSecs - n.timestamp > FAILURE_WINDOW_SECS) continue;
|
|
return n;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findRecent(notifications: NotificationItem[], nowSecs: number): NotificationItem | null {
|
|
for (const n of notifications) {
|
|
if (!n.stack_name) continue;
|
|
if (n.level === 'error') continue;
|
|
if (nowSecs - n.timestamp > RECENT_WINDOW_SECS) continue;
|
|
return n;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Priority cascade (first match wins):
|
|
* 1. active-op: a deploy panel is preparing/streaming
|
|
* 2. failure: newest unread stack-scoped error in the last 24h
|
|
* 3. recent-event: newest non-error stack notification in the last hour
|
|
* 4. automation: auto-update is enabled and a next run is scheduled
|
|
* 5. disconnected: notification WebSocket is down
|
|
* 6. quiet-live: nothing else to surface
|
|
*
|
|
* Note: recent-event preempts automation because a fresh deploy/restart event
|
|
* is more time-relevant than ambient steady-state ("your last action was 30s
|
|
* ago" beats "auto-update will run at 02:00"). The PR description and tests
|
|
* follow the same order; if you change the cascade, update both.
|
|
*/
|
|
function deriveSummary(inputs: SummaryInputs, nowSecs: number): SidebarActivitySummary {
|
|
const { panelState, panelStartedAt, notifications, autoUpdateEnabledCount, totalStackCount, nextAutoUpdateRunAt, tickerConnected } = inputs;
|
|
|
|
if (panelState.isOpen && (panelState.status === 'preparing' || panelState.status === 'streaming') && panelStartedAt !== null) {
|
|
return { kind: 'active-op', stackName: panelState.stackName, action: panelState.action, startedAt: panelStartedAt };
|
|
}
|
|
|
|
// Notifications are pre-sorted newest-first by useNotifications.
|
|
const failure = findFailure(notifications, nowSecs);
|
|
if (failure) {
|
|
return { kind: 'failure', notif: failure };
|
|
}
|
|
|
|
const recent = findRecent(notifications, nowSecs);
|
|
if (!recent && autoUpdateEnabledCount > 0 && nextAutoUpdateRunAt !== null) {
|
|
return { kind: 'automation', enabledCount: autoUpdateEnabledCount, totalCount: totalStackCount, nextRunAt: nextAutoUpdateRunAt };
|
|
}
|
|
|
|
if (recent) {
|
|
return { kind: 'recent-event', notif: recent };
|
|
}
|
|
|
|
if (!tickerConnected) {
|
|
return { kind: 'disconnected' };
|
|
}
|
|
|
|
return { kind: 'quiet-live' };
|
|
}
|
|
|
|
export function useSidebarActivitySummary(inputs: SummaryInputs): SidebarActivitySummary {
|
|
const [tick, forceTick] = useReducer((x: number) => x + 1, 0);
|
|
|
|
useEffect(() => {
|
|
const id = setInterval(forceTick, NOW_TICK_MS);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
return useMemo(() => deriveSummary(inputs, Math.floor(Date.now() / 1000)),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[
|
|
tick,
|
|
inputs.notifications,
|
|
inputs.tickerConnected,
|
|
inputs.panelState.isOpen,
|
|
inputs.panelState.stackName,
|
|
inputs.panelState.action,
|
|
inputs.panelState.status,
|
|
inputs.panelStartedAt,
|
|
inputs.autoUpdateEnabledCount,
|
|
inputs.totalStackCount,
|
|
inputs.nextAutoUpdateRunAt,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Count stacks with auto-update enabled. Backend defaults missing rows to
|
|
* enabled (DatabaseService.getStackAutoUpdateSettingsForNode); callers must
|
|
* NOT treat absence as disabled.
|
|
*/
|
|
export function countEnabledAutoUpdates(files: string[], settings: Record<string, boolean>): number {
|
|
let n = 0;
|
|
for (const f of files) if (settings[f] ?? true) n++;
|
|
return n;
|
|
}
|
|
|
|
// Exported for unit tests so we don't need to spin up a renderer to validate cascade logic.
|
|
export const __testing = { deriveSummary };
|