This commit is contained in:
RGJorge
2026-05-02 23:00:53 +00:00
parent 5225c1aeda
commit 019a48c89e
4 changed files with 208 additions and 8 deletions
+70 -1
View File
@@ -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>
+94
View File
@@ -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>
);
}
+16
View File
@@ -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); }
+28 -7
View File
@@ -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>