mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.13
This commit is contained in:
+3
-3
@@ -23,17 +23,17 @@
|
|||||||
"zod": "^3"
|
"zod": "^3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@dagrejs/dagre": "^1",
|
||||||
|
"@tailwindcss/vite": "^4",
|
||||||
"@types/dockerode": "^3",
|
"@types/dockerode": "^3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^4",
|
"@vitejs/plugin-react": "^4",
|
||||||
|
"@xyflow/react": "^12",
|
||||||
"concurrently": "^9",
|
"concurrently": "^9",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"@xyflow/react": "^12",
|
|
||||||
"@dagrejs/dagre": "^1",
|
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"@tailwindcss/vite": "^4",
|
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite": "^6"
|
"vite": "^6"
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-61
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
import { useEffect, useRef, useState, useMemo, useCallback, useSyncExternalStore, startTransition } from "react";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -15,6 +15,7 @@ import "@xyflow/react/dist/style.css";
|
|||||||
import { ServiceNode } from "./nodes/ServiceNode";
|
import { ServiceNode } from "./nodes/ServiceNode";
|
||||||
import { GroupNode } from "./nodes/GroupNode";
|
import { GroupNode } from "./nodes/GroupNode";
|
||||||
import { useDocker } from "./hooks/useDocker";
|
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 { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout";
|
||||||
import { DetailPanel } from "./panels/DetailPanel";
|
import { DetailPanel } from "./panels/DetailPanel";
|
||||||
import { LoginScreen } from "./components/LoginScreen";
|
import { LoginScreen } from "./components/LoginScreen";
|
||||||
@@ -43,21 +44,18 @@ export default function App() {
|
|||||||
const [needsAuth, setNeedsAuth] = useState<boolean | null>(null);
|
const [needsAuth, setNeedsAuth] = useState<boolean | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/health").then((r) => {
|
const saved = getToken();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (saved) headers["Authorization"] = `Bearer ${saved}`;
|
||||||
|
|
||||||
|
fetch("/api/health", { headers }).then((r) => {
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
setNeedsAuth(false);
|
setNeedsAuth(false);
|
||||||
setAuthToken("");
|
setAuthToken(saved || "");
|
||||||
} else if (r.status === 401) {
|
} else if (r.status === 401) {
|
||||||
const saved = getToken();
|
if (saved) localStorage.removeItem("df:token");
|
||||||
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);
|
setNeedsAuth(true);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}).catch(() => setNeedsAuth(false));
|
}).catch(() => setNeedsAuth(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -68,11 +66,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard({ token }: { token: string }) {
|
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<Record<string, { x: number; y: number }>>({});
|
||||||
|
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
|
||||||
|
savedPositions.current = pos;
|
||||||
|
}, []);
|
||||||
|
const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince } = useDocker(token, statsStore, onPositions);
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
const initialLayoutDone = useRef(false);
|
const initialLayoutDone = useRef(false);
|
||||||
const savedPositions = useRef<Record<string, { x: number; y: number }>>({});
|
|
||||||
const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter);
|
const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter);
|
||||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||||
const [detailService, setDetailService] = useState<Service | null>(null);
|
const [detailService, setDetailService] = useState<Service | null>(null);
|
||||||
@@ -90,28 +92,22 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
const [panelClosing, setPanelClosing] = useState(false);
|
const [panelClosing, setPanelClosing] = useState(false);
|
||||||
const closeDetail = useCallback(() => {
|
const closeDetail = useCallback(() => {
|
||||||
if (panelClosing) return;
|
if (panelClosing) return;
|
||||||
|
startTransition(() => {
|
||||||
setPanelClosing(true);
|
setPanelClosing(true);
|
||||||
setSelectedNode(null);
|
setSelectedNode(null);
|
||||||
|
});
|
||||||
if (prevViewport.current && reactFlowRef.current) {
|
if (prevViewport.current && reactFlowRef.current) {
|
||||||
reactFlowRef.current.setViewport(prevViewport.current, { duration: 400 });
|
reactFlowRef.current.setViewport(prevViewport.current, { duration: 400 });
|
||||||
prevViewport.current = null;
|
prevViewport.current = null;
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
startTransition(() => {
|
||||||
setDetailService(null);
|
setDetailService(null);
|
||||||
setPanelClosing(false);
|
setPanelClosing(false);
|
||||||
|
});
|
||||||
}, 400);
|
}, 400);
|
||||||
}, [panelClosing]);
|
}, [panelClosing]);
|
||||||
|
|
||||||
// Load saved positions
|
|
||||||
useEffect(() => {
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
fetch("/api/positions", { headers })
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => { savedPositions.current = data || {}; })
|
|
||||||
.catch(() => {});
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
// Save positions (debounced)
|
// Save positions (debounced)
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const savePositions = useCallback((nodes: Node[]) => {
|
const savePositions = useCallback((nodes: Node[]) => {
|
||||||
@@ -132,6 +128,9 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
// Ref to hold the edge recomputation function (avoids circular deps with filteredConnections)
|
||||||
|
const recomputeEdgesRef = useRef<(nodes: Node[]) => void>(() => {});
|
||||||
|
|
||||||
const handleNodesChange = useCallback((changes: NodeChange<Node>[]) => {
|
const handleNodesChange = useCallback((changes: NodeChange<Node>[]) => {
|
||||||
onNodesChange(changes);
|
onNodesChange(changes);
|
||||||
|
|
||||||
@@ -140,46 +139,60 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
|
|
||||||
const isDragEnd = changes.some((c) => c.type === "position" && (c as any).dragging === false);
|
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) => {
|
setNodes((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
let nodes = [...prev];
|
const nodes = new Array<Node>(prev.length);
|
||||||
|
for (let i = 0; i < prev.length; i++) nodes[i] = prev[i];
|
||||||
|
|
||||||
// Clamp Y only
|
// Clamp Y only
|
||||||
nodes = nodes.map((n) => {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
if (!n.parentId) return n;
|
const n = nodes[i];
|
||||||
|
if (!n.parentId) continue;
|
||||||
const clampedY = Math.max(MIN_Y, n.position.y);
|
const clampedY = Math.max(MIN_Y, n.position.y);
|
||||||
if (clampedY !== n.position.y) {
|
if (clampedY !== n.position.y) {
|
||||||
changed = true;
|
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
|
// Keep leftmost child at MIN_X — build parent→children index once
|
||||||
const groupIds = [...new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!))];
|
const childrenByParent = new Map<string, number[]>();
|
||||||
for (const gid of groupIds) {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
const kids = nodes.filter((n) => n.parentId === gid);
|
const pid = nodes[i].parentId;
|
||||||
const minChildX = Math.min(...kids.map((k) => k.position.x));
|
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) {
|
if (minChildX !== MIN_X) {
|
||||||
const shift = minChildX - MIN_X;
|
const shift = minChildX - MIN_X;
|
||||||
changed = true;
|
changed = true;
|
||||||
nodes = nodes.map((n) => {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
if (n.id === gid) return { ...n, position: { x: n.position.x + shift, y: n.position.y } };
|
const n = nodes[i];
|
||||||
if (n.parentId === gid) return { ...n, position: { x: n.position.x - shift, y: n.position.y } };
|
if (n.id === gid) nodes[i] = { ...n, position: { x: n.position.x + shift, y: n.position.y } };
|
||||||
return n;
|
else if (n.parentId === gid) nodes[i] = { ...n, position: { x: n.position.x - shift, y: n.position.y } };
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize groups to fit children
|
// Resize groups to fit children
|
||||||
nodes = nodes.map((n) => {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
if (!n.id.startsWith("group-")) return n;
|
const n = nodes[i];
|
||||||
const kids = nodes.filter((c) => c.parentId === n.id);
|
if (!n.id.startsWith("group-")) continue;
|
||||||
if (kids.length === 0) return n;
|
const kidIdxs = childrenByParent.get(n.id);
|
||||||
|
if (!kidIdxs || kidIdxs.length === 0) continue;
|
||||||
|
|
||||||
let maxRight = 0;
|
let maxRight = 0;
|
||||||
let maxBottom = 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);
|
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
|
||||||
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + 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) {
|
if (newW !== curW || newH !== curH) {
|
||||||
changed = true;
|
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;
|
return changed ? nodes : prev;
|
||||||
});
|
});
|
||||||
}, [onNodesChange, setNodes, savePositions]);
|
}, [onNodesChange, setNodes, savePositions]);
|
||||||
@@ -236,7 +249,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections, stats);
|
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
|
||||||
|
|
||||||
if (!initialLayoutDone.current) {
|
if (!initialLayoutDone.current) {
|
||||||
let positioned = newNodes.map((n) => {
|
let positioned = newNodes.map((n) => {
|
||||||
@@ -318,12 +331,12 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [filteredServices, filteredConnections, statsVersion]);
|
}, [filteredServices, filteredConnections]);
|
||||||
|
|
||||||
// Recompute edges + handles when nodes move
|
// Recompute edges + handles on drag end (not every pixel)
|
||||||
useEffect(() => {
|
const recomputeEdges = useCallback((currentNodes: Node[]) => {
|
||||||
if (nodes.length === 0 || filteredConnections.length === 0) return;
|
if (currentNodes.length === 0 || filteredConnections.length === 0) return;
|
||||||
const { edges: newEdges, activeHandles } = computeEdges(nodes, filteredConnections);
|
const { edges: newEdges, activeHandles } = computeEdges(currentNodes, filteredConnections);
|
||||||
setEdges(newEdges);
|
setEdges(newEdges);
|
||||||
setNodes((prev) =>
|
setNodes((prev) =>
|
||||||
prev.map((n) => {
|
prev.map((n) => {
|
||||||
@@ -334,7 +347,8 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
return { ...n, data: { ...n.data, activeHandles: handles } };
|
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
|
// Flash nodes on Docker events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -362,17 +376,26 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
}, [events]);
|
}, [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(() => {
|
const totalStats = useMemo(() => {
|
||||||
let cpu = 0;
|
let cpu = 0;
|
||||||
let mem = 0;
|
let mem = 0;
|
||||||
for (const svc of filteredServices) {
|
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; }
|
if (s) { cpu += s.cpu; mem += s.mem_mb; }
|
||||||
}
|
}
|
||||||
return { cpu, mem };
|
return { cpu, mem };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [filteredServices, allStats]);
|
||||||
}, [filteredServices, statsVersion]);
|
|
||||||
|
// 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
|
// Dim nodes/edges when detail panel is open
|
||||||
const dimmedNodes = useMemo(() => {
|
const dimmedNodes = useMemo(() => {
|
||||||
@@ -380,9 +403,10 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
return nodes.map((n) => {
|
return nodes.map((n) => {
|
||||||
if (n.type !== "service") return n;
|
if (n.type !== "service") return n;
|
||||||
const isSelected = n.id === selectedNode;
|
const isSelected = n.id === selectedNode;
|
||||||
|
if (isSelected) return n;
|
||||||
return {
|
return {
|
||||||
...n,
|
...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: [] },
|
data: { ...n.data, activeHandles: [] },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -399,6 +423,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
}, [edges, selectedNode]);
|
}, [edges, selectedNode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<StatsStoreContext.Provider value={statsStore}>
|
||||||
<div className="h-screen w-screen bg-slate-900 flex flex-col">
|
<div className="h-screen w-screen bg-slate-900 flex flex-col">
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
services={services}
|
services={services}
|
||||||
@@ -411,7 +436,17 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
totalStats={totalStats}
|
totalStats={totalStats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Loading skeleton */}
|
||||||
|
{services.length === 0 && (
|
||||||
|
<div className="flex-1 min-h-0 relative m-2 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)] flex items-center justify-center gap-8 bg-slate-900">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="w-[220px] h-[140px] rounded-xl bg-slate-800/60 animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Canvas — inset */}
|
{/* Canvas — inset */}
|
||||||
|
{services.length > 0 && (
|
||||||
<div className="flex-1 min-h-0 relative m-2 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)]">
|
<div className="flex-1 min-h-0 relative m-2 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)]">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
onInit={(instance) => { reactFlowRef.current = instance; }}
|
onInit={(instance) => { reactFlowRef.current = instance; }}
|
||||||
@@ -453,8 +488,11 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
|
|
||||||
reactFlowRef.current?.setViewport({ x: targetX, y: targetY, zoom }, { duration: 400 });
|
reactFlowRef.current?.setViewport({ x: targetX, y: targetY, zoom }, { duration: 400 });
|
||||||
|
|
||||||
|
// Mark as non-urgent so the browser paints before React reconciles
|
||||||
|
startTransition(() => {
|
||||||
setSelectedNode(node.id);
|
setSelectedNode(node.id);
|
||||||
setDetailService(svc);
|
setDetailService(svc);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onPaneClick={() => {
|
onPaneClick={() => {
|
||||||
if (detailService) closeDetail();
|
if (detailService) closeDetail();
|
||||||
@@ -489,7 +527,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
<DetailPanel
|
<DetailPanel
|
||||||
service={filteredServices.find((s) => s.uid === detailService.uid) || detailService}
|
service={filteredServices.find((s) => s.uid === detailService.uid) || detailService}
|
||||||
stats={stats.get(detailService.uid)}
|
stats={stats.get(detailService.uid)}
|
||||||
logLines={logLines}
|
logLines={panelLogLines}
|
||||||
token={token}
|
token={token}
|
||||||
closing={panelClosing}
|
closing={panelClosing}
|
||||||
onClose={closeDetail}
|
onClose={closeDetail}
|
||||||
@@ -502,6 +540,8 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</StatsStoreContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function HeaderBar({
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<img
|
<img
|
||||||
src="/alteonx-logo.png"
|
src="/alteonx-logo.webp"
|
||||||
alt="Flowteon"
|
alt="Flowteon"
|
||||||
className="w-7 h-7"
|
className="w-7 h-7"
|
||||||
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
|
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function LoginScreen({ onAuth }: LoginScreenProps) {
|
|||||||
<div className="flex flex-col items-center gap-6 w-80">
|
<div className="flex flex-col items-center gap-6 w-80">
|
||||||
{/* Logo + Title */}
|
{/* Logo + Title */}
|
||||||
<img
|
<img
|
||||||
src="/alteonx-logo.png"
|
src="/alteonx-logo.webp"
|
||||||
alt="Flowteon"
|
alt="Flowteon"
|
||||||
className={`w-16 h-16 transition-all duration-700 ${connected ? "scale-110" : ""}`}
|
className={`w-16 h-16 transition-all duration-700 ${connected ? "scale-110" : ""}`}
|
||||||
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
|
style={{ filter: "brightness(0) saturate(100%) invert(45%) sepia(85%) saturate(2000%) hue-rotate(200deg) brightness(1.1)" }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Node, Edge } from "@xyflow/react";
|
import type { Node, Edge } from "@xyflow/react";
|
||||||
import type { Service, Connection, Stats } from "../../shared/types";
|
import type { Service, Connection } from "../../shared/types";
|
||||||
|
|
||||||
export const NODE_WIDTH = 240;
|
export const NODE_WIDTH = 240;
|
||||||
export const NODE_HEIGHT = 160;
|
export const NODE_HEIGHT = 160;
|
||||||
@@ -67,8 +67,7 @@ export interface LayoutResult {
|
|||||||
|
|
||||||
export function buildLayout(
|
export function buildLayout(
|
||||||
services: Service[],
|
services: Service[],
|
||||||
connections: Connection[],
|
connections: Connection[]
|
||||||
statsMap: Map<string, Stats>
|
|
||||||
): LayoutResult {
|
): LayoutResult {
|
||||||
if (services.length === 0) return { nodes: [], edges: [] };
|
if (services.length === 0) return { nodes: [], edges: [] };
|
||||||
|
|
||||||
@@ -162,7 +161,6 @@ export function buildLayout(
|
|||||||
data: {
|
data: {
|
||||||
...svc,
|
...svc,
|
||||||
label: svc.name,
|
label: svc.name,
|
||||||
stats: statsMap.get(svc.uid) || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
|
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
|
||||||
|
import type { StatsStore } from "./useStatsStore";
|
||||||
|
|
||||||
function arraysEqual(a: Service[], b: Service[]): boolean {
|
function arraysEqual(a: Service[], b: Service[]): boolean {
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
@@ -10,11 +11,10 @@ function arraysEqual(a: Service[], b: Service[]): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDocker(token = "") {
|
export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) {
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [connections, setConnections] = useState<Connection[]>([]);
|
const [connections, setConnections] = useState<Connection[]>([]);
|
||||||
const statsRef = useRef<Map<string, Stats>>(new Map());
|
const statsRef = useRef<Map<string, Stats>>(new Map());
|
||||||
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 }
|
// Processing state: uid → { expected state, start time, min duration before clearing }
|
||||||
@@ -23,18 +23,20 @@ export function useDocker(token = "") {
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
// Initial HTTP fetch so data loads even if WS is slow
|
// Single init call: services + connections + positions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
||||||
Promise.all([
|
fetch("/api/init", { headers })
|
||||||
fetch("/api/services", { headers }).then((r) => r.ok ? r.json() : []),
|
.then((r) => r.ok ? r.json() : null)
|
||||||
fetch("/api/connections", { headers }).then((r) => r.ok ? r.json() : []),
|
.then((data) => {
|
||||||
]).then(([svcs, conns]) => {
|
if (!data) return;
|
||||||
setServices((prev) => prev.length === 0 ? svcs : prev);
|
setServices((prev) => prev.length === 0 ? data.services : prev);
|
||||||
setConnections((prev) => prev.length === 0 ? conns : prev);
|
setConnections((prev) => prev.length === 0 ? data.connections : prev);
|
||||||
}).catch(() => {});
|
if (onPositions) onPositions(data.positions || {});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
@@ -120,15 +122,12 @@ export function useDocker(token = "") {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "stats": {
|
case "stats": {
|
||||||
let changed = false;
|
|
||||||
for (const s of msg.data) {
|
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);
|
statsRef.current.set(s.service, s);
|
||||||
changed = true;
|
|
||||||
}
|
}
|
||||||
|
if (statsStore) {
|
||||||
|
statsStore.update(statsRef.current);
|
||||||
}
|
}
|
||||||
if (changed) setStatsVersion((v) => v + 1);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "docker_event":
|
case "docker_event":
|
||||||
@@ -198,5 +197,5 @@ export function useDocker(token = "") {
|
|||||||
return actionTimestamps.current.get(uid);
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, Stats>;
|
||||||
|
getNodeSnapshot: (uid: string) => Stats | undefined;
|
||||||
|
update: (statsMap: Map<string, Stats>) => void;
|
||||||
|
/** Internal version per node — used by useNodeStats to detect changes */
|
||||||
|
_nodeVersions: Map<string, number>;
|
||||||
|
_globalVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatsStore(): StatsStore {
|
||||||
|
let current = new Map<string, Stats>();
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
const nodeVersions = new Map<string, number>();
|
||||||
|
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<string, Stats>) {
|
||||||
|
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<StatsStore | null>(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<string, { version: number; stats: Stats | undefined }>
|
||||||
|
>();
|
||||||
|
|
||||||
|
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<string, Stats> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useState, useEffect } 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 { useNodeStats } from "../hooks/useStatsStore";
|
||||||
import {
|
import {
|
||||||
Database,
|
Database,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -23,7 +24,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Stats } from "../../shared/types";
|
|
||||||
|
|
||||||
interface ServiceNodeData {
|
interface ServiceNodeData {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -31,7 +31,6 @@ interface ServiceNodeData {
|
|||||||
state: string;
|
state: string;
|
||||||
ports: { host: number; container: number }[];
|
ports: { host: number; container: number }[];
|
||||||
project: string;
|
project: string;
|
||||||
stats: Stats | null;
|
|
||||||
flash?: string;
|
flash?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
activeHandles?: string[];
|
activeHandles?: string[];
|
||||||
@@ -113,8 +112,9 @@ function ProcessingTimer({ startedAt }: { startedAt: number }) {
|
|||||||
return <span className="text-[10px] font-mono text-yellow-400 ml-1">{elapsed}s</span>;
|
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, id }: NodeProps) {
|
||||||
const d = data as unknown as ServiceNodeData;
|
const d = data as unknown as ServiceNodeData;
|
||||||
|
const nodeStats = useNodeStats(id);
|
||||||
const s = stateStyles[d.state] || stateStyles.exited;
|
const s = stateStyles[d.state] || stateStyles.exited;
|
||||||
const { Icon, color: iconColor } = guessIcon(d.image, d.label);
|
const { Icon, color: iconColor } = guessIcon(d.image, d.label);
|
||||||
const flashClass = d.flash || "";
|
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"}`}
|
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
|
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}
|
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 */}
|
{/* Top handles — left offset, transform centered horizontally */}
|
||||||
{offsets.map((o, i) => (
|
{offsets.map((o, i) => (
|
||||||
@@ -204,23 +204,23 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{d.stats && (
|
{nodeStats && (
|
||||||
<div className="mt-2 space-y-1.5">
|
<div className="mt-2 space-y-1.5">
|
||||||
<div className="flex justify-between text-[11px] text-slate-400">
|
<div className="flex justify-between text-[11px] text-slate-400">
|
||||||
<span>CPU {d.stats.cpu.toFixed(1)}%</span>
|
<span>CPU {nodeStats.cpu.toFixed(1)}%</span>
|
||||||
<span>MEM {d.stats.mem_mb.toFixed(0)}MB</span>
|
<span>MEM {nodeStats.mem_mb.toFixed(0)}MB</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-cyan-500/60 rounded-full transition-all duration-700"
|
className="h-full bg-cyan-500/60 rounded-full transition-all duration-700"
|
||||||
style={{ width: `${Math.min(d.stats.cpu, 100)}%` }}
|
style={{ width: `${Math.min(nodeStats.cpu, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-violet-500/60 rounded-full transition-all duration-700"
|
className="h-full bg-violet-500/60 rounded-full transition-all duration-700"
|
||||||
style={{ width: `${Math.min(d.stats.mem_percent, 100)}%` }}
|
style={{ width: `${Math.min(nodeStats.mem_percent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { 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";
|
||||||
|
|
||||||
@@ -215,7 +215,19 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
setAutoScroll((prev) => prev === atBottom ? prev : atBottom);
|
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<string>();
|
||||||
|
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 isCrashed = (service.state as string) === "crashed";
|
||||||
const stateColor = isProcessing ? "text-yellow-400" :
|
const stateColor = isProcessing ? "text-yellow-400" :
|
||||||
@@ -442,15 +454,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connected services */}
|
{/* Connected services */}
|
||||||
{(() => {
|
{connectedSvcs.length > 0 && (
|
||||||
const connectedUids = new Set<string>();
|
|
||||||
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 (
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Connected to</span>
|
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Connected to</span>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -465,8 +469,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -741,7 +744,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
{autoScroll ? <Pause size={12} /> : <Play size={12} />}
|
{autoScroll ? <Pause size={12} /> : <Play size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setLogsModal(true)}
|
onClick={() => startTransition(() => setLogsModal(true))}
|
||||||
className="p-1 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
className="p-1 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
|
||||||
title="Open logs fullscreen"
|
title="Open logs fullscreen"
|
||||||
>
|
>
|
||||||
@@ -824,7 +827,12 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
ref={modalScrollRef}
|
ref={modalScrollRef}
|
||||||
className="flex-1 overflow-y-auto overflow-x-auto font-mono text-xs leading-5 px-6 py-3"
|
className="flex-1 overflow-y-auto overflow-x-auto font-mono text-xs leading-5 px-6 py-3"
|
||||||
>
|
>
|
||||||
{allLines.map((l, i) => (
|
{allLines.length > 500 && (
|
||||||
|
<div className="text-slate-600 text-center py-2 text-[11px]">
|
||||||
|
{allLines.length - 500} lines hidden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(allLines.length > 500 ? allLines.slice(-500) : allLines).map((l, i) => (
|
||||||
<div key={i} className="flex gap-0 hover:bg-slate-800/40">
|
<div key={i} className="flex gap-0 hover:bg-slate-800/40">
|
||||||
{l.timestamp && (
|
{l.timestamp && (
|
||||||
<span className="text-slate-600 shrink-0 select-none pr-3 whitespace-nowrap">
|
<span className="text-slate-600 shrink-0 select-none pr-3 whitespace-nowrap">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
+30
-1
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
|
import { compress } from "hono/compress";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
||||||
@@ -9,6 +10,9 @@ import type { Service, WSMessage } from "../shared/types";
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
// ── Compression ──
|
||||||
|
app.use("*", compress());
|
||||||
|
|
||||||
// ── CORS ──
|
// ── CORS ──
|
||||||
app.use("/api/*", cors());
|
app.use("/api/*", cors());
|
||||||
|
|
||||||
@@ -33,7 +37,7 @@ const WS_RECONNECT_MS = 3000;
|
|||||||
if (AUTH_TOKEN) {
|
if (AUTH_TOKEN) {
|
||||||
app.use("*", async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
// Skip static assets and auth page
|
// Skip static assets and auth page
|
||||||
if (c.req.path === "/" || c.req.path.startsWith("/assets") || c.req.path.endsWith(".png") || c.req.path.endsWith(".ico")) return next();
|
if (c.req.path === "/" || c.req.path.startsWith("/assets") || c.req.path.endsWith(".png") || c.req.path.endsWith(".webp") || c.req.path.endsWith(".ico")) return next();
|
||||||
if (c.req.path === "/api/auth") return next();
|
if (c.req.path === "/api/auth") return next();
|
||||||
|
|
||||||
const token = c.req.header("Authorization")?.replace("Bearer ", "");
|
const token = c.req.header("Authorization")?.replace("Bearer ", "");
|
||||||
@@ -56,6 +60,19 @@ app.get("/api/connections", async (c) => {
|
|||||||
|
|
||||||
app.get("/api/health", (c) => c.json({ ok: true, mode: ALL ? "all" : "filtered", projects: PROJECTS }));
|
app.get("/api/health", (c) => c.json({ ok: true, mode: ALL ? "all" : "filtered", projects: PROJECTS }));
|
||||||
|
|
||||||
|
// ── Combined init endpoint (services + connections + positions in one call) ──
|
||||||
|
app.get("/api/init", async (c) => {
|
||||||
|
const services = await discoverServices(ALL, PROJECTS);
|
||||||
|
const connections = await discoverConnections(services);
|
||||||
|
let positions: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(POSITIONS_FILE)) {
|
||||||
|
positions = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8"));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return c.json({ services, connections, positions });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Container actions ──
|
// ── Container actions ──
|
||||||
app.post("/api/containers/:id/stop", async (c) => {
|
app.post("/api/containers/:id/stop", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
@@ -193,6 +210,18 @@ app.put("/api/positions", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Cache headers for static assets ──
|
||||||
|
app.use("/*", async (c, next) => {
|
||||||
|
await next();
|
||||||
|
const p = c.req.path;
|
||||||
|
if (p.startsWith("/assets/")) {
|
||||||
|
// Hashed filenames — cache forever
|
||||||
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
} else if (p.endsWith(".webp") || p.endsWith(".png") || p.endsWith(".ico")) {
|
||||||
|
c.header("Cache-Control", "public, max-age=86400");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Serve frontend build ──
|
// ── Serve frontend build ──
|
||||||
app.use("/*", serveStatic({ root: "./dist" }));
|
app.use("/*", serveStatic({ root: "./dist" }));
|
||||||
app.get("/*", serveStatic({ root: "./dist", path: "index.html" }));
|
app.get("/*", serveStatic({ root: "./dist", path: "index.html" }));
|
||||||
|
|||||||
Reference in New Issue
Block a user