mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.16
This commit is contained in:
+70
-1
@@ -18,6 +18,7 @@ import { useDocker } from "./hooks/useDocker";
|
||||
import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore";
|
||||
import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
|
||||
import { DetailPanel } from "./panels/DetailPanel";
|
||||
import { NodeContextMenu } from "./components/NodeContextMenu";
|
||||
import { LoginScreen } from "./components/LoginScreen";
|
||||
import { OffsetEdge } from "./components/OffsetEdge";
|
||||
import { HeaderBar, type Page } from "./components/HeaderBar";
|
||||
@@ -84,9 +85,11 @@ function Dashboard({ token }: { token: string }) {
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const [detailService, setDetailService] = useState<Service | null>(null);
|
||||
const [openLogsFullscreen, setOpenLogsFullscreen] = useState(false);
|
||||
const reactFlowRef = useRef<any>(null);
|
||||
const prevViewport = useRef<{ x: number; y: number; zoom: number } | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; service: Service } | null>(null);
|
||||
|
||||
// Close filter dropdown on outside click
|
||||
useEffect(() => {
|
||||
@@ -121,6 +124,7 @@ function Dashboard({ token }: { token: string }) {
|
||||
startTransition(() => {
|
||||
setDetailService(null);
|
||||
setPanelClosing(false);
|
||||
setOpenLogsFullscreen(false);
|
||||
});
|
||||
}, 400);
|
||||
}, [panelClosing]);
|
||||
@@ -448,7 +452,7 @@ function Dashboard({ token }: { token: string }) {
|
||||
token={token}
|
||||
totalStats={totalStats}
|
||||
activePage={activePage}
|
||||
onPageChange={setActivePage}
|
||||
onPageChange={(page) => { setContextMenu(null); setActivePage(page); }}
|
||||
events={events}
|
||||
/>
|
||||
|
||||
@@ -465,7 +469,15 @@ function Dashboard({ token }: { token: string }) {
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStart={() => { isDragging.current = true; }}
|
||||
onNodeDragStop={() => { isDragging.current = false; }}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
if (node.type !== "service") return;
|
||||
const svc = filteredServices.find((s) => s.uid === node.id);
|
||||
if (!svc) return;
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, service: svc });
|
||||
}}
|
||||
onNodeClick={(_e, node) => {
|
||||
setContextMenu(null);
|
||||
if (isDragging.current) return;
|
||||
if (node.type !== "service") return;
|
||||
|
||||
@@ -503,7 +515,9 @@ function Dashboard({ token }: { token: string }) {
|
||||
setDetailService(svc);
|
||||
});
|
||||
}}
|
||||
onMoveStart={() => { setContextMenu(null); }}
|
||||
onPaneClick={() => {
|
||||
setContextMenu(null);
|
||||
if (detailService) closeDetail();
|
||||
}}
|
||||
nodeTypes={nodeTypes}
|
||||
@@ -603,6 +617,60 @@ function Dashboard({ token }: { token: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextMenu && (
|
||||
<NodeContextMenu
|
||||
position={{ x: contextMenu.x, y: contextMenu.y }}
|
||||
service={contextMenu.service}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onAction={(action) => {
|
||||
const svc = contextMenu.service;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers })
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
const expectedState: Service["state"] =
|
||||
action === "stop" || action === "remove" ? "exited" :
|
||||
action === "start" || action === "restart" || action === "rebuild" ? "running" :
|
||||
svc.state;
|
||||
const minDuration = action === "restart" || action === "rebuild" ? 5000 : 0;
|
||||
setProcessing(svc.uid, expectedState, minDuration);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
onOpenLogs={() => {
|
||||
const svc = contextMenu.service;
|
||||
const node = nodes.find((n) => n.id === svc.uid);
|
||||
if (!node) return;
|
||||
|
||||
if (reactFlowRef.current && !prevViewport.current) {
|
||||
prevViewport.current = reactFlowRef.current.getViewport();
|
||||
}
|
||||
|
||||
let absX = node.position.x;
|
||||
let absY = node.position.y;
|
||||
if (node.parentId) {
|
||||
const parent = nodes.find((n) => n.id === node.parentId);
|
||||
if (parent) { absX += parent.position.x; absY += parent.position.y; }
|
||||
}
|
||||
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight - 48;
|
||||
const zoom = 1.5;
|
||||
const targetX = vw * 0.75 - (absX + NODE_W / 2) * zoom;
|
||||
const targetY = vh * 0.5 - (absY + NODE_H / 2) * zoom;
|
||||
reactFlowRef.current?.setViewport({ x: targetX, y: targetY, zoom }, { duration: 400 });
|
||||
|
||||
startTransition(() => {
|
||||
setOpenLogsFullscreen(true);
|
||||
setSelectedNode(svc.uid);
|
||||
setDetailService(svc);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailService && (
|
||||
<DetailPanel
|
||||
service={filteredServices.find((s) => s.uid === detailService.uid) || detailService}
|
||||
@@ -617,6 +685,7 @@ function Dashboard({ token }: { token: string }) {
|
||||
connections={filteredConnections}
|
||||
services={filteredServices}
|
||||
getLogsSince={getLogsSince}
|
||||
initialLogsFullscreen={openLogsFullscreen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer } from "lucide-react";
|
||||
import type { Service } from "../../shared/types";
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
position: { x: number; y: number };
|
||||
service: Service;
|
||||
onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild") => void;
|
||||
onOpenLogs: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClose }: NodeContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as HTMLElement)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const scrollHandler = () => onClose();
|
||||
const keyHandler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("mousedown", handler);
|
||||
document.addEventListener("scroll", scrollHandler, true);
|
||||
document.addEventListener("keydown", keyHandler);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handler);
|
||||
document.removeEventListener("scroll", scrollHandler, true);
|
||||
document.removeEventListener("keydown", keyHandler);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Adjust position so menu doesn't overflow viewport
|
||||
const menuWidth = 180;
|
||||
const menuHeight = 200;
|
||||
const x = position.x + menuWidth > window.innerWidth ? position.x - menuWidth : position.x;
|
||||
const y = position.y + menuHeight > window.innerHeight - 50 ? position.y - menuHeight : position.y;
|
||||
|
||||
const isRunning = service.state === "running";
|
||||
const firstPort = service.ports.length > 0 ? service.ports[0] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[10000] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/50 py-1.5 min-w-[180px]"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<MenuItem icon={RotateCw} label="Restart" color="text-yellow-400" onClick={() => { onAction("restart"); onClose(); }} />
|
||||
<MenuItem icon={Square} label="Stop" color="text-red-400" onClick={() => { onAction("stop"); onClose(); }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem icon={Play} label="Start" color="text-emerald-400" onClick={() => { onAction("start"); onClose(); }} />
|
||||
<MenuItem icon={Trash2} label="Remove" color="text-red-400" onClick={() => { onAction("remove"); onClose(); }} />
|
||||
</>
|
||||
)}
|
||||
{service.compose_file && (
|
||||
<>
|
||||
<div className="border-t border-slate-700/50 my-1" />
|
||||
<MenuItem icon={Hammer} label="Rebuild" color="text-cyan-400" onClick={() => { onAction("rebuild"); onClose(); }} />
|
||||
</>
|
||||
)}
|
||||
<div className="border-t border-slate-700/50 my-1" />
|
||||
<MenuItem icon={Terminal} label="Open Logs" color="text-cyan-400" onClick={() => { onOpenLogs(); onClose(); }} />
|
||||
{isRunning && firstPort && (
|
||||
<a
|
||||
href={`http://${window.location.hostname}:${firstPort.host}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm text-slate-300 hover:bg-slate-700/60 transition-colors cursor-pointer"
|
||||
onClick={onClose}
|
||||
>
|
||||
<ExternalLink size={14} className="text-slate-400" />
|
||||
<span>Open :{firstPort.host}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({ icon: Icon, label, color, onClick }: { icon: typeof Play; label: string; color: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm text-slate-300 hover:bg-slate-700/60 transition-colors"
|
||||
>
|
||||
<Icon size={14} className={color} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,22 @@
|
||||
background-color: #334155 !important;
|
||||
}
|
||||
|
||||
/* Disable hover effect on nodes */
|
||||
.react-flow__node:hover {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Pointer cursor on service nodes, grabbing while dragging */
|
||||
.react-flow__node-service {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.react-flow__node-service.dragging {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
.react-flow__node-group {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
/* Flash animations for Docker events */
|
||||
@keyframes flash-border-green {
|
||||
0% { border-color: #22c55e; box-shadow: 0 0 12px 2px rgba(34, 197, 94, 0.4); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "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 { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink } from "lucide-react";
|
||||
import type { Service, Stats, LogLine, WSMessage, Connection } from "../../shared/types";
|
||||
|
||||
type Tab = "info" | "config" | "env" | "stats";
|
||||
@@ -41,9 +41,10 @@ interface DetailPanelProps {
|
||||
connections: Connection[];
|
||||
services: Service[];
|
||||
getLogsSince: (uid: string) => number | undefined;
|
||||
initialLogsFullscreen?: boolean;
|
||||
}
|
||||
|
||||
export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, sendMessage, clearLogLines, connections, services, getLogsSince }: DetailPanelProps) {
|
||||
export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen }: DetailPanelProps) {
|
||||
const [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -55,15 +56,15 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
const [envVisibleAll, setEnvVisibleAll] = useState(false);
|
||||
const [envVisibleSet, setEnvVisibleSet] = useState<Set<number>>(new Set());
|
||||
const [copiedEnvIdx, setCopiedEnvIdx] = useState<number | null>(null);
|
||||
const [logsModal, setLogsModal] = useState(false);
|
||||
const [logsModal, setLogsModal] = useState(!!initialLogsFullscreen);
|
||||
const modalScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll modal to bottom when opened
|
||||
// Scroll modal to bottom when opened or when logs arrive
|
||||
useEffect(() => {
|
||||
if (logsModal && modalScrollRef.current) {
|
||||
modalScrollRef.current.scrollTop = modalScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logsModal]);
|
||||
}, [logsModal, initialLogs, logLines]);
|
||||
const isProcessing = (service.state as string) === "processing";
|
||||
const processingStartedAt = (service as any)._processingStartedAt as number | undefined;
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
@@ -251,6 +252,18 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={`w-2 h-2 rounded-full ${stateDot}`} />
|
||||
<span className="text-sm font-semibold text-white truncate">{service.name}</span>
|
||||
{service.ports.length > 0 && service.state === "running" && (
|
||||
<a
|
||||
href={`http://${window.location.hostname}:${service.ports[0].host}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-slate-500 hover:text-cyan-400 transition-colors"
|
||||
title={`Open http://${window.location.hostname}:${service.ports[0].host}`}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
<span className="text-[11px] font-mono">:{service.ports[0].host}</span>
|
||||
</a>
|
||||
)}
|
||||
<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" : ""})</> :
|
||||
@@ -427,10 +440,18 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Ports</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{service.ports.map((p, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 text-sm font-mono bg-slate-800/80 text-cyan-300 px-2.5 py-1 rounded">
|
||||
<a
|
||||
key={i}
|
||||
href={`http://${window.location.hostname}:${p.host}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-mono bg-slate-800/80 text-cyan-300 px-2.5 py-1 rounded hover:bg-slate-700/80 hover:text-cyan-200 transition-colors cursor-pointer"
|
||||
title={`Open http://${window.location.hostname}:${p.host}`}
|
||||
>
|
||||
<Globe size={13} className="text-slate-500" />
|
||||
{p.host} → {p.container}
|
||||
</span>
|
||||
<ExternalLink size={11} className="text-slate-500" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user