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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user