mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 19:57:12 +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,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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user