fix(nodes): SSH join-node fix + Test Connection button

Root cause:
  gateway binary was built before SwarmJoinNodeViaSSH was added → 404 on
  /api/swarm/join-node → tRPC threw 'Gateway unavailable' error.

Gateway fixes:
  - Rebuilt gateway image (golang:1.23-alpine + go mod tidy → clean go.sum)
  - SwarmJoinNodeViaSSH now live in binary (was already coded, not compiled)
  - NEW: POST /api/swarm/ssh-test handler (SwarmSSHTest)
      • Dials SSH with 10s timeout
      • Runs 'docker version' on remote to check Docker availability
      • Returns {ok, sshOk, dockerOk, dockerVersion, error, step}
  - Route registered: r.Post("/swarm/ssh-test", h.SwarmSSHTest)

Server fixes:
  - gateway-proxy.ts: added testSSHConnection() → POST /api/swarm/ssh-test
  - routers.ts: added nodes.sshTest mutation (input: host/port/user/password)

UI (client/src/pages/Nodes.tsx) — AddNodeDialog rewritten:
  - Separate state for testResult and joinResult (two independent panels)
  - NEW: yellow 'Test Connection' button → calls nodes.sshTest
      shows SSH OK + Docker version, or human-readable error
  - 'Join Swarm' button remains independent
  - Human-readable error messages per step:
      ssh_connect → 'Cannot connect — check IP, port, SSH running'
      docker_join → 'docker swarm join failed: ...'
      trpc        → 'Gateway unavailable — check gateway container'
  - Input changes clear stale test/join results
  - ✕ close button in dialog header
  - Disabled state unified (busy || joinDone)
This commit is contained in:
bboxwtf
2026-03-21 22:37:59 +00:00
parent 37e0a21ec3
commit 3be643992d
7 changed files with 251 additions and 82 deletions

View File

