feat(stack-view): per-container health strip and structured logs viewer (#689)

* feat(stack-view): per-container health strip and structured logs viewer

Replaces the flat container list with a per-container health strip showing
healthcheck state, uptime, port mapping with an open-app link, and live
cpu/memory/network sparklines fed by a 60-sample ring buffer on the stats
WebSocket.

Adds a structured logs viewer that parses docker timestamps (emitted by the
-t flag on the logs stream) and classifies each line by level. Rows render
as a DOM grid with filter pills (all / info / warn / err with count),
following indicator, and plain-text download. A segmented toggle switches
between the structured viewer and the original xterm view; the choice is
persisted in localStorage.

* fix(stack-view): disable no-control-regex for ANSI escape pattern

ANSI escape sequences start with ESC (0x1B), which is a control character.
The regex is intentional and cannot be rewritten without it.
This commit is contained in:
Anso
2026-04-19 00:17:51 -04:00
committed by GitHub
parent 82aabfe64c
commit a65a1c0e86
5 changed files with 478 additions and 120 deletions
+1 -1
View File
@@ -285,7 +285,7 @@ export class ComposeService {
}
};
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
const child = spawn('docker', ['logs', '-f', '-t', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
+37
View File
@@ -117,6 +117,43 @@ The header answers three questions at a glance:
- `exited` (red) when no containers are up.
- **What does it ship?** A mono line under the title shows the primary image tag and the short image digest, with a copy button to grab the full digest.
## Container health strip
Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" without expanding anything.
<Frame>
<img src="/images/stack-view/health-strip-logs.png" alt="Stack view showing the per-container health strip above the structured logs viewer" />
</Frame>
Each row includes:
- **Health badge.** A colored glyph reports the Docker healthcheck state: `✓` green for passing, `✗` red for failing, `…` amber while the healthcheck start period is still in flight. Containers without a `healthcheck:` block show a neutral `✓`.
- **Container name** in mono.
- **Meta line.** Uptime (`up 20 hours`), healthcheck state when one is defined, and the primary port mapping (`6875 → 80/tcp`).
- **Open link.** When the container publishes a port, an `open ↗` link launches the app in a new tab using the active node's hostname.
- **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling 60-sample sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds.
- **Action icons.** Shortcuts to open this container's logs or a bash shell.
## Logs viewer
The logs area at the bottom of the stack view has two modes. Toggle between them with the segmented control in the top-right of the panel; your choice is remembered for next time.
**Structured** (default) parses every line into a DOM row with three columns: local time, level badge, and message. Level detection is automatic:
- `err` rows get a red left rail and a subtle rose tint.
- `warn` rows get a warm tint.
- `info` rows render plainly.
The toolbar includes:
- **Following indicator.** A pulsing green dot reads `following` while auto-scroll is engaged. Scrolling up stops follow; a `resume follow` link re-engages it and jumps to the bottom.
- **Level filter.** `all · info · warn · err` pills. The `err` pill shows the running error count as a badge.
- **Download.** Exports the current buffer as a plain text file.
The structured viewer holds up to 10,000 lines; older entries are dropped from the top as new ones arrive.
**Raw terminal** keeps the original xterm-based viewer for cases where ANSI art, control sequences, or interactive TTY output matter. Lines include the raw ISO timestamp.
## Deploying a stack
Select a stack and click **Start** (or **Deploy** from the context menu) in the stack header. This runs `docker compose up -d`, pulling images if needed and creating or recreating containers.
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

+215 -119
View File
@@ -37,7 +37,6 @@ import { Checkbox } from './ui/checkbox';
import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields';
import { Skeleton } from './ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-menu';
@@ -50,6 +49,8 @@ import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
import { GitSourcePanel } from './stack/GitSourcePanel';
import { AppStoreView } from './AppStoreView';
import { LogViewer } from './LogViewer';
import StructuredLogViewer from './StructuredLogViewer';
import { Sparkline } from './ui/sparkline';
import { GlobalObservabilityView } from './GlobalObservabilityView';
import { FleetView } from './FleetView';
import { AuditLogView } from './AuditLogView';
@@ -104,6 +105,23 @@ const formatBytes = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Extract the "up X time" portion from a Docker status string like
// "Up 12 days (healthy)" → "up 12 days". Returns null when the container
// is not in an uptime-reporting state (exited, created, restarting, etc.).
const extractUptime = (status: string | undefined): string | null => {
if (!status) return null;
const match = status.match(/^\s*Up\s+(.+?)(?:\s*\(.*\))?\s*$/i);
if (!match) return null;
return `up ${match[1].trim()}`;
};
const healthcheckLabel = (health?: 'healthy' | 'unhealthy' | 'starting' | 'none'): string | null => {
if (!health || health === 'none') return null;
if (health === 'healthy') return 'healthcheck passing';
if (health === 'unhealthy') return 'healthcheck failing';
return 'healthcheck starting';
};
type StackPill = { label: string; dotClass: string; className: string; pulse: boolean };
const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => {
@@ -184,15 +202,39 @@ export default function EditorLayout() {
const [envFiles, setEnvFiles] = useState<string[]>([]);
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
const [containers, setContainers] = useState<ContainerInfo[]>([]);
const [containerStats, setContainerStats] = useState<Record<string, { cpu: string, ram: string, net: string, lastRx?: number, lastTx?: number }>>({});
const [containerStats, setContainerStats] = useState<Record<string, {
cpu: string;
ram: string;
net: string;
lastRx?: number;
lastTx?: number;
history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] };
}>>({});
// Incoming WebSocket stats are written here first (no re-render), then flushed
// to React state in one batched update every 1.5 s.
const pendingStatsRef = useRef<Record<string, { cpu: string; ram: string; net: string; lastRx: number; lastTx: number }>>({});
const pendingStatsRef = useRef<Record<string, {
cpu: string;
ram: string;
net: string;
lastRx: number;
lastTx: number;
cpuNum: number;
memNum: number;
netInNum: number;
netOutNum: number;
}>>({});
// Raw rx/tx byte totals used for rate calculation. Never cleared on flush so
// the delta is always computed against the most recent known value, avoiding
// the stale-closure bug that occurs when reading containerStats directly.
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
const [logsMode, setLogsMode] = useState<'structured' | 'raw'>(() => {
if (typeof window === 'undefined') return 'structured';
return (localStorage.getItem('sencho.stackView.logsMode') as 'structured' | 'raw' | null) ?? 'structured';
});
useEffect(() => {
try { localStorage.setItem('sencho.stackView.logsMode', logsMode); } catch { /* ignore */ }
}, [logsMode]);
const [gitSourceOpen, setGitSourceOpen] = useState(false);
const [gitSourcePendingMap, setGitSourcePendingMap] = useState<Record<string, boolean>>({});
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
@@ -944,6 +986,10 @@ export default function EditorLayout() {
net: netIO,
lastRx: currentRx,
lastTx: currentTx,
cpuNum: parseFloat(cpuPercent) || 0,
memNum: data.memory_stats.usage / (1024 * 1024),
netInNum: rxRate,
netOutNum: txRate,
};
} catch {
// Ignore parse errors
@@ -963,16 +1009,26 @@ export default function EditorLayout() {
pendingStatsRef.current = {};
setContainerStats(prev => {
let hasChanges = false;
const next = { ...prev };
const HISTORY_CAP = 60;
for (const [id, newStats] of Object.entries(pending)) {
const old = prev[id];
if (!old || old.cpu !== newStats.cpu || old.ram !== newStats.ram || old.net !== newStats.net) {
next[id] = newStats;
hasChanges = true;
}
const prior = prev[id]?.history ?? { cpu: [], mem: [], netIn: [], netOut: [] };
const history = {
cpu: [...prior.cpu, newStats.cpuNum].slice(-HISTORY_CAP),
mem: [...prior.mem, newStats.memNum].slice(-HISTORY_CAP),
netIn: [...prior.netIn, newStats.netInNum].slice(-HISTORY_CAP),
netOut: [...prior.netOut, newStats.netOutNum].slice(-HISTORY_CAP),
};
next[id] = {
cpu: newStats.cpu,
ram: newStats.ram,
net: newStats.net,
lastRx: newStats.lastRx,
lastTx: newStats.lastTx,
history,
};
}
return hasChanges ? next : prev;
return next;
});
}, 1500);
@@ -1753,19 +1809,6 @@ export default function EditorLayout() {
return stackName;
};
const getContainerBadge = (container: ContainerInfo) => {
const status = (container.Status || '').toLowerCase();
const state = (container.State || '').toLowerCase();
if (status.includes('(unhealthy)') || state === 'exited' || state === 'dead') {
return { variant: 'destructive' as const, text: container.State };
}
if (status.includes('(starting)')) {
return { variant: 'secondary' as const, text: container.State };
}
return { variant: 'default' as const, text: container.State };
};
return (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
{/* Left Sidebar (Stacks) */}
@@ -2755,113 +2798,134 @@ export default function EditorLayout() {
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
{/* Containers List */}
{/* Per-container health strip */}
<div className="mt-4">
<h4 className="text-sm font-medium text-muted-foreground mb-3">CONTAINERS</h4>
{safeContainers.length === 0 ? (
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
{safeContainers.map(container => {
let mainPort: number | undefined;
let mainPortPrivate: number | undefined;
let mainPortProto: string | undefined;
if (container.Ports && container.Ports.length > 0) {
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
const IGNORE_PORTS = [1900, 53, 22];
// 1. Match typical Web UI Private ports
let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort));
// 2. Match typical Web UI Public ports
if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort));
// 3. Fallback to any port not in ignore list
if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort));
mainPort = (match || container.Ports[0]).PublicPort;
const chosen = match || container.Ports[0];
mainPort = chosen.PublicPort;
mainPortPrivate = chosen.PrivatePort;
mainPortProto = 'tcp';
}
const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container';
const isRunning = container.State === 'running';
const health = container.healthStatus;
const uptime = isRunning ? extractUptime(container.Status) : null;
const hcLabel = healthcheckLabel(health);
const stats = containerStats[container?.Id];
const history = stats?.history;
const badgeClass = health === 'unhealthy' || !isRunning
? 'bg-destructive text-destructive-foreground'
: health === 'starting'
? 'bg-warning text-warning-foreground'
: 'bg-success text-success-foreground';
const badgeGlyph = health === 'unhealthy' || !isRunning ? '✗' : health === 'starting' ? '…' : '✓';
const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)';
return (
<div key={container?.Id || Math.random()} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<HoverCard>
<HoverCardTrigger asChild>
<div className="cursor-help inline-flex">
<Badge variant={getContainerBadge(container).variant} className="text-xs">
{getContainerBadge(container).text || 'unknown'}
</Badge>
</div>
</HoverCardTrigger>
<HoverCardContent className="flex w-50 flex-col gap-0.5">
<div className="space-y-1">
<h4 className="text-sm font-medium">Container Status</h4>
<p className="text-sm text-muted-foreground">
{container?.Status || 'No status details available'}
</p>
</div>
</HoverCardContent>
</HoverCard>
<span className="text-xs text-muted-foreground whitespace-nowrap">
CPU: {container.State === 'running' ? (containerStats[container?.Id]?.cpu || 'N/A') : '0.00%'} | RAM: {container.State === 'running' ? (containerStats[container?.Id]?.ram || 'N/A') : '0.00 MB'} | NET: {container.State === 'running' ? (containerStats[container?.Id]?.net || '0 B ↓ / 0 B ↑') : '0 B/s ↓ / 0 B/s ↑'}
</span>
<div key={container?.Id || Math.random()} className="rounded-lg border border-muted bg-muted/30 px-3 py-2.5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className={cn('mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold', badgeClass)}>
{badgeGlyph}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px] text-stat-subtitle">
{uptime ? <span>{uptime}</span> : <span>{(container.State || 'unknown').toLowerCase()}</span>}
{hcLabel ? <><span>·</span><span>{hcLabel}</span></> : null}
{mainPort && mainPortPrivate ? (
<>
<span>·</span>
<span>{mainPort} {mainPortPrivate}/{mainPortProto}</span>
<button
type="button"
onClick={() => {
const host = activeNode?.type === 'remote' && activeNode?.api_url
? new URL(activeNode.api_url).hostname
: window.location.hostname;
window.open(`http://${host}:${mainPort}`, '_blank');
}}
className="inline-flex items-center gap-1 text-brand hover:underline"
>
open <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
</button>
</>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md"
onClick={() => openLogViewer(container?.Id, containerName)}
disabled={!isRunning}
aria-label="View logs"
>
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md"
onClick={() => openBashModal(container?.Id, containerName)}
disabled={!isRunning}
aria-label="Open bash shell"
>
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
)}
</div>
</div>
<div className="flex gap-1">
{mainPort && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="rounded-lg h-8 w-8"
onClick={() => {
const host = activeNode?.type === 'remote' && activeNode?.api_url
? new URL(activeNode.api_url).hostname
: window.location.hostname;
window.open(`http://${host}:${mainPort}`, '_blank');
}}
>
<ExternalLink className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Open App ({mainPort})</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="rounded-lg h-8 w-8"
onClick={() => openLogViewer(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
disabled={container?.State !== 'running'}
>
<ScrollText className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Live Logs</TooltipContent>
</Tooltip>
</TooltipProvider>
{isAdmin && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="rounded-lg h-8 w-8"
onClick={() => openBashModal(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
disabled={container?.State !== 'running'}
>
<Terminal className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Open Bash Terminal</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{isRunning ? (
<div className="mt-2 grid grid-cols-3 gap-2">
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[9px] uppercase tracking-wide text-stat-subtitle">cpu</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.cpu ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.cpu ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[9px] uppercase tracking-wide text-stat-subtitle">mem</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.ram ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.mem ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[9px] uppercase tracking-wide text-stat-subtitle">net i/o</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.net ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.netIn ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
</div>
) : null}
</div>
);
})}
@@ -2871,14 +2935,46 @@ export default function EditorLayout() {
</CardContent>
</Card>
{/* Terminal Section */}
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 min-h-[300px] shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
<h3 className="text-sm font-medium text-stat-subtitle mb-2">Terminal</h3>
<div className="h-[calc(100%-24px)]">
<ErrorBoundary>
<TerminalComponent stackName={stackName} />
</ErrorBoundary>
{/* Logs Section */}
<div className="flex-1 min-h-[320px] flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-stat-subtitle">Logs</h3>
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
<button
type="button"
onClick={() => setLogsMode('structured')}
className={cn(
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
)}
>
Structured
</button>
<button
type="button"
onClick={() => setLogsMode('raw')}
className={cn(
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
)}
>
Raw terminal
</button>
</div>
</div>
{logsMode === 'structured' ? (
<ErrorBoundary>
<StructuredLogViewer stackName={stackName} />
</ErrorBoundary>
) : (
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
<div className="h-full">
<ErrorBoundary>
<TerminalComponent stackName={stackName} />
</ErrorBoundary>
</div>
</div>
)}
</div>
</div>
@@ -0,0 +1,225 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from './ui/button';
import { Download } 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;
}
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;
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 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 closed = false;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (event) => {
if (closed) 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 });
}
};
ws.onerror = () => { /* surface nothing; reconnection is backend's job */ };
// Batch incoming lines into state every 250 ms to avoid thrashing React.
const flushInterval = window.setInterval(() => {
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;
});
}, 250);
return () => {
closed = true;
window.clearInterval(flushInterval);
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>
{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]',
)}
>
<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>
);
}