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
+44 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Button } from './ui/button';
import { Plus } from 'lucide-react';
import { UserProfileDropdown } from './UserProfileDropdown';
@@ -32,6 +32,10 @@ import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { StackSidebar } from '@/components/sidebar/StackSidebar';
import type { StackRowStatus } from '@/components/sidebar/stack-status-utils';
import { useSidebarActivitySummary, countEnabledAutoUpdates } from '@/components/sidebar/useSidebarActivitySummary';
import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun';
import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionStartedAt';
import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker';
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
import { toast } from '@/components/ui/toast-store';
@@ -39,7 +43,7 @@ export default function EditorLayout() {
const { isAdmin, can } = useAuth();
const { isPaid, license } = useLicense();
const { status: trivy } = useTrivyStatus();
const { runWithLog } = useDeployFeedback();
const { runWithLog, panelState } = useDeployFeedback();
const editorState = useEditorViewState();
const {
@@ -64,6 +68,7 @@ export default function EditorLayout() {
const stackListState = useStackListState();
const {
files,
selectedFile,
isLoading,
stackActions: stackActionMap,
@@ -71,6 +76,7 @@ export default function EditorLayout() {
searchQuery, setSearchQuery,
stackStatuses,
stackLabelMap,
autoUpdateSettings,
filterChip, setFilterChip,
bulkMode,
selectedFiles,
@@ -178,6 +184,40 @@ export default function EditorLayout() {
pendingLogsRef,
} = stackActions;
const panelStartedAt = usePanelSessionStartedAt(panelState);
const autoUpdateEnabledCount = useMemo(
() => countEnabledAutoUpdates(files, autoUpdateSettings),
[files, autoUpdateSettings],
);
const nextAutoUpdateRunAt = useNextAutoUpdateRun();
const activitySummary = useSidebarActivitySummary({
notifications,
tickerConnected,
panelState,
panelStartedAt,
autoUpdateEnabledCount,
totalStackCount: files.length,
nextAutoUpdateRunAt,
});
const handleActivityAction = useCallback((action: SidebarActivityAction) => {
switch (action.kind) {
case 'open-stack-notification':
stackActions.navigateToNotification(action.summary.notif);
return;
case 'open-auto-updates':
setActiveView('auto-updates');
return;
case 'open-activity':
setActiveView('global-observability');
return;
case 'noop':
return;
}
}, [stackActions, setActiveView]);
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
const stackName = selectedFile || '';
@@ -303,9 +343,8 @@ export default function EditorLayout() {
if (node) void stackActions.loadFileOnNode(node, file);
},
}}
notifications={notifications}
tickerConnected={tickerConnected}
onOpenActivity={() => setActiveView('global-observability')}
activitySummary={activitySummary}
onActivityAction={handleActivityAction}
bulkMode={bulkMode}
selectedFiles={selectedFiles}
isPaid={isPaid}
@@ -1,57 +1,178 @@
import { useEffect, useMemo, useReducer } from 'react';
import { Rocket, RefreshCw, CircleStop, AlertTriangle, Clock, Activity } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatTimeAgo } from '@/lib/relativeTime';
import type { NotificationItem } from '@/components/dashboard/types';
import { formatTimeAgo, formatAgeShort } from '@/lib/relativeTime';
import { VERB_LABELS, type ActionVerb } from '@/context/DeployFeedbackContext';
import type { SidebarActivitySummary } from './useSidebarActivitySummary';
const NOW_TICK_MS = 10_000;
export type SidebarActivityAction =
| { kind: 'open-stack-notification'; summary: Extract<SidebarActivitySummary, { kind: 'failure' | 'recent-event' }> }
| { kind: 'open-auto-updates' }
| { kind: 'open-activity' }
| { kind: 'noop' };
interface SidebarActivityTickerProps {
notifications: NotificationItem[];
connected: boolean;
onNavigate: () => void;
summary: SidebarActivitySummary;
onAction: (action: SidebarActivityAction) => void;
}
export function SidebarActivityTicker({ notifications, connected, onNavigate }: SidebarActivityTickerProps) {
const [tick, forceUpdate] = useReducer((x: number) => x + 1, 0);
useEffect(() => {
const id = setInterval(forceUpdate, NOW_TICK_MS);
return () => clearInterval(id);
}, []);
const latest = useMemo(() => {
const nowSecs = Math.floor(Date.now() / 1000);
return notifications
.filter(n => n.stack_name && (nowSecs - n.timestamp) <= 3600)
.sort((a, b) => b.timestamp - a.timestamp)[0] ?? null;
}, [notifications, tick]);
interface RenderConfig {
dotClass: string;
pulse: boolean;
Icon: LucideIcon | null;
iconClass: string;
primary: React.ReactNode;
kicker: string;
action: SidebarActivityAction;
}
const idle = latest === null;
const dotClass = connected
? 'bg-success shadow-[0_0_6px_var(--success)] animate-pulse'
: 'bg-warning';
const kicker = idle ? 'IDLE · NO RECENT ACTIVITY' : 'LIVE · VIEW ACTIVITY →';
const VERB_ICON: Record<ActionVerb, LucideIcon> = {
deploy: Rocket,
install: Rocket,
update: RefreshCw,
restart: RefreshCw,
down: CircleStop,
stop: CircleStop,
};
function formatClockHHMM(unixSecs: number): string {
const d = new Date(unixSecs * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function buildConfig(summary: SidebarActivitySummary): RenderConfig {
switch (summary.kind) {
case 'active-op': {
const elapsed = formatAgeShort(Date.now() - summary.startedAt);
return {
dotClass: 'bg-brand shadow-[0_0_6px_var(--brand)]',
pulse: true,
Icon: VERB_ICON[summary.action],
iconClass: 'text-brand',
primary: (
<span className="font-mono text-[11px] truncate">
<span className="text-foreground">{VERB_LABELS[summary.action].present} </span>
<span className="text-brand">{summary.stackName}</span>
<span className="text-muted-foreground"> · {elapsed}</span>
</span>
),
kicker: 'LIVE · STREAMING',
action: { kind: 'noop' },
};
}
case 'failure': {
const stack = summary.notif.stack_name ?? 'unknown';
return {
dotClass: 'bg-destructive shadow-[0_0_6px_var(--destructive)]',
pulse: false,
Icon: AlertTriangle,
iconClass: 'text-destructive',
primary: (
<span className="font-mono text-[11px] truncate">
<span className="text-destructive">Failed</span>
<span className="text-muted-foreground"> · </span>
<span className="text-foreground">{stack}</span>
<span className="text-muted-foreground"> · {formatTimeAgo(summary.notif.timestamp * 1000)}</span>
</span>
),
kicker: 'ALERT · VIEW LOGS →',
action: { kind: 'open-stack-notification', summary },
};
}
case 'automation': {
const nextLabel = formatClockHHMM(summary.nextRunAt);
return {
dotClass: 'bg-warning shadow-[0_0_6px_var(--warning)]',
pulse: false,
Icon: Clock,
iconClass: 'text-warning',
primary: (
<span className="font-mono text-[11px] truncate">
<span className="text-foreground">Auto-update </span>
<span className="text-brand">{summary.enabledCount}/{summary.totalCount}</span>
<span className="text-muted-foreground"> · next run {nextLabel}</span>
</span>
),
kicker: 'AUTOMATION · OPEN SCHEDULE →',
action: { kind: 'open-auto-updates' },
};
}
case 'recent-event': {
return {
dotClass: 'bg-brand shadow-[0_0_6px_var(--brand)]',
pulse: false,
Icon: Activity,
iconClass: 'text-brand',
primary: (
<span className="font-mono text-[11px] truncate">
<span className="text-brand">{summary.notif.stack_name}</span>
<span className="text-muted-foreground"> · {summary.notif.message} · {formatTimeAgo(summary.notif.timestamp * 1000)}</span>
</span>
),
kicker: 'LIVE · VIEW STACK →',
action: { kind: 'open-stack-notification', summary },
};
}
case 'disconnected': {
return {
dotClass: 'bg-warning',
pulse: false,
Icon: null,
iconClass: '',
primary: <span className="font-mono text-[11px] text-muted-foreground">Notifications reconnecting</span>,
kicker: 'LIVE · NOTIFICATIONS PAUSED',
action: { kind: 'noop' },
};
}
case 'quiet-live': {
return {
dotClass: 'bg-success shadow-[0_0_6px_var(--success)]',
pulse: false,
Icon: null,
iconClass: '',
primary: <span className="font-mono text-[11px] text-muted-foreground">Live · no stack changes in 1h</span>,
kicker: 'LIVE · OPEN ACTIVITY →',
action: { kind: 'open-activity' },
};
}
}
// Exhaustiveness guard: any new SidebarActivitySummary variant added later
// without a matching case will fail to assign to `never` and TS will
// surface the gap at build time.
const _exhaustive: never = summary;
throw new Error(`Unhandled summary kind: ${JSON.stringify(_exhaustive)}`);
}
export function SidebarActivityTicker({ summary, onAction }: SidebarActivityTickerProps) {
const config = buildConfig(summary);
const { Icon } = config;
const isClickable = config.action.kind !== 'noop';
return (
<button
type="button"
onClick={onNavigate}
onClick={() => onAction(config.action)}
disabled={!isClickable}
data-state={summary.kind}
className={cn(
'w-full flex flex-col gap-0.5 px-4 py-2 border-t border-glass-border',
'bg-sidebar/80 hover:bg-glass-highlight text-left',
'w-full flex flex-col gap-0.5 px-4 py-2 border-t border-glass-border text-left',
'bg-sidebar/80',
isClickable && 'hover:bg-glass-highlight cursor-pointer',
!isClickable && 'cursor-default',
)}
>
<div className="flex items-center gap-2">
<span data-testid="ticker-dot" className={cn('w-1.5 h-1.5 rounded-full', dotClass)} />
{idle ? (
<span className="font-mono text-[11px] text-muted-foreground">No recent activity</span>
) : (
<span className="font-mono text-[11px] truncate">
<span className="text-brand">{latest.stack_name}</span>
<span className="text-muted-foreground"> · {latest.message} · {formatTimeAgo(latest.timestamp * 1000)}</span>
</span>
<div className="flex items-center gap-2 min-w-0">
<span
data-testid="ticker-dot"
className={cn('w-1.5 h-1.5 rounded-full shrink-0', config.dotClass, config.pulse && 'animate-pulse')}
/>
{Icon !== null && (
<Icon className={cn('w-3 h-3 shrink-0', config.iconClass)} aria-hidden />
)}
<span className="flex-1 min-w-0 truncate">{config.primary}</span>
</div>
<span className="font-mono text-[9px] tracking-[0.22em] uppercase text-stat-subtitle pl-3.5">
{kicker}
{config.kicker}
</span>
</button>
);
@@ -1,9 +1,8 @@
import { useState, useCallback, type ReactNode } from 'react';
import { Command } from '@/components/ui/command';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { NotificationItem } from '@/components/dashboard/types';
import { SidebarActions } from './SidebarActions';
import { SidebarActivityTicker } from './SidebarActivityTicker';
import { SidebarActivityTicker, type SidebarActivityAction } from './SidebarActivityTicker';
import { SidebarBrand } from './SidebarBrand';
import { SidebarBulkBar } from './SidebarBulkBar';
import { SidebarFilterChips, type FilterCounts } from './SidebarFilterChips';
@@ -11,6 +10,7 @@ import { SidebarSearch } from './SidebarSearch';
import { StackList, type StackListProps } from './StackList';
import type { FilterChip } from './sidebar-types';
import type { BulkAction } from '@/hooks/useBulkStackActions';
import type { SidebarActivitySummary } from './useSidebarActivitySummary';
export interface StackSidebarProps {
isDarkMode: boolean;
@@ -25,9 +25,8 @@ export interface StackSidebarProps {
filterCounts: FilterCounts;
onFilterChipChange: (chip: FilterChip) => void;
list: StackListProps;
notifications: NotificationItem[];
tickerConnected: boolean;
onOpenActivity: () => void;
activitySummary: SidebarActivitySummary;
onActivityAction: (action: SidebarActivityAction) => void;
bulkMode: boolean;
selectedFiles: Set<string>;
isPaid: boolean;
@@ -41,7 +40,7 @@ export function StackSidebar(props: StackSidebarProps) {
const {
isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
list, notifications, tickerConnected, onOpenActivity,
list, activitySummary, onActivityAction,
bulkMode, selectedFiles, isPaid, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
} = props;
@@ -97,9 +96,8 @@ export function StackSidebar(props: StackSidebarProps) {
</ScrollArea>
</Command>
<SidebarActivityTicker
notifications={notifications}
connected={tickerConnected}
onNavigate={onOpenActivity}
summary={activitySummary}
onAction={onActivityAction}
/>
</div>
);
@@ -1,44 +1,93 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SidebarActivityTicker } from '../SidebarActivityTicker';
import type { SidebarActivitySummary } from '../useSidebarActivitySummary';
import type { NotificationItem } from '@/components/dashboard/types';
function notif(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 1,
level: 'info',
message: 'web deployed',
timestamp: Math.floor(Date.now() / 1000) - 12,
is_read: 0,
stack_name: 'web',
...overrides,
};
const baseNotif: NotificationItem = {
id: 42,
level: 'info',
message: 'web deployed',
timestamp: Math.floor(Date.now() / 1000) - 12,
is_read: 0,
stack_name: 'web',
};
function renderWith(summary: SidebarActivitySummary) {
const onAction = vi.fn();
const utils = render(<SidebarActivityTicker summary={summary} onAction={onAction} />);
return { ...utils, onAction };
}
describe('SidebarActivityTicker', () => {
it('shows idle fallback when no recent stack events', () => {
render(<SidebarActivityTicker notifications={[]} connected onNavigate={() => {}} />);
expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
});
it('renders stack name + message when a recent event exists', () => {
render(
<SidebarActivityTicker notifications={[notif()]} connected onNavigate={() => {}} />
);
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText(/deployed/i)).toBeInTheDocument();
});
it('marks connected dot when connected, amber dot when disconnected', () => {
const { rerender } = render(<SidebarActivityTicker notifications={[]} connected onNavigate={() => {}} />);
it('renders the quiet-live idle copy with a green dot', () => {
renderWith({ kind: 'quiet-live' });
expect(screen.getByText(/Live · no stack changes in 1h/i)).toBeInTheDocument();
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-success');
rerender(<SidebarActivityTicker notifications={[]} connected={false} onNavigate={() => {}} />);
});
it('renders the disconnected state with an amber dot and notifications-paused kicker', () => {
renderWith({ kind: 'disconnected' });
expect(screen.getByText(/Notifications reconnecting/)).toBeInTheDocument();
expect(screen.getByText(/NOTIFICATIONS PAUSED/)).toBeInTheDocument();
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-warning');
});
it('falls back to idle when events are older than 1 hour', () => {
const old = notif({ timestamp: Math.floor(Date.now() / 1000) - 60 * 60 - 1 });
render(<SidebarActivityTicker notifications={[old]} connected onNavigate={() => {}} />);
expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
it('renders an active deploy with pulsing brand dot and stack name', () => {
renderWith({ kind: 'active-op', stackName: 'api', action: 'deploy', startedAt: Date.now() - 1000 });
expect(screen.getByText('api')).toBeInTheDocument();
expect(screen.getByText(/Deploying/)).toBeInTheDocument();
const dot = screen.getByTestId('ticker-dot');
expect(dot).toHaveClass('bg-brand');
expect(dot).toHaveClass('animate-pulse');
});
it('renders failure state with destructive dot and routes click to open-stack-notification', () => {
const errNotif = { ...baseNotif, level: 'error' as const, message: 'deploy failed' };
const { onAction } = renderWith({ kind: 'failure', notif: errNotif });
expect(screen.getByText(/Failed/)).toBeInTheDocument();
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-destructive');
fireEvent.click(screen.getByRole('button'));
expect(onAction).toHaveBeenCalledWith({
kind: 'open-stack-notification',
summary: { kind: 'failure', notif: errNotif },
});
});
it('renders automation state with counts, next-run time, and routes click to open-auto-updates', () => {
const nextRun = Math.floor(Date.now() / 1000) + 600;
const { onAction } = renderWith({ kind: 'automation', enabledCount: 3, totalCount: 8, nextRunAt: nextRun });
expect(screen.getByText(/Auto-update/)).toBeInTheDocument();
expect(screen.getByText('3/8')).toBeInTheDocument();
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-warning');
fireEvent.click(screen.getByRole('button'));
expect(onAction).toHaveBeenCalledWith({ kind: 'open-auto-updates' });
});
it('renders recent-event with stack name and routes click to open-stack-notification', () => {
const { onAction } = renderWith({ kind: 'recent-event', notif: baseNotif });
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText(/deployed/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button'));
expect(onAction).toHaveBeenCalledWith({
kind: 'open-stack-notification',
summary: { kind: 'recent-event', notif: baseNotif },
});
});
it('quiet-live click opens activity', () => {
const { onAction } = renderWith({ kind: 'quiet-live' });
fireEvent.click(screen.getByRole('button'));
expect(onAction).toHaveBeenCalledWith({ kind: 'open-activity' });
});
it('active-op and disconnected are non-clickable noops', () => {
const { onAction, rerender } = renderWith({ kind: 'active-op', stackName: 'api', action: 'deploy', startedAt: Date.now() });
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
fireEvent.click(btn);
expect(onAction).not.toHaveBeenCalled();
rerender(<SidebarActivityTicker summary={{ kind: 'disconnected' }} onAction={onAction} />);
expect(screen.getByRole('button')).toBeDisabled();
});
});
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
// Module-scope spy that the hook will hit through apiFetch.
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}));
import { useNextAutoUpdateRun } from '../useNextAutoUpdateRun';
function okResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function fireInvalidate(detail: { action?: string; scope?: string }) {
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail }));
}
describe('useNextAutoUpdateRun', () => {
beforeEach(() => {
vi.useFakeTimers();
apiFetchMock.mockReset();
apiFetchMock.mockImplementation(() => Promise.resolve(okResponse([])));
});
afterEach(() => {
vi.useRealTimers();
});
it('fires once on mount', () => {
renderHook(() => useNextAutoUpdateRun());
expect(apiFetchMock).toHaveBeenCalledTimes(1);
expect(apiFetchMock).toHaveBeenCalledWith(
'/scheduled-tasks?action=update',
expect.objectContaining({ localOnly: true }),
);
});
it('returns the earliest enabled next_run_at across tasks', async () => {
apiFetchMock.mockImplementationOnce(() => Promise.resolve(okResponse([
{ enabled: 1, next_run_at: 1_900 },
{ enabled: 1, next_run_at: 1_700 },
{ enabled: 0, next_run_at: 1_500 },
{ enabled: 1, next_run_at: null },
])));
const { result } = renderHook(() => useNextAutoUpdateRun());
// Drain microtasks from the in-flight fetch without advancing fake timers
// (vi.runAllTimersAsync would loop forever on the 60s poll interval).
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current).toBe(1_700);
});
it('debounces rapid invalidations into a single refetch', async () => {
renderHook(() => useNextAutoUpdateRun());
expect(apiFetchMock).toHaveBeenCalledTimes(1);
act(() => {
fireInvalidate({ action: 'auto-update-settings-changed' });
fireInvalidate({ action: 'auto-update-settings-changed' });
fireInvalidate({ action: 'auto-update-settings-changed' });
});
// Debounce window not yet elapsed: still only the mount call.
expect(apiFetchMock).toHaveBeenCalledTimes(1);
await act(async () => { vi.advanceTimersByTime(260); });
expect(apiFetchMock).toHaveBeenCalledTimes(2);
});
it('ignores unrelated state-invalidate events', () => {
renderHook(() => useNextAutoUpdateRun());
apiFetchMock.mockClear();
act(() => {
fireInvalidate({ action: 'something-else' });
fireInvalidate({ scope: 'unrelated' });
});
vi.advanceTimersByTime(500);
expect(apiFetchMock).toHaveBeenCalledTimes(0);
});
it('polls every 60s', async () => {
renderHook(() => useNextAutoUpdateRun());
await act(async () => { await vi.runAllTicks(); });
apiFetchMock.mockClear();
await act(async () => { vi.advanceTimersByTime(60_000); });
expect(apiFetchMock).toHaveBeenCalledTimes(1);
await act(async () => { vi.advanceTimersByTime(60_000); });
expect(apiFetchMock).toHaveBeenCalledTimes(2);
});
it('cleans up listener and interval on unmount (no further fetches)', async () => {
const { unmount } = renderHook(() => useNextAutoUpdateRun());
apiFetchMock.mockClear();
unmount();
await act(async () => {
fireInvalidate({ action: 'auto-update-settings-changed' });
vi.advanceTimersByTime(120_000);
});
expect(apiFetchMock).toHaveBeenCalledTimes(0);
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { usePanelSessionStartedAt } from '../usePanelSessionStartedAt';
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
return {
isOpen: false,
stackName: '',
action: 'deploy',
status: 'preparing',
sessionId: 0,
...over,
};
}
describe('usePanelSessionStartedAt', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns null while the panel is closed', () => {
const { result } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
initialProps: panel(),
});
expect(result.current).toBeNull();
});
it('captures Date.now() when the panel opens', () => {
const { result, rerender } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
initialProps: panel(),
});
expect(result.current).toBeNull();
const opened = Date.now();
rerender(panel({ isOpen: true, stackName: 'web', sessionId: 1 }));
expect(result.current).toBe(opened);
});
it('resets the timestamp when sessionId changes even if isOpen stays true (same stack rerun)', () => {
const { result, rerender } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
initialProps: panel({ isOpen: true, stackName: 'web', sessionId: 1 }),
});
const first = result.current;
expect(first).not.toBeNull();
// Status flows to succeeded but the panel stays visible.
rerender(panel({ isOpen: true, stackName: 'web', status: 'succeeded', sessionId: 1 }));
expect(result.current).toBe(first);
// Advance the clock and trigger a same-stack rerun under a new sessionId.
vi.advanceTimersByTime(5_000);
rerender(panel({ isOpen: true, stackName: 'web', status: 'preparing', sessionId: 2 }));
expect(result.current).not.toBe(first);
expect(result.current).toBe((first ?? 0) + 5_000);
});
it('does not flap when the same panel session re-renders with no changes', () => {
const { result, rerender } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
initialProps: panel({ isOpen: true, stackName: 'web', sessionId: 1 }),
});
const captured = result.current;
vi.advanceTimersByTime(10_000);
rerender(panel({ isOpen: true, stackName: 'web', sessionId: 1, status: 'streaming' }));
expect(result.current).toBe(captured);
});
it('clears the timestamp when the panel closes', () => {
const { result, rerender } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
initialProps: panel({ isOpen: true, stackName: 'web', sessionId: 1 }),
});
expect(result.current).not.toBeNull();
rerender(panel({ isOpen: false, sessionId: 1 }));
expect(result.current).toBeNull();
});
});
@@ -0,0 +1,184 @@
import { describe, it, expect } from 'vitest';
import { __testing, countEnabledAutoUpdates } from '../useSidebarActivitySummary';
import type { NotificationItem } from '@/components/dashboard/types';
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
const { deriveSummary } = __testing;
const NOW_SECS = 1_700_000_000;
function notif(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 1,
level: 'info',
message: 'web deployed',
timestamp: NOW_SECS - 30,
is_read: 0,
stack_name: 'web',
...overrides,
};
}
const IDLE_PANEL: DeployPanelState = { isOpen: false, stackName: '', action: 'deploy', status: 'preparing', sessionId: 0 };
const STREAMING_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'streaming', sessionId: 1 };
const SUCCEEDED_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'succeeded', sessionId: 1 };
function inputs(overrides: Partial<Parameters<typeof deriveSummary>[0]> = {}) {
return {
notifications: [],
tickerConnected: true,
panelState: IDLE_PANEL,
panelStartedAt: null,
autoUpdateEnabledCount: 0,
totalStackCount: 0,
nextAutoUpdateRunAt: null,
...overrides,
};
}
describe('useSidebarActivitySummary.deriveSummary', () => {
it('returns quiet-live when nothing is happening and the WS is connected', () => {
expect(deriveSummary(inputs(), NOW_SECS)).toEqual({ kind: 'quiet-live' });
});
it('returns disconnected when the notification WS is down and there is no recent event', () => {
expect(deriveSummary(inputs({ tickerConnected: false }), NOW_SECS)).toEqual({ kind: 'disconnected' });
});
it('returns active-op while the deploy panel is streaming, preempting everything else', () => {
const failure = notif({ level: 'error', message: 'deploy failed', timestamp: NOW_SECS - 10 });
const r = deriveSummary(inputs({
panelState: STREAMING_PANEL,
panelStartedAt: Date.now(),
notifications: [failure],
}), NOW_SECS);
expect(r.kind).toBe('active-op');
if (r.kind === 'active-op') {
expect(r.stackName).toBe('api');
expect(r.action).toBe('deploy');
}
});
it('ignores the panel once it has finished (status: succeeded)', () => {
const r = deriveSummary(inputs({ panelState: SUCCEEDED_PANEL, panelStartedAt: Date.now() }), NOW_SECS);
expect(r.kind).toBe('quiet-live');
});
it('returns failure when an unread error is within 24h, preempting automation and recent-event', () => {
const failure = notif({ id: 9, level: 'error', message: 'deploy failed', timestamp: NOW_SECS - 60 });
const recent = notif({ id: 10, timestamp: NOW_SECS - 5 });
const r = deriveSummary(inputs({
notifications: [failure, recent],
autoUpdateEnabledCount: 1,
totalStackCount: 1,
nextAutoUpdateRunAt: NOW_SECS + 3600,
}), NOW_SECS);
expect(r.kind).toBe('failure');
if (r.kind === 'failure') expect(r.notif.id).toBe(9);
});
it('skips failure that is older than 24h or already read', () => {
const oldErr = notif({ id: 9, level: 'error', stack_name: 'web', timestamp: NOW_SECS - 25 * 60 * 60 });
const readErr = notif({ id: 10, level: 'error', stack_name: 'web', is_read: 1, timestamp: NOW_SECS - 60 });
const r = deriveSummary(inputs({ notifications: [oldErr, readErr] }), NOW_SECS);
expect(r.kind).not.toBe('failure');
});
it('returns automation when auto-update is enabled and no recent event exists', () => {
const r = deriveSummary(inputs({
autoUpdateEnabledCount: 2,
totalStackCount: 4,
nextAutoUpdateRunAt: NOW_SECS + 3600,
}), NOW_SECS);
expect(r.kind).toBe('automation');
if (r.kind === 'automation') {
expect(r.enabledCount).toBe(2);
expect(r.totalCount).toBe(4);
expect(r.nextRunAt).toBe(NOW_SECS + 3600);
}
});
it('prefers recent-event over automation when a fresh event is available', () => {
const recent = notif({ id: 7, timestamp: NOW_SECS - 5 });
const r = deriveSummary(inputs({
notifications: [recent],
autoUpdateEnabledCount: 1,
totalStackCount: 1,
nextAutoUpdateRunAt: NOW_SECS + 3600,
}), NOW_SECS);
expect(r.kind).toBe('recent-event');
if (r.kind === 'recent-event') expect(r.notif.id).toBe(7);
});
it('drops automation when no next-run is known, even with auto-update settings present', () => {
const r = deriveSummary(inputs({
autoUpdateEnabledCount: 1,
totalStackCount: 1,
nextAutoUpdateRunAt: null,
}), NOW_SECS);
expect(r.kind).toBe('quiet-live');
});
it('does NOT classify a read error notification as a recent-event (severity mis-signal guard)', () => {
const readErr = notif({ id: 11, level: 'error', is_read: 1, timestamp: NOW_SECS - 60 });
const r = deriveSummary(inputs({ notifications: [readErr] }), NOW_SECS);
expect(r.kind).toBe('quiet-live');
});
it('treats a panel that already finished (succeeded) as not active, so a fresh failure preempts', () => {
const failure = notif({ id: 12, level: 'error', timestamp: NOW_SECS - 5 });
const r = deriveSummary(inputs({
panelState: SUCCEEDED_PANEL,
panelStartedAt: Date.now(),
notifications: [failure],
}), NOW_SECS);
expect(r.kind).toBe('failure');
});
it('treats stack events older than 1h as not recent', () => {
const stale = notif({ timestamp: NOW_SECS - 60 * 60 - 1 });
const r = deriveSummary(inputs({ notifications: [stale] }), NOW_SECS);
expect(r.kind).toBe('quiet-live');
});
it('ignores notifications without a stack_name for recent-event detection', () => {
const systemNotif = notif({ stack_name: undefined, timestamp: NOW_SECS - 10 });
const r = deriveSummary(inputs({ notifications: [systemNotif] }), NOW_SECS);
expect(r.kind).toBe('quiet-live');
});
it('failure preempts disconnected fallback', () => {
const failure = notif({ level: 'error', timestamp: NOW_SECS - 30 });
const r = deriveSummary(inputs({ notifications: [failure], tickerConnected: false }), NOW_SECS);
expect(r.kind).toBe('failure');
});
it('does NOT classify a stackless system error as a sidebar failure (router would no-op)', () => {
const systemErr = notif({ id: 21, level: 'error', stack_name: undefined, timestamp: NOW_SECS - 30 });
const r = deriveSummary(inputs({ notifications: [systemErr] }), NOW_SECS);
expect(r.kind).not.toBe('failure');
expect(r.kind).toBe('quiet-live');
});
});
describe('countEnabledAutoUpdates', () => {
it('counts a stack with no explicit row as enabled (backend default-true contract)', () => {
expect(countEnabledAutoUpdates(['web', 'api'], {})).toBe(2);
});
it('respects an explicit false', () => {
expect(countEnabledAutoUpdates(['web', 'api', 'db'], { api: false })).toBe(2);
});
it('respects an explicit true', () => {
expect(countEnabledAutoUpdates(['web'], { web: true })).toBe(1);
});
it('returns 0 for an empty file list', () => {
expect(countEnabledAutoUpdates([], { web: true })).toBe(0);
});
it('ignores settings rows that do not correspond to known files', () => {
expect(countEnabledAutoUpdates(['web'], { ghost: false, web: true })).toBe(1);
});
});
@@ -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;
}
@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from 'react';
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
/**
* Returns the wall-clock timestamp (ms) at which the current deploy-panel
* session opened, or null if the panel is closed.
*
* Keyed off DeployPanelState.sessionId (a monotonic counter from
* DeployFeedbackContext), so a same-stack rerun, or any new runWithLog call
* after a previous one finished but the panel stayed visible, resets the
* timestamp. `isOpen` alone is not sufficient because the panel can flow
* straight from `succeeded`/`failed` back to `preparing` if the user fires
* a follow-up deploy without closing the panel first.
*/
export function usePanelSessionStartedAt(panelState: DeployPanelState): number | null {
const [startedAt, setStartedAt] = useState<number | null>(null);
const sessionRef = useRef<number>(0);
useEffect(() => {
if (!panelState.isOpen) {
if (sessionRef.current !== 0) {
sessionRef.current = 0;
setStartedAt(null);
}
return;
}
if (panelState.sessionId !== sessionRef.current) {
sessionRef.current = panelState.sessionId;
setStartedAt(Date.now());
}
}, [panelState.isOpen, panelState.sessionId]);
return startedAt;
}
@@ -0,0 +1,133 @@
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 };
@@ -20,6 +20,13 @@ export interface DeployPanelState {
action: ActionVerb;
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
errorMessage?: string;
/**
* Monotonic id incremented on every runWithLog call. Lets external
* consumers (e.g. the sidebar footer elapsed-time tracker) detect a new
* deploy of the same stack+action even when isOpen stays true across the
* transition.
*/
sessionId: number;
}
interface RunResult {
@@ -44,6 +51,7 @@ const DEFAULT_PANEL_STATE: DeployPanelState = {
stackName: '',
action: 'deploy',
status: 'preparing',
sessionId: 0,
};
const DeployFeedbackContext = createContext<DeployFeedbackContextValue | undefined>(undefined);
@@ -109,6 +117,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
stackName: params.stackName,
action: params.action,
status: 'preparing',
sessionId: mySession,
});
const deployStarted = new Promise<void>((resolve) => {