mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +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: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
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.
|
- `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.
|
- **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
|
## 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.
|
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 { GitSourceFields, type ApplyMode } from './stack/GitSourceFields';
|
||||||
import { Skeleton } from './ui/skeleton';
|
import { Skeleton } from './ui/skeleton';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
|
||||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-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 { GitSourcePanel } from './stack/GitSourcePanel';
|
||||||
import { AppStoreView } from './AppStoreView';
|
import { AppStoreView } from './AppStoreView';
|
||||||
import { LogViewer } from './LogViewer';
|
import { LogViewer } from './LogViewer';
|
||||||
|
import StructuredLogViewer from './StructuredLogViewer';
|
||||||
|
import { Sparkline } from './ui/sparkline';
|
||||||
import { GlobalObservabilityView } from './GlobalObservabilityView';
|
import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||||||
import { FleetView } from './FleetView';
|
import { FleetView } from './FleetView';
|
||||||
import { AuditLogView } from './AuditLogView';
|
import { AuditLogView } from './AuditLogView';
|
||||||
@@ -104,6 +105,23 @@ const formatBytes = (bytes: number) => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
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 };
|
type StackPill = { label: string; dotClass: string; className: string; pulse: boolean };
|
||||||
|
|
||||||
const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => {
|
const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => {
|
||||||
@@ -184,15 +202,39 @@ export default function EditorLayout() {
|
|||||||
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||||||
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
||||||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
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
|
// Incoming WebSocket stats are written here first (no re-render), then flushed
|
||||||
// to React state in one batched update every 1.5 s.
|
// 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
|
// 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 delta is always computed against the most recent known value, avoiding
|
||||||
// the stale-closure bug that occurs when reading containerStats directly.
|
// the stale-closure bug that occurs when reading containerStats directly.
|
||||||
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
||||||
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
|
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 [gitSourceOpen, setGitSourceOpen] = useState(false);
|
||||||
const [gitSourcePendingMap, setGitSourcePendingMap] = useState<Record<string, boolean>>({});
|
const [gitSourcePendingMap, setGitSourcePendingMap] = useState<Record<string, boolean>>({});
|
||||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||||
@@ -944,6 +986,10 @@ export default function EditorLayout() {
|
|||||||
net: netIO,
|
net: netIO,
|
||||||
lastRx: currentRx,
|
lastRx: currentRx,
|
||||||
lastTx: currentTx,
|
lastTx: currentTx,
|
||||||
|
cpuNum: parseFloat(cpuPercent) || 0,
|
||||||
|
memNum: data.memory_stats.usage / (1024 * 1024),
|
||||||
|
netInNum: rxRate,
|
||||||
|
netOutNum: txRate,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
@@ -963,16 +1009,26 @@ export default function EditorLayout() {
|
|||||||
pendingStatsRef.current = {};
|
pendingStatsRef.current = {};
|
||||||
|
|
||||||
setContainerStats(prev => {
|
setContainerStats(prev => {
|
||||||
let hasChanges = false;
|
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
|
const HISTORY_CAP = 60;
|
||||||
for (const [id, newStats] of Object.entries(pending)) {
|
for (const [id, newStats] of Object.entries(pending)) {
|
||||||
const old = prev[id];
|
const prior = prev[id]?.history ?? { cpu: [], mem: [], netIn: [], netOut: [] };
|
||||||
if (!old || old.cpu !== newStats.cpu || old.ram !== newStats.ram || old.net !== newStats.net) {
|
const history = {
|
||||||
next[id] = newStats;
|
cpu: [...prior.cpu, newStats.cpuNum].slice(-HISTORY_CAP),
|
||||||
hasChanges = true;
|
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);
|
}, 1500);
|
||||||
|
|
||||||
@@ -1753,19 +1809,6 @@ export default function EditorLayout() {
|
|||||||
return stackName;
|
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 (
|
return (
|
||||||
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
||||||
{/* Left Sidebar (Stacks) */}
|
{/* Left Sidebar (Stacks) */}
|
||||||
@@ -2755,113 +2798,134 @@ export default function EditorLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-2">
|
<CardContent className="p-4 pt-2">
|
||||||
{/* Containers List */}
|
{/* Per-container health strip */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground mb-3">CONTAINERS</h4>
|
<h4 className="text-sm font-medium text-muted-foreground mb-3">CONTAINERS</h4>
|
||||||
{safeContainers.length === 0 ? (
|
{safeContainers.length === 0 ? (
|
||||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
<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 => {
|
{safeContainers.map(container => {
|
||||||
let mainPort: number | undefined;
|
let mainPort: number | undefined;
|
||||||
|
let mainPortPrivate: number | undefined;
|
||||||
|
let mainPortProto: string | undefined;
|
||||||
if (container.Ports && container.Ports.length > 0) {
|
if (container.Ports && container.Ports.length > 0) {
|
||||||
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
|
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
|
||||||
const IGNORE_PORTS = [1900, 53, 22];
|
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));
|
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));
|
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));
|
if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort));
|
||||||
|
const chosen = match || container.Ports[0];
|
||||||
mainPort = (match || container.Ports[0]).PublicPort;
|
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 (
|
return (
|
||||||
<div key={container?.Id || Math.random()} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
<div key={container?.Id || Math.random()} className="rounded-lg border border-muted bg-muted/30 px-3 py-2.5">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||||
<HoverCard>
|
<div className={cn('mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold', badgeClass)}>
|
||||||
<HoverCardTrigger asChild>
|
{badgeGlyph}
|
||||||
<div className="cursor-help inline-flex">
|
</div>
|
||||||
<Badge variant={getContainerBadge(container).variant} className="text-xs">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
{getContainerBadge(container).text || 'unknown'}
|
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
|
||||||
</Badge>
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px] text-stat-subtitle">
|
||||||
</div>
|
{uptime ? <span>{uptime}</span> : <span>{(container.State || 'unknown').toLowerCase()}</span>}
|
||||||
</HoverCardTrigger>
|
{hcLabel ? <><span>·</span><span>{hcLabel}</span></> : null}
|
||||||
<HoverCardContent className="flex w-50 flex-col gap-0.5">
|
{mainPort && mainPortPrivate ? (
|
||||||
<div className="space-y-1">
|
<>
|
||||||
<h4 className="text-sm font-medium">Container Status</h4>
|
<span>·</span>
|
||||||
<p className="text-sm text-muted-foreground">
|
<span>{mainPort} → {mainPortPrivate}/{mainPortProto}</span>
|
||||||
{container?.Status || 'No status details available'}
|
<button
|
||||||
</p>
|
type="button"
|
||||||
</div>
|
onClick={() => {
|
||||||
</HoverCardContent>
|
const host = activeNode?.type === 'remote' && activeNode?.api_url
|
||||||
</HoverCard>
|
? new URL(activeNode.api_url).hostname
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
: window.location.hostname;
|
||||||
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 ↑'}
|
window.open(`http://${host}:${mainPort}`, '_blank');
|
||||||
</span>
|
}}
|
||||||
|
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>
|
</div>
|
||||||
<div className="flex gap-1">
|
{isRunning ? (
|
||||||
{mainPort && (
|
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||||
<TooltipProvider>
|
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||||
<Tooltip>
|
<div className="flex flex-col">
|
||||||
<TooltipTrigger asChild>
|
<span className="font-mono text-[9px] uppercase tracking-wide text-stat-subtitle">cpu</span>
|
||||||
<Button
|
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.cpu ?? '-'}</span>
|
||||||
size="sm"
|
</div>
|
||||||
variant="ghost"
|
<div className="ml-auto h-5 w-16">
|
||||||
className="rounded-lg h-8 w-8"
|
<Sparkline points={history?.cpu ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||||
onClick={() => {
|
</div>
|
||||||
const host = activeNode?.type === 'remote' && activeNode?.api_url
|
</div>
|
||||||
? new URL(activeNode.api_url).hostname
|
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||||
: window.location.hostname;
|
<div className="flex flex-col">
|
||||||
window.open(`http://${host}:${mainPort}`, '_blank');
|
<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>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<div className="ml-auto h-5 w-16">
|
||||||
</Button>
|
<Sparkline points={history?.mem ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||||
</TooltipTrigger>
|
</div>
|
||||||
<TooltipContent>Open App ({mainPort})</TooltipContent>
|
</div>
|
||||||
</Tooltip>
|
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||||
</TooltipProvider>
|
<div className="flex flex-col">
|
||||||
)}
|
<span className="font-mono text-[9px] uppercase tracking-wide text-stat-subtitle">net i/o</span>
|
||||||
<TooltipProvider>
|
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.net ?? '-'}</span>
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
<div className="ml-auto h-5 w-16">
|
||||||
<Button
|
<Sparkline points={history?.netIn ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||||
size="icon"
|
</div>
|
||||||
variant="ghost"
|
</div>
|
||||||
className="rounded-lg h-8 w-8"
|
</div>
|
||||||
onClick={() => openLogViewer(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
|
) : null}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -2871,14 +2935,46 @@ export default function EditorLayout() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Terminal Section */}
|
{/* Logs 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)]">
|
<div className="flex-1 min-h-[320px] flex flex-col gap-2">
|
||||||
<h3 className="text-sm font-medium text-stat-subtitle mb-2">Terminal</h3>
|
<div className="flex items-center justify-between">
|
||||||
<div className="h-[calc(100%-24px)]">
|
<h3 className="text-sm font-medium text-stat-subtitle">Logs</h3>
|
||||||
<ErrorBoundary>
|
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||||
<TerminalComponent stackName={stackName} />
|
<button
|
||||||
</ErrorBoundary>
|
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>
|
</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>
|
||||||
</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