From bb44db0cb12cf99935082461373f3d5d1805ff47 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 23 May 2026 15:43:06 -0400 Subject: [PATCH] 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. --- frontend/src/components/EditorLayout.tsx | 49 ++++- .../sidebar/SidebarActivityTicker.tsx | 195 ++++++++++++++---- .../src/components/sidebar/StackSidebar.tsx | 16 +- .../__tests__/SidebarActivityTicker.test.tsx | 113 +++++++--- .../__tests__/useNextAutoUpdateRun.test.tsx | 106 ++++++++++ .../usePanelSessionStartedAt.test.tsx | 82 ++++++++ .../useSidebarActivitySummary.test.ts | 184 +++++++++++++++++ .../sidebar/useNextAutoUpdateRun.ts | 63 ++++++ .../sidebar/usePanelSessionStartedAt.ts | 34 +++ .../sidebar/useSidebarActivitySummary.ts | 133 ++++++++++++ .../src/context/DeployFeedbackContext.tsx | 9 + 11 files changed, 901 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/sidebar/__tests__/useNextAutoUpdateRun.test.tsx create mode 100644 frontend/src/components/sidebar/__tests__/usePanelSessionStartedAt.test.tsx create mode 100644 frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts create mode 100644 frontend/src/components/sidebar/useNextAutoUpdateRun.ts create mode 100644 frontend/src/components/sidebar/usePanelSessionStartedAt.ts create mode 100644 frontend/src/components/sidebar/useSidebarActivitySummary.ts diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 80c832ab..5327ce3f 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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} diff --git a/frontend/src/components/sidebar/SidebarActivityTicker.tsx b/frontend/src/components/sidebar/SidebarActivityTicker.tsx index 60b071a2..f9a224eb 100644 --- a/frontend/src/components/sidebar/SidebarActivityTicker.tsx +++ b/frontend/src/components/sidebar/SidebarActivityTicker.tsx @@ -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 } + | { 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 = { + 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: ( + + {VERB_LABELS[summary.action].present} + {summary.stackName} + · {elapsed} + + ), + 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: ( + + Failed + · + {stack} + · {formatTimeAgo(summary.notif.timestamp * 1000)} + + ), + 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: ( + + Auto-update + {summary.enabledCount}/{summary.totalCount} + · next run {nextLabel} + + ), + 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: ( + + {summary.notif.stack_name} + · {summary.notif.message} · {formatTimeAgo(summary.notif.timestamp * 1000)} + + ), + kicker: 'LIVE · VIEW STACK →', + action: { kind: 'open-stack-notification', summary }, + }; + } + case 'disconnected': { + return { + dotClass: 'bg-warning', + pulse: false, + Icon: null, + iconClass: '', + primary: Notifications reconnecting, + 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: Live · no stack changes in 1h, + 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 ( ); diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index 46572846..4af8857f 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -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; 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) { ); diff --git a/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx b/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx index 141fedc4..be2aacb2 100644 --- a/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx +++ b/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx @@ -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 { - 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(); + return { ...utils, onAction }; } describe('SidebarActivityTicker', () => { - it('shows idle fallback when no recent stack events', () => { - render( {}} />); - expect(screen.getByText(/IDLE/i)).toBeInTheDocument(); - }); - - it('renders stack name + message when a recent event exists', () => { - render( - {}} /> - ); - expect(screen.getByText('web')).toBeInTheDocument(); - expect(screen.getByText(/deployed/i)).toBeInTheDocument(); - }); - - it('marks connected dot when connected, amber dot when disconnected', () => { - const { rerender } = render( {}} />); + 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( {}} />); + }); + + 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( {}} />); - 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(); + expect(screen.getByRole('button')).toBeDisabled(); }); }); diff --git a/frontend/src/components/sidebar/__tests__/useNextAutoUpdateRun.test.tsx b/frontend/src/components/sidebar/__tests__/useNextAutoUpdateRun.test.tsx new file mode 100644 index 00000000..5de20bc5 --- /dev/null +++ b/frontend/src/components/sidebar/__tests__/useNextAutoUpdateRun.test.tsx @@ -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); + }); +}); diff --git a/frontend/src/components/sidebar/__tests__/usePanelSessionStartedAt.test.tsx b/frontend/src/components/sidebar/__tests__/usePanelSessionStartedAt.test.tsx new file mode 100644 index 00000000..9cb0f533 --- /dev/null +++ b/frontend/src/components/sidebar/__tests__/usePanelSessionStartedAt.test.tsx @@ -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 { + 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(); + }); +}); diff --git a/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts b/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts new file mode 100644 index 00000000..61c2263c --- /dev/null +++ b/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts @@ -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 { + 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[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); + }); +}); diff --git a/frontend/src/components/sidebar/useNextAutoUpdateRun.ts b/frontend/src/components/sidebar/useNextAutoUpdateRun.ts new file mode 100644 index 00000000..11722d2f --- /dev/null +++ b/frontend/src/components/sidebar/useNextAutoUpdateRun.ts @@ -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 { + 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(null); + const abortRef = useRef(null); + + useEffect(() => { + let active = true; + let invalidateTimer: ReturnType | 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; +} diff --git a/frontend/src/components/sidebar/usePanelSessionStartedAt.ts b/frontend/src/components/sidebar/usePanelSessionStartedAt.ts new file mode 100644 index 00000000..5d76c1e9 --- /dev/null +++ b/frontend/src/components/sidebar/usePanelSessionStartedAt.ts @@ -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(null); + const sessionRef = useRef(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; +} diff --git a/frontend/src/components/sidebar/useSidebarActivitySummary.ts b/frontend/src/components/sidebar/useSidebarActivitySummary.ts new file mode 100644 index 00000000..df2ea10b --- /dev/null +++ b/frontend/src/components/sidebar/useSidebarActivitySummary.ts @@ -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): 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 }; diff --git a/frontend/src/context/DeployFeedbackContext.tsx b/frontend/src/context/DeployFeedbackContext.tsx index aa888ed1..a8d8a729 100644 --- a/frontend/src/context/DeployFeedbackContext.tsx +++ b/frontend/src/context/DeployFeedbackContext.tsx @@ -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(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((resolve) => {