feat(ui): redesign global logs as cockpit surface (#699)

Rework the Logs view into the cockpit language: a PageMasthead strip with
cyan rail, pulsing live dot, italic state word, and tracked-mono kicker;
a SignalRail of EVENTS/MIN (with rolling 60s sparkline), ERRORS, WARNINGS,
and CONTAINERS tiles; a segmented filter strip; a day-banded feed with
severity dots and whole-row tint on ERROR/WARN; and a floating glass chip
strip for pause/resume, clear, and download.

Decouple the live log stream from the Developer Mode toggle so SSE runs by
default for everyone, with polling as an invisible fallback when
EventSource is unavailable. Drop the Standard Log Polling Rate setting
and the global_logs_refresh key it persisted; Developer Mode still gates
Real-Time Metrics and Debug Diagnostics and nothing else.

Introduces the shared PageMasthead and SignalRail primitives for reuse
across other cockpit pages.
This commit is contained in:
Anso
2026-04-19 16:25:54 -04:00
committed by GitHub
parent ef4455f68d
commit b95442c8f7
13 changed files with 633 additions and 261 deletions
@@ -1,20 +1,26 @@
import { useEffect, useState, useMemo, useRef, useCallback, useLayoutEffect } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useEffect, useState, useMemo, useRef, useCallback, useLayoutEffect, memo } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { ScrollArea } from '@/components/ui/scroll-area';
import { RefreshCw, Download, Trash2, Search, Filter, AlertCircle } from 'lucide-react';
import { PageMasthead } from '@/components/ui/PageMasthead';
import { SignalRail, type SignalTile } from '@/components/ui/SignalRail';
import { SegmentedControl, type SegmentedControlOption } from '@/components/ui/segmented-control';
import { Download, Trash2, Search, Filter, AlertCircle, Pause, Play } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import { cn } from '@/lib/utils';
// Max entries held in React state. Bounds SSE-mode memory growth.
const MAX_LOG_ENTRIES = 2000;
// Max rows rendered as DOM nodes at once. Prevents the renderer from
// creating thousands of DOM nodes that OOM the browser on RAM-constrained hosts.
const MAX_DISPLAY_ROWS = 300;
const POLL_FALLBACK_MS = 5000;
const LIVE_WINDOW_MS = 10_000;
const SPARK_BUCKET_MS = 1_000;
const SPARK_BUCKETS = 60;
type StreamFilter = 'ALL' | 'STDOUT' | 'STDERR';
type LevelFilter = 'ALL' | 'ERROR' | 'WARN' | 'INFO';
interface LogEntry {
stackName: string;
@@ -23,67 +29,94 @@ interface LogEntry {
level: 'INFO' | 'WARN' | 'ERROR';
message: string;
timestampMs: number;
// Assigned client-side at ingestion. Gives React a stable, collision-free
// key so the slice window can shift without touching existing DOM nodes.
// Client-assigned so the slice window can shift without re-keying existing rows.
_id: number;
}
function timestampBandLabel(ms: number, now: number): string {
const diff = now - ms;
if (diff < 60_000) return 'NOW';
const mins = Math.floor(diff / 60_000);
if (mins < 60) return `${mins}M AGO`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}H AGO`;
const date = new Date(ms);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatUptime(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
if (h > 0) return `${h}H ${m.toString().padStart(2, '0')}M`;
const s = totalSeconds % 60;
return `${m}M ${s.toString().padStart(2, '0')}S`;
}
const levelDotClass: Record<LogEntry['level'], string> = {
ERROR: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_22%,transparent)]',
WARN: 'bg-warning',
INFO: 'bg-success/80',
};
const levelRowTint: Record<LogEntry['level'], string> = {
ERROR: 'bg-destructive/[0.08]',
WARN: 'bg-warning/[0.06]',
INFO: '',
};
const STREAM_OPTIONS: SegmentedControlOption<StreamFilter>[] = [
{ value: 'ALL', label: 'All' },
{ value: 'STDOUT', label: 'Out' },
{ value: 'STDERR', label: 'Err' },
];
const LEVEL_OPTIONS: SegmentedControlOption<LevelFilter>[] = [
{ value: 'ALL', label: 'All' },
{ value: 'INFO', label: 'Info' },
{ value: 'WARN', label: 'Warn' },
{ value: 'ERROR', label: 'Error' },
];
export function GlobalObservabilityView() {
const { activeNode } = useNodes();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [allStacks, setAllStacks] = useState<string[]>([]);
const [fetchError, setFetchError] = useState(false);
const [lastEventAt, setLastEventAt] = useState<number | null>(null);
// Settings state
const [devMode, setDevMode] = useState(false);
const [pollRate, setPollRate] = useState(5);
// Filters
const [searchQuery, setSearchQuery] = useState('');
const [selectedStacks, setSelectedStacks] = useState<string[]>([]);
const [streamFilter, setStreamFilter] = useState<'ALL' | 'STDOUT' | 'STDERR'>('ALL');
const [levelFilter, setLevelFilter] = useState<'ALL' | 'ERROR' | 'WARN' | 'INFO'>('ALL');
const [streamFilter, setStreamFilter] = useState<StreamFilter>('ALL');
const [levelFilter, setLevelFilter] = useState<LevelFilter>('ALL');
const [clearedAt, setClearedAt] = useState<number>(0);
const [isPaused, setIsPaused] = useState(false);
const [pendingCount, setPendingCount] = useState(0);
const bottomRef = useRef<HTMLDivElement>(null);
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
const viewportRef = useRef<HTMLDivElement>(null);
// SSE throttle buffer
const bufferRef = useRef<LogEntry[]>([]);
// Monotonic counter for stable React keys. Incremented once per log entry
// at ingestion so duplicate-content lines never share a key.
const logIdRef = useRef(0);
// Mirrors isPaused for use inside stable interval callbacks.
const pausedRef = useRef(isPaused);
useEffect(() => { pausedRef.current = isPaused; }, [isPaused]);
// Fetch settings on mount
const fetchSettings = useCallback(async () => {
try {
const res = await apiFetch('/settings');
if (res.ok) {
const data = await res.json();
setDevMode(data.developer_mode === '1');
setPollRate(parseInt(data.global_logs_refresh || '5', 10));
}
} catch (e) {
console.error('Failed to fetch settings:', e);
}
const [mountedAt, setMountedAt] = useState<number | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
const run = () => {
const now = Date.now();
setTick(now);
setMountedAt(prev => prev ?? now);
};
const init = setTimeout(run, 0);
const id = setInterval(run, 1000);
return () => { clearTimeout(init); clearInterval(id); };
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
// Re-fetch settings when they change from the settings modal
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ changedKeys: string[] }>).detail;
if (detail.changedKeys.some(k => k === 'developer_mode' || k === 'global_logs_refresh')) {
fetchSettings();
}
};
window.addEventListener(SENCHO_SETTINGS_CHANGED, handler);
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler);
}, [fetchSettings]);
// Fetch definitive stack list from the filesystem, independent of log data
useEffect(() => {
const fetchStacks = async () => {
try {
@@ -99,78 +132,106 @@ export function GlobalObservabilityView() {
fetchStacks();
}, []);
// Data fetching: Polling (standard) vs SSE (dev mode).
// Depends on activeNode?.id so the stream reconnects on node switch.
const activeNodeId = activeNode?.id;
useEffect(() => {
if (devMode) {
// SSE mode: use the node ID from context (not localStorage)
const nodeParam = activeNodeId != null ? String(activeNodeId) : '';
const eventSource = new EventSource(`/api/logs/global/stream?nodeId=${nodeParam}`);
const nodeParam = activeNodeId != null ? String(activeNodeId) : '';
const ingest = (entries: LogEntry[]) => {
entries.forEach(entry => { entry._id = ++logIdRef.current; });
bufferRef.current.push(...entries);
// Bound the buffer so a long pause doesn't grow memory unbounded.
const excess = bufferRef.current.length - MAX_LOG_ENTRIES;
if (excess > 0) bufferRef.current.splice(0, excess);
setLastEventAt(Date.now());
};
const flush = () => {
if (bufferRef.current.length === 0) return;
if (pausedRef.current) {
setPendingCount(bufferRef.current.length);
return;
}
const batch = bufferRef.current.splice(0);
setLogs(prev => {
const lastPrev = prev[prev.length - 1]?.timestampMs ?? -Infinity;
const firstBatch = batch[0]?.timestampMs ?? Infinity;
const merged = [...prev, ...batch];
// Fast path: SSE delivers monotonically, so skip the sort when the batch
// is already ordered after prev's tail.
if (firstBatch < lastPrev) {
merged.sort((a, b) => a.timestampMs - b.timestampMs);
}
return merged.slice(-MAX_LOG_ENTRIES);
});
setPendingCount(0);
};
let eventSource: EventSource | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let flushTimer: ReturnType<typeof setInterval> | null = null;
let didOpen = false;
let cancelled = false;
const startPolling = () => {
if (pollTimer || cancelled) return;
const fetchBatch = async () => {
try {
const res = await apiFetch('/logs/global');
if (!res.ok) {
setFetchError(true);
return;
}
const data: LogEntry[] = await res.json();
// Polling returns the current snapshot; replace rather than append
// to avoid duplicates across intervals.
data.forEach(entry => { entry._id = ++logIdRef.current; });
if (pausedRef.current) {
setPendingCount(data.length);
} else {
setLogs(data);
}
setLastEventAt(Date.now());
setFetchError(false);
} catch (err) {
console.error('Failed to fetch global logs:', err);
setFetchError(true);
}
};
fetchBatch();
pollTimer = setInterval(fetchBatch, POLL_FALLBACK_MS);
};
try {
eventSource = new EventSource(`/api/logs/global/stream?nodeId=${nodeParam}`);
eventSource.onopen = () => { didOpen = true; setFetchError(false); };
eventSource.onmessage = (event) => {
try {
const entry: LogEntry = JSON.parse(event.data);
entry._id = ++logIdRef.current;
bufferRef.current.push(entry);
ingest([entry]);
} catch { /* ignore parse errors */ }
};
eventSource.onerror = () => {
if (eventSource.readyState === EventSource.CLOSED) {
if (!didOpen) {
eventSource?.close();
eventSource = null;
startPolling();
} else if (eventSource?.readyState === EventSource.CLOSED) {
setFetchError(true);
}
};
// 500ms throttle: flush buffer into React state
const flushInterval = setInterval(() => {
if (bufferRef.current.length > 0) {
const batch = bufferRef.current.splice(0);
setLogs(prev => {
const merged = [...prev, ...batch];
merged.sort((a, b) => a.timestampMs - b.timestampMs);
return merged.slice(-MAX_LOG_ENTRIES);
});
}
}, 500);
setLoading(false);
setFetchError(false);
return () => {
eventSource.close();
clearInterval(flushInterval);
bufferRef.current = [];
};
} else {
// Standard polling mode
const fetchData = async () => {
setLoading(true);
try {
const logsRes = await apiFetch('/logs/global');
if (logsRes.ok) {
const data: LogEntry[] = await logsRes.json();
// Stamp each entry with a monotonic _id at ingestion so React
// has a stable, collision-free key for every log line.
data.forEach(entry => { entry._id = ++logIdRef.current; });
setLogs(data);
setFetchError(false);
} else {
setFetchError(true);
}
} catch (error) {
console.error('Failed to fetch global logs:', error);
setFetchError(true);
} finally {
setLoading(false);
}
};
fetchData();
const interval = setInterval(fetchData, pollRate * 1000);
return () => clearInterval(interval);
flushTimer = setInterval(flush, 500);
} catch {
startPolling();
}
}, [devMode, pollRate, activeNodeId]);
return () => {
cancelled = true;
eventSource?.close();
if (pollTimer) clearInterval(pollTimer);
if (flushTimer) clearInterval(flushTimer);
bufferRef.current = [];
};
}, [activeNodeId]);
const handleStackToggle = (stack: string) => {
setSelectedStacks(prev =>
@@ -182,6 +243,10 @@ export function GlobalObservabilityView() {
setClearedAt(Date.now());
};
const handleResume = () => {
setIsPaused(false);
};
const filteredLogs = useMemo(() => {
return logs.filter(log => {
if (log.timestampMs < clearedAt) return false;
@@ -198,16 +263,67 @@ export function GlobalObservabilityView() {
});
}, [logs, selectedStacks, streamFilter, levelFilter, searchQuery, clearedAt]);
// Counters don't depend on tick, so they don't recompute each second.
const counts = useMemo(() => {
let errors = 0;
let warns = 0;
const containers = new Set<string>();
for (const log of logs) {
if (log.timestampMs < clearedAt) continue;
containers.add(log.containerName);
if (log.level === 'ERROR') errors += 1;
else if (log.level === 'WARN') warns += 1;
}
return { errors, warns, containers: containers.size };
}, [logs, clearedAt]);
// Rolling 60s sparkline buckets; shifts with tick.
const buckets = useMemo(() => {
const windowStart = tick - SPARK_BUCKETS * SPARK_BUCKET_MS;
const b = new Array<number>(SPARK_BUCKETS).fill(0);
for (const log of logs) {
if (log.timestampMs < clearedAt) continue;
if (log.timestampMs >= windowStart) {
const idx = Math.min(SPARK_BUCKETS - 1, Math.floor((log.timestampMs - windowStart) / SPARK_BUCKET_MS));
b[idx] += 1;
}
}
return b;
}, [logs, clearedAt, tick]);
const signals = useMemo<SignalTile[]>(() => {
const eventsPerMin = buckets.reduce((a, b) => a + b, 0);
return [
{ kicker: 'EVENTS / MIN', value: String(eventsPerMin), tone: 'value', spark: buckets },
{ kicker: 'ERRORS', value: String(counts.errors), tone: counts.errors > 0 ? 'error' : 'subtitle' },
{ kicker: 'WARNINGS', value: String(counts.warns), tone: counts.warns > 0 ? 'warn' : 'subtitle' },
{ kicker: 'CONTAINERS', value: String(counts.containers), tone: 'value' },
];
}, [buckets, counts]);
const firstEventReceived = lastEventAt != null;
const liveTone: 'live' | 'idle' = lastEventAt != null && (tick - lastEventAt) < LIVE_WINDOW_MS ? 'live' : 'idle';
const masterTone = fetchError ? 'error' : liveTone;
const stateWord = fetchError ? 'Offline' : masterTone === 'live' ? 'Streaming' : 'Idle';
const nodeLabel = activeNode ? (activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase()) : 'LOCAL';
const kicker = `LIVE LOGS · NODE · ${nodeLabel}`;
const mastheadMetadata = useMemo(() => {
const uptime = mountedAt != null ? formatUptime(tick - mountedAt) : '—';
return [
{ label: 'LAST EVENT', value: lastEventAt ? timestampBandLabel(lastEventAt, tick) : '—', tone: 'subtitle' as const },
{ label: 'SESSION', value: uptime, tone: 'subtitle' as const },
];
}, [tick, lastEventAt, mountedAt]);
useEffect(() => {
if (isAutoScrollEnabled && bottomRef.current) {
// Use instant scroll to avoid stacking smooth-scroll animations on every
// 5-second poll cycle, which wastes layout work and renderer memory.
// Instant scroll avoids stacking smooth-scroll animations on every flush,
// which wastes layout work and renderer memory.
bottomRef.current.scrollIntoView({ behavior: 'instant' });
}
}, [filteredLogs, isAutoScrollEnabled]);
// Wire scroll detection via the Radix viewport ref (ScrollArea does not
// forward onScroll natively).
const handleScroll = useCallback(() => {
const el = viewportRef.current;
if (!el) return;
@@ -224,7 +340,10 @@ export function GlobalObservabilityView() {
const handleDownload = () => {
if (filteredLogs.length === 0) return;
const blob = new Blob([filteredLogs.map(l => `[${new Date(l.timestampMs).toLocaleTimeString([], { hour12: true })}] [${l.stackName}/${l.containerName}] ${l.level}: ${l.message}`).join('\n')], { type: 'text/plain;charset=utf-8' });
const blob = new Blob(
[filteredLogs.map(l => `[${new Date(l.timestampMs).toISOString()}] [${l.stackName}/${l.containerName}] ${l.level}: ${l.message}`).join('\n')],
{ type: 'text/plain;charset=utf-8' },
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -235,35 +354,42 @@ export function GlobalObservabilityView() {
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col h-full w-full bg-background text-foreground">
{/* Permanent Toolbar */}
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-border bg-card">
{activeNode?.type === 'remote' && (
<div className="flex items-center gap-1.5 border border-border rounded-md px-2.5 py-1 text-xs text-muted-foreground mr-1">
<span className="w-1.5 h-1.5 rounded-full bg-info shrink-0" />
{activeNode.name}
</div>
)}
const displayRows = filteredLogs.slice(-MAX_DISPLAY_ROWS);
const overflow = Math.max(0, filteredLogs.length - displayRows.length);
return (
<div className="relative flex h-full w-full flex-col bg-background text-foreground">
<PageMasthead
kicker={kicker}
state={stateWord}
tone={masterTone}
pulsing={masterTone === 'live'}
metadata={mastheadMetadata}
/>
<SignalRail tiles={signals} />
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-card-border bg-card px-[var(--density-row-x)] py-[var(--density-cell-y)]">
<div className="relative flex items-center">
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
<Search className="absolute left-2.5 h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
<Input
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm w-48 bg-transparent"
className="h-8 w-56 bg-transparent pl-8 text-sm focus-visible:ring-brand/50"
/>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-sm">
<Filter className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
<Button variant="outline" size="sm" className="h-8 gap-2 text-sm shadow-btn-glow">
<Filter className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">
Stacks · {selectedStacks.length === 0 ? 'All' : selectedStacks.length}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuContent align="start" className="w-52">
{allStacks.map(stack => (
<DropdownMenuCheckboxItem
key={stack}
@@ -274,88 +400,157 @@ export function GlobalObservabilityView() {
</DropdownMenuCheckboxItem>
))}
{allStacks.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">No stacks found</div>
<div className="px-2 py-1.5 text-sm text-stat-subtitle">No stacks found</div>
)}
</DropdownMenuContent>
</DropdownMenu>
<Select value={streamFilter} onValueChange={(val) => setStreamFilter(val as 'ALL' | 'STDOUT' | 'STDERR')}>
<SelectTrigger className="w-[110px] h-8 text-sm">
<SelectValue placeholder="Stream" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Streams</SelectItem>
<SelectItem value="STDOUT">STDOUT</SelectItem>
<SelectItem value="STDERR">STDERR</SelectItem>
</SelectContent>
</Select>
<Select value={levelFilter} onValueChange={(val) => setLevelFilter(val as 'ALL' | 'ERROR' | 'WARN' | 'INFO')}>
<SelectTrigger className="w-[100px] h-8 text-sm">
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Levels</SelectItem>
<SelectItem value="ERROR">ERROR</SelectItem>
<SelectItem value="WARN">WARN</SelectItem>
<SelectItem value="INFO">INFO</SelectItem>
</SelectContent>
</Select>
<div className="flex-1" />
{devMode && (
<div className="flex items-center px-2 text-xs text-success font-mono animate-pulse">
LIVE
</div>
)}
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<Button variant="outline" size="sm" onClick={handleDownload} disabled={filteredLogs.length === 0} className="h-8 text-sm px-2">
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<SegmentedControl
options={STREAM_OPTIONS}
value={streamFilter}
onChange={setStreamFilter}
ariaLabel="Stream filter"
/>
<SegmentedControl
options={LEVEL_OPTIONS}
value={levelFilter}
onChange={setLevelFilter}
ariaLabel="Level filter"
/>
</div>
{fetchError && (
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-border bg-destructive/5 text-destructive text-xs">
<AlertCircle className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
<div className="flex shrink-0 items-center gap-2 border-b border-destructive/30 bg-destructive/[0.06] px-[var(--density-row-x)] py-1.5 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5 shrink-0" strokeWidth={1.5} />
Failed to fetch logs. Retrying...
</div>
)}
<ScrollArea type="hover" className="flex-1 min-h-0" viewportRef={viewportRef}>
<div className="p-4 relative">
{loading && logs.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
<ScrollArea type="hover" className="relative min-h-0 flex-1" viewportRef={viewportRef}>
<div className="relative px-[var(--density-row-x)] py-[var(--density-cell-y)]">
{!firstEventReceived && logs.length === 0 && (
<div className="flex flex-col items-center gap-2 py-16 text-stat-subtitle">
<span className="font-mono text-[10px] uppercase tracking-[0.22em]">Awaiting events</span>
<span className="text-xs italic">Logs will appear here as containers emit them.</span>
</div>
)}
{filteredLogs.length > 0 ? (
{displayRows.length > 0 ? (
<>
{filteredLogs.length > MAX_DISPLAY_ROWS && (
<div className="text-muted-foreground italic text-xs text-center mb-3 py-1 border-b border-border">
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
{overflow > 0 && (
<div className="mb-3 border-b border-card-border pb-2 text-center">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length}
</span>
</div>
)}
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => (
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-accent/50 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
<span className="text-muted-foreground mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
<span className="text-info font-semibold mr-2">[{log.containerName}]</span>
<span className={`mr-2 font-medium ${log.level === 'ERROR' ? 'text-destructive' : log.level === 'WARN' ? 'text-warning' : 'text-success'}`}>{log.level}:</span>
<span className={log.source === 'STDERR' ? 'text-destructive/80' : 'text-foreground/80'}>{log.message}</span>
</div>
))}
<LogBandedList rows={displayRows} now={tick} />
<div ref={bottomRef} />
</>
) : (
<div className="text-muted-foreground italic p-4 text-center mt-10">
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
) : (firstEventReceived && logs.length > 0) ? (
<div className="flex flex-col items-center gap-2 py-16 text-stat-subtitle">
<span className="font-mono text-[10px] uppercase tracking-[0.22em]">No matches</span>
<span className="text-xs italic">Try a broader filter to see logs again.</span>
</div>
)}
) : null}
</div>
</ScrollArea>
<div className="pointer-events-none absolute bottom-4 right-6 z-10 flex items-center gap-2">
{isPaused && pendingCount > 0 && (
<button
type="button"
onClick={handleResume}
className="pointer-events-auto rounded-md border border-brand/40 bg-brand/15 px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.18em] text-brand shadow-btn-glow transition-colors hover:bg-brand/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
>
{pendingCount} new · resume
</button>
)}
<div className="pointer-events-auto flex items-center gap-1 rounded-md border border-glass-border bg-popover/95 p-1 shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
<Button
variant="ghost"
size="sm"
onClick={() => setIsPaused(p => !p)}
className="h-7 gap-2 px-2 text-xs"
aria-label={isPaused ? 'Resume stream' : 'Pause stream'}
>
{isPaused
? <Play className="h-3.5 w-3.5" strokeWidth={1.5} />
: <Pause className="h-3.5 w-3.5" strokeWidth={1.5} />}
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">
{isPaused ? 'Resume' : 'Pause'}
</span>
</Button>
<div className="h-5 w-px bg-card-border" />
<Button
variant="ghost"
size="sm"
onClick={handleClearLogs}
className="h-7 gap-2 px-2 text-xs text-stat-subtitle hover:text-stat-value"
aria-label="Clear log buffer"
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">Clear</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDownload}
disabled={filteredLogs.length === 0}
className="h-7 gap-2 px-2 text-xs text-stat-subtitle hover:text-stat-value disabled:opacity-40"
aria-label="Download logs"
>
<Download className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">Download</span>
</Button>
</div>
</div>
</div>
);
}
function LogBandedList({ rows, now }: { rows: LogEntry[]; now: number }) {
const nodes: ReactNode[] = [];
let prevBand: string | null = null;
for (const log of rows) {
const band = timestampBandLabel(log.timestampMs, now);
if (band !== prevBand) {
nodes.push(
<div
key={`band-${log._id}`}
className="mt-2 mb-1 border-b border-card-border/60 pb-0.5 font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle first:mt-0"
>
{band}
</div>,
);
prevBand = band;
}
nodes.push(<LogRow key={log._id} log={log} />);
}
return <>{nodes}</>;
}
const LogRow = memo(function LogRow({ log }: { log: LogEntry }) {
return (
<div
className={cn(
'flex items-start gap-3 rounded-sm px-2 py-[var(--density-cell-y)] font-mono text-xs leading-relaxed',
levelRowTint[log.level],
log.level === 'INFO' && 'hover:bg-accent/40',
)}
>
<span
aria-hidden="true"
className={cn('mt-[5px] h-2 w-2 shrink-0 rounded-full', levelDotClass[log.level])}
/>
<span className="shrink-0 tabular-nums text-stat-subtitle">
{new Date(log.timestampMs).toLocaleTimeString([], { hour12: false })}
</span>
<span className="shrink-0 truncate text-brand" title={`${log.stackName}/${log.containerName}`}>
{log.containerName}
</span>
<span className={cn('min-w-0 flex-1 whitespace-pre-wrap break-all', log.source === 'STDERR' ? 'text-destructive/90' : 'text-stat-value/90')}>
{log.message}
</span>
</div>
);
});
@@ -147,7 +147,6 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
const hasDeveloperChanges =
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days ||
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days;
@@ -178,7 +177,6 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
template_registry_url: nodeData.template_registry_url ?? '',
global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
@@ -234,7 +232,6 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
const saveDeveloperSettings = async () => {
const payload = {
developer_mode: settings.developer_mode,
global_logs_refresh: settings.global_logs_refresh,
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
audit_retention_days: settings.audit_retention_days,
@@ -5,7 +5,6 @@ import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useLicense } from '@/context/LicenseContext';
import { RefreshCw, Database, Info } from 'lucide-react';
import type { PatchableSettings } from './types';
@@ -62,7 +61,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics, Debug Diagnostics & Extended Logs</p>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics and Debug Diagnostics</p>
</div>
<TogglePill
id="developer_mode"
@@ -70,30 +69,6 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
onChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
/>
</div>
<div className="space-y-2 pt-4 border-t border-glass-border">
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>
Standard Log Polling Rate
</Label>
<Select
value={settings.global_logs_refresh}
onValueChange={(val) => onSettingChange('global_logs_refresh', val as '1' | '3' | '5' | '10')}
disabled={settings.developer_mode === '1'}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="Select rate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 second</SelectItem>
<SelectItem value="3">3 seconds</SelectItem>
<SelectItem value="5">5 seconds</SelectItem>
<SelectItem value="10">10 seconds</SelectItem>
</SelectContent>
</Select>
{settings.developer_mode === '1' && (
<p className="text-xs text-warning">SSE streaming is active - polling rate is overridden.</p>
)}
</div>
</div>
{/* Data Retention (Observability) */}
+1 -1
View File
@@ -179,7 +179,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
id: 'developer',
group: 'advanced',
label: 'Developer',
description: 'Retention windows, log refresh cadence, and debug modes.',
description: 'Retention windows and debug modes.',
keywords: ['retention', 'logs', 'metrics', 'debug', 'developer'],
tier: null,
scope: 'node',
@@ -4,7 +4,6 @@ export interface PatchableSettings {
host_disk_limit?: string;
docker_janitor_gb?: string;
global_crash?: '0' | '1';
global_logs_refresh?: '1' | '3' | '5' | '10';
developer_mode?: '0' | '1';
template_registry_url?: string;
metrics_retention_hours?: string;
@@ -18,7 +17,6 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
host_disk_limit: '90',
global_crash: '1',
docker_janitor_gb: '5',
global_logs_refresh: '5',
developer_mode: '0',
template_registry_url: '',
metrics_retention_hours: '24',
+126
View File
@@ -0,0 +1,126 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export type MastheadTone = 'live' | 'idle' | 'warn' | 'error';
export interface MastheadMetadataItem {
label: string;
value: string;
tone?: 'value' | 'warn' | 'error' | 'subtitle';
}
export interface PageMastheadProps {
kicker: string;
state: string;
tone: MastheadTone;
pulsing?: boolean;
metadata?: MastheadMetadataItem[];
children?: ReactNode;
className?: string;
}
const toneConfig: Record<MastheadTone, {
dotClass: string;
stateTextClass: string;
tintClass: string;
}> = {
live: {
dotClass: 'bg-brand shadow-[0_0_0_3px_color-mix(in_oklch,var(--brand)_22%,transparent)]',
stateTextClass: 'text-stat-value',
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
},
idle: {
dotClass: 'bg-stat-subtitle',
stateTextClass: 'text-stat-title',
tintClass: 'from-transparent via-transparent to-transparent',
},
warn: {
dotClass: 'bg-warning shadow-[0_0_0_3px_color-mix(in_oklch,var(--warning)_22%,transparent)]',
stateTextClass: 'text-warning',
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
},
error: {
dotClass: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_24%,transparent)]',
stateTextClass: 'text-destructive',
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
},
};
const metadataToneClass: Record<NonNullable<MastheadMetadataItem['tone']>, string> = {
value: 'text-stat-value',
warn: 'text-warning',
error: 'text-destructive',
subtitle: 'text-stat-subtitle',
};
export function PageMasthead({
kicker,
state,
tone,
pulsing = false,
metadata,
children,
className,
}: PageMastheadProps) {
const config = toneConfig[tone];
const shouldPulse = pulsing && (tone === 'live' || tone === 'warn');
return (
<div
className={cn(
'relative shrink-0 overflow-hidden border-b border-card-border bg-card',
className,
)}
>
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r', config.tintClass)} />
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
<div className="relative grid grid-cols-[1fr_auto] items-center gap-6 py-4 pl-7 pr-6">
<div className="flex min-w-0 items-center gap-4">
<span
aria-hidden="true"
className={cn(
'h-2.5 w-2.5 shrink-0 rounded-full',
config.dotClass,
shouldPulse && 'animate-[pulse_2.4s_ease-in-out_infinite]',
)}
/>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
{kicker}
</span>
<span className={cn('font-display italic text-2xl leading-none tracking-tight', config.stateTextClass)}>
{state}
</span>
</div>
{children ? <div className="ml-2 min-w-0">{children}</div> : null}
</div>
{metadata && metadata.length > 0 ? (
<div className="hidden items-stretch justify-end gap-0 md:flex">
{metadata.map((item, idx) => (
<div
key={item.label}
className={cn(
'flex flex-col gap-1 px-5',
idx > 0 && 'border-l border-border/60',
)}
>
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{item.label}
</span>
<span
className={cn(
'font-mono tabular-nums text-lg leading-none',
metadataToneClass[item.tone ?? 'value'],
)}
>
{item.value}
</span>
</div>
))}
</div>
) : null}
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { cn } from '@/lib/utils';
import { Sparkline } from '@/components/ui/sparkline';
export type SignalTone = 'value' | 'warn' | 'error' | 'subtitle';
export interface SignalTile {
kicker: string;
value: string;
tone?: SignalTone;
/** Optional series for a 64x20 sparkline stroked in brand cyan. */
spark?: number[];
}
interface SignalRailProps {
tiles: SignalTile[];
className?: string;
}
const toneClass: Record<SignalTone, string> = {
value: 'text-stat-value',
warn: 'text-warning',
error: 'text-destructive',
subtitle: 'text-stat-subtitle',
};
export function SignalRail({ tiles, className }: SignalRailProps) {
if (tiles.length === 0) return null;
return (
<div
className={cn(
'shrink-0 grid border-b border-card-border bg-card',
className,
)}
style={{ gridTemplateColumns: `repeat(${tiles.length}, minmax(0, 1fr))` }}
>
{tiles.map((tile, idx) => (
<div
key={tile.kicker}
className={cn(
'flex items-center justify-between gap-4 px-5 py-[var(--density-tile-y)]',
idx > 0 && 'border-l border-card-border',
)}
>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{tile.kicker}
</span>
<span
className={cn(
'font-mono tabular-nums tracking-tight text-2xl leading-none',
toneClass[tile.tone ?? 'value'],
)}
>
{tile.value}
</span>
</div>
{tile.spark && tile.spark.length > 1 ? (
<div className="h-5 w-16 shrink-0 opacity-90">
<Sparkline
points={tile.spark}
stroke="var(--brand)"
fill="var(--brand)"
strokeWidth={1.25}
/>
</div>
) : null}
</div>
))}
</div>
);
}