Files
sencho/frontend/src/components/StructuredLogViewer.tsx
T
Anso 07a2e8f0e3 feat(stack-logs): WebSocket reconnect with backoff and gap sentinel (#1197)
The structured log viewer opened its WebSocket once and never tried to
recover. When the remote node dropped the tunnel mid-tail or the central
restarted, log output went silent with no indicator. The 10k row buffer
kept prior content on screen, so the silence looked like the stack had
just stopped producing logs.

Adds exponential-backoff reconnect (1s, 2s, 4s, 8s, 16s, cap 30s) with
a "reconnecting..." banner replacing the green "following" pip while
the socket is down. Backoff resets to the first delay after every
successful re-open.

docker logs -f has no resumable offset, so on successful reconnect the
viewer drops a synthetic warning sentinel into the row stream:

  --- reconnected; older lines may be missing ---

The sentinel renders at 70% opacity to distinguish it from container
output. Operators see at a glance that there was a gap.

The closedByCleanup flag prevents the reconnect loop from racing the
effect cleanup path: when the component unmounts or stackName changes,
the flag is set BEFORE ws.close() so the onclose handler short-circuits
instead of scheduling another reconnect.

Resolves M-1 from the stack-management audit.
2026-05-24 15:39:48 -04:00

300 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from './ui/button';
import { Download, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
interface StructuredLogViewerProps {
stackName: string;
}
type LogLevel = 'info' | 'warn' | 'err';
interface LogRow {
id: number;
ts: string | null;
level: LogLevel;
message: string;
/** True when this row was synthesized by the client (e.g. reconnect sentinel). */
synthetic?: boolean;
}
type Filter = 'all' | LogLevel;
const BUFFER_CAP = 10_000;
const TIMESTAMP_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s+(.*)$/;
// eslint-disable-next-line no-control-regex
const ANSI_REGEX = /\x1b\[[0-9;]*[A-Za-z]/g;
const ERROR_REGEX = /\b(ERROR|ERR|FATAL|Exception)\b/i;
const WARN_REGEX = /\b(WARN|WARNING|WRN)\b/i;
// Reconnect backoff: 1s, 2s, 4s, 8s, 16s, 30s cap. After each successful
// open the schedule resets. docker logs -f has no resumable offset, so on
// reconnect we drop a synthetic sentinel row to mark the gap rather than
// pretend nothing was missed.
const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
function parseLine(raw: string): Omit<LogRow, 'id'> {
const stripped = raw.replace(ANSI_REGEX, '').replace(/[\r\n]+$/, '');
const match = stripped.match(TIMESTAMP_REGEX);
const ts = match ? match[1] : null;
const body = match ? match[2] : stripped;
let level: LogLevel = 'info';
if (ERROR_REGEX.test(body)) level = 'err';
else if (WARN_REGEX.test(body)) level = 'warn';
return { ts, level, message: body };
}
function formatTs(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export default function StructuredLogViewer({ stackName }: StructuredLogViewerProps) {
const [rows, setRows] = useState<LogRow[]>([]);
const [filter, setFilter] = useState<Filter>('all');
const [following, setFollowing] = useState(true);
const [connectionState, setConnectionState] = useState<'connecting' | 'open' | 'reconnecting'>('connecting');
const scrollRef = useRef<HTMLDivElement>(null);
const followingRef = useRef(true);
const rowIdRef = useRef(0);
const wsRef = useRef<WebSocket | null>(null);
const pendingRef = useRef<LogRow[]>([]);
useEffect(() => { followingRef.current = following; }, [following]);
useEffect(() => {
const cleanStackName = stackName.replace(/\.(yml|yaml)$/, '');
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const wsUrl = `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`;
let closedByCleanup = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0;
let rafId = 0;
const flushPending = () => {
rafId = 0;
if (pendingRef.current.length === 0) return;
const incoming = pendingRef.current;
pendingRef.current = [];
setRows((prev) => {
const merged = prev.concat(incoming);
return merged.length > BUFFER_CAP ? merged.slice(merged.length - BUFFER_CAP) : merged;
});
};
const scheduleFlush = () => {
if (rafId !== 0) return;
rafId = requestAnimationFrame(flushPending);
};
const pushSyntheticRow = (level: LogLevel, message: string) => {
rowIdRef.current += 1;
pendingRef.current.push({
id: rowIdRef.current,
ts: new Date().toISOString(),
level,
message,
synthetic: true,
});
scheduleFlush();
};
const connect = () => {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
if (attempt === 0) setConnectionState('connecting');
ws.onopen = () => {
if (closedByCleanup) {
try { ws.close(); } catch { /* ignore */ }
return;
}
if (attempt > 0) {
// Recovered from a disconnect. Mark the gap so the operator knows
// older lines may be missing - docker logs -f has no offset we
// could resume from.
pushSyntheticRow('warn', '--- reconnected; older lines may be missing ---');
}
attempt = 0;
setConnectionState('open');
};
ws.onmessage = (event) => {
if (closedByCleanup) return;
const text = typeof event.data === 'string' ? event.data : '';
if (!text) return;
for (const line of text.split(/\r?\n/)) {
if (!line) continue;
const parsed = parseLine(line);
if (!parsed.message) continue;
rowIdRef.current += 1;
pendingRef.current.push({ id: rowIdRef.current, ...parsed });
}
scheduleFlush();
};
ws.onerror = () => { /* fall through to onclose, which schedules reconnect */ };
ws.onclose = () => {
if (closedByCleanup) return;
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
attempt += 1;
setConnectionState('reconnecting');
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!closedByCleanup) connect();
}, delay);
};
};
connect();
return () => {
closedByCleanup = true;
if (rafId !== 0) cancelAnimationFrame(rafId);
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
const ws = wsRef.current;
if (ws) {
try { ws.close(); } catch { /* ignore */ }
}
wsRef.current = null;
pendingRef.current = [];
};
}, [stackName]);
const filtered = useMemo(() => {
if (filter === 'all') return rows;
return rows.filter(r => r.level === filter);
}, [rows, filter]);
const errCount = useMemo(() => rows.reduce((n, r) => r.level === 'err' ? n + 1 : n, 0), [rows]);
// Auto-scroll to bottom when following.
useEffect(() => {
if (!followingRef.current) return;
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [filtered]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const atBottom = distanceFromBottom < 24;
if (atBottom !== followingRef.current) {
followingRef.current = atBottom;
setFollowing(atBottom);
}
};
const resumeFollow = () => {
setFollowing(true);
followingRef.current = true;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
};
const downloadLogs = () => {
const text = rows.map(r => `${r.ts ?? ''} ${r.level.toUpperCase()} ${r.message}`.trim()).join('\n');
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${stackName.replace(/\.(yml|yaml)$/, '')}-logs.txt`;
a.click();
URL.revokeObjectURL(url);
};
const label = `logs · ${stackName.replace(/\.(yml|yaml)$/, '')}`;
return (
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
<div className="flex items-center justify-between border-b border-muted px-3 py-2 gap-3">
<div className="flex items-center gap-3 min-w-0">
<span className="font-mono text-xs text-stat-subtitle truncate">{label}</span>
{connectionState === 'reconnecting' ? (
<span className="flex items-center gap-1.5 text-[10px] font-mono text-warning" role="status">
<RefreshCw className="h-2.5 w-2.5 animate-spin" strokeWidth={2} />
reconnecting
</span>
) : following ? (
<span className="flex items-center gap-1.5 text-[10px] font-mono text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success animate-[pulse_2.4s_ease-in-out_infinite]" />
following
</span>
) : (
<button
type="button"
onClick={resumeFollow}
className="text-[10px] font-mono text-stat-subtitle hover:text-foreground transition-colors underline-offset-2 hover:underline"
>
resume follow
</button>
)}
</div>
<div className="flex items-center gap-1">
{(['all', 'info', 'warn', 'err'] as const).map(f => (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
className={cn(
'rounded-md px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
filter === f ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
)}
>
{f}{f === 'err' && errCount > 0 ? ` ${errCount}` : ''}
</button>
))}
<div className="mx-1 h-4 w-px bg-muted" />
<Button type="button" size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={downloadLogs} aria-label="Download logs">
<Download className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 min-h-0 overflow-y-auto font-mono text-[11px] leading-[1.5]"
>
{filtered.length === 0 ? (
<div className="px-3 py-2 text-stat-subtitle">Waiting for log output</div>
) : (
filtered.map(row => (
<div
key={row.id}
className={cn(
'grid grid-cols-[64px_44px_1fr] items-start gap-2 border-l-2 border-transparent px-3 py-0.5',
row.level === 'err' && 'border-destructive bg-destructive/[0.04]',
row.level === 'warn' && 'bg-warning/[0.04]',
row.synthetic && 'opacity-70',
)}
>
<span className="text-stat-subtitle">{formatTs(row.ts)}</span>
<span className={cn(
'font-mono text-[9px] uppercase tracking-wide',
row.level === 'err' && 'text-destructive',
row.level === 'warn' && 'text-warning',
row.level === 'info' && 'text-success/80',
)}>
{row.level}
</span>
<span className="whitespace-pre-wrap break-all text-foreground/90">{row.message}</span>
</div>
))
)}
</div>
</div>
);
}