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 }) {
|
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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user