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
-2
View File
@@ -5614,7 +5614,6 @@ const ALLOWED_SETTING_KEYS = new Set([
'host_disk_limit',
'docker_janitor_gb',
'global_crash',
'global_logs_refresh',
'developer_mode',
'template_registry_url',
'metrics_retention_hours',
@@ -5630,7 +5629,6 @@ const SettingsPatchSchema = z.object({
host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String),
docker_janitor_gb: z.coerce.number().min(0).transform(String),
global_crash: z.enum(['0', '1']),
global_logs_refresh: z.enum(['1', '3', '5', '10']),
developer_mode: z.enum(['0', '1']),
template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }),
metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String),
-1
View File
@@ -931,7 +931,6 @@ export class DatabaseService {
stmt.run('host_disk_limit', '90');
stmt.run('global_crash', '1');
stmt.run('docker_janitor_gb', '5');
stmt.run('global_logs_refresh', '5');
stmt.run('developer_mode', '0');
stmt.run('metrics_retention_hours', '24');
stmt.run('log_retention_days', '30');
+43 -29
View File
@@ -3,66 +3,80 @@ title: Global Observability
description: A unified, searchable log stream from every container across all your stacks.
---
The **Logs** tab aggregates output from all running containers into a single scrollable view. Instead of tailing logs one container at a time, you see everything in one place, with filtering to focus on what matters.
The **Logs** tab aggregates output from all running containers into a single scrollable view. Instead of tailing logs one container at a time, you see everything in one place, with contextual metrics and filtering to focus on what matters.
<Frame>
<img src="/images/global-observability/global-observability-overview.png" alt="Global Observability view showing real-time log lines from multiple containers" />
<img src="/images/global-observability/global-observability-overview.png" alt="Global Observability view with live masthead, signal rail, and streaming log feed" />
</Frame>
## Cockpit layout
The page is laid out as a vertical stack of surfaces, each with a distinct role:
| Surface | What it shows |
|---------|---------------|
| **Masthead** | Stream state (`Streaming`, `Idle`, or `Offline`) with a live status dot. Right side shows time since the last event and current session uptime. |
| **Signal rail** | Four tiles: `EVENTS / MIN` with a 60-second sparkline, `ERRORS` count, `WARNINGS` count, and distinct `CONTAINERS` count. Values reflect the current buffer. |
| **Filter strip** | Search box, stacks multi-select, stream pills (`All`, `Out`, `Err`), and level pills (`All`, `Info`, `Warn`, `Error`). |
| **Feed** | Chronological rows grouped by day-banded headers (`NOW`, `2M AGO`, `TODAY 14:20`). Each row shows a severity dot, timestamp, container name, and message. Error rows are tinted rose; warning rows tinted amber. |
| **Chip strip** | Floating bottom-right controls for `Pause`, `Clear`, and `Download`. |
## Streaming
Logs stream live via Server-Sent Events the moment you open the page. The masthead dot pulses cyan while the stream is active; it settles to grey when no events have arrived in the last ten seconds, and turns rose if the connection fails.
If your browser cannot open an `EventSource` connection, Sencho falls back to polling every five seconds automatically. The transport choice is transparent, and no setting is needed to enable streaming.
## Log format
Each line displays:
Each row shows:
- **Timestamp** - when the log line was emitted (local time, 12-hour format)
- **Container name** - the specific container that produced the line, shown in brackets
- **Level** - `INFO`, `WARN`, or `ERROR` (where detectable), colour-coded for quick scanning
- **Message** - the raw log output, tinted red for `STDERR` lines
- **Severity dot** - green for `INFO`, amber for `WARN`, rose with a soft glow for `ERROR`
- **Timestamp** - when the log line was emitted (24-hour local time)
- **Container name** - the specific container that produced the line, rendered in brand cyan
- **Message** - the raw log output, tinted rose for `STDERR` lines
`WARN` lines appear in amber, `ERROR` lines in red, and `INFO` lines in green so you can spot problems at a glance.
Rows for errors are tinted rose across the full width; warnings are tinted amber. The whole-row tint makes problems easy to scan without reading every line.
## Streaming modes
## Day bands
Sencho supports two modes for fetching logs, controlled by the **Developer Mode** toggle in **Settings → Developer**:
| Mode | How it works | Best for |
|------|-------------|----------|
| **Standard** (default) | Polls all containers on a configurable interval | General use; lower overhead |
| **Developer mode** | Server-Sent Events (SSE) stream; logs arrive as they are emitted | Debugging; watching events in real time |
When Developer Mode is active, a pulsing green **● LIVE** indicator appears in the toolbar to confirm the SSE stream is connected.
The default polling interval is 5 seconds. You can change it to 1, 3, 5, or 10 seconds in **Settings → Developer → Standard Log Polling Rate**. This setting is disabled while Developer Mode is active because SSE streaming replaces polling entirely.
When consecutive log rows span a large time gap, a tracked-mono header is drawn between them showing how long ago the next block of rows was emitted. Labels roll up as time passes: recent events are `NOW`, then `2M AGO`, `12M AGO`, `1H AGO`, and eventually a full calendar date.
## Filtering logs
Use the controls in the toolbar above the log panel to narrow what you see:
Use the filter strip above the feed to narrow what you see:
| Control | What it does |
|---------|-------------|
| **Search** | Full-text filter across the message, container name, and stack name (case-insensitive) |
| **Search** | Full-text filter across the message, container name, and stack name (case-insensitive). |
| **Stacks** | Checkbox dropdown; select one or more stacks to show only their containers' logs. Displays "All" when nothing is selected. |
| **All Streams** | Filter by output stream: `All Streams`, `STDOUT`, or `STDERR` |
| **All Levels** | Filter by severity: `All Levels`, `ERROR`, `WARN`, or `INFO` |
| **Stream pills** | Filter by output stream: `All`, `Out` (STDOUT), or `Err` (STDERR). |
| **Level pills** | Filter by severity: `All`, `Info`, `Warn`, or `Error`. |
Filters combine. For example, you can show only `ERROR` lines from a specific stack while searching for a keyword.
Filters combine. For example, you can show only `Error` lines from a specific stack while searching for a keyword.
## Pause and resume
Click **Pause** in the chip strip to freeze the feed while you inspect a run of events. The stream keeps filling in the background, and a cyan pill appears showing how many new events are waiting. Click the pill or **Resume** to flush them and return to live mode.
## Controls
| Button | What it does |
|--------|-------------|
| **Clear** (trash icon) | Clears the current log buffer in the UI. Does not delete logs from Docker. |
| **Download** (download icon) | Exports the currently filtered logs as a `.txt` file. The filename includes a timestamp for easy identification. |
|--------|--------------|
| **Pause / Resume** | Freezes and resumes the feed without disconnecting the stream. |
| **Clear** | Clears the current log buffer in the UI. Does not delete logs from Docker. |
| **Download** | Exports the currently filtered logs as a `.txt` file. The filename includes an ISO timestamp for easy identification. |
## Auto-scroll
The log view automatically scrolls to the bottom as new lines arrive. If you scroll up to inspect older entries, auto-scroll pauses so you are not pulled away. Scrolling back to the bottom re-enables it.
The feed automatically scrolls to the bottom as new rows arrive. If you scroll up to inspect older entries, auto-scroll pauses so you are not pulled away. Scrolling back to the bottom re-enables it.
## Display limits
To keep the browser responsive, the log viewer enforces two capacity limits:
- **Memory buffer** - a maximum of 2,000 log entries are held in memory at a time. Older entries are dropped as new ones arrive.
- **Rendered rows** - only the most recent 300 entries from the filtered set are rendered as DOM nodes. If more entries match, a notice appears at the top of the log area showing how many entries exist versus how many are displayed.
- **Rendered rows** - only the most recent 300 entries from the filtered set are rendered as DOM nodes. If more entries match, a notice appears at the top of the feed showing how many entries exist versus how many are displayed.
For deeper investigation, use the [per-container log viewer](/features/editor#log-viewer) in the Editor tab, or `docker compose logs` directly from the [Host Console](/features/host-console).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+8 -9
View File
@@ -253,16 +253,19 @@ Quick reference:
## Developer
**Scope:** Per-node (streaming and polling settings), Global (data retention)
**Scope:** Per-node (developer mode), Global (data retention)
Advanced settings for log streaming behavior and data retention. Most users can leave these at their defaults.
<Frame>
<img src="/images/settings/settings-developer-section.png" alt="Developer settings panel with the Developer Mode toggle and Data Retention fields" />
</Frame>
### Streaming
Advanced settings for debug diagnostics and data retention. Most users can leave these at their defaults.
### Debug
| Setting | Default | Description |
|---------|---------|-------------|
| **Developer Mode** | Off | Enables real-time SSE streaming for [Global Observability](/features/global-observability), and activates debug diagnostics (timing, cache state, file resolution) in the server logs |
| **Standard Log Polling Rate** | 5s | Polling interval in standard mode. Options: `1s`, `3s`, `5s`, `10s`. Disabled when Developer Mode is on. |
| **Developer Mode** | Off | Enables Real-Time Metrics and Debug Diagnostics (timing, cache state, file resolution) in the server logs. Does not affect [Global Observability](/features/global-observability) streaming, which is always on. |
### Data Retention
@@ -274,10 +277,6 @@ Advanced settings for log streaming behavior and data retention. Most users can
Click **Save Developer Settings** to apply.
<Note>
Lower polling rates (1s) increase backend CPU usage as Sencho polls Docker more frequently. Use only when actively debugging.
</Note>
---
## Nodes
@@ -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>
);
}