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