mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
bb44db0cb1
* refactor(sidebar): rebuild footer as priority-driven Ops Pulse strip Replace the simple notification ticker with a derived activity summary that picks one of six states (active-op, failure, automation, recent-event, quiet-live, disconnected) and routes per-state clicks to logs, schedules, or activity. The hook owns the cascade; the component is pure presentation; EditorLayout owns wiring. Failure detection covers unread errors in the last 24h; recent-event is limited to non-error stack notifications in the last hour; automation reads the next /scheduled-tasks?action=update run and a debounced state-invalidate listener; the deploy-panel composite key is used for elapsed-time tracking so close-then-immediately-reopen counts as a new session. * refactor(sidebar): apply Ops Pulse audit fixes - countEnabledAutoUpdates now defaults missing autoUpdateSettings entries to enabled, matching the backend's getStackAutoUpdateSettingsForNode contract. Previously the automation state could not render even with the documented per-row default-true. - findFailure now requires a stack_name so the sidebar does not select a system-level error whose click would no-op through navigateToNotification. System errors continue to surface via the top-bar NotificationPanel. - DeployPanelState gains a monotonic sessionId sourced from the existing internal counter, and the new usePanelSessionStartedAt hook keys the elapsed-time tracker off it so a same-stack rerun always resets even when isOpen stays true across succeeded then preparing. - buildConfig splits quiet-live out of the default and adds an exhaustiveness guard so future SidebarActivitySummary variants fail to compile. - New unit tests cover the default-true aggregation, the same-stack session reset, the non-stack failure guard, and the useNextAutoUpdateRun debounce and cleanup paths. Frontend suite: 276 / 276 pass.
180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
import { Rocket, RefreshCw, CircleStop, AlertTriangle, Clock, Activity } from 'lucide-react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { formatTimeAgo, formatAgeShort } from '@/lib/relativeTime';
|
|
import { VERB_LABELS, type ActionVerb } from '@/context/DeployFeedbackContext';
|
|
import type { SidebarActivitySummary } from './useSidebarActivitySummary';
|
|
|
|
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 {
|
|
summary: SidebarActivitySummary;
|
|
onAction: (action: SidebarActivityAction) => void;
|
|
}
|
|
|
|
interface RenderConfig {
|
|
dotClass: string;
|
|
pulse: boolean;
|
|
Icon: LucideIcon | null;
|
|
iconClass: string;
|
|
primary: React.ReactNode;
|
|
kicker: string;
|
|
action: SidebarActivityAction;
|
|
}
|
|
|
|
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={() => 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 text-left',
|
|
'bg-sidebar/80',
|
|
isClickable && 'hover:bg-glass-highlight cursor-pointer',
|
|
!isClickable && 'cursor-default',
|
|
)}
|
|
>
|
|
<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">
|
|
{config.kicker}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|