mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user