mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 20:59:09 +00:00
feat(stack-view): per-container health strip and structured logs viewer (#689)
* feat(stack-view): per-container health strip and structured logs viewer Replaces the flat container list with a per-container health strip showing healthcheck state, uptime, port mapping with an open-app link, and live cpu/memory/network sparklines fed by a 60-sample ring buffer on the stats WebSocket. Adds a structured logs viewer that parses docker timestamps (emitted by the -t flag on the logs stream) and classifies each line by level. Rows render as a DOM grid with filter pills (all / info / warn / err with count), following indicator, and plain-text download. A segmented toggle switches between the structured viewer and the original xterm view; the choice is persisted in localStorage. * fix(stack-view): disable no-control-regex for ANSI escape pattern ANSI escape sequences start with ESC (0x1B), which is a control character. The regex is intentional and cannot be rewritten without it.
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -117,6 +117,43 @@ The header answers three questions at a glance:
|
||||
- `exited` (red) when no containers are up.
|
||||
- **What does it ship?** A mono line under the title shows the primary image tag and the short image digest, with a copy button to grab the full digest.
|
||||
|
||||
## Container health strip
|
||||
|
||||
Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" without expanding anything.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-view/health-strip-logs.png" alt="Stack view showing the per-container health strip above the structured logs viewer" />
|
||||
</Frame>
|
||||
|
||||
Each row includes:
|
||||
|
||||
- **Health badge.** A colored glyph reports the Docker healthcheck state: `✓` green for passing, `✗` red for failing, `…` amber while the healthcheck start period is still in flight. Containers without a `healthcheck:` block show a neutral `✓`.
|
||||
- **Container name** in mono.
|
||||
- **Meta line.** Uptime (`up 20 hours`), healthcheck state when one is defined, and the primary port mapping (`6875 → 80/tcp`).
|
||||
- **Open link.** When the container publishes a port, an `open ↗` link launches the app in a new tab using the active node's hostname.
|
||||
- **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling 60-sample sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds.
|
||||
- **Action icons.** Shortcuts to open this container's logs or a bash shell.
|
||||
|
||||
## Logs viewer
|
||||
|
||||
The logs area at the bottom of the stack view has two modes. Toggle between them with the segmented control in the top-right of the panel; your choice is remembered for next time.
|
||||
|
||||
**Structured** (default) parses every line into a DOM row with three columns: local time, level badge, and message. Level detection is automatic:
|
||||
|
||||
- `err` rows get a red left rail and a subtle rose tint.
|
||||
- `warn` rows get a warm tint.
|
||||
- `info` rows render plainly.
|
||||
|
||||
The toolbar includes:
|
||||
|
||||
- **Following indicator.** A pulsing green dot reads `following` while auto-scroll is engaged. Scrolling up stops follow; a `resume follow` link re-engages it and jumps to the bottom.
|
||||
- **Level filter.** `all · info · warn · err` pills. The `err` pill shows the running error count as a badge.
|
||||
- **Download.** Exports the current buffer as a plain text file.
|
||||
|
||||
The structured viewer holds up to 10,000 lines; older entries are dropped from the top as new ones arrive.
|
||||
|
||||
**Raw terminal** keeps the original xterm-based viewer for cases where ANSI art, control sequences, or interactive TTY output matter. Lines include the raw ISO timestamp.
|
||||
|
||||
## Deploying a stack
|
||||
|
||||
Select a stack and click **Start** (or **Deploy** from the context menu) in the stack header. This runs `docker compose up -d`, pulling images if needed and creating or recreating containers.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
@@ -37,7 +37,6 @@ import { Checkbox } from './ui/checkbox';
|
||||
import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-menu';
|
||||
@@ -50,6 +49,8 @@ import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
|
||||
import { GitSourcePanel } from './stack/GitSourcePanel';
|
||||
import { AppStoreView } from './AppStoreView';
|
||||
import { LogViewer } from './LogViewer';
|
||||
import StructuredLogViewer from './StructuredLogViewer';
|
||||
import { Sparkline } from './ui/sparkline';
|
||||
import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||||
import { FleetView } from './FleetView';
|
||||
import { AuditLogView } from './AuditLogView';
|
||||
@@ -104,6 +105,23 @@ const formatBytes = (bytes: number) => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Extract the "up X time" portion from a Docker status string like
|
||||
// "Up 12 days (healthy)" → "up 12 days". Returns null when the container
|
||||
// is not in an uptime-reporting state (exited, created, restarting, etc.).
|
||||
const extractUptime = (status: string | undefined): string | null => {
|
||||
if (!status) return null;
|
||||
const match = status.match(/^\s*Up\s+(.+?)(?:\s*\(.*\))?\s*$/i);
|
||||
if (!match) return null;
|
||||
return `up ${match[1].trim()}`;
|
||||
};
|
||||
|
||||
const healthcheckLabel = (health?: 'healthy' | 'unhealthy' | 'starting' | 'none'): string | null => {
|
||||
if (!health || health === 'none') return null;
|
||||
if (health === 'healthy') return 'healthcheck passing';
|
||||
if (health === 'unhealthy') return 'healthcheck failing';
|
||||
return 'healthcheck starting';
|
||||
};
|
||||
|
||||
type StackPill = { label: string; dotClass: string; className: string; pulse: boolean };
|
||||
|
||||
const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => {
|
||||
@@ -184,15 +202,39 @@ export default function EditorLayout() {
|
||||
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||||
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
||||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
||||
const [containerStats, setContainerStats] = useState<Record<string, { cpu: string, ram: string, net: string, lastRx?: number, lastTx?: number }>>({});
|
||||
const [containerStats, setContainerStats] = useState<Record<string, {
|
||||
cpu: string;
|
||||
ram: string;
|
||||
net: string;
|
||||
lastRx?: number;
|
||||
lastTx?: number;
|
||||
history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] };
|
||||
}>>({});
|
||||
// Incoming WebSocket stats are written here first (no re-render), then flushed
|
||||
// to React state in one batched update every 1.5 s.
|
||||
const pendingStatsRef = useRef<Record<string, { cpu: string; ram: string; net: string; lastRx: number; lastTx: number }>>({});
|
||||
const pendingStatsRef = useRef<Record<string, {
|
||||
cpu: string;
|
||||
ram: string;
|
||||
net: string;
|
||||
lastRx: number;
|
||||
lastTx: number;
|
||||
cpuNum: number;
|
||||
memNum: number;
|
||||
netInNum: number;
|
||||
netOutNum: number;
|
||||
}>>({});
|
||||
// Raw rx/tx byte totals used for rate calculation. Never cleared on flush so
|
||||
// the delta is always computed against the most recent known value, avoiding
|
||||
// the stale-closure bug that occurs when reading containerStats directly.
|
||||
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
||||
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
|
||||
const [logsMode, setLogsMode] = useState<'structured' | 'raw'>(() => {
|
||||
if (typeof window === 'undefined') return 'structured';
|
||||
return (localStorage.getItem('sencho.stackView.logsMode') as 'structured' | 'raw' | null) ?? 'structured';
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('sencho.stackView.logsMode', logsMode); } catch { /* ignore */ }
|
||||
}, [logsMode]);
|
||||
const [gitSourceOpen, setGitSourceOpen] = useState(false);
|
||||
const [gitSourcePendingMap, setGitSourcePendingMap] = useState<Record<string, boolean>>({});
|
||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||
@@ -944,6 +986,10 @@ export default function EditorLayout() {
|
||||
net: netIO,
|
||||
lastRx: currentRx,
|
||||
lastTx: currentTx,
|
||||
cpuNum: parseFloat(cpuPercent) || 0,
|
||||
memNum: data.memory_stats.usage / (1024 * 1024),
|
||||
netInNum: rxRate,
|
||||
netOutNum: txRate,
|
||||
};
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
@@ -963,16 +1009,26 @@ export default function EditorLayout() {
|
||||
pendingStatsRef.current = {};
|
||||
|
||||
setContainerStats(prev => {
|
||||
let hasChanges = false;
|
||||
const next = { ...prev };
|
||||
const HISTORY_CAP = 60;
|
||||
for (const [id, newStats] of Object.entries(pending)) {
|
||||
const old = prev[id];
|
||||
if (!old || old.cpu !== newStats.cpu || old.ram !== newStats.ram || old.net !== newStats.net) {
|
||||
next[id] = newStats;
|
||||
hasChanges = true;
|
||||
}
|
||||
const prior = prev[id]?.history ?? { cpu: [], mem: [], netIn: [], netOut: [] };
|
||||
const history = {
|
||||
cpu: [...prior.cpu, newStats.cpuNum].slice(-HISTORY_CAP),
|
||||
mem: [...prior.mem, newStats.memNum].slice(-HISTORY_CAP),
|
||||
netIn: [...prior.netIn, newStats.netInNum].slice(-HISTORY_CAP),
|
||||
netOut: [...prior.netOut, newStats.netOutNum].slice(-HISTORY_CAP),
|
||||
};
|
||||
next[id] = {
|
||||
cpu: newStats.cpu,
|
||||
ram: newStats.ram,
|
||||
net: newStats.net,
|
||||
lastRx: newStats.lastRx,
|
||||
lastTx: newStats.lastTx,
|
||||
history,
|
||||
};
|
||||
}
|
||||
return hasChanges ? next : prev;
|
||||
return next;
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
@@ -1753,19 +1809,6 @@ export default function EditorLayout() {
|
||||
return stackName;
|
||||
};
|
||||
|
||||
const getContainerBadge = (container: ContainerInfo) => {
|
||||
const status = (container.Status || '').toLowerCase();
|
||||
const state = (container.State || '').toLowerCase();
|
||||
|
||||
if (status.includes('(unhealthy)') || state === 'exited' || state === 'dead') {
|
||||
return { variant: 'destructive' as const, text: container.State };
|
||||
}
|
||||
if (status.includes('(starting)')) {
|
||||
return { variant: 'secondary' as const, text: container.State };
|
||||
}
|
||||
return { variant: 'default' as const, text: container.State };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
||||
{/* Left Sidebar (Stacks) */}
|
||||
@@ -2755,113 +2798,134 @@ export default function EditorLayout() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
{/* Containers List */}
|
||||
{/* Per-container health strip */}
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-3">CONTAINERS</h4>
|
||||
{safeContainers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{safeContainers.map(container => {
|
||||
let mainPort: number | undefined;
|
||||
let mainPortPrivate: number | undefined;
|
||||
let mainPortProto: string | undefined;
|
||||
if (container.Ports && container.Ports.length > 0) {
|
||||
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
|
||||
const IGNORE_PORTS = [1900, 53, 22];
|
||||
|
||||
// 1. Match typical Web UI Private ports
|
||||
let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort));
|
||||
// 2. Match typical Web UI Public ports
|
||||
if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort));
|
||||
// 3. Fallback to any port not in ignore list
|
||||
if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort));
|
||||
|
||||
mainPort = (match || container.Ports[0]).PublicPort;
|
||||
const chosen = match || container.Ports[0];
|
||||
mainPort = chosen.PublicPort;
|
||||
mainPortPrivate = chosen.PrivatePort;
|
||||
mainPortProto = 'tcp';
|
||||
}
|
||||
|
||||
const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container';
|
||||
const isRunning = container.State === 'running';
|
||||
const health = container.healthStatus;
|
||||
const uptime = isRunning ? extractUptime(container.Status) : null;
|
||||
const hcLabel = healthcheckLabel(health);
|
||||
const stats = containerStats[container?.Id];
|
||||
const history = stats?.history;
|
||||
|
||||
const badgeClass = health === 'unhealthy' || !isRunning
|
||||
? 'bg-destructive text-destructive-foreground'
|
||||
: health === 'starting'
|
||||
? 'bg-warning text-warning-foreground'
|
||||
: 'bg-success text-success-foreground';
|
||||
const badgeGlyph = health === 'unhealthy' || !isRunning ? '✗' : health === 'starting' ? '…' : '✓';
|
||||
const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)';
|
||||
|
||||
return (
|
||||
<div key={container?.Id || Math.random()} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="cursor-help inline-flex">
|
||||
<Badge variant={getContainerBadge(container).variant} className="text-xs">
|
||||
{getContainerBadge(container).text || 'unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="flex w-50 flex-col gap-0.5">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium">Container Status</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{container?.Status || 'No status details available'}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
CPU: {container.State === 'running' ? (containerStats[container?.Id]?.cpu || 'N/A') : '0.00%'} | RAM: {container.State === 'running' ? (containerStats[container?.Id]?.ram || 'N/A') : '0.00 MB'} | NET: {container.State === 'running' ? (containerStats[container?.Id]?.net || '0 B ↓ / 0 B ↑') : '0 B/s ↓ / 0 B/s ↑'}
|
||||
</span>
|
||||
<div key={container?.Id || Math.random()} className="rounded-lg border border-muted bg-muted/30 px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div className={cn('mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold', badgeClass)}>
|
||||
{badgeGlyph}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px] text-stat-subtitle">
|
||||
{uptime ? <span>{uptime}</span> : <span>{(container.State || 'unknown').toLowerCase()}</span>}
|
||||
{hcLabel ? <><span>·</span><span>{hcLabel}</span></> : null}
|
||||
{mainPort && mainPortPrivate ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{mainPort} → {mainPortPrivate}/{mainPortProto}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const host = activeNode?.type === 'remote' && activeNode?.api_url
|
||||
? new URL(activeNode.api_url).hostname
|
||||
: window.location.hostname;
|
||||
window.open(`http://${host}:${mainPort}`, '_blank');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-brand hover:underline"
|
||||
>
|
||||
open <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md"
|
||||
onClick={() => openLogViewer(container?.Id, containerName)}
|
||||
disabled={!isRunning}
|
||||
aria-label="View logs"
|
||||
>
|
||||
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md"
|
||||
onClick={() => openBashModal(container?.Id, containerName)}
|
||||
disabled={!isRunning}
|
||||
aria-label="Open bash shell"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{mainPort && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8"
|
||||
onClick={() => {
|
||||
const host = activeNode?.type === 'remote' && activeNode?.api_url
|
||||
? new URL(activeNode.api_url).hostname
|
||||
: window.location.hostname;
|
||||
window.open(`http://${host}:${mainPort}`, '_blank');
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open App ({mainPort})</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8"
|
||||
onClick={() => openLogViewer(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
|
||||
disabled={container?.State !== 'running'}
|
||||
>
|
||||
<ScrollText className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Live Logs</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAdmin && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8"
|
||||
onClick={() => openBashModal(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
|
||||
disabled={container?.State !== 'running'}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open Bash Terminal</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{isRunning ? (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[9px] uppercase tracking-wide text-stat-subtitle">cpu</span>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.cpu ?? '-'}</span>
|
||||
</div>
|
||||
<div className="ml-auto h-5 w-16">
|
||||
<Sparkline points={history?.cpu ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[9px] uppercase tracking-wide text-stat-subtitle">mem</span>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.ram ?? '-'}</span>
|
||||
</div>
|
||||
<div className="ml-auto h-5 w-16">
|
||||
<Sparkline points={history?.mem ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[9px] uppercase tracking-wide text-stat-subtitle">net i/o</span>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.net ?? '-'}</span>
|
||||
</div>
|
||||
<div className="ml-auto h-5 w-16">
|
||||
<Sparkline points={history?.netIn ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -2871,14 +2935,46 @@ export default function EditorLayout() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminal Section */}
|
||||
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 min-h-[300px] shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
|
||||
<h3 className="text-sm font-medium text-stat-subtitle mb-2">Terminal</h3>
|
||||
<div className="h-[calc(100%-24px)]">
|
||||
<ErrorBoundary>
|
||||
<TerminalComponent stackName={stackName} />
|
||||
</ErrorBoundary>
|
||||
{/* Logs Section */}
|
||||
<div className="flex-1 min-h-[320px] flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-stat-subtitle">Logs</h3>
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogsMode('structured')}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
|
||||
logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Structured
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogsMode('raw')}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
|
||||
logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Raw terminal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{logsMode === 'structured' ? (
|
||||
<ErrorBoundary>
|
||||
<StructuredLogViewer stackName={stackName} />
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
|
||||
<div className="h-full">
|
||||
<ErrorBoundary>
|
||||
<TerminalComponent stackName={stackName} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Download } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StructuredLogViewerProps {
|
||||
stackName: string;
|
||||
}
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'err';
|
||||
|
||||
interface LogRow {
|
||||
id: number;
|
||||
ts: string | null;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type Filter = 'all' | LogLevel;
|
||||
|
||||
const BUFFER_CAP = 10_000;
|
||||
const TIMESTAMP_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s+(.*)$/;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_REGEX = /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||
const ERROR_REGEX = /\b(ERROR|ERR|FATAL|Exception)\b/i;
|
||||
const WARN_REGEX = /\b(WARN|WARNING|WRN)\b/i;
|
||||
|
||||
function parseLine(raw: string): Omit<LogRow, 'id'> {
|
||||
const stripped = raw.replace(ANSI_REGEX, '').replace(/[\r\n]+$/, '');
|
||||
const match = stripped.match(TIMESTAMP_REGEX);
|
||||
const ts = match ? match[1] : null;
|
||||
const body = match ? match[2] : stripped;
|
||||
let level: LogLevel = 'info';
|
||||
if (ERROR_REGEX.test(body)) level = 'err';
|
||||
else if (WARN_REGEX.test(body)) level = 'warn';
|
||||
return { ts, level, message: body };
|
||||
}
|
||||
|
||||
function formatTs(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export default function StructuredLogViewer({ stackName }: StructuredLogViewerProps) {
|
||||
const [rows, setRows] = useState<LogRow[]>([]);
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [following, setFollowing] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const followingRef = useRef(true);
|
||||
const rowIdRef = useRef(0);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pendingRef = useRef<LogRow[]>([]);
|
||||
|
||||
useEffect(() => { followingRef.current = following; }, [following]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanStackName = stackName.replace(/\.(yml|yaml)$/, '');
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`;
|
||||
|
||||
let closed = false;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (closed) return;
|
||||
const text = typeof event.data === 'string' ? event.data : '';
|
||||
if (!text) return;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
const parsed = parseLine(line);
|
||||
if (!parsed.message) continue;
|
||||
rowIdRef.current += 1;
|
||||
pendingRef.current.push({ id: rowIdRef.current, ...parsed });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => { /* surface nothing; reconnection is backend's job */ };
|
||||
|
||||
// Batch incoming lines into state every 250 ms to avoid thrashing React.
|
||||
const flushInterval = window.setInterval(() => {
|
||||
if (pendingRef.current.length === 0) return;
|
||||
const incoming = pendingRef.current;
|
||||
pendingRef.current = [];
|
||||
setRows((prev) => {
|
||||
const merged = prev.concat(incoming);
|
||||
return merged.length > BUFFER_CAP ? merged.slice(merged.length - BUFFER_CAP) : merged;
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
window.clearInterval(flushInterval);
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
wsRef.current = null;
|
||||
pendingRef.current = [];
|
||||
};
|
||||
}, [stackName]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'all') return rows;
|
||||
return rows.filter(r => r.level === filter);
|
||||
}, [rows, filter]);
|
||||
|
||||
const errCount = useMemo(() => rows.reduce((n, r) => r.level === 'err' ? n + 1 : n, 0), [rows]);
|
||||
|
||||
// Auto-scroll to bottom when following.
|
||||
useEffect(() => {
|
||||
if (!followingRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [filtered]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const atBottom = distanceFromBottom < 24;
|
||||
if (atBottom !== followingRef.current) {
|
||||
followingRef.current = atBottom;
|
||||
setFollowing(atBottom);
|
||||
}
|
||||
};
|
||||
|
||||
const resumeFollow = () => {
|
||||
setFollowing(true);
|
||||
followingRef.current = true;
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const text = rows.map(r => `${r.ts ?? ''} ${r.level.toUpperCase()} ${r.message}`.trim()).join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${stackName.replace(/\.(yml|yaml)$/, '')}-logs.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const label = `logs · ${stackName.replace(/\.(yml|yaml)$/, '')}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
|
||||
<div className="flex items-center justify-between border-b border-muted px-3 py-2 gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="font-mono text-xs text-stat-subtitle truncate">{label}</span>
|
||||
{following ? (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-mono text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success animate-[pulse_2.4s_ease-in-out_infinite]" />
|
||||
following
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resumeFollow}
|
||||
className="text-[10px] font-mono text-stat-subtitle hover:text-foreground transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
resume follow
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(['all', 'info', 'warn', 'err'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFilter(f)}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
|
||||
filter === f ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{f}{f === 'err' && errCount > 0 ? ` ${errCount}` : ''}
|
||||
</button>
|
||||
))}
|
||||
<div className="mx-1 h-4 w-px bg-muted" />
|
||||
<Button type="button" size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={downloadLogs} aria-label="Download logs">
|
||||
<Download className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto font-mono text-[11px] leading-[1.5]"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-2 text-stat-subtitle">Waiting for log output…</div>
|
||||
) : (
|
||||
filtered.map(row => (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'grid grid-cols-[64px_44px_1fr] items-start gap-2 border-l-2 border-transparent px-3 py-0.5',
|
||||
row.level === 'err' && 'border-destructive bg-destructive/[0.04]',
|
||||
row.level === 'warn' && 'bg-warning/[0.04]',
|
||||
)}
|
||||
>
|
||||
<span className="text-stat-subtitle">{formatTs(row.ts)}</span>
|
||||
<span className={cn(
|
||||
'font-mono text-[9px] uppercase tracking-wide',
|
||||
row.level === 'err' && 'text-destructive',
|
||||
row.level === 'warn' && 'text-warning',
|
||||
row.level === 'info' && 'text-success/80',
|
||||
)}>
|
||||
{row.level}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-all text-foreground/90">{row.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user