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.
- package/README.md +121 -0
- package/bin/swarmy.js +232 -0
- package/bin/swarmy.mjs +232 -0
- package/manager/package.json +43 -0
- package/manager/src/client/App.tsx +168 -0
- package/manager/src/client/components/AllNodesView.tsx +29 -0
- package/manager/src/client/components/Breadcrumbs.tsx +63 -0
- package/manager/src/client/components/ContainerCard.tsx +36 -0
- package/manager/src/client/components/NodeCard.tsx +51 -0
- package/manager/src/client/components/NodeView.tsx +52 -0
- package/manager/src/client/components/Sidebar.tsx +152 -0
- package/manager/src/client/components/TerminalPanel.tsx +132 -0
- package/manager/src/client/components/TopBar.tsx +24 -0
- package/manager/src/client/components/VncPanel.tsx +104 -0
- package/manager/src/client/components/ui/ConfirmDialog.tsx +54 -0
- package/manager/src/client/hooks/useHashRouter.ts +37 -0
- package/manager/src/client/hooks/useLocalStorage.ts +22 -0
- package/manager/src/client/hooks/useSwarmState.ts +174 -0
- package/manager/src/client/index.css +1 -0
- package/manager/src/client/index.html +12 -0
- package/manager/src/client/lib/utils.ts +6 -0
- package/manager/src/client/main.tsx +10 -0
- package/manager/src/client/novnc.d.ts +9 -0
- package/manager/vite.config.ts +60 -0
- package/package.json +27 -0
- package/scripts/build.sh +11 -0
- package/scripts/run.sh +35 -0
- package/scripts/stop.sh +15 -0
- package/worker/package.json +23 -0
|
@@ -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
|
+
}
|