@@ -31,29 +31,49 @@ import {
// ─── Add Node via SSH Dialog ─────────────────────────────────────────────────
type TestResult = { ok: boolean; sshOk?: boolean; dockerOk?: boolean; dockerVersion?: string; error?: string; step?: string };
type JoinResult = { ok: boolean; output?: string; error?: string; note?: string; command?: string; step?: string };
function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [host, setHost] = useState("");
const [port, setPort] = useState(22);
const [user, setUser] = useState("root");
const [host, setHost] = useState("");
const [port, setPort] = useState(22);
const [user, setUser] = useState("root");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"worker" | "manager">("worker");
const [result, setResult] = useState<{ ok: boolean; output?: string; error?: string; note?: string; command?: string } | null>(null);
const [role, setRole] = useState<"worker" | "manager">("worker");
const [showPassword, setShowPassword] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [joinResult, setJoinResult] = useState<JoinResult | null>(null);
const testMut = trpc.nodes.sshTest.useMutation({
onSuccess: (data) => setTestResult(data as TestResult),
onError: (e) => setTestResult({ ok: false, error: e.message, step: "trpc" }),
});
const joinMut = trpc.nodes.joinNode.useMutation({
onSuccess: (data) => {
setResult(data);
if (data.ok) onSuccess();
setJoinResult(data as JoinResult);
if ((data as JoinResult).ok) onSuccess();
},
onError: (e) => setResult({ ok: false, error: e.message }),
onError: (e) => setJoinResult({ ok: false, error: e.message, step: "trpc" }),
});
const busy = testMut.isPending || joinMut.isPending;
const canAct = !!host.trim() && !!user.trim() && !!password && !busy;
const joinDone = joinResult?.ok === true;
const handleTest = () => {
setTestResult(null);
setJoinResult(null);
testMut.mutate({ host: host.trim(), port, user: user.trim(), password });
};
const handleJoin = () => {
setResult(null);
setJoinResult(null);
joinMut.mutate({ host: host.trim(), port, user: user.trim(), password, role });
};
const canSubmit = host.trim() && user.trim() && password && !joinMut.isPending;
const inputClass = "h-8 text-xs font-mono";
const disabled = busy || joinDone;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
@@ -62,9 +82,11 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
animate={{ opacity: 1, scale: 1 }}
className="bg-card border border-border rounded-lg p-6 w-full max-w-md shadow-2xl"
>
{/* Header */}
<div className="flex items-center gap-2 mb-5">
<UserPlus className="w-4 h-4 text-cyan-400" />
<h3 className="text-sm font-bold">Add Node to Swarm via SSH</h3>
<button onClick={onClose} className="ml-auto text-muted-foreground hover:text-foreground text-lg leading-none"></button>
</div>
<div className="space-y-3">
@@ -72,59 +94,40 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
<div className="grid grid-cols-[1fr_80px] gap-2">
<div>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">IP Address / Hostname *</label>
<Input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="192.168.1.100"
className="h-8 text-xs font-mono"
disabled={joinMut.isPending}
/>
<Input value={host} onChange={(e) => { setHost(e.target.value); setTestResult(null); setJoinResult(null); }}
placeholder="192.168.1.100" className={inputClass} disabled={disabled} />
</div>
<div>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">SSH Port</label>
<Input
type="number"
value={port}
onChange={(e) => setPort(parseInt(e.target.value) || 22)}
className="h-8 text-xs font-mono"
min={1} max={65535}
disabled={joinMut.isPending}
/>
<Input type="number" value={port} onChange={(e) => setPort(parseInt(e.target.value) || 22)}
className={inputClass} min={1} max={65535} disabled={disabled} />
</div>
</div>
{/* User */}
<div>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">SSH Username *</label>
<Input
value={user}
onChange={(e) => setUser(e.target.value)}
placeholder="root"
className="h-8 text-xs font-mono"
disabled={joinMut.isPending}
/>
<Input value={user} onChange={(e) => { setUser(e.target.value); setTestResult(null); }}
placeholder="root" className={inputClass} disabled={disabled} />
</div>
{/* Password */}
<div>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block flex items-center gap-1">
<KeyRound className="w-2.5 h-2.5" /> SSH Password *
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">
<KeyRound className="w-2.5 h-2.5 inline mr-1" />SSH Password *
</label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && canSubmit && handleJoin()}
onChange={(e) => { setPassword(e.target.value); setTestResult(null); setJoinResult(null); }}
onKeyDown={(e) => e.key === "Enter" && canAct && handleTest()}
placeholder="••••••••"
className="h-8 text-xs font-mono pr-16"
disabled={joinMut.isPending}
className={`${inputClass} pr-16`}
disabled={disabled}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-muted-foreground hover:text-foreground"
>
<button type="button" onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-muted-foreground hover:text-foreground">
{showPassword ? "hide" : "show"}
</button>
</div>
@@ -135,18 +138,14 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">Node Role</label>
<div className="flex gap-2">
{(["worker", "manager"] as const).map((r) => (
<button
key={r}
onClick={() => setRole(r)}
disabled={joinMut.isPending}
className={`flex-1 h-8 text-xs font-mono rounded border transition-colors capitalize ${
<button key={r} onClick={() => setRole(r)} disabled={disabled}
className={`flex-1 h-8 text-xs font-mono rounded border transition-colors capitalize disabled:opacity-50 ${
role === r
? r === "manager"
? "bg-purple-500/15 text-purple-400 border-purple-500/40"
: "bg-cyan-500/15 text-cyan-400 border-cyan-500/40"
: "border-border/40 text-muted-foreground hover:border-primary/40 hover:text-foreground"
} disabled:opacity-50`}
>
}`}>
{r === "manager" ? <Shield className="w-3 h-3 inline mr-1" /> : <Server className="w-3 h-3 inline mr-1" />}
{r}
</button>
@@ -159,57 +158,119 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
</p>
</div>
{/* Result */}
{result && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
{/* ── Test Result ────────────────────────────────────────────────── */}
{testResult && (
<motion.div initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
className={`rounded-lg border p-3 text-xs font-mono ${
result.ok
testResult.ok
? "bg-green-500/10 border-green-500/30 text-green-300"
: "bg-red-500/10 border-red-500/30 text-red-300"
}`}
>
<div className="flex items-center gap-1.5 mb-1 font-semibold">
{result.ok
? <><CheckCircle2 className="w-3.5 h-3.5" /> Node joined successfully!</>
: <><XCircle className="w-3.5 h-3.5" /> Failed to join node</>
}`}>
<div className="flex items-center gap-1.5 font-semibold mb-1">
{testResult.ok
? <><CheckCircle2 className="w-3.5 h-3.5" /> Connection OK</>
: <><XCircle className="w-3.5 h-3.5" /> Connection failed</>
}
</div>
{result.note && <p className="text-[10px] opacity-80">{result.note}</p>}
{result.output && (
{testResult.ok && (
<div className="space-y-0.5 text-[10px]">
<div className="flex items-center gap-1.5">
<CheckCircle2 className="w-3 h-3 text-green-400" />
<span>SSH authenticated successfully</span>
</div>
<div className="flex items-center gap-1.5">
{testResult.dockerOk
? <><CheckCircle2 className="w-3 h-3 text-green-400" /><span>Docker v{testResult.dockerVersion}</span></>
: <><XCircle className="w-3 h-3 text-yellow-400" /><span className="text-yellow-300">Docker not found install Docker on the remote host</span></>
}
</div>
</div>
)}
{testResult.error && (
<p className="text-[10px] mt-1 opacity-90">
{testResult.step === "ssh_connect"
? "Cannot connect — check IP, port, and that SSH is running on the remote host"
: testResult.step === "trpc"
? "Gateway unavailable — make sure the GoClaw gateway container is running"
: testResult.error}
</p>
)}
</motion.div>
)}
{/* ── Join Result ─────────────────────────────────────────────────── */}
{joinResult && (
<motion.div initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
className={`rounded-lg border p-3 text-xs font-mono ${
joinResult.ok
? "bg-green-500/10 border-green-500/30 text-green-300"
: "bg-red-500/10 border-red-500/30 text-red-300"
}`}>
<div className="flex items-center gap-1.5 mb-1 font-semibold">
{joinResult.ok
? <><CheckCircle2 className="w-3.5 h-3.5" /> Node joined the swarm!</>
: <><XCircle className="w-3.5 h-3.5" /> Failed to join swarm</>
}
</div>
{joinResult.note && <p className="text-[10px] opacity-80">{joinResult.note}</p>}
{joinResult.output && (
<pre className="mt-1 text-[9px] whitespace-pre-wrap break-all opacity-80 max-h-24 overflow-y-auto">
{result.output}
{joinResult.output}
</pre>
)}
{result.error && <p className="text-[10px] mt-1 opacity-80">{result.error}</p>}
{result.command && (
{joinResult.error && (
<p className="text-[10px] mt-1 opacity-90">
{joinResult.step === "ssh_connect"
? "SSH connection failed — check credentials"
: joinResult.step === "docker_join"
? `docker swarm join failed: ${joinResult.error}`
: joinResult.step === "trpc"
? "Gateway unavailable — make sure the GoClaw gateway container is running"
: joinResult.error}
</p>
)}
{joinResult.command && (
<div className="mt-2 pt-2 border-t border-current/20">
<p className="text-[9px] opacity-60 mb-0.5">Command executed:</p>
<code className="text-[9px] opacity-70 break-all">{result.command}</code>
<code className="text-[9px] opacity-70 break-all">{joinResult.command}</code>
</div>
)}
</motion.div>
)}
</div>
{/* ── Buttons ──────────────────────────────────────────────────────── */}
<div className="flex gap-2 mt-5">
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs">
{result?.ok ? "Close" : "Cancel"}
<Button variant="outline" onClick={onClose} className="h-8 text-xs px-4">
{joinDone ? "Close" : "Cancel"}
</Button>
{!result?.ok && (
{!joinDone && (<>
{/* Test connection */}
<Button
variant="outline"
onClick={handleTest}
disabled={!canAct}
className="flex-1 h-8 text-xs border-yellow-500/40 text-yellow-400 hover:bg-yellow-500/10 disabled:opacity-40"
>
{testMut.isPending
? <><Loader2 className="w-3 h-3 animate-spin mr-1" />Testing</>
: <><Wifi className="w-3 h-3 mr-1" />Test Connection</>
}
</Button>
{/* Join swarm */}
<Button
onClick={handleJoin}
disabled={!canSubmit}
className="flex-1 h-8 text-xs bg-cyan-500/15 text-cyan-400 border-cyan-500/30 hover:bg-cyan-500/25"
disabled={!canAct}
className="flex-1 h-8 text-xs bg-cyan-500/15 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/25 disabled:opacity-40"
>
{joinMut.isPending ? (
<><Loader2 className="w-3 h-3 animate-spin mr-1" />Connecting</>
) : (
<><UserPlus className="w-3 h-3 mr-1" />Join Swarm</>
)}
{joinMut.isPending
? <><Loader2 className="w-3 h-3 animate-spin mr-1" />Joining</>
: <><UserPlus className="w-3 h-3 mr-1" />Join Swarm</>
}
</Button>
)}
</>)}
</div>
</motion.div>
</div>

View File

@@ -140,6 +140,7 @@ func main() {
r.Post("/swarm/services/{id}/scale", h.SwarmScaleService)
r.Get("/swarm/join-token", h.SwarmJoinToken)
r.Post("/swarm/join-node", h.SwarmJoinNodeViaSSH)
r.Post("/swarm/ssh-test", h.SwarmSSHTest)
r.Post("/swarm/shell", h.SwarmShell)
r.Get("/swarm/agents", h.SwarmListAgents)
r.Post("/swarm/agents/{name}/start", h.SwarmStartAgent)

View File

@@ -6,7 +6,6 @@ require (
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1
github.com/go-sql-driver/mysql v1.8.1
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.37.0
)

View File

@@ -6,11 +6,11 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nuvVzoTd+M1hukKvKQLaDc=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=

View File

@@ -1518,3 +1518,73 @@ func (h *Handler) SwarmJoinNodeViaSSH(w http.ResponseWriter, r *http.Request) {
"command": joinCmd,
})
}
// POST /api/swarm/ssh-test
// Tests SSH connectivity and checks if Docker is accessible on the remote host.
// Body: { "host": "1.2.3.4", "port": 22, "user": "root", "password": "secret" }
func (h *Handler) SwarmSSHTest(w http.ResponseWriter, r *http.Request) {
var body struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Host == "" || body.User == "" || body.Password == "" {
respondError(w, http.StatusBadRequest, "host, user and password are required")
return
}
if body.Port == 0 {
body.Port = 22
}
sshCfg := &ssh.ClientConfig{
User: body.User,
Auth: []ssh.AuthMethod{
ssh.Password(body.Password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", body.Host, body.Port)
log.Printf("[SSHTest] Dialing %s as %s", addr, body.User)
client, err := ssh.Dial("tcp", addr, sshCfg)
if err != nil {
respond(w, http.StatusOK, map[string]any{
"ok": false,
"step": "ssh_connect",
"error": fmt.Sprintf("SSH connection failed: %s", err.Error()),
})
return
}
defer client.Close()
// Run a quick docker version check to see if Docker daemon is accessible
sess, err := client.NewSession()
if err != nil {
respond(w, http.StatusOK, map[string]any{
"ok": false,
"step": "ssh_session",
"error": fmt.Sprintf("SSH session failed: %s", err.Error()),
})
return
}
defer sess.Close()
out, _ := sess.CombinedOutput("docker version --format '{{.Server.Version}}' 2>/dev/null || echo 'docker_not_found'")
dockerVer := strings.TrimSpace(string(out))
dockerOk := dockerVer != "" && dockerVer != "docker_not_found"
log.Printf("[SSHTest] %s — SSH OK, docker: %s", addr, dockerVer)
respond(w, http.StatusOK, map[string]any{
"ok": true,
"sshOk": true,
"dockerOk": dockerOk,
"dockerVersion": dockerVer,
"host": body.Host,
})
}

View File

@@ -716,6 +716,27 @@ export async function joinSwarmNodeViaSSH(opts: {
} catch { return null; }
}
/**
* Test SSH connectivity and Docker availability on a remote host (no swarm join).
*/
export async function testSSHConnection(opts: {
host: string;
port?: number;
user: string;
password: string;
}): Promise<{ ok: boolean; sshOk?: boolean; dockerOk?: boolean; dockerVersion?: string; error?: string; step?: string } | null> {
try {
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/ssh-test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
signal: AbortSignal.timeout(20_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

@@ -35,6 +35,7 @@ import {
stopSwarmAgent,
getOllamaModelInfo,
joinSwarmNodeViaSSH,
testSSHConnection,
} from "./gateway-proxy";
// Shared system user id for non-authenticated agent management
@@ -1067,6 +1068,22 @@ export const appRouter = router({
return result;
}),
/**
* Test SSH connectivity and Docker availability on a remote host — no swarm join performed.
*/
sshTest: 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),
}))
.mutation(async ({ input }) => {
const result = await testSSHConnection(input);
if (!result) throw new Error("Gateway unavailable — cannot reach SSH test endpoint");
return result;
}),
/**
* Get live container stats (CPU%, RAM) for all running containers.
*/