diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 73de9d24..a7a03a98 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -285,7 +285,7 @@ export class ComposeService { } }; - const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], { + const child = spawn('docker', ['logs', '-f', '-t', '--tail', '100', containerName], { env: { ...process.env, PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 73cf9c1d..c883cd54 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -117,6 +117,43 @@ The header answers three questions at a glance: - `exited` (red) when no containers are up. - **What does it ship?** A mono line under the title shows the primary image tag and the short image digest, with a copy button to grab the full digest. +## Container health strip + +Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" without expanding anything. + + + Stack view showing the per-container health strip above the structured logs viewer + + +Each row includes: + +- **Health badge.** A colored glyph reports the Docker healthcheck state: `✓` green for passing, `✗` red for failing, `…` amber while the healthcheck start period is still in flight. Containers without a `healthcheck:` block show a neutral `✓`. +- **Container name** in mono. +- **Meta line.** Uptime (`up 20 hours`), healthcheck state when one is defined, and the primary port mapping (`6875 → 80/tcp`). +- **Open link.** When the container publishes a port, an `open ↗` link launches the app in a new tab using the active node's hostname. +- **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling 60-sample sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds. +- **Action icons.** Shortcuts to open this container's logs or a bash shell. + +## Logs viewer + +The logs area at the bottom of the stack view has two modes. Toggle between them with the segmented control in the top-right of the panel; your choice is remembered for next time. + +**Structured** (default) parses every line into a DOM row with three columns: local time, level badge, and message. Level detection is automatic: + +- `err` rows get a red left rail and a subtle rose tint. +- `warn` rows get a warm tint. +- `info` rows render plainly. + +The toolbar includes: + +- **Following indicator.** A pulsing green dot reads `following` while auto-scroll is engaged. Scrolling up stops follow; a `resume follow` link re-engages it and jumps to the bottom. +- **Level filter.** `all · info · warn · err` pills. The `err` pill shows the running error count as a badge. +- **Download.** Exports the current buffer as a plain text file. + +The structured viewer holds up to 10,000 lines; older entries are dropped from the top as new ones arrive. + +**Raw terminal** keeps the original xterm-based viewer for cases where ANSI art, control sequences, or interactive TTY output matter. Lines include the raw ISO timestamp. + ## Deploying a stack Select a stack and click **Start** (or **Deploy** from the context menu) in the stack header. This runs `docker compose up -d`, pulling images if needed and creating or recreating containers. diff --git a/docs/images/stack-view/health-strip-logs.png b/docs/images/stack-view/health-strip-logs.png new file mode 100644 index 00000000..402d4624 Binary files /dev/null and b/docs/images/stack-view/health-strip-logs.png differ diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index fdb51aa8..8d5a0caf 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -37,7 +37,6 @@ import { Checkbox } from './ui/checkbox'; import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields'; import { Skeleton } from './ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; -import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-menu'; @@ -50,6 +49,8 @@ import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; import { AppStoreView } from './AppStoreView'; import { LogViewer } from './LogViewer'; +import StructuredLogViewer from './StructuredLogViewer'; +import { Sparkline } from './ui/sparkline'; import { GlobalObservabilityView } from './GlobalObservabilityView'; import { FleetView } from './FleetView'; import { AuditLogView } from './AuditLogView'; @@ -104,6 +105,23 @@ const formatBytes = (bytes: number) => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; +// Extract the "up X time" portion from a Docker status string like +// "Up 12 days (healthy)" → "up 12 days". Returns null when the container +// is not in an uptime-reporting state (exited, created, restarting, etc.). +const extractUptime = (status: string | undefined): string | null => { + if (!status) return null; + const match = status.match(/^\s*Up\s+(.+?)(?:\s*\(.*\))?\s*$/i); + if (!match) return null; + return `up ${match[1].trim()}`; +}; + +const healthcheckLabel = (health?: 'healthy' | 'unhealthy' | 'starting' | 'none'): string | null => { + if (!health || health === 'none') return null; + if (health === 'healthy') return 'healthcheck passing'; + if (health === 'unhealthy') return 'healthcheck failing'; + return 'healthcheck starting'; +}; + type StackPill = { label: string; dotClass: string; className: string; pulse: boolean }; const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => { @@ -184,15 +202,39 @@ export default function EditorLayout() { const [envFiles, setEnvFiles] = useState([]); const [selectedEnvFile, setSelectedEnvFile] = useState(''); const [containers, setContainers] = useState([]); - const [containerStats, setContainerStats] = useState>({}); + const [containerStats, setContainerStats] = useState>({}); // Incoming WebSocket stats are written here first (no re-render), then flushed // to React state in one batched update every 1.5 s. - const pendingStatsRef = useRef>({}); + const pendingStatsRef = useRef>({}); // Raw rx/tx byte totals used for rate calculation. Never cleared on flush so // the delta is always computed against the most recent known value, avoiding // the stale-closure bug that occurs when reading containerStats directly. const rawBytesRef = useRef>({}); const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose'); + const [logsMode, setLogsMode] = useState<'structured' | 'raw'>(() => { + if (typeof window === 'undefined') return 'structured'; + return (localStorage.getItem('sencho.stackView.logsMode') as 'structured' | 'raw' | null) ?? 'structured'; + }); + useEffect(() => { + try { localStorage.setItem('sencho.stackView.logsMode', logsMode); } catch { /* ignore */ } + }, [logsMode]); const [gitSourceOpen, setGitSourceOpen] = useState(false); const [gitSourcePendingMap, setGitSourcePendingMap] = useState>({}); const monacoEditorRef = useRef(null); @@ -944,6 +986,10 @@ export default function EditorLayout() { net: netIO, lastRx: currentRx, lastTx: currentTx, + cpuNum: parseFloat(cpuPercent) || 0, + memNum: data.memory_stats.usage / (1024 * 1024), + netInNum: rxRate, + netOutNum: txRate, }; } catch { // Ignore parse errors @@ -963,16 +1009,26 @@ export default function EditorLayout() { pendingStatsRef.current = {}; setContainerStats(prev => { - let hasChanges = false; const next = { ...prev }; + const HISTORY_CAP = 60; for (const [id, newStats] of Object.entries(pending)) { - const old = prev[id]; - if (!old || old.cpu !== newStats.cpu || old.ram !== newStats.ram || old.net !== newStats.net) { - next[id] = newStats; - hasChanges = true; - } + const prior = prev[id]?.history ?? { cpu: [], mem: [], netIn: [], netOut: [] }; + const history = { + cpu: [...prior.cpu, newStats.cpuNum].slice(-HISTORY_CAP), + mem: [...prior.mem, newStats.memNum].slice(-HISTORY_CAP), + netIn: [...prior.netIn, newStats.netInNum].slice(-HISTORY_CAP), + netOut: [...prior.netOut, newStats.netOutNum].slice(-HISTORY_CAP), + }; + next[id] = { + cpu: newStats.cpu, + ram: newStats.ram, + net: newStats.net, + lastRx: newStats.lastRx, + lastTx: newStats.lastTx, + history, + }; } - return hasChanges ? next : prev; + return next; }); }, 1500); @@ -1753,19 +1809,6 @@ export default function EditorLayout() { return stackName; }; - const getContainerBadge = (container: ContainerInfo) => { - const status = (container.Status || '').toLowerCase(); - const state = (container.State || '').toLowerCase(); - - if (status.includes('(unhealthy)') || state === 'exited' || state === 'dead') { - return { variant: 'destructive' as const, text: container.State }; - } - if (status.includes('(starting)')) { - return { variant: 'secondary' as const, text: container.State }; - } - return { variant: 'default' as const, text: container.State }; - }; - return (
{/* Left Sidebar (Stacks) */} @@ -2755,113 +2798,134 @@ export default function EditorLayout() {
- {/* Containers List */} + {/* Per-container health strip */}

CONTAINERS

{safeContainers.length === 0 ? (
No containers running for this stack.
) : ( -
+
{safeContainers.map(container => { let mainPort: number | undefined; + let mainPortPrivate: number | undefined; + let mainPortProto: string | undefined; if (container.Ports && container.Ports.length > 0) { const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000]; const IGNORE_PORTS = [1900, 53, 22]; - - // 1. Match typical Web UI Private ports let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort)); - // 2. Match typical Web UI Public ports if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort)); - // 3. Fallback to any port not in ignore list if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort)); - - mainPort = (match || container.Ports[0]).PublicPort; + const chosen = match || container.Ports[0]; + mainPort = chosen.PublicPort; + mainPortPrivate = chosen.PrivatePort; + mainPortProto = 'tcp'; } + const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container'; + const isRunning = container.State === 'running'; + const health = container.healthStatus; + const uptime = isRunning ? extractUptime(container.Status) : null; + const hcLabel = healthcheckLabel(health); + const stats = containerStats[container?.Id]; + const history = stats?.history; + + const badgeClass = health === 'unhealthy' || !isRunning + ? 'bg-destructive text-destructive-foreground' + : health === 'starting' + ? 'bg-warning text-warning-foreground' + : 'bg-success text-success-foreground'; + const badgeGlyph = health === 'unhealthy' || !isRunning ? '✗' : health === 'starting' ? '…' : '✓'; + const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)'; + return ( -
-
-
- - -
- - {getContainerBadge(container).text || 'unknown'} - -
-
- -
-

Container Status

-

- {container?.Status || 'No status details available'} -

-
-
-
- - CPU: {container.State === 'running' ? (containerStats[container?.Id]?.cpu || 'N/A') : '0.00%'} | RAM: {container.State === 'running' ? (containerStats[container?.Id]?.ram || 'N/A') : '0.00 MB'} | NET: {container.State === 'running' ? (containerStats[container?.Id]?.net || '0 B ↓ / 0 B ↑') : '0 B/s ↓ / 0 B/s ↑'} - +
+
+
+
+ {badgeGlyph} +
+
+
{containerName}
+
+ {uptime ? {uptime} : {(container.State || 'unknown').toLowerCase()}} + {hcLabel ? <>·{hcLabel} : null} + {mainPort && mainPortPrivate ? ( + <> + · + {mainPort} → {mainPortPrivate}/{mainPortProto} + + + ) : null} +
+
+
+
+ + {isAdmin && ( + + )}
-
- {mainPort && ( - - - - - - Open App ({mainPort}) - - - )} - - - - - - View Live Logs - - - {isAdmin && ( - - - - - - Open Bash Terminal - - - )} -
+ {isRunning ? ( +
+
+
+ cpu + {stats?.cpu ?? '-'} +
+
+ +
+
+
+
+ mem + {stats?.ram ?? '-'} +
+
+ +
+
+
+
+ net i/o + {stats?.net ?? '-'} +
+
+ +
+
+
+ ) : null}
); })} @@ -2871,14 +2935,46 @@ export default function EditorLayout() { - {/* Terminal Section */} -
-

Terminal

-
- - - + {/* Logs Section */} +
+
+

Logs

+
+ + +
+ {logsMode === 'structured' ? ( + + + + ) : ( +
+
+ + + +
+
+ )}
diff --git a/frontend/src/components/StructuredLogViewer.tsx b/frontend/src/components/StructuredLogViewer.tsx new file mode 100644 index 00000000..1dcc6f62 --- /dev/null +++ b/frontend/src/components/StructuredLogViewer.tsx @@ -0,0 +1,225 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from './ui/button'; +import { Download } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface StructuredLogViewerProps { + stackName: string; +} + +type LogLevel = 'info' | 'warn' | 'err'; + +interface LogRow { + id: number; + ts: string | null; + level: LogLevel; + message: string; +} + +type Filter = 'all' | LogLevel; + +const BUFFER_CAP = 10_000; +const TIMESTAMP_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s+(.*)$/; +// eslint-disable-next-line no-control-regex +const ANSI_REGEX = /\x1b\[[0-9;]*[A-Za-z]/g; +const ERROR_REGEX = /\b(ERROR|ERR|FATAL|Exception)\b/i; +const WARN_REGEX = /\b(WARN|WARNING|WRN)\b/i; + +function parseLine(raw: string): Omit { + const stripped = raw.replace(ANSI_REGEX, '').replace(/[\r\n]+$/, ''); + const match = stripped.match(TIMESTAMP_REGEX); + const ts = match ? match[1] : null; + const body = match ? match[2] : stripped; + let level: LogLevel = 'info'; + if (ERROR_REGEX.test(body)) level = 'err'; + else if (WARN_REGEX.test(body)) level = 'warn'; + return { ts, level, message: body }; +} + +function formatTs(iso: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; +} + +export default function StructuredLogViewer({ stackName }: StructuredLogViewerProps) { + const [rows, setRows] = useState([]); + const [filter, setFilter] = useState('all'); + const [following, setFollowing] = useState(true); + const scrollRef = useRef(null); + const followingRef = useRef(true); + const rowIdRef = useRef(0); + const wsRef = useRef(null); + const pendingRef = useRef([]); + + useEffect(() => { followingRef.current = following; }, [following]); + + useEffect(() => { + const cleanStackName = stackName.replace(/\.(yml|yaml)$/, ''); + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const activeNodeId = localStorage.getItem('sencho-active-node') || ''; + const wsUrl = `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`; + + let closed = false; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onmessage = (event) => { + if (closed) return; + const text = typeof event.data === 'string' ? event.data : ''; + if (!text) return; + for (const line of text.split(/\r?\n/)) { + if (!line) continue; + const parsed = parseLine(line); + if (!parsed.message) continue; + rowIdRef.current += 1; + pendingRef.current.push({ id: rowIdRef.current, ...parsed }); + } + }; + + ws.onerror = () => { /* surface nothing; reconnection is backend's job */ }; + + // Batch incoming lines into state every 250 ms to avoid thrashing React. + const flushInterval = window.setInterval(() => { + if (pendingRef.current.length === 0) return; + const incoming = pendingRef.current; + pendingRef.current = []; + setRows((prev) => { + const merged = prev.concat(incoming); + return merged.length > BUFFER_CAP ? merged.slice(merged.length - BUFFER_CAP) : merged; + }); + }, 250); + + return () => { + closed = true; + window.clearInterval(flushInterval); + try { ws.close(); } catch { /* ignore */ } + wsRef.current = null; + pendingRef.current = []; + }; + }, [stackName]); + + const filtered = useMemo(() => { + if (filter === 'all') return rows; + return rows.filter(r => r.level === filter); + }, [rows, filter]); + + const errCount = useMemo(() => rows.reduce((n, r) => r.level === 'err' ? n + 1 : n, 0), [rows]); + + // Auto-scroll to bottom when following. + useEffect(() => { + if (!followingRef.current) return; + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [filtered]); + + const handleScroll = () => { + const el = scrollRef.current; + if (!el) return; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + const atBottom = distanceFromBottom < 24; + if (atBottom !== followingRef.current) { + followingRef.current = atBottom; + setFollowing(atBottom); + } + }; + + const resumeFollow = () => { + setFollowing(true); + followingRef.current = true; + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }; + + const downloadLogs = () => { + const text = rows.map(r => `${r.ts ?? ''} ${r.level.toUpperCase()} ${r.message}`.trim()).join('\n'); + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${stackName.replace(/\.(yml|yaml)$/, '')}-logs.txt`; + a.click(); + URL.revokeObjectURL(url); + }; + + const label = `logs · ${stackName.replace(/\.(yml|yaml)$/, '')}`; + + return ( +
+
+
+ {label} + {following ? ( + + + following + + ) : ( + + )} +
+
+ {(['all', 'info', 'warn', 'err'] as const).map(f => ( + + ))} +
+ +
+
+
+ {filtered.length === 0 ? ( +
Waiting for log output…
+ ) : ( + filtered.map(row => ( +
+ {formatTs(row.ts)} + + {row.level} + + {row.message} +
+ )) + )} +
+
+ ); +}