This commit is contained in:
RGJorge
2026-05-02 00:44:13 +00:00
parent f055489d81
commit afe99108b2
9 changed files with 642 additions and 76 deletions
+39
View File
@@ -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
View File
@@ -70,7 +70,7 @@ export default function App() {
} }
function Dashboard({ token }: { token: string }) { 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); const engineRef = useRef<ParticleEngine>(null);
if (!engineRef.current) { if (!engineRef.current) {
engineRef.current = new ParticleEngine(); engineRef.current = new ParticleEngine();
@@ -99,15 +99,15 @@ function Dashboard({ token }: { token: string }) {
const closeDetail = useCallback(() => { const closeDetail = useCallback(() => {
if (panelClosing) return; if (panelClosing) return;
setPanelClosing(true); setPanelClosing(true);
setSelectedNode(null);
if (prevViewport.current && reactFlowRef.current) { if (prevViewport.current && reactFlowRef.current) {
reactFlowRef.current.setViewport(prevViewport.current, { duration: 500 }); reactFlowRef.current.setViewport(prevViewport.current, { duration: 400 });
prevViewport.current = null; prevViewport.current = null;
} }
setSelectedNode(null);
setTimeout(() => { setTimeout(() => {
setDetailService(null); setDetailService(null);
setPanelClosing(false); setPanelClosing(false);
}, 300); }, 400);
}, [panelClosing]); }, [panelClosing]);
// Load saved positions // 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) // Zoom to 1 and position node at ~75% from left (panel opens on left)
const vw = window.innerWidth; const vw = window.innerWidth;
const vh = window.innerHeight - 48; // subtract header height 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 targetX = vw * 0.75 - (absX + NODE_W / 2) * zoom;
const targetY = vh * 0.5 - (absY + NODE_H / 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); setSelectedNode(node.id);
setDetailService(svc); setDetailService(svc);
@@ -549,16 +549,18 @@ function Dashboard({ token }: { token: string }) {
{detailService && ( {detailService && (
<DetailPanel <DetailPanel
service={detailService} service={filteredServices.find((s) => s.uid === detailService.uid) || detailService}
stats={stats.get(detailService.uid)} stats={stats.get(detailService.uid)}
logLines={logLines} logLines={logLines}
token={token} token={token}
closing={panelClosing} closing={panelClosing}
onClose={closeDetail} onClose={closeDetail}
onAction={setProcessing}
sendMessage={sendMessage} sendMessage={sendMessage}
clearLogLines={clearLogLines} clearLogLines={clearLogLines}
connections={filteredConnections} connections={filteredConnections}
services={filteredServices} services={filteredServices}
getLogsSince={getLogsSince}
/> />
)} )}
</div> </div>
+50 -3
View File
@@ -17,6 +17,8 @@ export function useDocker(token = "") {
const [statsVersion, setStatsVersion] = useState(0); const [statsVersion, setStatsVersion] = useState(0);
const [events, setEvents] = useState<DockerEvent[]>([]); const [events, setEvents] = useState<DockerEvent[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]); 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 [flows, setFlows] = useState<Flow[]>([]);
const [flowSettings, setFlowSettings] = useState<FlowSettings>({ const [flowSettings, setFlowSettings] = useState<FlowSettings>({
particle_size: 5, trail: true, trail_opacity: 0.3, glow: true, max_particles: 50, 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"]) { switch (msg.type as WSMessage["type"]) {
case "services": case "services": {
setServices((prev) => arraysEqual(prev, msg.data) ? prev : msg.data); 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; break;
}
case "connections": case "connections":
setConnections((prev) => { setConnections((prev) => {
if (prev.length === msg.data.length && if (prev.length === msg.data.length &&
@@ -173,5 +207,18 @@ export function useDocker(token = "") {
return () => { particleSpawnCallbacks.current.delete(cb); }; 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 };
} }
+25 -2
View File
@@ -1,4 +1,4 @@
import { memo } from "react"; import { memo, useState, useEffect } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react"; import { Handle, Position, type NodeProps } from "@xyflow/react";
import { import {
Database, Database,
@@ -20,6 +20,7 @@ import {
Rabbit, Rabbit,
Mail, Mail,
BarChart3, BarChart3,
AlertTriangle,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import type { Stats } from "../../shared/types"; 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" }, 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" }, 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" }, 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 // 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" }; 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) { export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
const d = data as unknown as ServiceNodeData; const d = data as unknown as ServiceNodeData;
const s = stateStyles[d.state] || stateStyles.exited; 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-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-bold text-white text-sm truncate">{d.label}</span> <span className="font-bold text-white text-sm truncate">{d.label}</span>
<div className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} /> {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>
<div className="text-xs text-slate-500 truncate mt-0.5"> <div className="text-xs text-slate-500 truncate mt-0.5">
{d.image.startsWith("sha256:") ? `Sin Tag (${d.image.slice(7, 19)})` : d.image} {d.image.startsWith("sha256:") ? `Sin Tag (${d.image.slice(7, 19)})` : d.image}
+358 -31
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useCallback } from "react"; 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"; import type { Service, Stats, LogLine, WSMessage, Connection } from "../../shared/types";
type Tab = "info" | "config" | "env" | "stats"; type Tab = "info" | "config" | "env" | "stats";
@@ -35,13 +35,15 @@ interface DetailPanelProps {
token: string; token: string;
closing?: boolean; closing?: boolean;
onClose: () => void; onClose: () => void;
onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void;
sendMessage: (msg: WSMessage) => void; sendMessage: (msg: WSMessage) => void;
clearLogLines: () => void; clearLogLines: () => void;
connections: Connection[]; connections: Connection[];
services: Service[]; 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 [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const [loading, setLoading] = 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 [envVisibleAll, setEnvVisibleAll] = useState(false);
const [envVisibleSet, setEnvVisibleSet] = useState<Set<number>>(new Set()); const [envVisibleSet, setEnvVisibleSet] = useState<Set<number>>(new Set());
const [copiedEnvIdx, setCopiedEnvIdx] = useState<number | null>(null); 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 // Slide-in animation
useEffect(() => { useEffect(() => {
@@ -67,7 +152,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
// Slide-out then unmount // Slide-out then unmount
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setVisible(false); setVisible(false);
setTimeout(() => onClose(), 300); setTimeout(() => onClose(), 400);
}, [onClose]); }, [onClose]);
// Fetch initial logs + subscribe // Fetch initial logs + subscribe
@@ -75,11 +160,14 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
setInitialLogs([]); setInitialLogs([]);
setLoading(true); setLoading(true);
clearLogLines(); clearLogLines();
initialScrollDone.current = false;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; 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((r) => r.ok ? r.json() : [])
.then((lines: LogLine[]) => { .then((lines: LogLine[]) => {
setInitialLogs(lines); setInitialLogs(lines);
@@ -99,51 +187,176 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
}, [service.id, token, sendMessage, clearLogLines]); }, [service.id, token, sendMessage, clearLogLines]);
// Auto-scroll // Auto-scroll
const initialScrollDone = useRef(false);
useEffect(() => { 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; 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]); }, [initialLogs, logLines, autoScroll]);
const programmaticScroll = useRef(false);
const manualPause = useRef(false);
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (!scrollRef.current) return; if (!scrollRef.current) return;
if (programmaticScroll.current) { programmaticScroll.current = false; return; }
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const atBottom = scrollHeight - scrollTop - clientHeight < 40; const atBottom = scrollHeight - scrollTop - clientHeight < 5;
setAutoScroll(atBottom); 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 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 === "running" ? "text-emerald-400" :
service.state === "exited" || service.state === "dead" ? "text-red-400" : service.state === "exited" || service.state === "dead" ? "text-red-400" :
"text-yellow-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 === "running" ? "bg-emerald-400" :
service.state === "exited" || service.state === "dead" ? "bg-red-400" : service.state === "exited" || service.state === "dead" ? "bg-red-400" :
"bg-yellow-400"; "bg-yellow-400";
return ( return (
<div <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 */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800 shrink-0"> <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"> <div className="flex items-center gap-2.5">
<span className={`w-2 h-2 rounded-full ${stateDot}`} /> <span className={`w-2 h-2 rounded-full ${stateDot}`} />
<span className="text-sm font-semibold text-white truncate">{service.name}</span> <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> </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> </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 */} {/* Tabs */}
<div className="flex items-center border-b border-slate-800 shrink-0"> <div className="flex items-center border-b border-slate-800 shrink-0">
{TABS.map((tab) => { {TABS.map((tab) => {
@@ -172,6 +385,21 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
{/* Info tab */} {/* Info tab */}
{activeTab === "info" && ( {activeTab === "info" && (
<div className="px-4 py-3 space-y-3"> <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 && ( {service.status && (
<DetailRow label="Status" value={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="border-t border-slate-700/60" />
<div className="absolute inset-x-0 -top-3 flex justify-center"> <div className="absolute inset-x-0 -top-3 flex justify-center">
<button <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" 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"} 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" /> <Terminal size={14} className="text-cyan-400" />
<span className="text-sm font-medium text-slate-300">Logs</span> <span className="text-sm font-medium text-slate-300">Logs</span>
{service.state === "running" && subscribedRef.current && ( {service.state === "running" && subscribedRef.current && (
<span className="flex items-center gap-1 text-[10px] text-cyan-400"> <span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span className="w-1 h-1 rounded-full bg-cyan-400 animate-pulse" />
live
</span>
)} )}
</div> </div>
<button <div className="flex items-center gap-1">
onClick={() => setAutoScroll((v) => !v)} <button
className="p-1 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors" onClick={() => {
title={autoScroll ? "Pause auto-scroll" : "Resume auto-scroll"} setAutoScroll((v) => {
> if (v) manualPause.current = true;
{autoScroll ? <Pause size={12} /> : <Play size={12} />} else manualPause.current = false;
</button> 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>
<div <div
ref={scrollRef} ref={scrollRef}
@@ -513,7 +767,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
{formatTimestamp(l.timestamp)} {formatTimestamp(l.timestamp)}
</span> </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} {l.line}
</span> </span>
</div> </div>
@@ -521,10 +775,83 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
</div> </div>
</div> </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> </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 }) { function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return ( return (
<div> <div>
+18 -4
View File
@@ -45,12 +45,21 @@ export async function discoverServices(all: boolean, projects: string[]): Promis
.map((entry: any) => `[${entry.ExitCode}] ${entry.Output?.trim() || ""}`) .map((entry: any) => `[${entry.ExitCode}] ${entry.Output?.trim() || ""}`)
.filter((s: string) => s.length > 4); .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 { return {
id: c.Id.slice(0, 12), id: c.Id.slice(0, 12),
uid: `${project}/${name}`, uid: `${project}/${name}`,
name, name,
image: c.Image, image: c.Image,
state: c.State as Service["state"], state,
status: c.Status, status: c.Status,
ports: [...new Map( ports: [...new Map(
c.Ports.filter((p) => p.PublicPort).map((p) => [ 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, cpu_quota: info?.HostConfig?.CpuQuota || 0,
health_status: healthStatus, health_status: healthStatus,
health_log: healthLog, 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 ── // ── 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 container = docker.getContainer(id);
const logBuffer = await container.logs({ const opts: Record<string, any> = {
stdout: true, stdout: true,
stderr: true, stderr: true,
tail, tail,
timestamps: true, timestamps: true,
}); };
if (since) opts.since = since;
const logBuffer = await container.logs(opts);
const lines: LogLine[] = []; const lines: LogLine[] = [];
const raw = Buffer.isBuffer(logBuffer) ? logBuffer : Buffer.from(logBuffer as any); const raw = Buffer.isBuffer(logBuffer) ? logBuffer : Buffer.from(logBuffer as any);
+135 -27
View File
@@ -3,10 +3,10 @@ import { serveStatic } from "hono/bun";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import path from "path"; import path from "path";
import fs from "fs"; 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 { pollStats, watchDockerEvents } from "./watcher";
import { loadFlows, getFlows, getSettings } from "./flows"; import { loadFlows, getFlows, getSettings } from "./flows";
import type { WSMessage } from "../shared/types"; import type { Service, WSMessage } from "../shared/types";
const app = new Hono(); const app = new Hono();
@@ -64,14 +64,112 @@ app.get("/api/flows", (c) => {
return c.json({ flows: getFlows(), settings: getSettings() }); 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) => { app.get("/api/logs/:id", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
if (!/^[a-f0-9]{12,64}$/.test(id)) { if (!/^[a-f0-9]{12,64}$/.test(id)) {
return c.json({ error: "Invalid container ID" }, 400); return c.json({ error: "Invalid container ID" }, 400);
} }
const tail = Math.min(Math.max(parseInt(c.req.query("tail") || "200") || 200, 1), 5000); 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 { try {
const lines = await getContainerLogs(id, tail); const lines = await getContainerLogs(id, tail, since);
return c.json(lines); return c.json(lines);
} catch (err) { } catch (err) {
return c.json({ error: "Failed to fetch logs" }, 500); return c.json({ error: "Failed to fetch logs" }, 500);
@@ -139,11 +237,14 @@ function cleanupLogStream(ws: WebSocket) {
} }
// ── Docker events ── // ── Docker events ──
let servicesLock = false;
let statsLock = false;
async function refreshServices() { async function refreshServices() {
if (servicesLock) return;
servicesLock = true;
try { try {
const services = await discoverServices(ALL, PROJECTS); 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("|"); const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|");
if (svcHash !== lastServicesHash) { if (svcHash !== lastServicesHash) {
@@ -151,49 +252,56 @@ async function refreshServices() {
broadcast({ type: "services", data: services }); broadcast({ type: "services", data: services });
} }
const connections = await discoverConnections(services);
const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|"); const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|");
if (connHash !== lastConnectionsHash) { if (connHash !== lastConnectionsHash) {
lastConnectionsHash = connHash; lastConnectionsHash = connHash;
broadcast({ type: "connections", data: connections }); broadcast({ type: "connections", data: connections });
} }
broadcast({ type: "stats", data: stats }); // Stats polling is separate — don't block services refresh
refreshStats(services);
} catch (err) { } catch (err) {
console.error("Refresh error:", err); console.error("Refresh error:", err);
} finally {
servicesLock = false;
} }
} }
// Quick refresh — services + connections, no stats (fast) let statsLockTimer: ReturnType<typeof setTimeout> | undefined;
async function quickRefresh() { 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 { try {
const services = await discoverServices(ALL, PROJECTS); const stats = await pollStats(services);
const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|"); broadcast({ type: "stats", data: stats });
if (svcHash !== lastServicesHash) { } catch (err) {
lastServicesHash = svcHash; console.error("Stats error:", err);
broadcast({ type: "services", data: services }); } finally {
clearTimeout(statsLockTimer);
// Also refresh connections when services change statsLock = false;
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 });
}
}
} catch {}
} }
// Debounced refresh for Docker events // Debounced refresh for Docker events
let refreshTimer: ReturnType<typeof setTimeout> | undefined; let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined; let retryTimer: ReturnType<typeof setTimeout> | undefined;
let lateRetryTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleRefresh() { function scheduleRefresh() {
// Invalidate hash so next refresh always broadcasts (restart: same final state but clients need the update)
lastServicesHash = "";
clearTimeout(refreshTimer); clearTimeout(refreshTimer);
clearTimeout(retryTimer); clearTimeout(retryTimer);
// First check at 1.5s, retry at 3.5s to catch stragglers (e.g. slow destroy) clearTimeout(lateRetryTimer);
refreshTimer = setTimeout(() => { refreshTimer = setTimeout(() => {
quickRefresh(); refreshServices();
retryTimer = setTimeout(quickRefresh, 2000); retryTimer = setTimeout(refreshServices, 1500);
}, 1500); // Late retry for restart/rebuild: client ignores first 5s, so re-broadcast after that
lateRetryTimer = setTimeout(() => { lastServicesHash = ""; refreshServices(); }, 6000);
}, 500);
} }
watchDockerEvents((event) => { watchDockerEvents((event) => {
+4 -1
View File
@@ -8,7 +8,10 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
for (const svc of running) { for (const svc of running) {
try { try {
const container = docker.getContainer(svc.id); 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 = const cpuDelta =
raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage; raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
+4 -1
View File
@@ -3,7 +3,7 @@ export interface Service {
uid: string; uid: string;
name: string; name: string;
image: string; image: string;
state: "running" | "exited" | "paused" | "restarting" | "dead"; state: "running" | "exited" | "paused" | "restarting" | "dead" | "crashed";
status: string; status: string;
ports: { host: number; container: number }[]; ports: { host: number; container: number }[];
networks: string[]; networks: string[];
@@ -16,6 +16,9 @@ export interface Service {
cpu_quota: number; cpu_quota: number;
health_status: string; health_status: string;
health_log: string[]; health_log: string[];
exit_code: number;
restart_count: number;
oom_killed: boolean;
} }
export interface Connection { export interface Connection {