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:
@@ -0,0 +1,39 @@
|
||||
# Flowteon — Roadmap de Monitoreo
|
||||
|
||||
## Fase 1: Acciones básicas
|
||||
- [ ] Stop / Start / Restart desde el DetailPanel
|
||||
- [ ] Confirmación antes de ejecutar acciones destructivas (stop/restart)
|
||||
- [ ] Feedback visual del estado de la acción (loading, success, error)
|
||||
- [ ] Rebuild (docker compose up --build) por servicio
|
||||
|
||||
## Fase 2: Notificaciones
|
||||
- [ ] Webhooks configurables (Discord, Slack)
|
||||
- [ ] PWA — Service Worker + manifest
|
||||
- [ ] Push notifications cuando un contenedor cae o health check falla
|
||||
- [ ] Panel de configuración de notificaciones en la UI
|
||||
|
||||
## Fase 3: Historial y métricas
|
||||
- [ ] SQLite para persistir stats (CPU, RAM, network I/O)
|
||||
- [ ] Gráficas temporales de CPU/RAM por servicio (últimas 1h, 6h, 24h, 7d)
|
||||
- [ ] Dashboard de métricas agregadas
|
||||
- [ ] Retención configurable (auto-limpiar datos viejos)
|
||||
|
||||
## Fase 4: Alertas
|
||||
- [ ] Reglas de alertas (ej: CPU > 80% por 5 min)
|
||||
- [ ] Historial de alertas disparadas
|
||||
- [ ] Integración con notificaciones (Fase 2)
|
||||
- [ ] Alertas por health check fallido
|
||||
|
||||
## Fase 5: Info avanzada
|
||||
- [ ] Volumes/Mounts por contenedor
|
||||
- [ ] Terminal interactiva (docker exec) desde la UI
|
||||
- [ ] Network inspector (tráfico entre servicios)
|
||||
- [ ] Image layers y tamaño
|
||||
|
||||
## Fase 6: Deployment (futuro)
|
||||
- [ ] GitHub integration (webhook + clone + build)
|
||||
- [ ] Build pipeline (docker build desde Dockerfile)
|
||||
- [ ] Deploy management (docker-compose dinámico)
|
||||
- [ ] Domain routing automático (Traefik/Caddy)
|
||||
- [ ] Rollbacks (mantener imágenes anteriores)
|
||||
- [ ] Env var editing + rebuild desde la UI
|
||||
+9
-7
@@ -70,7 +70,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
function Dashboard({ token }: { token: string }) {
|
||||
const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn } = useDocker(token);
|
||||
const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn, setProcessing, getLogsSince } = useDocker(token);
|
||||
const engineRef = useRef<ParticleEngine>(null);
|
||||
if (!engineRef.current) {
|
||||
engineRef.current = new ParticleEngine();
|
||||
@@ -99,15 +99,15 @@ function Dashboard({ token }: { token: string }) {
|
||||
const closeDetail = useCallback(() => {
|
||||
if (panelClosing) return;
|
||||
setPanelClosing(true);
|
||||
setSelectedNode(null);
|
||||
if (prevViewport.current && reactFlowRef.current) {
|
||||
reactFlowRef.current.setViewport(prevViewport.current, { duration: 500 });
|
||||
reactFlowRef.current.setViewport(prevViewport.current, { duration: 400 });
|
||||
prevViewport.current = null;
|
||||
}
|
||||
setSelectedNode(null);
|
||||
setTimeout(() => {
|
||||
setDetailService(null);
|
||||
setPanelClosing(false);
|
||||
}, 300);
|
||||
}, 400);
|
||||
}, [panelClosing]);
|
||||
|
||||
// Load saved positions
|
||||
@@ -508,11 +508,11 @@ function Dashboard({ token }: { token: string }) {
|
||||
// Zoom to 1 and position node at ~75% from left (panel opens on left)
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight - 48; // subtract header height
|
||||
const zoom = 1;
|
||||
const zoom = 1.5;
|
||||
const targetX = vw * 0.75 - (absX + NODE_W / 2) * zoom;
|
||||
const targetY = vh * 0.5 - (absY + NODE_H / 2) * zoom;
|
||||
|
||||
reactFlowRef.current?.setViewport({ x: targetX, y: targetY, zoom }, { duration: 500 });
|
||||
reactFlowRef.current?.setViewport({ x: targetX, y: targetY, zoom }, { duration: 400 });
|
||||
|
||||
setSelectedNode(node.id);
|
||||
setDetailService(svc);
|
||||
@@ -549,16 +549,18 @@ function Dashboard({ token }: { token: string }) {
|
||||
|
||||
{detailService && (
|
||||
<DetailPanel
|
||||
service={detailService}
|
||||
service={filteredServices.find((s) => s.uid === detailService.uid) || detailService}
|
||||
stats={stats.get(detailService.uid)}
|
||||
logLines={logLines}
|
||||
token={token}
|
||||
closing={panelClosing}
|
||||
onClose={closeDetail}
|
||||
onAction={setProcessing}
|
||||
sendMessage={sendMessage}
|
||||
clearLogLines={clearLogLines}
|
||||
connections={filteredConnections}
|
||||
services={filteredServices}
|
||||
getLogsSince={getLogsSince}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,8 @@ export function useDocker(token = "") {
|
||||
const [statsVersion, setStatsVersion] = useState(0);
|
||||
const [events, setEvents] = useState<DockerEvent[]>([]);
|
||||
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||
// Processing state: uid → { expected state, start time, min duration before clearing }
|
||||
const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
|
||||
const [flows, setFlows] = useState<Flow[]>([]);
|
||||
const [flowSettings, setFlowSettings] = useState<FlowSettings>({
|
||||
particle_size: 5, trail: true, trail_opacity: 0.3, glow: true, max_particles: 50,
|
||||
@@ -85,9 +87,41 @@ export function useDocker(token = "") {
|
||||
}
|
||||
|
||||
switch (msg.type as WSMessage["type"]) {
|
||||
case "services":
|
||||
setServices((prev) => arraysEqual(prev, msg.data) ? prev : msg.data);
|
||||
case "services": {
|
||||
const processing = processingRef.current;
|
||||
let incoming = msg.data as Service[];
|
||||
if (processing.size > 0) {
|
||||
const now = Date.now();
|
||||
incoming = incoming.map((s: Service) => {
|
||||
const entry = processing.get(s.uid);
|
||||
if (!entry) return s;
|
||||
// Don't clear processing until minDuration has passed (restart/rebuild need time for stop→start cycle)
|
||||
const elapsed = now - entry.startedAt;
|
||||
if (elapsed < entry.minDuration) {
|
||||
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
|
||||
}
|
||||
// Server confirms expected state → clear processing
|
||||
if (s.state === entry.expected) {
|
||||
processing.delete(s.uid);
|
||||
return s;
|
||||
}
|
||||
// Container crashed while we expected "running" → clear processing, show crashed
|
||||
if (s.state === "crashed" && entry.expected === "running") {
|
||||
processing.delete(s.uid);
|
||||
return s;
|
||||
}
|
||||
// Timeout after 15s → give up, show real state
|
||||
if (now - entry.startedAt > 15000) {
|
||||
processing.delete(s.uid);
|
||||
return s;
|
||||
}
|
||||
// State doesn't match expected → keep in processing, ignore stale data
|
||||
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
|
||||
});
|
||||
}
|
||||
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||
break;
|
||||
}
|
||||
case "connections":
|
||||
setConnections((prev) => {
|
||||
if (prev.length === msg.data.length &&
|
||||
@@ -173,5 +207,18 @@ export function useDocker(token = "") {
|
||||
return () => { particleSpawnCallbacks.current.delete(cb); };
|
||||
}, []);
|
||||
|
||||
return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn };
|
||||
const actionTimestamps = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const setProcessing = useCallback((uid: string, expectedState: Service["state"], minDuration = 0) => {
|
||||
const startedAt = Date.now();
|
||||
processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration });
|
||||
actionTimestamps.current.set(uid, Math.floor(startedAt / 1000));
|
||||
setServices((prev) => prev.map((s) => s.uid === uid ? { ...s, state: "processing" as any, _processingStartedAt: startedAt } as any : s));
|
||||
}, []);
|
||||
|
||||
const getLogsSince = useCallback((uid: string): number | undefined => {
|
||||
return actionTimestamps.current.get(uid);
|
||||
}, []);
|
||||
|
||||
return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines, flows, flowSettings, onParticleSpawn, setProcessing, getLogsSince };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { memo, useState, useEffect } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import {
|
||||
Database,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Rabbit,
|
||||
Mail,
|
||||
BarChart3,
|
||||
AlertTriangle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { Stats } from "../../shared/types";
|
||||
@@ -45,6 +46,8 @@ const stateStyles: Record<string, { ring: string; dot: string; bg: string; borde
|
||||
paused: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10", border: "border-amber-500/60" },
|
||||
restarting: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10", border: "border-amber-500/60" },
|
||||
dead: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10", border: "border-red-500/60" },
|
||||
crashed: { ring: "ring-orange-500/50", dot: "bg-orange-500", bg: "bg-orange-500/10", border: "border-orange-500/60" },
|
||||
processing: { ring: "ring-yellow-500/50", dot: "bg-yellow-500 animate-pulse", bg: "bg-yellow-500/10", border: "border-yellow-500/60" },
|
||||
};
|
||||
|
||||
// Map image/name patterns to Lucide icons and colors
|
||||
@@ -100,6 +103,17 @@ function guessIcon(image: string, name: string): { Icon: LucideIcon; color: stri
|
||||
return { Icon: Container, color: "#64748b" };
|
||||
}
|
||||
|
||||
function ProcessingTimer({ startedAt }: { startedAt: number }) {
|
||||
const [elapsed, setElapsed] = useState(Math.floor((Date.now() - startedAt) / 1000));
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - startedAt) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [startedAt]);
|
||||
return <span className="text-[10px] font-mono text-yellow-400 ml-1">{elapsed}s</span>;
|
||||
}
|
||||
|
||||
export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
|
||||
const d = data as unknown as ServiceNodeData;
|
||||
const s = stateStyles[d.state] || stateStyles.exited;
|
||||
@@ -164,7 +178,16 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white text-sm truncate">{d.label}</span>
|
||||
{d.state === "processing" ? (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
|
||||
{(d as any)._processingStartedAt && <ProcessingTimer startedAt={(d as any)._processingStartedAt} />}
|
||||
</div>
|
||||
) : d.state === "crashed" ? (
|
||||
<AlertTriangle size={12} className="text-orange-500 shrink-0" />
|
||||
) : (
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate mt-0.5">
|
||||
{d.image.startsWith("sha256:") ? `Sin Tag (${d.image.slice(7, 19)})` : d.image}
|
||||
|
||||
@@ -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,42 +187,133 @@ 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"
|
||||
@@ -143,6 +322,40 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
@@ -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>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setAutoScroll((v) => !v)}
|
||||
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>
|
||||
|
||||
+18
-4
@@ -45,12 +45,21 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
|
||||
.map((entry: any) => `[${entry.ExitCode}] ${entry.Output?.trim() || ""}`)
|
||||
.filter((s: string) => s.length > 4);
|
||||
|
||||
const exitCode: number = info?.State?.ExitCode ?? 0;
|
||||
const restartCount: number = info?.RestartCount ?? 0;
|
||||
const oomKilled: boolean = info?.State?.OOMKilled ?? false;
|
||||
|
||||
// Detect crashed: exited with non-zero exit code or OOM killed
|
||||
const rawState = c.State as string;
|
||||
const isCrashed = rawState === "exited" && (exitCode !== 0 || oomKilled);
|
||||
const state: Service["state"] = isCrashed ? "crashed" : rawState as Service["state"];
|
||||
|
||||
return {
|
||||
id: c.Id.slice(0, 12),
|
||||
uid: `${project}/${name}`,
|
||||
name,
|
||||
image: c.Image,
|
||||
state: c.State as Service["state"],
|
||||
state,
|
||||
status: c.Status,
|
||||
ports: [...new Map(
|
||||
c.Ports.filter((p) => p.PublicPort).map((p) => [
|
||||
@@ -68,6 +77,9 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
|
||||
cpu_quota: info?.HostConfig?.CpuQuota || 0,
|
||||
health_status: healthStatus,
|
||||
health_log: healthLog,
|
||||
exit_code: exitCode,
|
||||
restart_count: restartCount,
|
||||
oom_killed: oomKilled,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -184,14 +196,16 @@ export async function discoverConnections(services: Service[]): Promise<Connecti
|
||||
|
||||
// ── Container logs ──
|
||||
|
||||
export async function getContainerLogs(id: string, tail = 200): Promise<LogLine[]> {
|
||||
export async function getContainerLogs(id: string, tail = 200, since?: number): Promise<LogLine[]> {
|
||||
const container = docker.getContainer(id);
|
||||
const logBuffer = await container.logs({
|
||||
const opts: Record<string, any> = {
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail,
|
||||
timestamps: true,
|
||||
});
|
||||
};
|
||||
if (since) opts.since = since;
|
||||
const logBuffer = await container.logs(opts);
|
||||
|
||||
const lines: LogLine[] = [];
|
||||
const raw = Buffer.isBuffer(logBuffer) ? logBuffer : Buffer.from(logBuffer as any);
|
||||
|
||||
+134
-26
@@ -3,10 +3,10 @@ import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
||||
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
||||
import { pollStats, watchDockerEvents } from "./watcher";
|
||||
import { loadFlows, getFlows, getSettings } from "./flows";
|
||||
import type { WSMessage } from "../shared/types";
|
||||
import type { Service, WSMessage } from "../shared/types";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -64,14 +64,112 @@ app.get("/api/flows", (c) => {
|
||||
return c.json({ flows: getFlows(), settings: getSettings() });
|
||||
});
|
||||
|
||||
// ── Container actions ──
|
||||
app.post("/api/containers/:id/stop", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
await container.stop();
|
||||
// Docker events will trigger refresh automatically when state changes
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already stopped" });
|
||||
return c.json({ error: err?.message || "Failed to stop container" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/containers/:id/start", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
await container.start();
|
||||
// Docker events will trigger refresh automatically when state changes
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already running" });
|
||||
return c.json({ error: err?.message || "Failed to start container" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/containers/:id/restart", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
await container.restart();
|
||||
// Docker events will trigger refresh automatically when state changes
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.message || "Failed to restart container" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/containers/:id/rebuild", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
const info = await container.inspect();
|
||||
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
|
||||
const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
|
||||
if (!composeFile || !serviceName) {
|
||||
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
|
||||
}
|
||||
const proc = Bun.spawn(["docker", "compose", "-f", composeFile, "up", "--build", "-d", serviceName], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return c.json({ error: stderr || `Rebuild failed with exit code ${exitCode}` }, 500);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.message || "Failed to rebuild container" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/containers/:id/remove", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400);
|
||||
try {
|
||||
const container = docker.getContainer(id);
|
||||
const info = await container.inspect();
|
||||
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
|
||||
const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
|
||||
if (!composeFile || !serviceName) {
|
||||
// Not a compose service — just stop and remove the container
|
||||
try { await container.stop(); } catch {}
|
||||
await container.remove({ force: true });
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
const proc = Bun.spawn(["docker", "compose", "-f", composeFile, "rm", "-sf", serviceName], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return c.json({ error: stderr || `Remove failed with exit code ${exitCode}` }, 500);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.message || "Failed to remove container" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/logs/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (!/^[a-f0-9]{12,64}$/.test(id)) {
|
||||
return c.json({ error: "Invalid container ID" }, 400);
|
||||
}
|
||||
const tail = Math.min(Math.max(parseInt(c.req.query("tail") || "200") || 200, 1), 5000);
|
||||
const since = c.req.query("since") ? parseInt(c.req.query("since")!) : undefined;
|
||||
try {
|
||||
const lines = await getContainerLogs(id, tail);
|
||||
const lines = await getContainerLogs(id, tail, since);
|
||||
return c.json(lines);
|
||||
} catch (err) {
|
||||
return c.json({ error: "Failed to fetch logs" }, 500);
|
||||
@@ -139,11 +237,14 @@ function cleanupLogStream(ws: WebSocket) {
|
||||
}
|
||||
|
||||
// ── Docker events ──
|
||||
let servicesLock = false;
|
||||
let statsLock = false;
|
||||
|
||||
async function refreshServices() {
|
||||
if (servicesLock) return;
|
||||
servicesLock = true;
|
||||
try {
|
||||
const services = await discoverServices(ALL, PROJECTS);
|
||||
const connections = await discoverConnections(services);
|
||||
const stats = await pollStats(services);
|
||||
|
||||
const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|");
|
||||
if (svcHash !== lastServicesHash) {
|
||||
@@ -151,49 +252,56 @@ async function refreshServices() {
|
||||
broadcast({ type: "services", data: services });
|
||||
}
|
||||
|
||||
const connections = await discoverConnections(services);
|
||||
const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|");
|
||||
if (connHash !== lastConnectionsHash) {
|
||||
lastConnectionsHash = connHash;
|
||||
broadcast({ type: "connections", data: connections });
|
||||
}
|
||||
|
||||
broadcast({ type: "stats", data: stats });
|
||||
// Stats polling is separate — don't block services refresh
|
||||
refreshStats(services);
|
||||
} catch (err) {
|
||||
console.error("Refresh error:", err);
|
||||
} finally {
|
||||
servicesLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick refresh — services + connections, no stats (fast)
|
||||
async function quickRefresh() {
|
||||
let statsLockTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
async function refreshStats(services: Service[]) {
|
||||
if (statsLock) return;
|
||||
statsLock = true;
|
||||
// Safety: force-unlock after 30s in case pollStats hangs
|
||||
clearTimeout(statsLockTimer);
|
||||
statsLockTimer = setTimeout(() => { statsLock = false; }, 30000);
|
||||
try {
|
||||
const services = await discoverServices(ALL, PROJECTS);
|
||||
const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|");
|
||||
if (svcHash !== lastServicesHash) {
|
||||
lastServicesHash = svcHash;
|
||||
broadcast({ type: "services", data: services });
|
||||
|
||||
// Also refresh connections when services change
|
||||
const connections = await discoverConnections(services);
|
||||
const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|");
|
||||
if (connHash !== lastConnectionsHash) {
|
||||
lastConnectionsHash = connHash;
|
||||
broadcast({ type: "connections", data: connections });
|
||||
const stats = await pollStats(services);
|
||||
broadcast({ type: "stats", data: stats });
|
||||
} catch (err) {
|
||||
console.error("Stats error:", err);
|
||||
} finally {
|
||||
clearTimeout(statsLockTimer);
|
||||
statsLock = false;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Debounced refresh for Docker events
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lateRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function scheduleRefresh() {
|
||||
// Invalidate hash so next refresh always broadcasts (restart: same final state but clients need the update)
|
||||
lastServicesHash = "";
|
||||
clearTimeout(refreshTimer);
|
||||
clearTimeout(retryTimer);
|
||||
// First check at 1.5s, retry at 3.5s to catch stragglers (e.g. slow destroy)
|
||||
clearTimeout(lateRetryTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
quickRefresh();
|
||||
retryTimer = setTimeout(quickRefresh, 2000);
|
||||
}, 1500);
|
||||
refreshServices();
|
||||
retryTimer = setTimeout(refreshServices, 1500);
|
||||
// Late retry for restart/rebuild: client ignores first 5s, so re-broadcast after that
|
||||
lateRetryTimer = setTimeout(() => { lastServicesHash = ""; refreshServices(); }, 6000);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
watchDockerEvents((event) => {
|
||||
|
||||
@@ -8,7 +8,10 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
|
||||
for (const svc of running) {
|
||||
try {
|
||||
const container = docker.getContainer(svc.id);
|
||||
const raw = await container.stats({ stream: false });
|
||||
const raw = await Promise.race([
|
||||
container.stats({ stream: false }),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 3000)),
|
||||
]) as any;
|
||||
|
||||
const cpuDelta =
|
||||
raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
|
||||
|
||||
+4
-1
@@ -3,7 +3,7 @@ export interface Service {
|
||||
uid: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: "running" | "exited" | "paused" | "restarting" | "dead";
|
||||
state: "running" | "exited" | "paused" | "restarting" | "dead" | "crashed";
|
||||
status: string;
|
||||
ports: { host: number; container: number }[];
|
||||
networks: string[];
|
||||
@@ -16,6 +16,9 @@ export interface Service {
|
||||
cpu_quota: number;
|
||||
health_status: string;
|
||||
health_log: string[];
|
||||
exit_code: number;
|
||||
restart_count: number;
|
||||
oom_killed: boolean;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
|
||||
Reference in New Issue
Block a user