diff --git a/backend/src/index.ts b/backend/src/index.ts index 412e6ee3..c30da513 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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), diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 4c71bec9..0d5d2a24 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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'); diff --git a/docs/features/global-observability.mdx b/docs/features/global-observability.mdx index 243b0f31..8fd245c9 100644 --- a/docs/features/global-observability.mdx +++ b/docs/features/global-observability.mdx @@ -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. - Global Observability view showing real-time log lines from multiple containers + Global Observability view with live masthead, signal rail, and streaming log feed +## 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). diff --git a/docs/images/global-observability/global-observability-overview.png b/docs/images/global-observability/global-observability-overview.png index 37c2f428..d745536c 100644 Binary files a/docs/images/global-observability/global-observability-overview.png and b/docs/images/global-observability/global-observability-overview.png differ diff --git a/docs/images/settings/settings-developer-section.png b/docs/images/settings/settings-developer-section.png new file mode 100644 index 00000000..a282011d Binary files /dev/null and b/docs/images/settings/settings-developer-section.png differ diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index ae5c5abe..b913df38 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -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. + + Developer settings panel with the Developer Mode toggle and Data Retention fields + -### 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. - - Lower polling rates (1s) increase backend CPU usage as Sencho polls Docker more frequently. Use only when actively debugging. - - --- ## Nodes diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index 8fd76d47..193c986a 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -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 = { + 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 = { + ERROR: 'bg-destructive/[0.08]', + WARN: 'bg-warning/[0.06]', + INFO: '', +}; + +const STREAM_OPTIONS: SegmentedControlOption[] = [ + { value: 'ALL', label: 'All' }, + { value: 'STDOUT', label: 'Out' }, + { value: 'STDERR', label: 'Err' }, +]; + +const LEVEL_OPTIONS: SegmentedControlOption[] = [ + { 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([]); - const [loading, setLoading] = useState(true); const [allStacks, setAllStacks] = useState([]); const [fetchError, setFetchError] = useState(false); + const [lastEventAt, setLastEventAt] = useState(null); - // Settings state - const [devMode, setDevMode] = useState(false); - const [pollRate, setPollRate] = useState(5); - - // Filters const [searchQuery, setSearchQuery] = useState(''); const [selectedStacks, setSelectedStacks] = useState([]); - const [streamFilter, setStreamFilter] = useState<'ALL' | 'STDOUT' | 'STDERR'>('ALL'); - const [levelFilter, setLevelFilter] = useState<'ALL' | 'ERROR' | 'WARN' | 'INFO'>('ALL'); + const [streamFilter, setStreamFilter] = useState('ALL'); + const [levelFilter, setLevelFilter] = useState('ALL'); const [clearedAt, setClearedAt] = useState(0); + + const [isPaused, setIsPaused] = useState(false); + const [pendingCount, setPendingCount] = useState(0); + const bottomRef = useRef(null); const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); const viewportRef = useRef(null); - // SSE throttle buffer const bufferRef = useRef([]); - // 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(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 | null = null; + let flushTimer: ReturnType | 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(); + 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(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(() => { + 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 ( -
- {/* Permanent Toolbar */} -
- {activeNode?.type === 'remote' && ( -
- - {activeNode.name} -
- )} + const displayRows = filteredLogs.slice(-MAX_DISPLAY_ROWS); + const overflow = Math.max(0, filteredLogs.length - displayRows.length); + return ( +
+ + + + +
- + 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" />
- - + {allStacks.map(stack => ( ))} {allStacks.length === 0 && ( -
No stacks found
+
No stacks found
)}
- - - - -
- - {devMode && ( -
- ● LIVE -
- )} - - - + +
{fetchError && ( -
- +
+ Failed to fetch logs. Retrying...
)} - -
- {loading && logs.length === 0 && ( -
- + +
+ {!firstEventReceived && logs.length === 0 && ( +
+ Awaiting events + Logs will appear here as containers emit them.
)} - {filteredLogs.length > 0 ? ( + {displayRows.length > 0 ? ( <> - {filteredLogs.length > MAX_DISPLAY_ROWS && ( -
- Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries. + {overflow > 0 && ( +
+ + Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} +
)} - {filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => ( -
- [{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] - [{log.containerName}] - {log.level}: - {log.message} -
- ))} +
- ) : ( -
- {logs.length === 0 ? "No active logs found." : "No logs match the current filters."} + ) : (firstEventReceived && logs.length > 0) ? ( +
+ No matches + Try a broader filter to see logs again.
- )} + ) : null}
+ +
+ {isPaused && pendingCount > 0 && ( + + )} +
+ +
+ + +
+
); } + +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( +
+ {band} +
, + ); + prevBand = band; + } + nodes.push(); + } + return <>{nodes}; +} + +const LogRow = memo(function LogRow({ log }: { log: LogEntry }) { + return ( +
+
+ ); +}); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c9bd5dcc..119f1282 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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, diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx index 8603f3b2..aaa97682 100644 --- a/frontend/src/components/settings/DeveloperSection.tsx +++ b/frontend/src/components/settings/DeveloperSection.tsx @@ -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,
-

Enable Real-Time Metrics, Debug Diagnostics & Extended Logs

+

Enable Real-Time Metrics and Debug Diagnostics

onSettingChange('developer_mode', c ? '1' : '0')} />
- -
- - - {settings.developer_mode === '1' && ( -

SSE streaming is active - polling rate is overridden.

- )} -
{/* Data Retention (Observability) */} diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 74b1635d..6dd5162e 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -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', diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index cff72d43..6fb95bae 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -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', diff --git a/frontend/src/components/ui/PageMasthead.tsx b/frontend/src/components/ui/PageMasthead.tsx new file mode 100644 index 00000000..c6f25fec --- /dev/null +++ b/frontend/src/components/ui/PageMasthead.tsx @@ -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 = { + 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, 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 ( +
+
+
+
+
+
+ + {metadata && metadata.length > 0 ? ( +
+ {metadata.map((item, idx) => ( +
0 && 'border-l border-border/60', + )} + > + + {item.label} + + + {item.value} + +
+ ))} +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/ui/SignalRail.tsx b/frontend/src/components/ui/SignalRail.tsx new file mode 100644 index 00000000..eefbdf4c --- /dev/null +++ b/frontend/src/components/ui/SignalRail.tsx @@ -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 = { + 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 ( +
+ {tiles.map((tile, idx) => ( +
0 && 'border-l border-card-border', + )} + > +
+ + {tile.kicker} + + + {tile.value} + +
+ {tile.spark && tile.spark.length > 1 ? ( +
+ +
+ ) : null} +
+ ))} +
+ ); +}