mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.11
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { X, Pause, Play, Terminal, Network, Globe, Info, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||
import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2 } from "lucide-react";
|
||||
import type { Service, Stats, LogLine, WSMessage, Connection } from "../../shared/types";
|
||||
|
||||
type Tab = "info" | "config" | "env" | "stats";
|
||||
@@ -35,13 +35,15 @@ interface DetailPanelProps {
|
||||
token: string;
|
||||
closing?: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void;
|
||||
sendMessage: (msg: WSMessage) => void;
|
||||
clearLogLines: () => void;
|
||||
connections: Connection[];
|
||||
services: Service[];
|
||||
getLogsSince: (uid: string) => number | undefined;
|
||||
}
|
||||
|
||||
export function DetailPanel({ service, stats, logLines, token, closing, onClose, sendMessage, clearLogLines, connections, services }: DetailPanelProps) {
|
||||
export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, sendMessage, clearLogLines, connections, services, getLogsSince }: DetailPanelProps) {
|
||||
const [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -53,6 +55,89 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
const [envVisibleAll, setEnvVisibleAll] = useState(false);
|
||||
const [envVisibleSet, setEnvVisibleSet] = useState<Set<number>>(new Set());
|
||||
const [copiedEnvIdx, setCopiedEnvIdx] = useState<number | null>(null);
|
||||
const [logsModal, setLogsModal] = useState(false);
|
||||
const modalScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll modal to bottom when opened
|
||||
useEffect(() => {
|
||||
if (logsModal && modalScrollRef.current) {
|
||||
modalScrollRef.current.scrollTop = modalScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logsModal]);
|
||||
const isProcessing = (service.state as string) === "processing";
|
||||
const processingStartedAt = (service as any)._processingStartedAt as number | undefined;
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const prevProcessingRef = useRef(false);
|
||||
const actionTimestampRef = useRef<number | null>(null);
|
||||
|
||||
// Timer for processing counter
|
||||
useEffect(() => {
|
||||
if (!isProcessing || !processingStartedAt) { setElapsed(0); return; }
|
||||
setElapsed(Math.floor((Date.now() - processingStartedAt) / 1000));
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - processingStartedAt) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isProcessing, processingStartedAt]);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [actionResult, setActionResult] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<"stop" | "restart" | "rebuild" | "remove" | null>(null);
|
||||
|
||||
const executeAction = useCallback(async (action: "stop" | "start" | "restart" | "rebuild" | "remove") => {
|
||||
setActionLoading(action);
|
||||
setActionResult(null);
|
||||
setConfirmAction(null);
|
||||
actionTimestampRef.current = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const res = await fetch(`/api/containers/${service.id}/${action}`, { method: "POST", headers });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setActionResult({ type: "success", message: `${action} successful` });
|
||||
setInitialLogs([]);
|
||||
clearLogLines();
|
||||
// Set processing — pass expected state so we wait for server to confirm
|
||||
const expectedState: Service["state"] =
|
||||
action === "stop" || action === "remove" ? "exited" :
|
||||
action === "start" || action === "restart" || action === "rebuild" ? "running" :
|
||||
service.state;
|
||||
// restart/rebuild go through stop→start cycle, need minDuration to avoid clearing on intermediate states
|
||||
const minDuration = action === "restart" || action === "rebuild" ? 5000 : 0;
|
||||
onAction(service.uid, expectedState, minDuration);
|
||||
if (action === "remove") {
|
||||
setTimeout(() => handleClose(), 1000);
|
||||
}
|
||||
} else {
|
||||
setActionResult({ type: "error", message: data.error || `Failed to ${action}` });
|
||||
}
|
||||
} catch {
|
||||
setActionResult({ type: "error", message: `Failed to ${action}` });
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
setTimeout(() => setActionResult(null), 3000);
|
||||
}
|
||||
}, [service.id, service.uid, token, onAction]);
|
||||
|
||||
// Re-subscribe logs when exiting processing state
|
||||
useEffect(() => {
|
||||
const wasProcessing = prevProcessingRef.current;
|
||||
prevProcessingRef.current = isProcessing;
|
||||
if (wasProcessing && !isProcessing) {
|
||||
// Service just finished processing — re-fetch logs (only new ones since the action)
|
||||
setInitialLogs([]);
|
||||
clearLogLines();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const since = actionTimestampRef.current;
|
||||
const sinceParam = since ? `&since=${since}` : "";
|
||||
fetch(`/api/logs/${service.id}?tail=200${sinceParam}`, { headers })
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((lines: LogLine[]) => setInitialLogs(lines))
|
||||
.catch(() => {});
|
||||
sendMessage({ type: "subscribe_logs", container: service.id });
|
||||
}
|
||||
}, [isProcessing, service.state, service.id, token, sendMessage, clearLogLines]);
|
||||
|
||||
// Slide-in animation
|
||||
useEffect(() => {
|
||||
@@ -67,7 +152,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
// Slide-out then unmount
|
||||
const handleClose = useCallback(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => onClose(), 300);
|
||||
setTimeout(() => onClose(), 400);
|
||||
}, [onClose]);
|
||||
|
||||
// Fetch initial logs + subscribe
|
||||
@@ -75,11 +160,14 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
setInitialLogs([]);
|
||||
setLoading(true);
|
||||
clearLogLines();
|
||||
initialScrollDone.current = false;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
fetch(`/api/logs/${service.id}?tail=200`, { headers })
|
||||
const since = getLogsSince(service.uid);
|
||||
const sinceParam = since ? `&since=${since}` : "";
|
||||
fetch(`/api/logs/${service.id}?tail=200${sinceParam}`, { headers })
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((lines: LogLine[]) => {
|
||||
setInitialLogs(lines);
|
||||
@@ -99,51 +187,176 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
}, [service.id, token, sendMessage, clearLogLines]);
|
||||
|
||||
// Auto-scroll
|
||||
const initialScrollDone = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
if (!autoScroll || !scrollRef.current) return;
|
||||
programmaticScroll.current = true;
|
||||
// Instant scroll until initial logs are loaded, then smooth
|
||||
if (!initialScrollDone.current && initialLogs.length > 0) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
initialScrollDone.current = true;
|
||||
} else if (initialScrollDone.current) {
|
||||
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
}, [initialLogs, logLines, autoScroll]);
|
||||
|
||||
const programmaticScroll = useRef(false);
|
||||
const manualPause = useRef(false);
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollRef.current) return;
|
||||
if (programmaticScroll.current) { programmaticScroll.current = false; return; }
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight < 40;
|
||||
setAutoScroll(atBottom);
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight < 5;
|
||||
if (manualPause.current) {
|
||||
// Only clear manual pause when user scrolls exactly to bottom
|
||||
if (atBottom) { manualPause.current = false; setAutoScroll(true); }
|
||||
return;
|
||||
}
|
||||
setAutoScroll((prev) => prev === atBottom ? prev : atBottom);
|
||||
}, []);
|
||||
|
||||
const allLines = [...initialLogs, ...logLines.filter((l) => l.container === service.id)];
|
||||
|
||||
const stateColor =
|
||||
const isCrashed = (service.state as string) === "crashed";
|
||||
const stateColor = isProcessing ? "text-yellow-400" :
|
||||
isCrashed ? "text-orange-400" :
|
||||
service.state === "running" ? "text-emerald-400" :
|
||||
service.state === "exited" || service.state === "dead" ? "text-red-400" :
|
||||
"text-yellow-400";
|
||||
|
||||
const stateDot =
|
||||
const stateDot = isProcessing ? "bg-yellow-400 animate-pulse" :
|
||||
isCrashed ? "bg-orange-400" :
|
||||
service.state === "running" ? "bg-emerald-400" :
|
||||
service.state === "exited" || service.state === "dead" ? "bg-red-400" :
|
||||
"bg-yellow-400";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-0 left-0 bottom-0 w-[900px] bg-slate-900/95 backdrop-blur-sm border-r border-slate-700/60 flex flex-col z-50 rounded-l-xl transition-transform duration-300 ease-out ${visible ? "translate-x-0" : "-translate-x-full"}`}
|
||||
className={`absolute top-0 left-0 bottom-0 w-[900px] bg-slate-900/95 backdrop-blur-sm border-r border-slate-700/60 flex flex-col z-50 rounded-l-xl transition-transform duration-[400ms] ease-out ${visible ? "translate-x-0" : "-translate-x-full"}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800 shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={`w-2 h-2 rounded-full ${stateDot}`} />
|
||||
<span className="text-sm font-semibold text-white truncate">{service.name}</span>
|
||||
<span className={`text-xs font-mono ${stateColor}`}>{service.state}</span>
|
||||
<span className={`text-xs font-mono ${stateColor} flex items-center gap-1`}>
|
||||
{isProcessing ? `processing... ${elapsed}s` :
|
||||
isCrashed ? <><AlertTriangle size={11} />crashed (exit {service.exit_code}{service.oom_killed ? ", OOM" : ""})</> :
|
||||
service.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Action buttons */}
|
||||
{isProcessing ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-medium text-yellow-400">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Processing...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{service.compose_file && (
|
||||
<button
|
||||
onClick={() => setConfirmAction("rebuild")}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-cyan-400 hover:bg-cyan-400/10 transition-colors disabled:opacity-40"
|
||||
title="Rebuild"
|
||||
>
|
||||
{actionLoading === "rebuild" ? <Loader2 size={12} className="animate-spin" /> : <Hammer size={12} />}
|
||||
Rebuild
|
||||
</button>
|
||||
)}
|
||||
{service.state === "running" ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setConfirmAction("restart")}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-yellow-400 hover:bg-yellow-400/10 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
>
|
||||
{actionLoading === "restart" ? <Loader2 size={12} className="animate-spin" /> : <RotateCw size={12} />}
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction("stop")}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40"
|
||||
title="Stop"
|
||||
>
|
||||
{actionLoading === "stop" ? <Loader2 size={12} className="animate-spin" /> : <Square size={12} />}
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setConfirmAction("remove")}
|
||||
disabled={!!actionLoading}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40"
|
||||
title="Remove"
|
||||
>
|
||||
{actionLoading === "remove" ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
||||
Remove
|
||||
</button>
|
||||
<button
|
||||
onClick={() => executeAction("start")}
|
||||
disabled={!!actionLoading}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-colors disabled:opacity-40 ${
|
||||
isCrashed ? "text-orange-400 hover:bg-orange-400/10" : "text-emerald-400 hover:bg-emerald-400/10"
|
||||
}`}
|
||||
title={isCrashed ? "Retry start" : "Start"}
|
||||
>
|
||||
{actionLoading === "start" ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
||||
{isCrashed ? "Retry" : "Start"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="w-px h-4 bg-slate-700 mx-1" />
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
{confirmAction && (
|
||||
<div className="px-4 py-2.5 bg-slate-800/90 border-b border-slate-700/60 flex items-center gap-3 shrink-0">
|
||||
<AlertTriangle size={14} className={`shrink-0 ${
|
||||
confirmAction === "stop" || confirmAction === "remove" ? "text-red-400" :
|
||||
confirmAction === "rebuild" ? "text-cyan-400" :
|
||||
"text-yellow-400"
|
||||
}`} />
|
||||
<span className="text-xs text-slate-300 flex-1">
|
||||
{confirmAction === "stop" ? "Stop this container? This will interrupt the service." :
|
||||
confirmAction === "restart" ? "Restart this container? This will briefly interrupt the service." :
|
||||
confirmAction === "remove" ? "Remove this container? This will stop and delete it." :
|
||||
"Rebuild this container? This will rebuild the image and recreate the container."}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => executeAction(confirmAction)}
|
||||
className={`px-3 py-1 rounded text-[11px] font-medium text-white transition-colors ${
|
||||
confirmAction === "stop" || confirmAction === "remove" ? "bg-red-700 hover:bg-red-600" :
|
||||
confirmAction === "rebuild" ? "bg-cyan-700 hover:bg-cyan-600" :
|
||||
"bg-yellow-700 hover:bg-yellow-600"
|
||||
}`}
|
||||
>
|
||||
{confirmAction === "stop" ? "Stop" : confirmAction === "restart" ? "Restart" : confirmAction === "remove" ? "Remove" : "Rebuild"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmAction(null)}
|
||||
className="px-3 py-1 rounded text-[11px] font-medium text-slate-400 hover:text-slate-200 bg-slate-700 hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center border-b border-slate-800 shrink-0">
|
||||
{TABS.map((tab) => {
|
||||
@@ -172,6 +385,21 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
{/* Info tab */}
|
||||
{activeTab === "info" && (
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{/* Crash banner */}
|
||||
{isCrashed && (
|
||||
<div className="flex items-start gap-2.5 bg-orange-500/10 border border-orange-500/30 rounded-lg px-3 py-2.5">
|
||||
<AlertTriangle size={16} className="text-orange-400 shrink-0 mt-0.5" />
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="font-semibold text-orange-300">Container crashed</div>
|
||||
<div className="text-slate-400">
|
||||
Exit code: <span className="text-orange-300 font-mono">{service.exit_code}</span>
|
||||
{service.oom_killed && <span className="ml-2 text-red-400 font-semibold">OOM Killed</span>}
|
||||
{service.restart_count > 0 && <span className="ml-2">Restarted <span className="text-orange-300 font-mono">{service.restart_count}</span> times</span>}
|
||||
</div>
|
||||
<div className="text-slate-500">Check the logs below for details</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{service.status && (
|
||||
<DetailRow label="Status" value={service.status} />
|
||||
)}
|
||||
@@ -467,7 +695,21 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
<div className="border-t border-slate-700/60" />
|
||||
<div className="absolute inset-x-0 -top-3 flex justify-center">
|
||||
<button
|
||||
onClick={() => setLogsExpanded((v) => !v)}
|
||||
onClick={() => {
|
||||
setLogsExpanded((v) => !v);
|
||||
// Scroll to bottom during and after transition
|
||||
const scrollToBottom = () => {
|
||||
if (scrollRef.current) {
|
||||
programmaticScroll.current = true;
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
scrollToBottom();
|
||||
setTimeout(scrollToBottom, 50);
|
||||
setTimeout(scrollToBottom, 150);
|
||||
setTimeout(scrollToBottom, 300);
|
||||
setTimeout(scrollToBottom, 350);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-0.5 rounded-full bg-slate-800 border border-slate-700/60 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors text-[10px] font-medium"
|
||||
title={logsExpanded ? "Collapse logs" : "Expand logs"}
|
||||
>
|
||||
@@ -481,19 +723,31 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
<Terminal size={14} className="text-cyan-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Logs</span>
|
||||
{service.state === "running" && subscribedRef.current && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-cyan-400">
|
||||
<span className="w-1 h-1 rounded-full bg-cyan-400 animate-pulse" />
|
||||
live
|
||||
</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoScroll((v) => !v)}
|
||||
className="p-1 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
||||
title={autoScroll ? "Pause auto-scroll" : "Resume auto-scroll"}
|
||||
>
|
||||
{autoScroll ? <Pause size={12} /> : <Play size={12} />}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setAutoScroll((v) => {
|
||||
if (v) manualPause.current = true;
|
||||
else manualPause.current = false;
|
||||
return !v;
|
||||
});
|
||||
}}
|
||||
className="p-1 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
||||
title={autoScroll ? "Pause auto-scroll" : "Resume auto-scroll"}
|
||||
>
|
||||
{autoScroll ? <Pause size={12} /> : <Play size={12} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLogsModal(true)}
|
||||
className="p-1 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
||||
title="Open logs fullscreen"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
@@ -513,7 +767,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
{formatTimestamp(l.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
<span className={`whitespace-pre-wrap break-all ${l.stream === "stderr" ? "text-red-400" : "text-slate-300"}`}>
|
||||
<span className={`whitespace-pre truncate ${logLineColor(l)}`}>
|
||||
{l.line}
|
||||
</span>
|
||||
</div>
|
||||
@@ -521,10 +775,83 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs fullscreen modal */}
|
||||
{logsModal && (
|
||||
<div className="fixed inset-0 z-[100] bg-slate-900 flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-slate-800 shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Terminal size={16} className="text-cyan-400" />
|
||||
<span className="text-sm font-semibold text-white">{service.name}</span>
|
||||
<span className="text-xs text-slate-500 font-mono">logs</span>
|
||||
{service.state === "running" && subscribedRef.current && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = allLines.map((l) => `${l.timestamp ? formatTimestamp(l.timestamp) + " " : ""}${l.line}`).join("\n");
|
||||
try {
|
||||
navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopiedEnvIdx(-99);
|
||||
setTimeout(() => setCopiedEnvIdx(null), 1500);
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
||||
title="Copy all logs"
|
||||
>
|
||||
{copiedEnvIdx === -99 ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLogsModal(false)}
|
||||
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={modalScrollRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-auto font-mono text-xs leading-5 px-6 py-3"
|
||||
>
|
||||
{allLines.map((l, i) => (
|
||||
<div key={i} className="flex gap-0 hover:bg-slate-800/40">
|
||||
{l.timestamp && (
|
||||
<span className="text-slate-600 shrink-0 select-none pr-3 whitespace-nowrap">
|
||||
{formatTimestamp(l.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
<span className={`whitespace-pre-wrap break-all ${logLineColor(l)}`}>
|
||||
{l.line}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ERROR_PATTERN = /\b(error|fatal|critical|exception|traceback|panic|failed|segfault)\b/i;
|
||||
const WARN_PATTERN = /\b(warn|warning)\b/i;
|
||||
|
||||
function logLineColor(l: LogLine): string {
|
||||
if (ERROR_PATTERN.test(l.line)) return "text-red-400";
|
||||
if (WARN_PATTERN.test(l.line)) return "text-yellow-400";
|
||||
return "text-slate-400";
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user