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,132 @@
1
+ import { useEffect, useRef, useCallback, useState } from "react";
2
+ import { Terminal } from "@xterm/xterm";
3
+ import { FitAddon } from "@xterm/addon-fit";
4
+ import { WebLinksAddon } from "@xterm/addon-web-links";
5
+ import "@xterm/xterm/css/xterm.css";
6
+ import { Plus, X, Maximize2, Minimize2 } from "lucide-react";
7
+ import type { SwarmState } from "../hooks/useSwarmState";
8
+ import { ConfirmDialog } from "./ui/ConfirmDialog";
9
+
10
+ interface Props {
11
+ state: SwarmState;
12
+ selectedContainerId: string | null;
13
+ selectedSessionId: string | null;
14
+ onSelectSession: (id: string | null) => void;
15
+ fullscreen: boolean;
16
+ onToggleFullscreen: () => void;
17
+ }
18
+
19
+ export function TerminalPanel({ state, selectedContainerId, selectedSessionId, onSelectSession, fullscreen, onToggleFullscreen }: Props) {
20
+ const termRef = useRef<HTMLDivElement>(null);
21
+ const xtermRef = useRef<Terminal | null>(null);
22
+ const wsRef = useRef<WebSocket | null>(null);
23
+ const [confirmKill, setConfirmKill] = useState<string | null>(null);
24
+
25
+ const session = state.sessions.find((s) => s.id === selectedSessionId);
26
+ const containerSessions = state.sessions.filter((s) => s.container_id === selectedContainerId);
27
+ const killTarget = containerSessions.find((s) => s.id === confirmKill);
28
+
29
+ useEffect(() => {
30
+ if (!termRef.current || !selectedSessionId) return;
31
+
32
+ const term = new Terminal({
33
+ theme: { background: "#09090b", foreground: "#e4e4e7", cursor: "#3b82f6" },
34
+ fontSize: 13, fontFamily: "monospace", cursorBlink: true,
35
+ });
36
+ const fit = new FitAddon();
37
+ term.loadAddon(fit);
38
+ term.loadAddon(new WebLinksAddon());
39
+ term.open(termRef.current);
40
+ fit.fit();
41
+ xtermRef.current = term;
42
+
43
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
44
+ const ws = new WebSocket(`${protocol}//${location.host}/ws/session/${selectedSessionId}?token=${state.token}`);
45
+ wsRef.current = ws;
46
+
47
+ ws.onmessage = (e) => term.write(e.data);
48
+ term.onData((data) => {
49
+ if (ws.readyState === WebSocket.OPEN) ws.send(data);
50
+ });
51
+
52
+ const observer = new ResizeObserver(() => fit.fit());
53
+ observer.observe(termRef.current);
54
+
55
+ return () => { observer.disconnect(); ws.close(); term.dispose(); };
56
+ }, [selectedSessionId, state.token]);
57
+
58
+ const handleNew = useCallback(async () => {
59
+ if (!selectedContainerId) return;
60
+ const data = await state.createSession(selectedContainerId);
61
+ if (data?.id) onSelectSession(data.id);
62
+ }, [selectedContainerId, state, onSelectSession]);
63
+
64
+ const handleKillConfirmed = useCallback(() => {
65
+ if (!confirmKill) return;
66
+ state.deleteSession(confirmKill);
67
+ if (selectedSessionId === confirmKill) {
68
+ const rest = containerSessions.filter((x) => x.id !== confirmKill);
69
+ onSelectSession(rest.length > 0 ? rest[0].id : null);
70
+ }
71
+ setConfirmKill(null);
72
+ }, [confirmKill, state, selectedSessionId, containerSessions, onSelectSession]);
73
+
74
+ return (
75
+ <div className="h-full flex flex-col bg-zinc-950">
76
+ <div className="h-8 bg-zinc-900 border-b border-zinc-800 flex items-center overflow-x-auto flex-shrink-0">
77
+ {containerSessions.map((s) => (
78
+ <div key={s.id}
79
+ className={`flex items-center gap-1 px-3 h-full text-xs cursor-pointer border-r border-zinc-800 ${
80
+ s.id === selectedSessionId ? "bg-zinc-950 text-zinc-100 border-b-2 border-b-blue-500" : "text-zinc-500 hover:text-zinc-300"
81
+ }`}
82
+ onClick={() => onSelectSession(s.id)}>
83
+ <span className="truncate max-w-24">{s.command}</span>
84
+ {s.status === "exited" && <span className="text-zinc-600 text-[10px]">exited</span>}
85
+ <button className="cursor-pointer p-0.5 hover:bg-zinc-800 rounded ml-1"
86
+ onClick={(e) => {
87
+ e.stopPropagation();
88
+ if (s.status === "running") {
89
+ setConfirmKill(s.id);
90
+ } else {
91
+ state.deleteSession(s.id);
92
+ if (selectedSessionId === s.id) {
93
+ const rest = containerSessions.filter((x) => x.id !== s.id);
94
+ onSelectSession(rest.length > 0 ? rest[0].id : null);
95
+ }
96
+ }
97
+ }}>
98
+ <X className="w-3 h-3" />
99
+ </button>
100
+ </div>
101
+ ))}
102
+ <button className="cursor-pointer px-2 h-full text-zinc-500 hover:text-zinc-300" onClick={handleNew} title="New session">
103
+ <Plus className="w-3 h-3" />
104
+ </button>
105
+ <div className="ml-auto flex items-center gap-1 px-2">
106
+ {session && (
107
+ <button className={`cursor-pointer text-xs px-2 py-0.5 rounded ${session.interactive ? "bg-green-900/30 text-green-400" : "bg-zinc-800 text-zinc-500"}`}
108
+ onClick={() => state.toggleInteractive(session.id, !session.interactive)}>
109
+ {session.interactive ? "Interactive" : "Observe"}
110
+ </button>
111
+ )}
112
+ <button onClick={onToggleFullscreen} className="cursor-pointer p-0.5 hover:bg-zinc-800 rounded text-zinc-400">
113
+ {fullscreen ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
114
+ </button>
115
+ </div>
116
+ </div>
117
+ <div ref={termRef} className="flex-1 overflow-hidden" />
118
+
119
+ {confirmKill && (
120
+ <ConfirmDialog
121
+ open
122
+ onOpenChange={(open) => { if (!open) setConfirmKill(null); }}
123
+ title="Kill session?"
124
+ description={`This will terminate the running "${killTarget?.command ?? "session"}" process. This action cannot be undone.`}
125
+ confirmLabel="Kill"
126
+ variant="danger"
127
+ onConfirm={handleKillConfirmed}
128
+ />
129
+ )}
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,24 @@
1
+ import { PanelLeft } from "lucide-react";
2
+ import { Breadcrumbs } from "./Breadcrumbs";
3
+ import type { Route } from "../hooks/useHashRouter";
4
+ import type { SwarmState } from "../hooks/useSwarmState";
5
+
6
+ interface TopBarProps {
7
+ sidebarOpen: boolean;
8
+ onToggleSidebar: () => void;
9
+ route: Route;
10
+ state: SwarmState;
11
+ navigate: (hash: string) => void;
12
+ }
13
+
14
+ export function TopBar({ sidebarOpen, onToggleSidebar, route, state, navigate }: TopBarProps) {
15
+ return (
16
+ <div className="h-10 bg-zinc-900 border-b border-zinc-800 flex items-center px-3 gap-3 flex-shrink-0 relative z-50">
17
+ <button onClick={onToggleSidebar} className="cursor-pointer p-1 hover:bg-zinc-800 rounded text-zinc-400 hover:text-zinc-100" title={sidebarOpen ? "Hide sidebar" : "Show sidebar"}>
18
+ <PanelLeft className="w-4 h-4" />
19
+ </button>
20
+ <span className="text-sm font-semibold text-zinc-300 flex-shrink-0">Swarm Manager</span>
21
+ <Breadcrumbs route={route} state={state} navigate={navigate} />
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,104 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Maximize2, Minimize2 } from "lucide-react";
3
+
4
+ interface VncPanelProps {
5
+ containerId: string | null;
6
+ token: string;
7
+ fullscreen: boolean;
8
+ onToggleFullscreen: () => void;
9
+ }
10
+
11
+ export function VncPanel({ containerId, token, fullscreen, onToggleFullscreen }: VncPanelProps) {
12
+ const canvasRef = useRef<HTMLDivElement>(null);
13
+ const rfbRef = useRef<any>(null);
14
+ const [status, setStatus] = useState("disconnected");
15
+
16
+ const captureAndUpload = useCallback(async () => {
17
+ if (!containerId || !rfbRef.current) return;
18
+ try {
19
+ const sourceCanvas = canvasRef.current?.querySelector("canvas");
20
+ if (!sourceCanvas) return;
21
+ const scale = 320 / sourceCanvas.width;
22
+ const offscreen = document.createElement("canvas");
23
+ offscreen.width = 320;
24
+ offscreen.height = Math.round(sourceCanvas.height * scale);
25
+ const ctx = offscreen.getContext("2d");
26
+ if (!ctx) return;
27
+ ctx.drawImage(sourceCanvas, 0, 0, offscreen.width, offscreen.height);
28
+ const dataUrl = offscreen.toDataURL("image/jpeg", 0.6);
29
+ const base64 = dataUrl.split(",")[1];
30
+ await fetch(`/api/containers/${containerId}/thumbnail`, {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
33
+ body: JSON.stringify({ data: base64 }),
34
+ });
35
+ } catch {
36
+ // Thumbnail capture failures are non-critical — silently ignore
37
+ }
38
+ }, [containerId, token]);
39
+
40
+ const captureSync = useCallback(() => {
41
+ if (!containerId || !rfbRef.current) return;
42
+ try {
43
+ const sourceCanvas = canvasRef.current?.querySelector("canvas");
44
+ if (!sourceCanvas) return;
45
+ const scale = 320 / sourceCanvas.width;
46
+ const offscreen = document.createElement("canvas");
47
+ offscreen.width = 320;
48
+ offscreen.height = Math.round(sourceCanvas.height * scale);
49
+ const ctx = offscreen.getContext("2d");
50
+ if (!ctx) return;
51
+ ctx.drawImage(sourceCanvas, 0, 0, offscreen.width, offscreen.height);
52
+ const dataUrl = offscreen.toDataURL("image/jpeg", 0.6);
53
+ const base64 = dataUrl.split(",")[1];
54
+ navigator.sendBeacon(
55
+ `/api/containers/${containerId}/thumbnail?token=${token}`,
56
+ new Blob([JSON.stringify({ data: base64 })], { type: "application/json" })
57
+ );
58
+ } catch {}
59
+ }, [containerId, token]);
60
+
61
+ useEffect(() => {
62
+ if (!containerId || !canvasRef.current) { setStatus("no container"); return; }
63
+
64
+ let disposed = false;
65
+ let rfb: any = null;
66
+ let intervalId: ReturnType<typeof setInterval> | undefined;
67
+ async function connect() {
68
+ const { default: RFB } = await import("@novnc/novnc/lib/rfb.js");
69
+ if (disposed || !canvasRef.current) return;
70
+ rfb = new RFB(canvasRef.current, `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws/vnc/${containerId}?token=${token}`);
71
+ rfb.scaleViewport = true;
72
+ rfb.resizeSession = false;
73
+ rfbRef.current = rfb;
74
+ rfb.addEventListener("connect", () => { if (!disposed) setStatus("connected"); });
75
+ rfb.addEventListener("disconnect", () => { if (!disposed) setStatus("disconnected"); });
76
+ intervalId = setInterval(captureAndUpload, 30_000);
77
+ }
78
+ connect().catch((e) => { if (!disposed) { console.error("VNC connect error:", e); setStatus("failed"); } });
79
+
80
+ return () => {
81
+ disposed = true;
82
+ captureSync();
83
+ clearInterval(intervalId);
84
+ rfbRef.current?.disconnect();
85
+ rfbRef.current = null;
86
+ };
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, [containerId, token]);
89
+
90
+ return (
91
+ <div className="h-full flex flex-col bg-zinc-950">
92
+ <div className="h-8 bg-zinc-900 border-b border-zinc-800 flex items-center px-3 justify-between flex-shrink-0">
93
+ <span className="text-xs text-yellow-400">VNC {containerId ? `— ${containerId.slice(0, 8)}` : ""}</span>
94
+ <div className="flex items-center gap-2">
95
+ <span className="text-xs text-zinc-500">{status}</span>
96
+ <button onClick={onToggleFullscreen} className="cursor-pointer p-0.5 hover:bg-zinc-800 rounded text-zinc-400">
97
+ {fullscreen ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
98
+ </button>
99
+ </div>
100
+ </div>
101
+ <div ref={canvasRef} className="flex-1 overflow-hidden bg-zinc-950" />
102
+ </div>
103
+ );
104
+ }
@@ -0,0 +1,54 @@
1
+ import * as AlertDialog from "@radix-ui/react-alert-dialog";
2
+ import type { ReactNode } from "react";
3
+
4
+ interface ConfirmDialogProps {
5
+ open: boolean;
6
+ onOpenChange: (open: boolean) => void;
7
+ title: string;
8
+ description: string;
9
+ confirmLabel?: string;
10
+ onConfirm: () => void;
11
+ trigger?: ReactNode;
12
+ variant?: "danger" | "default";
13
+ }
14
+
15
+ export function ConfirmDialog({
16
+ open,
17
+ onOpenChange,
18
+ title,
19
+ description,
20
+ confirmLabel = "Confirm",
21
+ onConfirm,
22
+ variant = "danger",
23
+ }: ConfirmDialogProps) {
24
+ return (
25
+ <AlertDialog.Root open={open} onOpenChange={onOpenChange}>
26
+ <AlertDialog.Portal>
27
+ <AlertDialog.Overlay className="fixed inset-0 bg-black/60 z-50" />
28
+ <AlertDialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-900 p-6 shadow-xl">
29
+ <AlertDialog.Title className="text-sm font-semibold text-zinc-100">
30
+ {title}
31
+ </AlertDialog.Title>
32
+ <AlertDialog.Description className="mt-2 text-sm text-zinc-400">
33
+ {description}
34
+ </AlertDialog.Description>
35
+ <div className="mt-4 flex justify-end gap-2">
36
+ <AlertDialog.Cancel className="cursor-pointer rounded px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 border border-zinc-700">
37
+ Cancel
38
+ </AlertDialog.Cancel>
39
+ <AlertDialog.Action
40
+ className={`cursor-pointer rounded px-3 py-1.5 text-sm font-medium ${
41
+ variant === "danger"
42
+ ? "bg-red-600 text-white hover:bg-red-700"
43
+ : "bg-blue-600 text-white hover:bg-blue-700"
44
+ }`}
45
+ onClick={onConfirm}
46
+ >
47
+ {confirmLabel}
48
+ </AlertDialog.Action>
49
+ </div>
50
+ </AlertDialog.Content>
51
+ </AlertDialog.Portal>
52
+ </AlertDialog.Root>
53
+ );
54
+ }
@@ -0,0 +1,37 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+
3
+ export type Route =
4
+ | { view: "all-nodes" }
5
+ | { view: "node"; nodeId: string }
6
+ | { view: "container"; nodeId: string; containerId: string }
7
+ | { view: "session"; nodeId: string; containerId: string; sessionId: string };
8
+
9
+ function parseHash(hash: string): Route {
10
+ const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
11
+ if (parts[0] === "nodes" && parts[1] && parts[2] === "containers" && parts[3] && parts[4] === "sessions" && parts[5]) {
12
+ return { view: "session", nodeId: parts[1], containerId: parts[3], sessionId: parts[5] };
13
+ }
14
+ if (parts[0] === "nodes" && parts[1] && parts[2] === "containers" && parts[3]) {
15
+ return { view: "container", nodeId: parts[1], containerId: parts[3] };
16
+ }
17
+ if (parts[0] === "nodes" && parts[1]) {
18
+ return { view: "node", nodeId: parts[1] };
19
+ }
20
+ return { view: "all-nodes" };
21
+ }
22
+
23
+ export function useHashRouter() {
24
+ const [route, setRoute] = useState<Route>(() => parseHash(window.location.hash));
25
+
26
+ useEffect(() => {
27
+ const handler = () => setRoute(parseHash(window.location.hash));
28
+ window.addEventListener("hashchange", handler);
29
+ return () => window.removeEventListener("hashchange", handler);
30
+ }, []);
31
+
32
+ const navigate = useCallback((hash: string) => {
33
+ window.location.hash = hash;
34
+ }, []);
35
+
36
+ return { route, navigate };
37
+ }
@@ -0,0 +1,22 @@
1
+ import { useState, useCallback } from "react";
2
+
3
+ export function useLocalStorage<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
4
+ const [state, setState] = useState<T>(() => {
5
+ try {
6
+ const stored = localStorage.getItem(key);
7
+ return stored !== null ? JSON.parse(stored) : defaultValue;
8
+ } catch {
9
+ return defaultValue;
10
+ }
11
+ });
12
+
13
+ const setValue = useCallback((value: T | ((prev: T) => T)) => {
14
+ setState((prev) => {
15
+ const next = typeof value === "function" ? (value as (prev: T) => T)(prev) : value;
16
+ try { localStorage.setItem(key, JSON.stringify(next)); } catch {}
17
+ return next;
18
+ });
19
+ }, [key]);
20
+
21
+ return [state, setValue];
22
+ }
@@ -0,0 +1,174 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+
3
+ export interface SwarmNode {
4
+ id: string; hostname: string; ip: string; status: string;
5
+ max_containers: number; last_heartbeat: number;
6
+ }
7
+ export interface SwarmContainer {
8
+ id: string; node_id: string; docker_id: string; image: string;
9
+ status: string; vnc_port: number; max_sessions: number;
10
+ }
11
+ export interface SwarmSession {
12
+ id: string; container_id: string; command: string; status: string;
13
+ exit_code: number | null; interactive: number;
14
+ }
15
+ export interface SwarmState {
16
+ connected: boolean; nodes: SwarmNode[]; containers: SwarmContainer[];
17
+ sessions: SwarmSession[]; token: string;
18
+ deleteNode: (id: string) => Promise<any>;
19
+ createContainer: (nodeId: string, image?: string) => Promise<any>;
20
+ deleteContainer: (id: string) => Promise<any>;
21
+ createSession: (containerId: string, command?: string) => Promise<any>;
22
+ deleteSession: (id: string) => Promise<any>;
23
+ sendInput: (sessionId: string, data: string) => Promise<any>;
24
+ toggleInteractive: (sessionId: string, interactive: boolean) => Promise<any>;
25
+ }
26
+
27
+ const TOKEN_KEY = "swarm-token";
28
+
29
+ export function useSwarmState(): SwarmState {
30
+ const [connected, setConnected] = useState(false);
31
+ const [nodes, setNodes] = useState<SwarmNode[]>([]);
32
+ const [containers, setContainers] = useState<SwarmContainer[]>([]);
33
+ const [sessions, setSessions] = useState<SwarmSession[]>([]);
34
+ const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? "");
35
+ const wsRef = useRef<WebSocket | null>(null);
36
+ const reconnectRef = useRef<ReturnType<typeof setTimeout>>(undefined);
37
+
38
+ useEffect(() => {
39
+ if (!token) {
40
+ const input = prompt("Enter SWARM_TOKEN:");
41
+ if (input) { setToken(input); localStorage.setItem(TOKEN_KEY, input); }
42
+ }
43
+ }, [token]);
44
+
45
+ useEffect(() => {
46
+ if (!token) return;
47
+ let disposed = false;
48
+
49
+ function connectWs() {
50
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
51
+ const ws = new WebSocket(`${protocol}//${location.host}/ws/ui?token=${token}`);
52
+ wsRef.current = ws;
53
+
54
+ ws.onopen = () => setConnected(true);
55
+ ws.onclose = () => {
56
+ setConnected(false);
57
+ if (!disposed) reconnectRef.current = setTimeout(connectWs, 3000);
58
+ };
59
+ ws.onmessage = (event) => {
60
+ const msg = JSON.parse(event.data);
61
+ switch (msg.type) {
62
+ case "snapshot":
63
+ setNodes(msg.nodes); setContainers(msg.containers); setSessions(msg.sessions);
64
+ break;
65
+ case "node-online":
66
+ setNodes((prev) =>
67
+ prev.some((n) => n.id === msg.nodeId)
68
+ ? prev.map((n) => n.id === msg.nodeId ? { ...n, status: "online" } : n)
69
+ : [...prev, { id: msg.nodeId, hostname: msg.hostname ?? "", ip: msg.ip ?? "", status: "online", max_containers: 5, last_heartbeat: 0 }]
70
+ );
71
+ break;
72
+ case "node-offline":
73
+ setNodes((prev) =>
74
+ prev.map((n) => n.id === msg.nodeId ? { ...n, status: "offline" } : n)
75
+ );
76
+ break;
77
+ case "container-started":
78
+ setContainers((prev) => {
79
+ const exists = prev.some((c) => c.id === msg.containerId);
80
+ if (exists) {
81
+ return prev.map((c) => c.id === msg.containerId ? { ...c, docker_id: msg.dockerId, vnc_port: msg.vncPort, status: "running" } : c);
82
+ }
83
+ return [...prev, { id: msg.containerId, node_id: msg.nodeId ?? "", docker_id: msg.dockerId, image: "swarm-base", status: "running", vnc_port: msg.vncPort, max_sessions: 10 }];
84
+ });
85
+ break;
86
+ case "container-stopped":
87
+ setContainers((prev) => prev.filter((c) => c.id !== msg.containerId));
88
+ setSessions((prev) => prev.filter((s) => s.container_id !== msg.containerId));
89
+ break;
90
+ case "session-started":
91
+ setSessions((prev) => {
92
+ if (prev.some((s) => s.id === msg.sessionId)) return prev;
93
+ return [...prev, { id: msg.sessionId, container_id: msg.containerId ?? "", command: msg.command ?? "bash", status: "running", exit_code: null, interactive: 1 }];
94
+ });
95
+ break;
96
+ case "session-exited":
97
+ setSessions((prev) =>
98
+ prev.map((s) => s.id === msg.sessionId ? { ...s, status: "exited", exit_code: msg.exitCode } : s)
99
+ );
100
+ break;
101
+ case "session-deleted":
102
+ setSessions((prev) => prev.filter((s) => s.id !== msg.sessionId));
103
+ break;
104
+ case "node-deleted":
105
+ setNodes((prev) => prev.filter((n) => n.id !== msg.nodeId));
106
+ setContainers((prev) => {
107
+ const nodeContainerIds = prev.filter((c) => c.node_id === msg.nodeId).map((c) => c.id);
108
+ setSessions((s) => s.filter((ses) => !nodeContainerIds.includes(ses.container_id)));
109
+ return prev.filter((c) => c.node_id !== msg.nodeId);
110
+ });
111
+ break;
112
+ }
113
+ };
114
+ }
115
+
116
+ connectWs();
117
+ return () => { disposed = true; clearTimeout(reconnectRef.current); wsRef.current?.close(); };
118
+ }, [token]);
119
+
120
+ const hdrs = useCallback(() => ({
121
+ "Content-Type": "application/json", Authorization: `Bearer ${token}`,
122
+ }), [token]);
123
+
124
+ const deleteNode = useCallback(async (id: string) => {
125
+ const res = await fetch(`/api/nodes/${id}`, { method: "DELETE", headers: hdrs() });
126
+ const data = await res.json();
127
+ if (res.ok) {
128
+ setNodes((prev) => prev.filter((n) => n.id !== id));
129
+ setContainers((prev) => {
130
+ const nodeContainerIds = prev.filter((c) => c.node_id === id).map((c) => c.id);
131
+ setSessions((s) => s.filter((ses) => !nodeContainerIds.includes(ses.container_id)));
132
+ return prev.filter((c) => c.node_id !== id);
133
+ });
134
+ }
135
+ return data;
136
+ }, [hdrs]);
137
+
138
+ const createContainer = useCallback(async (nodeId: string, image = "swarm-base") => {
139
+ const res = await fetch("/api/containers", { method: "POST", headers: hdrs(), body: JSON.stringify({ nodeId, image }) });
140
+ const data = await res.json();
141
+ if (res.ok) setContainers((prev) => [...prev, { ...data, node_id: nodeId, docker_id: "", vnc_port: 0, max_sessions: 10 }]);
142
+ return data;
143
+ }, [hdrs]);
144
+
145
+ const deleteContainer = useCallback(async (id: string) => {
146
+ return (await fetch(`/api/containers/${id}`, { method: "DELETE", headers: hdrs() })).json();
147
+ }, [hdrs]);
148
+
149
+ const createSession = useCallback(async (containerId: string, command = "bash") => {
150
+ const res = await fetch("/api/sessions", { method: "POST", headers: hdrs(), body: JSON.stringify({ containerId, command }) });
151
+ const data = await res.json();
152
+ if (res.ok) setSessions((prev) => [...prev, { ...data, container_id: containerId, interactive: 1, exit_code: null }]);
153
+ return data;
154
+ }, [hdrs]);
155
+
156
+ const deleteSession = useCallback(async (id: string) => {
157
+ const res = await fetch(`/api/sessions/${id}`, { method: "DELETE", headers: hdrs() });
158
+ const data = await res.json();
159
+ if (res.ok) setSessions((prev) => prev.filter((s) => s.id !== id));
160
+ return data;
161
+ }, [hdrs]);
162
+
163
+ const sendInputFn = useCallback(async (sessionId: string, data: string) => {
164
+ return (await fetch(`/api/sessions/${sessionId}/input`, { method: "POST", headers: hdrs(), body: JSON.stringify({ data }) })).json();
165
+ }, [hdrs]);
166
+
167
+ const toggleInteractive = useCallback(async (sessionId: string, interactive: boolean) => {
168
+ const res = await fetch(`/api/sessions/${sessionId}`, { method: "PATCH", headers: hdrs(), body: JSON.stringify({ interactive }) });
169
+ if (res.ok) setSessions((prev) => prev.map((s) => s.id === sessionId ? { ...s, interactive: interactive ? 1 : 0 } : s));
170
+ return res.json();
171
+ }, [hdrs]);
172
+
173
+ return { connected, nodes, containers, sessions, token, deleteNode, createContainer, deleteContainer, createSession, deleteSession, sendInput: sendInputFn, toggleInteractive };
174
+ }
@@ -0,0 +1 @@
1
+ @import "tailwindcss";
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Swarm Manager</title>
7
+ </head>
8
+ <body class="dark">
9
+ <div id="root"></div>
10
+ <script type="module" src="/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import App from "./App";
4
+ import "./index.css";
5
+
6
+ ReactDOM.createRoot(document.getElementById("root")!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ );
@@ -0,0 +1,9 @@
1
+ declare module "@novnc/novnc/lib/rfb.js" {
2
+ export default class RFB {
3
+ constructor(target: HTMLElement, url: string);
4
+ scaleViewport: boolean;
5
+ resizeSession: boolean;
6
+ disconnect(): void;
7
+ addEventListener(event: string, handler: (...args: any[]) => void): void;
8
+ }
9
+ }
@@ -0,0 +1,60 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+ import path from "path";
5
+ import fs from "fs";
6
+
7
+ function novncStripAwait() {
8
+ return {
9
+ name: "novnc-strip-toplevel-await",
10
+ transform(code: string, id: string) {
11
+ if (!id.includes("novnc/lib/util/browser")) return;
12
+ return code.replace(
13
+ /^(exports\.supportsWebCodecsH264Decode\s*=\s*supportsWebCodecsH264Decode\s*=\s*)await\s+/m,
14
+ "$1/* await stripped */ "
15
+ );
16
+ },
17
+ };
18
+ }
19
+
20
+ export default defineConfig({
21
+ plugins: [react(), tailwindcss(), novncStripAwait()],
22
+ root: "src/client",
23
+ resolve: {
24
+ alias: {
25
+ "@": path.resolve(__dirname, "src"),
26
+ },
27
+ },
28
+ server: {
29
+ port: 5174,
30
+ strictPort: true,
31
+ },
32
+ build: {
33
+ outDir: "../../dist/client",
34
+ emptyOutDir: true,
35
+ target: "esnext",
36
+ },
37
+ optimizeDeps: {
38
+ esbuildOptions: {
39
+ target: "esnext",
40
+ plugins: [
41
+ {
42
+ name: "novnc-strip-toplevel-await",
43
+ setup(build) {
44
+ build.onLoad({ filter: /novnc\/lib\/util\/browser\.js$/ }, (args) => {
45
+ let code = fs.readFileSync(args.path, "utf-8");
46
+ // Replace the top-level await with a synchronous call wrapped in a
47
+ // self-executing async function (the result is a Promise, but the
48
+ // exported value is only used lazily for H264 decode detection).
49
+ code = code.replace(
50
+ /^(exports\.supportsWebCodecsH264Decode\s*=\s*supportsWebCodecsH264Decode\s*=\s*)await\s+/m,
51
+ "$1/* await stripped */ "
52
+ );
53
+ return { contents: code, loader: "js" };
54
+ });
55
+ },
56
+ },
57
+ ],
58
+ },
59
+ },
60
+ });