From 49c4cde4d790d1e9d42163521da46fc820fbee1b Mon Sep 17 00:00:00 2001 From: RGJorge Date: Sat, 2 May 2026 03:36:34 +0000 Subject: [PATCH] v0.0.13 --- package.json | 6 +- src/client/App.tsx | 176 ++++++++++++++++---------- src/client/components/HeaderBar.tsx | 2 +- src/client/components/LoginScreen.tsx | 2 +- src/client/engine/layout.ts | 6 +- src/client/hooks/useDocker.ts | 35 +++-- src/client/hooks/useStatsStore.ts | 132 +++++++++++++++++++ src/client/nodes/ServiceNode.tsx | 18 +-- src/client/panels/DetailPanel.tsx | 38 +++--- src/client/public/alteonx-logo.webp | Bin 0 -> 4404 bytes src/server/index.ts | 31 ++++- 11 files changed, 326 insertions(+), 120 deletions(-) create mode 100644 src/client/hooks/useStatsStore.ts create mode 100644 src/client/public/alteonx-logo.webp diff --git a/package.json b/package.json index 8f5cc4e..52376d6 100644 --- a/package.json +++ b/package.json @@ -23,17 +23,17 @@ "zod": "^3" }, "devDependencies": { + "@dagrejs/dagre": "^1", + "@tailwindcss/vite": "^4", "@types/dockerode": "^3", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4", + "@xyflow/react": "^12", "concurrently": "^9", "react": "^19", "react-dom": "^19", - "@xyflow/react": "^12", - "@dagrejs/dagre": "^1", "tailwindcss": "^4", - "@tailwindcss/vite": "^4", "typescript": "^5", "vite": "^6" } diff --git a/src/client/App.tsx b/src/client/App.tsx index be9f47a..b14a712 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo, useCallback } from "react"; +import { useEffect, useRef, useState, useMemo, useCallback, useSyncExternalStore, startTransition } from "react"; import { ReactFlow, Background, @@ -15,6 +15,7 @@ import "@xyflow/react/dist/style.css"; import { ServiceNode } from "./nodes/ServiceNode"; import { GroupNode } from "./nodes/GroupNode"; 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 { LoginScreen } from "./components/LoginScreen"; @@ -43,20 +44,17 @@ export default function App() { const [needsAuth, setNeedsAuth] = useState(null); useEffect(() => { - fetch("/api/health").then((r) => { + const saved = getToken(); + const headers: Record = {}; + if (saved) headers["Authorization"] = `Bearer ${saved}`; + + fetch("/api/health", { headers }).then((r) => { if (r.ok) { setNeedsAuth(false); - setAuthToken(""); + setAuthToken(saved || ""); } else if (r.status === 401) { - const saved = getToken(); - if (saved) { - fetch("/api/health", { headers: { Authorization: `Bearer ${saved}` } }).then((r2) => { - if (r2.ok) { setAuthToken(saved); setNeedsAuth(false); } - else { localStorage.removeItem("df:token"); setNeedsAuth(true); } - }); - } else { - setNeedsAuth(true); - } + if (saved) localStorage.removeItem("df:token"); + setNeedsAuth(true); } }).catch(() => setNeedsAuth(false)); }, []); @@ -68,11 +66,15 @@ export default function App() { } function Dashboard({ token }: { token: string }) { - const { services, connections, stats, statsVersion, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince } = useDocker(token); + const statsStore = useMemo(() => createStatsStore(), []); + const savedPositions = useRef>({}); + const onPositions = useCallback((pos: Record) => { + savedPositions.current = pos; + }, []); + const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince } = useDocker(token, statsStore, onPositions); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const initialLayoutDone = useRef(false); - const savedPositions = useRef>({}); const [hiddenProjects, setHiddenProjects] = useState>(loadFilter); const [selectedNode, setSelectedNode] = useState(null); const [detailService, setDetailService] = useState(null); @@ -90,28 +92,22 @@ function Dashboard({ token }: { token: string }) { const [panelClosing, setPanelClosing] = useState(false); const closeDetail = useCallback(() => { if (panelClosing) return; - setPanelClosing(true); - setSelectedNode(null); + startTransition(() => { + setPanelClosing(true); + setSelectedNode(null); + }); if (prevViewport.current && reactFlowRef.current) { reactFlowRef.current.setViewport(prevViewport.current, { duration: 400 }); prevViewport.current = null; } setTimeout(() => { - setDetailService(null); - setPanelClosing(false); + startTransition(() => { + setDetailService(null); + setPanelClosing(false); + }); }, 400); }, [panelClosing]); - // Load saved positions - useEffect(() => { - const headers: Record = {}; - if (token) headers["Authorization"] = `Bearer ${token}`; - fetch("/api/positions", { headers }) - .then((r) => r.json()) - .then((data) => { savedPositions.current = data || {}; }) - .catch(() => {}); - }, [token]); - // Save positions (debounced) const saveTimer = useRef>(undefined); const savePositions = useCallback((nodes: Node[]) => { @@ -132,6 +128,9 @@ function Dashboard({ token }: { token: string }) { }, 500); }, [token]); + // Ref to hold the edge recomputation function (avoids circular deps with filteredConnections) + const recomputeEdgesRef = useRef<(nodes: Node[]) => void>(() => {}); + const handleNodesChange = useCallback((changes: NodeChange[]) => { onNodesChange(changes); @@ -140,46 +139,60 @@ function Dashboard({ token }: { token: string }) { const isDragEnd = changes.some((c) => c.type === "position" && (c as any).dragging === false); + // Skip expensive clamping/resizing during drag — only run on drag end + if (!isDragEnd) return; + setNodes((prev) => { let changed = false; - let nodes = [...prev]; + const nodes = new Array(prev.length); + for (let i = 0; i < prev.length; i++) nodes[i] = prev[i]; // Clamp Y only - nodes = nodes.map((n) => { - if (!n.parentId) return n; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (!n.parentId) continue; const clampedY = Math.max(MIN_Y, n.position.y); if (clampedY !== n.position.y) { changed = true; - return { ...n, position: { x: n.position.x, y: clampedY } }; + nodes[i] = { ...n, position: { x: n.position.x, y: clampedY } }; } - return n; - }); + } - // Keep leftmost child at MIN_X - const groupIds = [...new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!))]; - for (const gid of groupIds) { - const kids = nodes.filter((n) => n.parentId === gid); - const minChildX = Math.min(...kids.map((k) => k.position.x)); + // Keep leftmost child at MIN_X — build parent→children index once + const childrenByParent = new Map(); + for (let i = 0; i < nodes.length; i++) { + const pid = nodes[i].parentId; + if (!pid) continue; + let arr = childrenByParent.get(pid); + if (!arr) { arr = []; childrenByParent.set(pid, arr); } + arr.push(i); + } + + for (const [gid, kidIdxs] of childrenByParent) { + let minChildX = Infinity; + for (const ki of kidIdxs) minChildX = Math.min(minChildX, nodes[ki].position.x); if (minChildX !== MIN_X) { const shift = minChildX - MIN_X; changed = true; - nodes = nodes.map((n) => { - if (n.id === gid) return { ...n, position: { x: n.position.x + shift, y: n.position.y } }; - if (n.parentId === gid) return { ...n, position: { x: n.position.x - shift, y: n.position.y } }; - return n; - }); + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (n.id === gid) nodes[i] = { ...n, position: { x: n.position.x + shift, y: n.position.y } }; + else if (n.parentId === gid) nodes[i] = { ...n, position: { x: n.position.x - shift, y: n.position.y } }; + } } } // Resize groups to fit children - nodes = nodes.map((n) => { - if (!n.id.startsWith("group-")) return n; - const kids = nodes.filter((c) => c.parentId === n.id); - if (kids.length === 0) return n; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (!n.id.startsWith("group-")) continue; + const kidIdxs = childrenByParent.get(n.id); + if (!kidIdxs || kidIdxs.length === 0) continue; let maxRight = 0; let maxBottom = 0; - for (const k of kids) { + for (const ki of kidIdxs) { + const k = nodes[ki]; maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD); maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD); } @@ -193,12 +206,12 @@ function Dashboard({ token }: { token: string }) { if (newW !== curW || newH !== curH) { changed = true; - return { ...n, style: { ...n.style, width: newW, height: newH } }; + nodes[i] = { ...n, style: { ...n.style, width: newW, height: newH } }; } - return n; - }); + } - if (isDragEnd) savePositions(nodes); + savePositions(nodes); + recomputeEdgesRef.current(nodes); return changed ? nodes : prev; }); }, [onNodesChange, setNodes, savePositions]); @@ -236,7 +249,7 @@ function Dashboard({ token }: { token: string }) { return; } - const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections, stats); + const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections); if (!initialLayoutDone.current) { let positioned = newNodes.map((n) => { @@ -318,12 +331,12 @@ function Dashboard({ token }: { token: string }) { return result; }); } - }, [filteredServices, filteredConnections, statsVersion]); + }, [filteredServices, filteredConnections]); - // Recompute edges + handles when nodes move - useEffect(() => { - if (nodes.length === 0 || filteredConnections.length === 0) return; - const { edges: newEdges, activeHandles } = computeEdges(nodes, filteredConnections); + // Recompute edges + handles on drag end (not every pixel) + const recomputeEdges = useCallback((currentNodes: Node[]) => { + if (currentNodes.length === 0 || filteredConnections.length === 0) return; + const { edges: newEdges, activeHandles } = computeEdges(currentNodes, filteredConnections); setEdges(newEdges); setNodes((prev) => prev.map((n) => { @@ -334,7 +347,8 @@ function Dashboard({ token }: { token: string }) { return { ...n, data: { ...n.data, activeHandles: handles } }; }) ); - }, [nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","), filteredConnections]); + }, [filteredConnections, setEdges, setNodes]); + recomputeEdgesRef.current = recomputeEdges; // Flash nodes on Docker events useEffect(() => { @@ -362,17 +376,26 @@ function Dashboard({ token }: { token: string }) { }, 1200); }, [events]); - // Total resource consumption + // Total resource consumption (subscribes to all stats changes via the store directly) + const allStats = useSyncExternalStore( + useCallback((cb: () => void) => statsStore.subscribe(cb), [statsStore]), + useCallback(() => statsStore.getSnapshot(), [statsStore]) + ); const totalStats = useMemo(() => { let cpu = 0; let mem = 0; for (const svc of filteredServices) { - const s = stats.get(svc.uid); + const s = allStats.get(svc.uid); if (s) { cpu += s.cpu; mem += s.mem_mb; } } return { cpu, mem }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filteredServices, statsVersion]); + }, [filteredServices, allStats]); + + // Pre-filter log lines for the detail panel to avoid passing the full array + const panelLogLines = useMemo( + () => detailService ? logLines.filter((l) => l.container === detailService.id) : [], + [logLines, detailService] + ); // Dim nodes/edges when detail panel is open const dimmedNodes = useMemo(() => { @@ -380,9 +403,10 @@ function Dashboard({ token }: { token: string }) { return nodes.map((n) => { if (n.type !== "service") return n; const isSelected = n.id === selectedNode; + if (isSelected) return n; return { ...n, - style: { ...n.style, opacity: isSelected ? 1 : 0.25, transition: "opacity 0.4s ease" }, + style: { ...n.style, opacity: 0.25, transition: "opacity 0.4s ease" }, data: { ...n.data, activeHandles: [] }, }; }); @@ -399,6 +423,7 @@ function Dashboard({ token }: { token: string }) { }, [edges, selectedNode]); return ( +
+ {/* Loading skeleton */} + {services.length === 0 && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + {/* Canvas — inset */} + {services.length > 0 && (
{ reactFlowRef.current = instance; }} @@ -453,8 +488,11 @@ function Dashboard({ token }: { token: string }) { reactFlowRef.current?.setViewport({ x: targetX, y: targetY, zoom }, { duration: 400 }); - setSelectedNode(node.id); - setDetailService(svc); + // Mark as non-urgent so the browser paints before React reconciles + startTransition(() => { + setSelectedNode(node.id); + setDetailService(svc); + }); }} onPaneClick={() => { if (detailService) closeDetail(); @@ -489,7 +527,7 @@ function Dashboard({ token }: { token: string }) { s.uid === detailService.uid) || detailService} stats={stats.get(detailService.uid)} - logLines={logLines} + logLines={panelLogLines} token={token} closing={panelClosing} onClose={closeDetail} @@ -502,6 +540,8 @@ function Dashboard({ token }: { token: string }) { /> )}
+ )}
+ ); } diff --git a/src/client/components/HeaderBar.tsx b/src/client/components/HeaderBar.tsx index 630e991..7feb133 100644 --- a/src/client/components/HeaderBar.tsx +++ b/src/client/components/HeaderBar.tsx @@ -43,7 +43,7 @@ export function HeaderBar({
Flowteon {/* Logo + Title */} Flowteon + connections: Connection[] ): LayoutResult { if (services.length === 0) return { nodes: [], edges: [] }; @@ -162,7 +161,6 @@ export function buildLayout( data: { ...svc, label: svc.name, - stats: statsMap.get(svc.uid) || null, }, }); }); diff --git a/src/client/hooks/useDocker.ts b/src/client/hooks/useDocker.ts index 590c9c5..4b68781 100644 --- a/src/client/hooks/useDocker.ts +++ b/src/client/hooks/useDocker.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types"; +import type { StatsStore } from "./useStatsStore"; function arraysEqual(a: Service[], b: Service[]): boolean { if (a.length !== b.length) return false; @@ -10,11 +11,10 @@ function arraysEqual(a: Service[], b: Service[]): boolean { return true; } -export function useDocker(token = "") { +export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record) => void) { const [services, setServices] = useState([]); const [connections, setConnections] = useState([]); const statsRef = useRef>(new Map()); - const [statsVersion, setStatsVersion] = useState(0); const [events, setEvents] = useState([]); const [logLines, setLogLines] = useState([]); // Processing state: uid → { expected state, start time, min duration before clearing } @@ -23,18 +23,20 @@ export function useDocker(token = "") { const wsRef = useRef(null); const reconnectTimer = useRef>(undefined); - // Initial HTTP fetch so data loads even if WS is slow + // Single init call: services + connections + positions useEffect(() => { const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; - Promise.all([ - fetch("/api/services", { headers }).then((r) => r.ok ? r.json() : []), - fetch("/api/connections", { headers }).then((r) => r.ok ? r.json() : []), - ]).then(([svcs, conns]) => { - setServices((prev) => prev.length === 0 ? svcs : prev); - setConnections((prev) => prev.length === 0 ? conns : prev); - }).catch(() => {}); + fetch("/api/init", { headers }) + .then((r) => r.ok ? r.json() : null) + .then((data) => { + if (!data) return; + setServices((prev) => prev.length === 0 ? data.services : prev); + setConnections((prev) => prev.length === 0 ? data.connections : prev); + if (onPositions) onPositions(data.positions || {}); + }) + .catch(() => {}); }, [token]); const connect = useCallback(() => { @@ -120,15 +122,12 @@ export function useDocker(token = "") { }); break; case "stats": { - let changed = false; for (const s of msg.data) { - const existing = statsRef.current.get(s.service); - if (!existing || existing.cpu !== s.cpu || existing.mem_mb !== s.mem_mb) { - statsRef.current.set(s.service, s); - changed = true; - } + statsRef.current.set(s.service, s); + } + if (statsStore) { + statsStore.update(statsRef.current); } - if (changed) setStatsVersion((v) => v + 1); break; } case "docker_event": @@ -198,5 +197,5 @@ export function useDocker(token = "") { return actionTimestamps.current.get(uid); }, []); - return { services, connections, stats: statsRef.current, statsVersion, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince }; + return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince }; } diff --git a/src/client/hooks/useStatsStore.ts b/src/client/hooks/useStatsStore.ts new file mode 100644 index 0000000..2a549c0 --- /dev/null +++ b/src/client/hooks/useStatsStore.ts @@ -0,0 +1,132 @@ +import { createContext, useContext, useSyncExternalStore, useCallback } from "react"; +import type { Stats } from "../../shared/types"; + +type Listener = () => void; + +export interface StatsStore { + subscribe: (listener: Listener) => () => void; + getSnapshot: () => Map; + getNodeSnapshot: (uid: string) => Stats | undefined; + update: (statsMap: Map) => void; + /** Internal version per node — used by useNodeStats to detect changes */ + _nodeVersions: Map; + _globalVersion: number; +} + +export function createStatsStore(): StatsStore { + let current = new Map(); + const listeners = new Set(); + const nodeVersions = new Map(); + let globalVersion = 0; + + function notify() { + for (const l of listeners) l(); + } + + return { + subscribe(listener: Listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getSnapshot() { + return current; + }, + getNodeSnapshot(uid: string) { + return current.get(uid); + }, + update(statsMap: Map) { + let changed = false; + for (const [key, value] of statsMap) { + const existing = current.get(key); + if (!existing || existing.cpu !== value.cpu || existing.mem_mb !== value.mem_mb) { + current.set(key, value); + nodeVersions.set(key, (nodeVersions.get(key) || 0) + 1); + changed = true; + } + } + if (changed) { + globalVersion++; + notify(); + } + }, + _nodeVersions: nodeVersions, + _globalVersion: globalVersion, + }; +} + +export const StatsStoreContext = createContext(null); + +/** + * Subscribe to stats for a single node — only re-renders when THAT node's stats change. + */ +export function useNodeStats(uid: string): Stats | undefined { + const store = useContext(StatsStoreContext); + if (!store) throw new Error("useNodeStats must be used within StatsStoreContext.Provider"); + + const subscribe = useCallback( + (cb: () => void) => store.subscribe(cb), + [store] + ); + + // Snapshot returns a value that changes identity only when this node's stats change + const getSnapshot = useCallback(() => { + const version = store._nodeVersions.get(uid) || 0; + const stats = store.getNodeSnapshot(uid); + // Return a stable reference: version acts as the cache key for useSyncExternalStore + return { version, stats }; + }, [store, uid]); + + // useSyncExternalStore compares by Object.is, so we need a ref-stable approach + // We use a wrapper that caches the result object when version hasn't changed + const cached = useSyncExternalStoreWithNodeCache(subscribe, store, uid); + return cached; +} + +// Internal helper: caches snapshot per uid so useSyncExternalStore sees stable refs +const nodeSnapshotCaches = new WeakMap< + StatsStore, + Map +>(); + +function useSyncExternalStoreWithNodeCache( + subscribe: (cb: () => void) => () => void, + store: StatsStore, + uid: string +): Stats | undefined { + const getSnapshot = useCallback(() => { + if (!nodeSnapshotCaches.has(store)) { + nodeSnapshotCaches.set(store, new Map()); + } + const cache = nodeSnapshotCaches.get(store)!; + const currentVersion = store._nodeVersions.get(uid) || 0; + const cached = cache.get(uid); + + if (cached && cached.version === currentVersion) { + return cached; + } + + const entry = { version: currentVersion, stats: store.getNodeSnapshot(uid) }; + cache.set(uid, entry); + return entry; + }, [store, uid]); + + const snapshot = useSyncExternalStore(subscribe, getSnapshot); + return snapshot.stats; +} + +/** + * Subscribe to the full stats map — re-renders on ANY stats change. + * Use sparingly (e.g., for total resource display in header). + */ +export function useAllStats(): Map { + const store = useContext(StatsStoreContext); + if (!store) throw new Error("useAllStats must be used within StatsStoreContext.Provider"); + + const subscribe = useCallback( + (cb: () => void) => store.subscribe(cb), + [store] + ); + const getSnapshot = useCallback(() => store.getSnapshot(), [store]); + + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx index 2f0c6ed..aea08c6 100644 --- a/src/client/nodes/ServiceNode.tsx +++ b/src/client/nodes/ServiceNode.tsx @@ -1,5 +1,6 @@ import { memo, useState, useEffect } from "react"; import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { useNodeStats } from "../hooks/useStatsStore"; import { Database, Zap, @@ -23,7 +24,6 @@ import { AlertTriangle, type LucideIcon, } from "lucide-react"; -import type { Stats } from "../../shared/types"; interface ServiceNodeData { label: string; @@ -31,7 +31,6 @@ interface ServiceNodeData { state: string; ports: { host: number; container: number }[]; project: string; - stats: Stats | null; flash?: string; id?: string; activeHandles?: string[]; @@ -113,8 +112,9 @@ function ProcessingTimer({ startedAt }: { startedAt: number }) { return {elapsed}s; } -export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) { +export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) { const d = data as unknown as ServiceNodeData; + const nodeStats = useNodeStats(id); const s = stateStyles[d.state] || stateStyles.exited; const { Icon, color: iconColor } = guessIcon(d.image, d.label); const flashClass = d.flash || ""; @@ -137,7 +137,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) { title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`} className={`relative rounded-xl border ${s.border} ${s.bg} backdrop-blur-sm shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${s.ring} - transition-all duration-300 ${flashClass}`} + transition-[opacity,box-shadow] duration-300 ${flashClass}`} > {/* Top handles — left offset, transform centered horizontally */} {offsets.map((o, i) => ( @@ -204,23 +204,23 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) { )} {/* Stats */} - {d.stats && ( + {nodeStats && (
- CPU {d.stats.cpu.toFixed(1)}% - MEM {d.stats.mem_mb.toFixed(0)}MB + CPU {nodeStats.cpu.toFixed(1)}% + MEM {nodeStats.mem_mb.toFixed(0)}MB
diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index b0a1353..480f87d 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +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 type { Service, Stats, LogLine, WSMessage, Connection } from "../../shared/types"; @@ -215,7 +215,19 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, setAutoScroll((prev) => prev === atBottom ? prev : atBottom); }, []); - const allLines = [...initialLogs, ...logLines.filter((l) => l.container === service.id)]; + const allLines = useMemo( + () => [...initialLogs, ...logLines], + [initialLogs, logLines] + ); + + const connectedSvcs = useMemo(() => { + const connectedUids = new Set(); + for (const c of connections) { + if (c.from === service.uid) connectedUids.add(c.to); + if (c.to === service.uid) connectedUids.add(c.from); + } + return services.filter((s) => connectedUids.has(s.uid)); + }, [connections, services, service.uid]); const isCrashed = (service.state as string) === "crashed"; const stateColor = isProcessing ? "text-yellow-400" : @@ -442,15 +454,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, )} {/* Connected services */} - {(() => { - const connectedUids = new Set(); - for (const c of connections) { - if (c.from === service.uid) connectedUids.add(c.to); - if (c.to === service.uid) connectedUids.add(c.from); - } - const connectedSvcs = services.filter((s) => connectedUids.has(s.uid)); - if (connectedSvcs.length === 0) return null; - return ( + {connectedSvcs.length > 0 && (
Connected to
@@ -465,8 +469,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, })}
- ); - })()} + )}
)} @@ -741,7 +744,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, {autoScroll ? : }