diff --git a/src/client/App.tsx b/src/client/App.tsx index cb07e5f..287a4f1 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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(null); const [selectedNode, setSelectedNode] = useState(null); const [detailService, setDetailService] = useState(null); + const [openLogsFullscreen, setOpenLogsFullscreen] = useState(false); const reactFlowRef = useRef(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 }) { )} + {contextMenu && ( + setContextMenu(null)} + onAction={(action) => { + const svc = contextMenu.service; + const headers: Record = {}; + 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 && ( s.uid === detailService.uid) || detailService} @@ -617,6 +685,7 @@ function Dashboard({ token }: { token: string }) { connections={filteredConnections} services={filteredServices} getLogsSince={getLogsSince} + initialLogsFullscreen={openLogsFullscreen} /> )} diff --git a/src/client/components/NodeContextMenu.tsx b/src/client/components/NodeContextMenu.tsx new file mode 100644 index 0000000..e9aac4d --- /dev/null +++ b/src/client/components/NodeContextMenu.tsx @@ -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(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 ( +
+ {isRunning ? ( + <> + { onAction("restart"); onClose(); }} /> + { onAction("stop"); onClose(); }} /> + + ) : ( + <> + { onAction("start"); onClose(); }} /> + { onAction("remove"); onClose(); }} /> + + )} + {service.compose_file && ( + <> +
+ { onAction("rebuild"); onClose(); }} /> + + )} +
+ { onOpenLogs(); onClose(); }} /> + {isRunning && firstPort && ( + + + Open :{firstPort.host} + + )} +
+ ); +} + +function MenuItem({ icon: Icon, label, color, onClick }: { icon: typeof Play; label: string; color: string; onClick: () => void }) { + return ( + + ); +} diff --git a/src/client/index.css b/src/client/index.css index e845938..b2c3d56 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -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); } diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index 480f87d..294ac73 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -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([]); 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>(new Set()); const [copiedEnvIdx, setCopiedEnvIdx] = useState(null); - const [logsModal, setLogsModal] = useState(false); + const [logsModal, setLogsModal] = useState(!!initialLogsFullscreen); const modalScrollRef = useRef(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,
{service.name} + {service.ports.length > 0 && service.state === "running" && ( + + + :{service.ports[0].host} + + )} {isProcessing ? `processing... ${elapsed}s` : isCrashed ? <>crashed (exit {service.exit_code}{service.oom_killed ? ", OOM" : ""}) : @@ -427,10 +440,18 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, Ports
{service.ports.map((p, i) => ( - + {p.host} → {p.container} - + + ))}