swarmy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ import { useSwarmState } from "./hooks/useSwarmState";
2
+ import { useHashRouter } from "./hooks/useHashRouter";
3
+ import { useLocalStorage } from "./hooks/useLocalStorage";
4
+ import { TopBar } from "./components/TopBar";
5
+ import { Sidebar } from "./components/Sidebar";
6
+ import { VncPanel } from "./components/VncPanel";
7
+ import { TerminalPanel } from "./components/TerminalPanel";
8
+ import { AllNodesView } from "./components/AllNodesView";
9
+ import { NodeView } from "./components/NodeView";
10
+ import { useState, useRef, useCallback, useEffect } from "react";
11
+
12
+ const DEFAULT_SIDEBAR_WIDTH = 224; // 14rem = w-56
13
+ const DEFAULT_VNC_HEIGHT = 50;
14
+
15
+ export default function App() {
16
+ const state = useSwarmState();
17
+ const { route, navigate } = useHashRouter();
18
+ const [sidebarOpen, setSidebarOpen] = useLocalStorage("swarm-sidebar-open", true);
19
+ const [vncFullscreen, setVncFullscreen] = useState(false);
20
+ const [termFullscreen, setTermFullscreen] = useState(false);
21
+ const [vncHeight, setVncHeight] = useLocalStorage("swarm-vnc-height", DEFAULT_VNC_HEIGHT);
22
+ const [sidebarWidth, setSidebarWidth] = useLocalStorage("swarm-sidebar-width", DEFAULT_SIDEBAR_WIDTH);
23
+ const containerRef = useRef<HTMLDivElement>(null);
24
+
25
+ const selectedContainerId = (route.view === "container" || route.view === "session") ? route.containerId : null;
26
+ const selectedSessionId = route.view === "session" ? route.sessionId : null;
27
+
28
+ const handleSelectSession = useCallback((sessionId: string | null) => {
29
+ if (sessionId && (route.view === "container" || route.view === "session")) {
30
+ navigate(`#/nodes/${route.nodeId}/containers/${route.containerId}/sessions/${sessionId}`);
31
+ }
32
+ }, [route, navigate]);
33
+
34
+ const handleVncDrag = useCallback((e: React.MouseEvent) => {
35
+ e.preventDefault();
36
+ const container = containerRef.current;
37
+ if (!container) return;
38
+ const onMove = (ev: MouseEvent) => {
39
+ const rect = container.getBoundingClientRect();
40
+ setVncHeight(Math.max(10, Math.min(90, ((ev.clientY - rect.top) / rect.height) * 100)));
41
+ };
42
+ const onUp = () => {
43
+ document.removeEventListener("mousemove", onMove);
44
+ document.removeEventListener("mouseup", onUp);
45
+ };
46
+ document.addEventListener("mousemove", onMove);
47
+ document.addEventListener("mouseup", onUp);
48
+ }, [setVncHeight]);
49
+
50
+ const handleSidebarDrag = useCallback((e: React.MouseEvent) => {
51
+ e.preventDefault();
52
+ const onMove = (ev: MouseEvent) => {
53
+ setSidebarWidth(Math.max(150, Math.min(500, ev.clientX)));
54
+ };
55
+ const onUp = () => {
56
+ document.removeEventListener("mousemove", onMove);
57
+ document.removeEventListener("mouseup", onUp);
58
+ };
59
+ document.addEventListener("mousemove", onMove);
60
+ document.addEventListener("mouseup", onUp);
61
+ }, [setSidebarWidth]);
62
+
63
+ useEffect(() => {
64
+ if (!state.connected) return;
65
+
66
+ if (route.view === "session") {
67
+ if (!state.sessions.find((s) => s.id === route.sessionId)) {
68
+ navigate(`#/nodes/${route.nodeId}/containers/${route.containerId}`);
69
+ return;
70
+ }
71
+ }
72
+ if (route.view === "container" || route.view === "session") {
73
+ if (!state.containers.find((c) => c.id === route.containerId)) {
74
+ navigate(`#/nodes/${route.nodeId}`);
75
+ return;
76
+ }
77
+ }
78
+ if (route.view !== "all-nodes") {
79
+ if (!state.nodes.find((n) => n.id === route.nodeId)) {
80
+ navigate("#/");
81
+ return;
82
+ }
83
+ }
84
+ }, [route, state.connected, state.nodes, state.containers, state.sessions, navigate]);
85
+
86
+ let mainContent: React.ReactNode;
87
+ if (route.view === "all-nodes") {
88
+ mainContent = <AllNodesView state={state} navigate={navigate} />;
89
+ } else if (route.view === "node") {
90
+ mainContent = <NodeView nodeId={route.nodeId} state={state} navigate={navigate} />;
91
+ } else {
92
+ // container or session view
93
+ if (!state.connected) {
94
+ mainContent = (
95
+ <div className="h-full flex items-center justify-center text-sm text-zinc-500">Connecting...</div>
96
+ );
97
+ } else {
98
+ mainContent = (
99
+ <div ref={containerRef} className="flex-1 flex flex-col overflow-hidden h-full">
100
+ {!termFullscreen && (
101
+ <div className="overflow-hidden" style={{ height: vncFullscreen ? "100%" : `${vncHeight}%` }}>
102
+ <VncPanel
103
+ containerId={selectedContainerId}
104
+ token={state.token}
105
+ fullscreen={vncFullscreen}
106
+ onToggleFullscreen={() => setVncFullscreen(!vncFullscreen)}
107
+ />
108
+ </div>
109
+ )}
110
+ {!vncFullscreen && !termFullscreen && (
111
+ <div
112
+ className="h-1 bg-zinc-700 hover:bg-blue-500 cursor-ns-resize flex-shrink-0"
113
+ onMouseDown={handleVncDrag}
114
+ onDoubleClick={() => setVncHeight(DEFAULT_VNC_HEIGHT)}
115
+ />
116
+ )}
117
+ {!vncFullscreen && (
118
+ <div className="overflow-hidden" style={{ height: termFullscreen ? "100%" : `${100 - vncHeight}%` }}>
119
+ <TerminalPanel
120
+ state={state}
121
+ selectedContainerId={selectedContainerId}
122
+ selectedSessionId={selectedSessionId}
123
+ onSelectSession={handleSelectSession}
124
+ fullscreen={termFullscreen}
125
+ onToggleFullscreen={() => setTermFullscreen(!termFullscreen)}
126
+ />
127
+ </div>
128
+ )}
129
+ </div>
130
+ );
131
+ }
132
+ }
133
+
134
+ return (
135
+ <div className="h-screen flex flex-col bg-zinc-950 text-zinc-100">
136
+ <TopBar sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} route={route} state={state} navigate={navigate} />
137
+ <div className="flex flex-1 overflow-hidden">
138
+ {sidebarOpen && (
139
+ <>
140
+ {/* Backdrop — mobile only */}
141
+ <div
142
+ className="fixed inset-0 top-10 bg-black/60 z-40 md:hidden"
143
+ onClick={() => setSidebarOpen(false)}
144
+ />
145
+ {/* Sidebar wrapper: full-screen overlay on mobile, inline on desktop */}
146
+ <div className="fixed top-10 inset-x-0 bottom-0 z-50 max-md:[&>div]:!w-full md:relative md:inset-auto md:z-auto">
147
+ <Sidebar
148
+ state={state}
149
+ route={route}
150
+ navigate={navigate}
151
+ onItemSelect={() => { if (window.innerWidth < 768) setSidebarOpen(false); }}
152
+ width={sidebarWidth}
153
+ />
154
+ </div>
155
+ <div
156
+ className="w-1 bg-zinc-700 hover:bg-blue-500 cursor-ew-resize flex-shrink-0 hidden md:block"
157
+ onMouseDown={handleSidebarDrag}
158
+ onDoubleClick={() => setSidebarWidth(DEFAULT_SIDEBAR_WIDTH)}
159
+ />
160
+ </>
161
+ )}
162
+ <div className="flex-1 flex flex-col overflow-hidden">
163
+ {mainContent}
164
+ </div>
165
+ </div>
166
+ </div>
167
+ );
168
+ }
@@ -0,0 +1,29 @@
1
+ import type { SwarmState } from "../hooks/useSwarmState";
2
+ import { NodeCard } from "./NodeCard";
3
+
4
+ interface AllNodesViewProps {
5
+ state: SwarmState;
6
+ navigate: (hash: string) => void;
7
+ }
8
+
9
+ export function AllNodesView({ state, navigate }: AllNodesViewProps) {
10
+ return (
11
+ <div className="h-full overflow-auto p-4">
12
+ <div className="flex items-center justify-between mb-4">
13
+ <h2 className="text-base font-medium">All Nodes</h2>
14
+ <span className="text-xs text-zinc-500">{state.nodes.length} nodes</span>
15
+ </div>
16
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
17
+ {state.nodes.map((node) => (
18
+ <NodeCard
19
+ key={node.id}
20
+ node={node}
21
+ containers={state.containers.filter((c) => c.node_id === node.id)}
22
+ token={state.token}
23
+ onClick={() => navigate(`#/nodes/${node.id}`)}
24
+ />
25
+ ))}
26
+ </div>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,63 @@
1
+ import { ChevronRight } from "lucide-react";
2
+ import type { Route } from "../hooks/useHashRouter";
3
+ import type { SwarmState } from "../hooks/useSwarmState";
4
+
5
+ interface BreadcrumbsProps {
6
+ route: Route;
7
+ state: SwarmState;
8
+ navigate: (hash: string) => void;
9
+ }
10
+
11
+ export function Breadcrumbs({ route, state, navigate }: BreadcrumbsProps) {
12
+ const segments: Array<{ label: string; hash?: string }> = [];
13
+
14
+ if (route.view === "all-nodes") {
15
+ segments.push({ label: "All Nodes" });
16
+ } else {
17
+ segments.push({ label: "All Nodes", hash: "#/" });
18
+ }
19
+
20
+ if (route.view === "node" || route.view === "container" || route.view === "session") {
21
+ const node = state.nodes.find((n) => n.id === route.nodeId);
22
+ const label = node?.id ?? route.nodeId;
23
+ if (route.view === "node") {
24
+ segments.push({ label });
25
+ } else {
26
+ segments.push({ label, hash: `#/nodes/${route.nodeId}` });
27
+ }
28
+ }
29
+
30
+ if (route.view === "container" || route.view === "session") {
31
+ const label = route.containerId.slice(0, 8);
32
+ if (route.view === "container") {
33
+ segments.push({ label });
34
+ } else {
35
+ segments.push({ label, hash: `#/nodes/${route.nodeId}/containers/${route.containerId}` });
36
+ }
37
+ }
38
+
39
+ if (route.view === "session") {
40
+ const session = state.sessions.find((s) => s.id === route.sessionId);
41
+ segments.push({ label: session?.command ?? route.sessionId.slice(0, 8) });
42
+ }
43
+
44
+ return (
45
+ <div className="flex items-center gap-1 text-xs text-zinc-500 overflow-hidden min-w-0">
46
+ {segments.map((seg, i) => (
47
+ <span key={i} className="flex items-center gap-1 min-w-0">
48
+ {i > 0 && <ChevronRight className="w-3 h-3 flex-shrink-0 text-zinc-600" />}
49
+ {seg.hash ? (
50
+ <button
51
+ onClick={() => navigate(seg.hash!)}
52
+ className="cursor-pointer hover:text-zinc-300 truncate"
53
+ >
54
+ {seg.label}
55
+ </button>
56
+ ) : (
57
+ <span className="text-zinc-300 truncate">{seg.label}</span>
58
+ )}
59
+ </span>
60
+ ))}
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,36 @@
1
+ import type { SwarmContainer, SwarmSession } from "../hooks/useSwarmState";
2
+
3
+ interface ContainerCardProps {
4
+ container: SwarmContainer;
5
+ sessions: SwarmSession[];
6
+ token: string;
7
+ onClick: () => void;
8
+ }
9
+
10
+ export function ContainerCard({ container, sessions, token, onClick }: ContainerCardProps) {
11
+ const runningSessions = sessions.filter((s) => s.status === "running");
12
+
13
+ return (
14
+ <div
15
+ onClick={onClick}
16
+ className="bg-zinc-900 border border-zinc-800 rounded-lg overflow-hidden cursor-pointer hover:border-zinc-600 transition-colors"
17
+ >
18
+ <div className="aspect-video bg-zinc-950 relative">
19
+ <img
20
+ src={`/api/containers/${container.id}/thumbnail?token=${token}&t=${Math.floor(Date.now() / 30000)}`}
21
+ alt=""
22
+ className="w-full h-full object-cover"
23
+ onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
24
+ />
25
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-zinc-600 -z-0">
26
+ no preview
27
+ </div>
28
+ </div>
29
+ <div className="px-3 py-2 flex items-center gap-2">
30
+ <span className={`w-2 h-2 rounded-full flex-shrink-0 ${container.status === "running" ? "bg-green-400" : "bg-zinc-600"}`} />
31
+ <span className="text-sm truncate flex-1">{container.id.slice(0, 8)}</span>
32
+ <span className="text-xs text-zinc-500">{runningSessions.length} sessions</span>
33
+ </div>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,51 @@
1
+ import type { SwarmNode, SwarmContainer } from "../hooks/useSwarmState";
2
+
3
+ interface NodeCardProps {
4
+ node: SwarmNode;
5
+ containers: SwarmContainer[];
6
+ token: string;
7
+ onClick: () => void;
8
+ }
9
+
10
+ export function NodeCard({ node, containers, token, onClick }: NodeCardProps) {
11
+ const runningContainers = containers.filter((c) => c.status === "running");
12
+ const heroContainer = runningContainers[0];
13
+ const extraCount = runningContainers.length - 1;
14
+
15
+ return (
16
+ <div
17
+ onClick={onClick}
18
+ className="bg-zinc-900 border border-zinc-800 rounded-lg overflow-hidden cursor-pointer hover:border-zinc-600 transition-colors"
19
+ >
20
+ <div className="aspect-video bg-zinc-950 relative">
21
+ {heroContainer ? (
22
+ <>
23
+ <img
24
+ src={`/api/containers/${heroContainer.id}/thumbnail?token=${token}&t=${Math.floor(Date.now() / 30000)}`}
25
+ alt=""
26
+ className="w-full h-full object-cover"
27
+ onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
28
+ />
29
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-zinc-600 -z-0">
30
+ no preview
31
+ </div>
32
+ {extraCount > 0 && (
33
+ <div className="absolute bottom-2 left-2 bg-black/70 text-xs text-zinc-300 px-2 py-0.5 rounded">
34
+ +{extraCount} more
35
+ </div>
36
+ )}
37
+ </>
38
+ ) : (
39
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-zinc-600">
40
+ <span className="m-auto">{node.status === "online" ? "no containers" : "offline"}</span>
41
+ </div>
42
+ )}
43
+ </div>
44
+ <div className="px-3 py-2 flex items-center gap-2">
45
+ <span className={`w-2 h-2 rounded-full flex-shrink-0 ${node.status === "online" ? "bg-green-400" : node.status === "reconnecting" ? "bg-yellow-400" : "bg-red-400"}`} />
46
+ <span className="text-sm truncate flex-1">{node.id}</span>
47
+ <span className="text-xs text-zinc-500">{runningContainers.length}/{node.max_containers}</span>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,52 @@
1
+ import { Plus } from "lucide-react";
2
+ import type { SwarmState } from "../hooks/useSwarmState";
3
+ import { ContainerCard } from "./ContainerCard";
4
+
5
+ interface NodeViewProps {
6
+ nodeId: string;
7
+ state: SwarmState;
8
+ navigate: (hash: string) => void;
9
+ }
10
+
11
+ export function NodeView({ nodeId, state, navigate }: NodeViewProps) {
12
+ const node = state.nodes.find((n) => n.id === nodeId);
13
+ const containers = state.containers.filter((c) => c.node_id === nodeId);
14
+
15
+ if (!node) {
16
+ return (
17
+ <div className="h-full flex items-center justify-center text-sm text-zinc-500">
18
+ Node not found
19
+ </div>
20
+ );
21
+ }
22
+
23
+ return (
24
+ <div className="h-full overflow-auto p-4">
25
+ <div className="flex items-center justify-between mb-4">
26
+ <div>
27
+ <h2 className="text-base font-medium">{node.id}</h2>
28
+ <span className="text-xs text-zinc-500">{containers.length} containers · {node.status}</span>
29
+ </div>
30
+ {node.status === "online" && containers.length < node.max_containers && (
31
+ <button
32
+ onClick={() => state.createContainer(nodeId)}
33
+ className="cursor-pointer flex items-center gap-1 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-500 rounded text-white"
34
+ >
35
+ <Plus className="w-3 h-3" /> New Container
36
+ </button>
37
+ )}
38
+ </div>
39
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
40
+ {containers.map((container) => (
41
+ <ContainerCard
42
+ key={container.id}
43
+ container={container}
44
+ sessions={state.sessions.filter((s) => s.container_id === container.id)}
45
+ token={state.token}
46
+ onClick={() => navigate(`#/nodes/${nodeId}/containers/${container.id}`)}
47
+ />
48
+ ))}
49
+ </div>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,152 @@
1
+ import { useCallback, useState } from "react";
2
+ import { ChevronDown, ChevronRight, Circle, Box, Terminal, Plus, Trash2 } from "lucide-react";
3
+ import type { SwarmState } from "../hooks/useSwarmState";
4
+ import type { Route } from "../hooks/useHashRouter";
5
+ import { useLocalStorage } from "../hooks/useLocalStorage";
6
+ import { ConfirmDialog } from "./ui/ConfirmDialog";
7
+
8
+ interface SidebarProps {
9
+ state: SwarmState;
10
+ route: Route;
11
+ navigate: (hash: string) => void;
12
+ onItemSelect?: () => void;
13
+ width: number;
14
+ }
15
+
16
+ export function Sidebar({ state, route, navigate, onItemSelect, width }: SidebarProps) {
17
+ const [expandedNodes, setExpandedNodes] = useLocalStorage<string[]>("swarm-expanded-nodes", []);
18
+ const [expandedContainers, setExpandedContainers] = useLocalStorage<string[]>("swarm-expanded-containers", []);
19
+ const [confirmDelete, setConfirmDelete] = useState<{ type: "container" | "node"; id: string } | null>(null);
20
+
21
+ const toggleList = useCallback((list: string[], id: string): string[] => {
22
+ return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
23
+ }, []);
24
+
25
+ const statusColor = (s: string) =>
26
+ s === "online" ? "text-green-400" : s === "reconnecting" ? "text-yellow-400" : "text-red-400";
27
+
28
+ return (
29
+ <div className="bg-zinc-900 md:bg-zinc-900/50 flex flex-col overflow-y-auto flex-shrink-0 h-full" style={{ width }}>
30
+ <div className="p-2 border-b border-zinc-800 flex justify-between items-center">
31
+ <span className="text-xs font-semibold text-zinc-400 uppercase">Nodes</span>
32
+ <span className="text-xs text-zinc-500">{state.nodes.length}</span>
33
+ </div>
34
+ <div className="flex-1 overflow-y-auto p-1">
35
+ {state.nodes.map((node) => {
36
+ const nodeContainers = state.containers.filter((c) => c.node_id === node.id);
37
+ const expanded = expandedNodes.includes(node.id);
38
+ const nodeActive = route.view !== "all-nodes" && "nodeId" in route && route.nodeId === node.id;
39
+ return (
40
+ <div key={node.id} className="mb-0.5">
41
+ <div
42
+ className={`flex items-center gap-1 px-2 py-1 rounded hover:bg-zinc-800 cursor-pointer text-sm ${nodeActive ? "text-blue-300" : ""}`}
43
+ onClick={() => {
44
+ setExpandedNodes((s) => toggleList(s, node.id));
45
+ navigate(`#/nodes/${node.id}`);
46
+ onItemSelect?.();
47
+ }}
48
+ >
49
+ {expanded ? <ChevronDown className="w-3 h-3 text-zinc-500" /> : <ChevronRight className="w-3 h-3 text-zinc-500" />}
50
+ <Circle className={`w-2 h-2 fill-current ${statusColor(node.status)}`} />
51
+ <span className="truncate flex-1">{node.id}</span>
52
+ <span className="text-xs text-zinc-600">{nodeContainers.length}/{node.max_containers}</span>
53
+ <button className="cursor-pointer p-0.5 hover:bg-red-900/50 rounded"
54
+ onClick={(e) => { e.stopPropagation(); setConfirmDelete({ type: "node", id: node.id }); }}
55
+ title="Remove node">
56
+ <Trash2 className="w-3 h-3 text-red-400" />
57
+ </button>
58
+ </div>
59
+ {expanded && (
60
+ <div className="ml-3">
61
+ {nodeContainers.map((container) => {
62
+ const cSessions = state.sessions.filter((s) => s.container_id === container.id);
63
+ const cExpanded = expandedContainers.includes(container.id);
64
+ const selected = (route.view === "container" || route.view === "session") && route.containerId === container.id;
65
+ return (
66
+ <div key={container.id} className="mb-0.5">
67
+ <div className={`flex items-center gap-1 px-2 py-1 rounded cursor-pointer text-sm ${selected ? "bg-blue-900/30 text-blue-300" : "hover:bg-zinc-800"}`}
68
+ onClick={() => {
69
+ setExpandedContainers((s) => toggleList(s, container.id));
70
+ navigate(`#/nodes/${node.id}/containers/${container.id}`);
71
+ onItemSelect?.();
72
+ }}>
73
+ {cExpanded ? <ChevronDown className="w-3 h-3 text-zinc-500" /> : <ChevronRight className="w-3 h-3 text-zinc-500" />}
74
+ <Box className="w-3 h-3 text-yellow-400" />
75
+ <span className="truncate flex-1 text-xs">{container.id.slice(0, 8)}</span>
76
+ <span className="text-xs text-zinc-600">{cSessions.filter((s) => s.status === "running").length}/{container.max_sessions}</span>
77
+ <button className="cursor-pointer p-0.5 hover:bg-red-900/50 rounded"
78
+ onClick={(e) => { e.stopPropagation(); setConfirmDelete({ type: "container", id: container.id }); }}
79
+ title="Stop & remove">
80
+ <Trash2 className="w-3 h-3 text-red-400" />
81
+ </button>
82
+ </div>
83
+ {cExpanded && (
84
+ <div className="ml-4">
85
+ {cSessions.map((session) => (
86
+ <div key={session.id}
87
+ className={`flex items-center gap-1 px-2 py-0.5 rounded cursor-pointer text-xs ${route.view === "session" && route.sessionId === session.id ? "bg-blue-900/20 text-blue-300" : "hover:bg-zinc-800 text-zinc-400"}`}
88
+ onClick={() => {
89
+ navigate(`#/nodes/${node.id}/containers/${container.id}/sessions/${session.id}`);
90
+ onItemSelect?.();
91
+ }}>
92
+ <Terminal className="w-3 h-3" />
93
+ <span className="truncate flex-1">{session.command}</span>
94
+ <span className={session.status === "running" ? "text-green-400" : "text-zinc-600"}>
95
+ {session.status === "running" ? "\u25CF" : "\u25CB"}
96
+ </span>
97
+ </div>
98
+ ))}
99
+ {container.status === "running" && cSessions.filter((s) => s.status === "running").length < container.max_sessions && (
100
+ <button className="cursor-pointer flex items-center gap-1 px-2 py-0.5 text-xs text-green-400 hover:bg-zinc-800 rounded w-full"
101
+ onClick={async () => { const data = await state.createSession(container.id); if (data?.id) { navigate(`#/nodes/${node.id}/containers/${container.id}/sessions/${data.id}`); onItemSelect?.(); } }}>
102
+ <Plus className="w-3 h-3" /> New Session
103
+ </button>
104
+ )}
105
+ </div>
106
+ )}
107
+ </div>
108
+ );
109
+ })}
110
+ {node.status === "online" && nodeContainers.length < node.max_containers && (
111
+ <button className="cursor-pointer flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-zinc-800 rounded w-full"
112
+ onClick={() => state.createContainer(node.id)}>
113
+ <Plus className="w-3 h-3" /> New Container
114
+ </button>
115
+ )}
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ })}
121
+ </div>
122
+
123
+ {confirmDelete && (
124
+ <ConfirmDialog
125
+ open
126
+ onOpenChange={(open) => { if (!open) setConfirmDelete(null); }}
127
+ title={confirmDelete.type === "node" ? "Remove node?" : "Remove container?"}
128
+ description={
129
+ confirmDelete.type === "node"
130
+ ? `This will remove node "${confirmDelete.id}" and all its containers and sessions. This action cannot be undone.`
131
+ : `This will stop and permanently remove container ${confirmDelete.id.slice(0, 8)} and all its sessions. This action cannot be undone.`
132
+ }
133
+ confirmLabel="Remove"
134
+ variant="danger"
135
+ onConfirm={() => {
136
+ if (confirmDelete.type === "node") {
137
+ state.deleteNode(confirmDelete.id);
138
+ if (route.view !== "all-nodes" && "nodeId" in route && route.nodeId === confirmDelete.id) navigate("#/");
139
+ } else {
140
+ state.deleteContainer(confirmDelete.id);
141
+ if ((route.view === "container" || route.view === "session") && route.containerId === confirmDelete.id) {
142
+ const container = state.containers.find((c) => c.id === confirmDelete.id);
143
+ navigate(container ? `#/nodes/${container.node_id}` : "#/");
144
+ }
145
+ }
146
+ setConfirmDelete(null);
147
+ }}
148
+ />
149
+ )}
150
+ </div>
151
+ );
152
+ }