feat(nodes): Add Node via SSH — join any host to Swarm cluster

- client/src/pages/Nodes.tsx:
  • Added showAddNode state (useState(false))
  • Added 'Add Node' button (green, UserPlus icon) in page header toolbar
  • Added 'Add Node via SSH' button in Nodes tab toolbar (always visible)
  • Added 'Add Node via SSH' button in empty-state of Nodes tab
  • Rendered <AddNodeDialog> overlay when showAddNode=true;
    onSuccess refreshes nodesQ + swarmInfoQ then closes dialog
  • AddNodeDialog component was already implemented: SSH host/port/user/
    password/role inputs, live result display (green/red), command echo

- server/gateway-proxy.ts:
  • joinSwarmNodeViaSSH() — POST /api/swarm/join-node with SSH credentials

- server/routers.ts:
  • nodes.joinNode mutation — validates input, calls joinSwarmNodeViaSSH,
    throws if gateway unavailable

- gateway/internal/api/handlers.go:
  • POST /api/swarm/join-node handler (SwarmJoinNodeViaSSH):
    retrieves join token, SSHes to remote host via golang.org/x/crypto/ssh,
    runs 'docker swarm join --token <token> <managerAddr>',
    handles already-member case as success

- gateway/cmd/gateway/main.go:
  • Route registered: r.Post("/swarm/join-node", h.SwarmJoinNodeViaSSH)

- gateway/go.mod + go.sum:
  • Added golang.org/x/crypto v0.37.0 dependency for SSH client support
This commit is contained in:
bboxwtf
2026-03-21 21:57:43 +00:00
parent 9627318380
commit 37e0a21ec3
7 changed files with 406 additions and 11 deletions

View File

@@ -682,6 +682,40 @@ export async function execSwarmShell(command: string): Promise<{ output: string;
} catch { return null; }
}
export interface JoinNodeResult {
ok: boolean;
output?: string;
error?: string;
step?: string;
note?: string;
host?: string;
role?: string;
command?: string;
}
/**
* SSH into a remote host and run "docker swarm join ..." to add it to the cluster.
* The gateway fetches the current join token automatically.
*/
export async function joinSwarmNodeViaSSH(opts: {
host: string;
port?: number;
user: string;
password: string;
role?: "worker" | "manager";
}): Promise<JoinNodeResult | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/join-node`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) return null;
return res.json();
} catch { return null; }
}
/** Add a label to a swarm node */
export async function addSwarmNodeLabel(nodeId: string, key: string, value: string): Promise<boolean> {
try {

View File

@@ -34,6 +34,7 @@ import {
startSwarmAgent,
stopSwarmAgent,
getOllamaModelInfo,
joinSwarmNodeViaSSH,
} from "./gateway-proxy";
// Shared system user id for non-authenticated agent management
@@ -1048,6 +1049,24 @@ export const appRouter = router({
return { ok };
}),
/**
* SSH into a remote host and run "docker swarm join ..." to add it to the cluster.
* The gateway fetches the join token automatically.
*/
joinNode: publicProcedure
.input(z.object({
host: z.string().min(1),
port: z.number().int().min(1).max(65535).default(22),
user: z.string().min(1),
password: z.string().min(1),
role: z.enum(["worker", "manager"]).default("worker"),
}))
.mutation(async ({ input }) => {
const result = await joinSwarmNodeViaSSH(input);
if (!result) throw new Error("Gateway unavailable — cannot reach SSH endpoint");
return result;
}),
/**
* Get live container stats (CPU%, RAM) for all running containers.
*/