feat(swarm): autonomous agent containers, Swarm Manager with auto-stop, /nodes UI overhaul

## 1. Fix /nodes Swarm Status Display
- Add SwarmStatusBanner component: clear green/red/loading state
- Shows nodeId, managerAddr, isManager badge
- Error state explains what to check (docker.sock mount)
- Header now shows 'swarm unreachable — check gateway' vs 'active'
- swarmOk now checks nodeId presence, not just data existence

## 2. Autonomous Agent Container
- New docker/Dockerfile.agent — builds Go agent binary from gateway/cmd/agent/
- New gateway/cmd/agent/main.go — standalone HTTP microservice:
  * GET /health — liveness probe with idle time info
  * POST /task — receives task, forwards to Gateway orchestrator
  * GET /info  — agent metadata (id, hostname, gateway url)
  * Idle watchdog: calls /api/swarm/agents/{name}/stop after IdleTimeoutMinutes
  * Connects to Swarm overlay network (goclaw-net) → reaches DB/Gateway by DNS
  * Env: AGENT_ID, GATEWAY_URL, DATABASE_URL, IDLE_TIMEOUT_MINUTES

## 3. Swarm Manager Agent (auto-stop after 15min idle)
- New gateway/internal/api/swarm_manager.go:
  * SwarmManager goroutine checks every 60s
  * Scales idle GoClaw agent services to 0 replicas after 15 min
  * Tracks lastActivity from task UpdatedAt timestamps
- New REST endpoints in gateway:
  * GET  /api/swarm/agents           — list agents with idleMinutes
  * POST /api/swarm/agents/{name}/start — scale up agent
  * POST /api/swarm/agents/{name}/stop  — scale to 0
  * DELETE /api/swarm/services/{id}     — remove service permanently
- SwarmManager started as background goroutine in main.go with context cancel

## 4. Docker Client Enhancements
- Added NetworkAttachment type and Networks field to ServiceSpec
- CreateAgentServiceFull(opts) — supports overlay networks, custom labels
- CreateAgentService() delegates to CreateAgentServiceFull for backward compat
- RemoveService(id) — DELETE /v1.44/services/{id}
- GetServiceLastActivity(id) — finds latest task UpdatedAt for idle detection

## 5. tRPC & Gateway Proxy
- New functions: removeSwarmService, listSwarmAgents, startSwarmAgent, stopSwarmAgent
- SwarmAgentInfo type with idleMinutes, lastActivity, desiredReplicas
- createAgentService now accepts networks[] parameter
- New tRPC endpoints: nodes.removeService, nodes.listAgents, nodes.startAgent, nodes.stopAgent

## 6. Nodes.tsx UI Overhaul
- SwarmStatusBanner component at top — no more silent 'connecting…'
- New 'Agents' tab with AgentManagerRow: idle time, auto-stop warning, start/stop/remove buttons
- IdleColor coding: green < 5m, yellow 5-10m, red 10m+ with countdown to auto-stop
- ServiceRow: added Remove button with confirmation dialog
- RemoveConfirmDialog component
- DeployAgentDialog: added overlay networks field, default env includes GATEWAY_URL
- All queries refetch after agent start/stop/remove
This commit is contained in:
bboxwtf
2026-03-21 20:37:21 +00:00
parent 12b8332b2f
commit a8a8ea1ee2
9 changed files with 1168 additions and 194 deletions

View File

@@ -2,18 +2,21 @@
* Nodes — Real Docker Swarm Management
*
* Shows:
* 1. Swarm overview (node count, managers, manager address, join tokens)
* 2. Node cards (hostname, role, IP, CPU/RAM, availability, labels, leader badge)
* 1. Swarm connection status — clearly shows if Swarm is active or unreachable
* 2. Swarm overview (node count, managers, manager address, join tokens)
* 3. Node cards (hostname, role, IP, CPU/RAM, availability, labels, leader badge)
* → Set availability (active/pause/drain) + add labels inline
* 3. Services table (all swarm services: name, image, replicas running/desired)
* → Scale replicas + view tasks per service (which node each replica runs on)
* 4. Deploy Agent dialog — create a new Swarm service from any Docker image
* 5. Host Shell (privileged nsenter → run commands directly on the host)
* 4. Services table (all swarm services: name, image, replicas running/desired)
* → Scale replicas, view tasks, remove service
* 5. Agents tab — GoClaw agents with idle time, start/stop controls
* → Auto-stop after 15 min idle (handled by SwarmManager in gateway)
* 6. Deploy Agent dialog — create a new Swarm service from any Docker image
* 7. Host Shell (privileged nsenter → run commands directly on the host)
*/
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -22,7 +25,8 @@ import {
Terminal, Crown, Layers, ChevronRight, ChevronDown,
Plus, Minus, Activity, Loader2,
Shield, Bot, ArrowUpRight, Eye, Tag, Power, Rocket,
GitBranch, Globe, AlertTriangle,
GitBranch, Globe, AlertTriangle, Trash2, Play, Square,
Wifi, WifiOff, Clock, Zap,
} from "lucide-react";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -35,12 +39,12 @@ function formatMB(mb: number) {
function getStateColor(state: string) {
switch (state?.toLowerCase()) {
case "ready":
case "running": return "text-green-400 border-green-400/30 bg-green-400/10";
case "running": return "text-green-400 border-green-400/30 bg-green-400/10";
case "down":
case "disconnected": return "text-red-400 border-red-400/30 bg-red-400/10";
case "drain":
case "pause": return "text-yellow-400 border-yellow-400/30 bg-yellow-400/10";
default: return "text-muted-foreground border-border bg-muted/20";
case "pause": return "text-yellow-400 border-yellow-400/30 bg-yellow-400/10";
default: return "text-muted-foreground border-border bg-muted/20";
}
}
@@ -55,6 +59,12 @@ function getTaskStateColor(state: string) {
}
}
function idleColor(minutes: number) {
if (minutes < 5) return "text-green-400";
if (minutes < 10) return "text-yellow-400";
return "text-red-400";
}
function CopyBtn({ text, label }: { text: string; label?: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
@@ -73,6 +83,75 @@ function CopyBtn({ text, label }: { text: string; label?: string }) {
);
}
// ─── Swarm Status Banner ──────────────────────────────────────────────────────
function SwarmStatusBanner({
isLoading, isError, swarmInfo,
}: {
isLoading: boolean;
isError: boolean;
swarmInfo: any;
}) {
if (isLoading) {
return (
<Card className="bg-card border-yellow-500/30">
<CardContent className="p-3 flex items-center gap-3">
<Loader2 className="w-4 h-4 animate-spin text-yellow-400 shrink-0" />
<div>
<p className="text-xs font-semibold text-yellow-400">Connecting to Docker Swarm</p>
<p className="text-[10px] font-mono text-muted-foreground">Fetching swarm state via Gateway</p>
</div>
</CardContent>
</Card>
);
}
if (isError || !swarmInfo) {
return (
<Card className="bg-card border-red-500/30">
<CardContent className="p-3 flex items-center gap-3">
<WifiOff className="w-4 h-4 text-red-400 shrink-0" />
<div className="flex-1">
<p className="text-xs font-semibold text-red-400">Swarm unreachable</p>
<p className="text-[10px] font-mono text-muted-foreground">
The GoClaw Gateway cannot contact the Docker Swarm API.
Ensure the gateway container is running with <code className="text-foreground/70">--mount /var/run/docker.sock</code>.
</p>
</div>
<div className="text-[9px] font-mono text-muted-foreground text-right shrink-0">
<p>Gateway: <code className="text-foreground/60">:18789</code></p>
<p>Socket: <code className="text-foreground/60">/var/run/docker.sock</code></p>
</div>
</CardContent>
</Card>
);
}
const isActive = swarmInfo.localNodeState === "active";
return (
<Card className={`bg-card ${isActive ? "border-green-500/30" : "border-yellow-500/30"}`}>
<CardContent className="p-3 flex items-center gap-3">
{isActive
? <Wifi className="w-4 h-4 text-green-400 shrink-0" />
: <WifiOff className="w-4 h-4 text-yellow-400 shrink-0" />
}
<div className="flex-1">
<p className={`text-xs font-semibold ${isActive ? "text-green-400" : "text-yellow-400"}`}>
Swarm {isActive ? "active" : swarmInfo.localNodeState ?? "unknown"}
</p>
<p className="text-[10px] font-mono text-muted-foreground">
Node ID: <code className="text-foreground/70">{swarmInfo.nodeId}</code>
{swarmInfo.managerAddr && <> · Manager: <code className="text-cyan-400">{swarmInfo.managerAddr}</code></>}
</p>
</div>
<div className="text-right shrink-0">
{swarmInfo.isManager && (
<Badge className="text-[9px] bg-purple-500/15 text-purple-400 border-purple-500/30">Manager</Badge>
)}
</div>
</CardContent>
</Card>
);
}
// ─── Node Card ────────────────────────────────────────────────────────────────
type NodeInfo = {
@@ -101,18 +180,16 @@ function NodeCard({ node, onRefresh }: { node: NodeInfo; onRefresh: () => void }
<Card className="bg-card border-border/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
{/* Role icon */}
<div className={`w-10 h-10 rounded-md flex items-center justify-center border shrink-0 ${
node.isLeader ? "bg-yellow-500/15 border-yellow-500/30" :
node.role === "manager" ? "bg-purple-500/15 border-purple-500/30" :
"bg-cyan-500/10 border-cyan-500/20"
}`}>
{node.isLeader ? <Crown className="w-5 h-5 text-yellow-400" /> :
{node.isLeader ? <Crown className="w-5 h-5 text-yellow-400" /> :
node.role === "manager" ? <Shield className="w-5 h-5 text-purple-400" /> :
<Server className="w-5 h-5 text-cyan-400" />}
</div>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-bold text-sm text-foreground">{node.hostname}</span>
@@ -136,14 +213,12 @@ function NodeCard({ node, onRefresh }: { node: NodeInfo; onRefresh: () => void }
<div className="flex items-center gap-3 mt-1.5 flex-wrap text-[10px] font-mono text-muted-foreground">
<span className="flex items-center gap-1"><Network className="w-3 h-3" />{node.ip}</span>
<span className="flex items-center gap-1"><Cpu className="w-3 h-3" />{node.cpuCores} cores</span>
<span className="flex items-center gap-1"><Cpu className="w-3 h-3" />{node.cpuCores} cores</span>
<span className="flex items-center gap-1"><HardDrive className="w-3 h-3" />{formatMB(node.memTotalMB)}</span>
<span>{node.os}/{node.arch}</span>
<span>Docker {node.dockerVersion}</span>
{node.id && <span className="text-muted-foreground/50">{node.id}</span>}
</div>
{/* Labels */}
{Object.keys(node.labels).length > 0 && (
<div className="flex gap-1 flex-wrap mt-2">
{Object.entries(node.labels).map(([k, v]) => (
@@ -155,15 +230,12 @@ function NodeCard({ node, onRefresh }: { node: NodeInfo; onRefresh: () => void }
)}
</div>
<button
onClick={() => setExpanded(!expanded)}
className="text-muted-foreground/50 hover:text-foreground transition-colors mt-1"
>
<button onClick={() => setExpanded(!expanded)}
className="text-muted-foreground/50 hover:text-foreground transition-colors mt-1">
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
</div>
{/* Expanded controls */}
{expanded && (
<div className="mt-3 pt-3 border-t border-border/30 space-y-3">
{node.managerAddr && (
@@ -179,7 +251,6 @@ function NodeCard({ node, onRefresh }: { node: NodeInfo; onRefresh: () => void }
<CopyBtn text={node.id} />
</div>
{/* Availability control */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-mono text-muted-foreground w-28 shrink-0">Availability:</span>
<div className="flex gap-1">
@@ -201,34 +272,24 @@ function NodeCard({ node, onRefresh }: { node: NodeInfo; onRefresh: () => void }
</div>
</div>
{/* Add label */}
{showLabelForm ? (
<div className="flex items-center gap-2 flex-wrap">
<Tag className="w-3 h-3 text-muted-foreground shrink-0" />
<Input
value={labelKey} onChange={(e) => setLabelKey(e.target.value)}
placeholder="key" className="h-6 text-[10px] font-mono w-24 px-2"
/>
<Input value={labelKey} onChange={(e) => setLabelKey(e.target.value)}
placeholder="key" className="h-6 text-[10px] font-mono w-24 px-2" />
<span className="text-muted-foreground text-[10px]">=</span>
<Input
value={labelVal} onChange={(e) => setLabelVal(e.target.value)}
placeholder="value" className="h-6 text-[10px] font-mono w-24 px-2"
/>
<Button
size="sm"
disabled={!labelKey || addLabelMut.isPending}
<Input value={labelVal} onChange={(e) => setLabelVal(e.target.value)}
placeholder="value" className="h-6 text-[10px] font-mono w-24 px-2" />
<Button size="sm" disabled={!labelKey || addLabelMut.isPending}
onClick={() => addLabelMut.mutate({ nodeId: node.id, key: labelKey, value: labelVal })}
className="h-6 px-2 text-[10px]"
>
className="h-6 px-2 text-[10px]">
{addLabelMut.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : "Add"}
</Button>
<button onClick={() => setShowLabelForm(false)} className="text-[10px] text-muted-foreground hover:text-foreground">Cancel</button>
</div>
) : (
<button
onClick={() => setShowLabelForm(true)}
className="flex items-center gap-1 text-[10px] font-mono text-muted-foreground hover:text-foreground transition-colors"
>
<button onClick={() => setShowLabelForm(true)}
className="flex items-center gap-1 text-[10px] font-mono text-muted-foreground hover:text-foreground transition-colors">
<Tag className="w-3 h-3" /> Add label
</button>
)}
@@ -249,16 +310,16 @@ type ServiceInfo = {
};
function ServiceRow({
svc,
onScale,
onViewTasks,
svc, onScale, onViewTasks, onRemove,
}: {
svc: ServiceInfo;
onScale: (id: string, current: number) => void;
onViewTasks: (id: string, name: string) => void;
onRemove: (id: string, name: string) => void;
}) {
const healthy = svc.runningTasks >= svc.desiredTasks && svc.desiredTasks > 0;
const partial = svc.runningTasks > 0 && svc.runningTasks < svc.desiredTasks;
const partial = svc.runningTasks > 0 && svc.runningTasks < svc.desiredTasks;
const stopped = svc.desiredReplicas === 0;
return (
<tr className="border-b border-border/30 hover:bg-secondary/20 transition-colors">
@@ -266,6 +327,7 @@ function ServiceRow({
<div className="flex items-center gap-2">
{svc.isGoClaw && <Bot className="w-3 h-3 text-cyan-400 shrink-0" />}
<span className="font-mono text-xs text-foreground font-medium">{svc.name}</span>
{stopped && <Badge className="text-[8px] h-3.5 px-1 bg-muted/50 text-muted-foreground border-border/40">stopped</Badge>}
</div>
<div className="text-[9px] font-mono text-muted-foreground/60 mt-0.5 truncate max-w-[200px]">
{svc.image.split(":")[0].split("/").pop()}:{svc.image.split(":")[1] || "latest"}
@@ -277,14 +339,12 @@ function ServiceRow({
<td className="px-3 py-2.5">
<div className="flex items-center gap-1.5">
<span className={`text-xs font-mono font-bold ${
stopped ? "text-muted-foreground" :
healthy ? "text-green-400" : partial ? "text-yellow-400" : "text-red-400"
}`}>
{svc.runningTasks}/{svc.desiredTasks}
{svc.runningTasks}/{stopped ? "0" : svc.desiredTasks}
</span>
<span className="text-[9px] text-muted-foreground">running</span>
{svc.desiredReplicas !== svc.desiredTasks && (
<span className="text-[9px] text-muted-foreground">({svc.desiredReplicas} desired)</span>
)}
<span className="text-[9px] text-muted-foreground">{stopped ? "replicas" : "running"}</span>
</div>
</td>
<td className="px-3 py-2.5">
@@ -294,32 +354,120 @@ function ServiceRow({
<code key={p} className="text-[9px] font-mono text-cyan-400">{p}</code>
))}
</div>
) : (
<span className="text-[9px] text-muted-foreground/40"></span>
)}
) : <span className="text-[9px] text-muted-foreground/40"></span>}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1.5">
<button
onClick={() => onScale(svc.id, svc.desiredReplicas)}
<div className="flex items-center gap-1">
<button onClick={() => onScale(svc.id, svc.desiredReplicas)}
className="p-1 rounded border border-border/40 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors"
title="Scale service"
>
title="Scale service">
<Layers className="w-3 h-3" />
</button>
<button
onClick={() => onViewTasks(svc.id, svc.name)}
<button onClick={() => onViewTasks(svc.id, svc.name)}
className="p-1 rounded border border-border/40 text-muted-foreground hover:text-cyan-400 hover:border-cyan-500/40 transition-colors"
title="View tasks / replicas"
>
title="View tasks">
<Eye className="w-3 h-3" />
</button>
<button onClick={() => onRemove(svc.id, svc.name)}
className="p-1 rounded border border-border/40 text-muted-foreground hover:text-red-400 hover:border-red-500/40 transition-colors"
title="Remove service">
<Trash2 className="w-3 h-3" />
</button>
</div>
</td>
</tr>
);
}
// ─── Agent Manager Row ────────────────────────────────────────────────────────
type AgentServiceInfo = {
id: string; name: string; image: string;
desiredReplicas: number; runningTasks: number;
lastActivity: string; idleMinutes: number; isGoClaw: boolean;
};
function AgentManagerRow({
agent, onRefresh,
}: {
agent: AgentServiceInfo; onRefresh: () => void;
}) {
const startMut = trpc.nodes.startAgent.useMutation({ onSuccess: onRefresh });
const stopMut = trpc.nodes.stopAgent.useMutation({ onSuccess: onRefresh });
const removeMut = trpc.nodes.removeService.useMutation({ onSuccess: onRefresh });
const stopped = agent.desiredReplicas === 0;
const idle = agent.idleMinutes;
return (
<div className="flex items-center gap-3 p-3 rounded-lg border border-border/40 bg-secondary/10 hover:bg-secondary/20 transition-colors">
<div className={`w-8 h-8 rounded-md flex items-center justify-center border shrink-0 ${
stopped ? "bg-muted/30 border-border/30" : "bg-cyan-500/10 border-cyan-500/20"
}`}>
<Bot className={`w-4 h-4 ${stopped ? "text-muted-foreground" : "text-cyan-400"}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-semibold text-foreground truncate">{agent.name}</span>
{stopped ? (
<Badge className="text-[8px] h-3.5 px-1 bg-muted/50 text-muted-foreground border-border/40 shrink-0">stopped</Badge>
) : (
<Badge className="text-[8px] h-3.5 px-1 bg-green-500/10 text-green-400 border-green-500/30 shrink-0">
{agent.runningTasks}/{agent.desiredReplicas} running
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 text-[9px] font-mono text-muted-foreground">
<span className="truncate max-w-[140px]">{agent.image.split("/").pop()}</span>
{!stopped && (
<span className={`flex items-center gap-0.5 ${idleColor(idle)}`}>
<Clock className="w-2.5 h-2.5" />
{idle < 1 ? "active" : `${idle.toFixed(0)}m idle`}
</span>
)}
{!stopped && idle >= 13 && (
<span className="text-yellow-400 flex items-center gap-0.5">
<AlertTriangle className="w-2.5 h-2.5" />
auto-stop in ~{Math.max(0, 15 - Math.ceil(idle))}m
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{stopped ? (
<button
onClick={() => startMut.mutate({ name: agent.name })}
disabled={startMut.isPending}
className="flex items-center gap-1 text-[9px] font-mono px-2 py-1 rounded border border-green-500/30 text-green-400 hover:bg-green-500/10 transition-colors disabled:opacity-50"
>
{startMut.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-2.5 h-2.5" />}
Start
</button>
) : (
<button
onClick={() => stopMut.mutate({ name: agent.name })}
disabled={stopMut.isPending}
className="flex items-center gap-1 text-[9px] font-mono px-2 py-1 rounded border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors disabled:opacity-50"
>
{stopMut.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Square className="w-2.5 h-2.5" />}
Stop
</button>
)}
<button
onClick={() => removeMut.mutate({ serviceId: agent.id })}
disabled={removeMut.isPending}
className="p-1.5 rounded border border-red-500/20 text-red-400/60 hover:text-red-400 hover:border-red-500/40 hover:bg-red-500/10 transition-colors disabled:opacity-50"
title="Remove service permanently"
>
{removeMut.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Trash2 className="w-3 h-3" />}
</button>
</div>
</div>
);
}
// ─── Scale Dialog ─────────────────────────────────────────────────────────────
function ScaleDialog({
@@ -343,11 +491,9 @@ function ScaleDialog({
className="w-8 h-8 rounded border border-border/50 flex items-center justify-center hover:bg-secondary/50">
<Minus className="w-4 h-4" />
</button>
<Input
type="number" min={0} max={100} value={val}
<Input type="number" min={0} max={100} value={val}
onChange={(e) => setVal(parseInt(e.target.value) || 0)}
className="text-center font-mono font-bold text-lg h-10"
/>
className="text-center font-mono font-bold text-lg h-10" />
<button onClick={() => setVal(Math.min(100, val + 1))}
className="w-8 h-8 rounded border border-border/50 flex items-center justify-center hover:bg-secondary/50">
<Plus className="w-4 h-4" />
@@ -364,14 +510,55 @@ function ScaleDialog({
);
}
// ─── Remove Confirm Dialog ─────────────────────────────────────────────────────
function RemoveConfirmDialog({
serviceName, onClose, onConfirm, isPending,
}: {
serviceName: string; onClose: () => void; onConfirm: () => void; isPending: boolean;
}) {
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-card border border-red-500/30 rounded-lg p-6 w-80 shadow-2xl"
>
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-red-400" />
<h3 className="text-sm font-bold text-red-400">Remove Service</h3>
</div>
<p className="text-xs text-muted-foreground mb-1">
This will permanently delete the Swarm service and stop all its containers:
</p>
<code className="text-xs font-mono text-foreground/80 block mb-5">{serviceName}</code>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs">Cancel</Button>
<Button
onClick={onConfirm}
disabled={isPending}
className="flex-1 h-8 text-xs bg-red-500/15 text-red-400 border-red-500/30 hover:bg-red-500/25"
>
{isPending ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <Trash2 className="w-3 h-3 mr-1" />}
Remove
</Button>
</div>
</motion.div>
</div>
);
}
// ─── Deploy Agent Dialog ───────────────────────────────────────────────────────
function DeployAgentDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [name, setName] = useState("goclaw-agent");
const [image, setImage] = useState("goclaw-gateway:latest");
const [image, setImage] = useState("goclaw-agent:latest");
const [replicas, setReplicas] = useState(1);
const [port, setPort] = useState(0);
const [envStr, setEnvStr] = useState("AGENT_ROLE=worker\nLOG_LEVEL=info");
const [port, setPort] = useState(8080);
const [envStr, setEnvStr] = useState(
"AGENT_ID=my-agent\nIDLE_TIMEOUT_MINUTES=15\nGATEWAY_URL=http://goclaw-gateway:18789"
);
const [networks, setNetworks] = useState("goclaw-net");
const [error, setError] = useState("");
const deployMut = trpc.nodes.deployAgentService.useMutation({
@@ -382,7 +569,8 @@ function DeployAgentDialog({ onClose, onSuccess }: { onClose: () => void; onSucc
const handleDeploy = () => {
setError("");
const env = envStr.split("\n").map((l) => l.trim()).filter(Boolean);
deployMut.mutate({ name, image, replicas, env, port: port || undefined });
const nets = networks.split(",").map((n) => n.trim()).filter(Boolean);
deployMut.mutate({ name, image, replicas, env, port: port || undefined, networks: nets });
};
return (
@@ -390,7 +578,7 @@ function DeployAgentDialog({ onClose, onSuccess }: { onClose: () => void; onSucc
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-card border border-border rounded-lg p-6 w-full max-w-md shadow-2xl"
className="bg-card border border-border rounded-lg p-6 w-full max-w-md shadow-2xl overflow-y-auto max-h-[90vh]"
>
<div className="flex items-center gap-2 mb-4">
<Rocket className="w-4 h-4 text-cyan-400" />
@@ -406,15 +594,15 @@ function DeployAgentDialog({ onClose, onSuccess }: { onClose: () => void; onSucc
<div>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">Docker Image</label>
<Input value={image} onChange={(e) => setImage(e.target.value)}
placeholder="goclaw-gateway:latest" className="h-8 text-xs font-mono" />
placeholder="goclaw-agent:latest" className="h-8 text-xs font-mono" />
<p className="text-[9px] text-muted-foreground/60 mt-0.5">
Use the gateway image or a custom agent image
Build with: <code className="text-foreground/50">docker build -f docker/Dockerfile.agent -t goclaw-agent:latest .</code>
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">Replicas</label>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<button onClick={() => setReplicas(Math.max(1, replicas - 1))}
className="w-7 h-7 rounded border border-border/50 flex items-center justify-center hover:bg-secondary/50 shrink-0">
<Minus className="w-3 h-3" />
@@ -429,12 +617,20 @@ function DeployAgentDialog({ onClose, onSuccess }: { onClose: () => void; onSucc
</div>
</div>
<div>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">Published Port (0=none)</label>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">Port (0=none)</label>
<Input type="number" min={0} max={65535} value={port}
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
className="h-7 text-xs font-mono" />
</div>
</div>
<div>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">Overlay Networks (comma-separated)</label>
<Input value={networks} onChange={(e) => setNetworks(e.target.value)}
placeholder="goclaw-net" className="h-8 text-xs font-mono" />
<p className="text-[9px] text-muted-foreground/60 mt-0.5">
Connect to Swarm overlay network so agents can reach Gateway &amp; DB by DNS
</p>
</div>
<div>
<label className="text-[10px] font-mono text-muted-foreground mb-1 block">Environment Variables (KEY=VALUE, one per line)</label>
<textarea
@@ -445,14 +641,12 @@ function DeployAgentDialog({ onClose, onSuccess }: { onClose: () => void; onSucc
placeholder="DATABASE_URL=...\nLLM_BASE_URL=https://ollama.com/v1"
/>
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-[10px] font-mono">
<AlertTriangle className="w-3 h-3 shrink-0" /> {error}
</div>
)}
</div>
<div className="flex gap-2 mt-5">
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs">Cancel</Button>
<Button
@@ -460,11 +654,10 @@ function DeployAgentDialog({ onClose, onSuccess }: { onClose: () => void; onSucc
disabled={!name || !image || deployMut.isPending}
className="flex-1 h-8 text-xs bg-cyan-500/15 text-cyan-400 border-cyan-500/30 hover:bg-cyan-500/25"
>
{deployMut.isPending ? (
<><Loader2 className="w-3 h-3 animate-spin mr-1" />Deploying</>
) : (
<><Rocket className="w-3 h-3 mr-1" />Deploy {replicas} replica{replicas > 1 ? "s" : ""}</>
)}
{deployMut.isPending
? <><Loader2 className="w-3 h-3 animate-spin mr-1" />Deploying</>
: <><Rocket className="w-3 h-3 mr-1" />Deploy {replicas} replica{replicas > 1 ? "s" : ""}</>
}
</Button>
</div>
</motion.div>
@@ -478,9 +671,8 @@ function TasksDrawer({ serviceId, serviceName, onClose }: {
serviceId: string; serviceName: string; onClose: () => void;
}) {
const tasksQ = trpc.nodes.serviceTasks.useQuery({ serviceId }, { refetchInterval: 5000 });
const tasks = tasksQ.data?.tasks ?? [];
const tasks = tasksQ.data?.tasks ?? [];
const running = tasks.filter((t) => t.state === "running").length;
return (
<div className="fixed inset-0 bg-black/60 flex items-end sm:items-center justify-center z-50">
<motion.div
@@ -511,8 +703,8 @@ function TasksDrawer({ serviceId, serviceName, onClose }: {
{tasks.map((task) => (
<div key={task.id} className="flex items-center gap-3 p-2.5 rounded border border-border/40 bg-secondary/10">
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${
task.state === "running" ? "bg-green-400" :
task.state === "failed" ? "bg-red-400" : "bg-yellow-400"
task.state === "running" ? "bg-green-400" :
task.state === "failed" ? "bg-red-400" : "bg-yellow-400"
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -546,60 +738,42 @@ function HostShell() {
const [cmd, setCmd] = useState("");
const [history, setHistory] = useState<Array<{ cmd: string; out: string; ok: boolean; ts: string }>>([]);
const shellMutation = trpc.nodes.execShell.useMutation();
const quickCmds = [
"docker node ls", "docker service ls", "docker service ps goclaw-gateway",
"uname -a", "df -h", "free -h", "docker ps",
];
const run = useCallback(async () => {
if (!cmd.trim()) return;
const c = cmd.trim();
const result = await shellMutation.mutateAsync({ command: cmd });
setHistory((h) => [
...h,
{ cmd, out: (result as any)?.output ?? "", ok: (result as any)?.success ?? false, ts: new Date().toLocaleTimeString() },
]);
setCmd("");
try {
const res = await shellMutation.mutateAsync({ command: c });
setHistory((h) => [...h, { cmd: c, out: res.output, ok: res.success, ts: new Date().toLocaleTimeString() }]);
} catch (e: any) {
setHistory((h) => [...h, { cmd: c, out: e.message, ok: false, ts: new Date().toLocaleTimeString() }]);
}
}, [cmd, shellMutation]);
const quickCmds = [
"docker node ls",
"docker service ls",
"docker ps",
"docker stats --no-stream",
"uname -a",
"df -h",
"free -h",
"docker swarm join-token worker",
];
return (
<Card className="bg-card border-border/50">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-cyan-400" />
<h3 className="text-sm font-bold">Host Shell</h3>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2 mb-1">
<Terminal className="w-4 h-4 text-green-400" />
<span className="text-xs font-bold">Host Shell</span>
<Badge variant="outline" className="text-[9px] font-mono">nsenter PID 1</Badge>
<span className="text-[10px] text-muted-foreground ml-auto">Runs on the HOST system</span>
<span className="text-[9px] text-muted-foreground font-mono ml-auto">privileged · host PID namespace</span>
</div>
<div className="bg-black/40 rounded-md p-3 font-mono text-[10px] max-h-64 overflow-y-auto space-y-2">
{history.length === 0 ? (
<span className="text-muted-foreground/50">No output yet run a command below</span>
) : history.map((h, i) => (
<div key={i}>
<div className="text-cyan-400">$ {h.cmd} <span className="text-muted-foreground/40 text-[9px]">{h.ts}</span></div>
<pre className={`whitespace-pre-wrap break-all mt-0.5 ${h.ok ? "text-green-300/80" : "text-red-300/80"}`}>{h.out || "(no output)"}</pre>
</div>
))}
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* History */}
{history.length > 0 && (
<div className="bg-background/50 rounded border border-border/30 p-3 max-h-80 overflow-y-auto space-y-3 font-mono text-[11px]">
{history.map((h, i) => (
<div key={i}>
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-cyan-400">$</span>
<span className="text-foreground">{h.cmd}</span>
<span className="ml-auto text-muted-foreground/40">{h.ts}</span>
</div>
<pre className={`whitespace-pre-wrap break-all pl-4 ${h.ok ? "text-foreground/80" : "text-red-400"}`}>
{h.out || "(no output)"}
</pre>
</div>
))}
</div>
)}
{/* Input */}
<div className="flex items-center gap-2">
<span className="text-cyan-400 font-mono shrink-0">$</span>
<Input
@@ -620,14 +794,10 @@ function HostShell() {
</Button>
</div>
{/* Quick commands */}
<div className="flex gap-1.5 flex-wrap">
{quickCmds.map((c) => (
<button
key={c}
onClick={() => setCmd(c)}
className="text-[9px] font-mono px-2 py-0.5 rounded border border-border/30 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors"
>
<button key={c} onClick={() => setCmd(c)}
className="text-[9px] font-mono px-2 py-0.5 rounded border border-border/30 text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors">
{c}
</button>
))}
@@ -641,8 +811,7 @@ function HostShell() {
function JoinTokenCard({ role }: { role: "worker" | "manager" }) {
const tokenQ = trpc.nodes.joinToken.useQuery({ role });
const data = tokenQ.data;
const data = tokenQ.data;
return (
<Card className="bg-card border-border/50">
<CardContent className="p-4">
@@ -684,23 +853,28 @@ function JoinTokenCard({ role }: { role: "worker" | "manager" }) {
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function Nodes() {
const [activeTab, setActiveTab] = useState<"nodes" | "services" | "shell">("nodes");
const [activeTab, setActiveTab] = useState<"nodes" | "services" | "agents" | "shell">("nodes");
const [scaleTarget, setScaleTarget] = useState<{ id: string; current: number } | null>(null);
const [removeTarget, setRemoveTarget] = useState<{ id: string; name: string } | null>(null);
const [tasksTarget, setTasksTarget] = useState<{ id: string; name: string } | null>(null);
const [showDeploy, setShowDeploy] = useState(false);
const swarmInfoQ = trpc.nodes.swarmInfo.useQuery(undefined, { refetchInterval: 15000 });
const nodesQ = trpc.nodes.list.useQuery(undefined, { refetchInterval: 10000 });
const servicesQ = trpc.nodes.services.useQuery(undefined, { refetchInterval: 10000 });
const scaleMutation = trpc.nodes.scaleService.useMutation();
const nodesQ = trpc.nodes.list.useQuery(undefined, { refetchInterval: 10000 });
const servicesQ = trpc.nodes.services.useQuery(undefined, { refetchInterval: 10000 });
const agentsQ = trpc.nodes.listAgents.useQuery(undefined, { refetchInterval: 10000 });
const scaleMutation = trpc.nodes.scaleService.useMutation();
const removeMutation = trpc.nodes.removeService.useMutation();
const swarmInfo = swarmInfoQ.data;
const nodes = (nodesQ.data as any)?.nodes ?? [];
const services = (servicesQ.data as any)?.services ?? [];
const swarmOk = !!swarmInfo;
const nodes = (nodesQ.data as any)?.nodes ?? [];
const services = (servicesQ.data as any)?.services ?? [];
const agents = (agentsQ.data as any)?.agents ?? [];
// Swarm is truly active when we get a valid nodeId back
const swarmOk = !!swarmInfo?.nodeId;
const handleScale = (id: string, current: number) => setScaleTarget({ id, current });
const confirmScale = async (n: number) => {
if (!scaleTarget) return;
await scaleMutation.mutateAsync({ serviceId: scaleTarget.id, replicas: n });
@@ -708,12 +882,22 @@ export default function Nodes() {
servicesQ.refetch();
};
const runningTasks = services.reduce((s: number, sv: ServiceInfo) => s + sv.runningTasks, 0);
const confirmRemove = async () => {
if (!removeTarget) return;
await removeMutation.mutateAsync({ serviceId: removeTarget.id });
setRemoveTarget(null);
servicesQ.refetch();
agentsQ.refetch();
};
const runningTasks = services.reduce((s: number, sv: ServiceInfo) => s + sv.runningTasks, 0);
const runningAgents = agents.filter((a: AgentServiceInfo) => a.desiredReplicas > 0).length;
const tabs = [
{ id: "nodes" as const, label: "Nodes", icon: Server, count: nodes.length },
{ id: "services" as const, label: "Services", icon: Layers, count: services.length },
{ id: "shell" as const, label: "Host Shell", icon: Terminal },
{ id: "nodes" as const, label: "Nodes", icon: Server, count: nodes.length },
{ id: "services" as const, label: "Services", icon: Layers, count: services.length },
{ id: "agents" as const, label: "Agents", icon: Bot, count: runningAgents },
{ id: "shell" as const, label: "Host Shell", icon: Terminal },
];
return (
@@ -730,24 +914,24 @@ export default function Nodes() {
{" · "}{swarmInfo.managers} manager{swarmInfo.managers !== 1 ? "s" : ""}
{swarmInfo.managerAddr ? ` · ${swarmInfo.managerAddr}` : ""}
</>
) : swarmInfoQ.isLoading ? (
<span className="text-yellow-400"> connecting</span>
) : (
<span className="text-yellow-400"> connecting to swarm</span>
<span className="text-red-400"> swarm unreachable check gateway</span>
)}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
variant="outline" size="sm"
onClick={() => setShowDeploy(true)}
className="h-8 gap-1.5 text-xs text-cyan-400 border-cyan-500/40 hover:bg-cyan-500/10"
>
<Rocket className="w-3.5 h-3.5" /> Deploy Agent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { nodesQ.refetch(); servicesQ.refetch(); swarmInfoQ.refetch(); }}
variant="outline" size="sm"
onClick={() => { nodesQ.refetch(); servicesQ.refetch(); swarmInfoQ.refetch(); agentsQ.refetch(); }}
className="h-8 gap-1.5 text-xs"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
@@ -755,14 +939,21 @@ export default function Nodes() {
</div>
</div>
{/* ── Swarm Status Banner ───────────────────────────────────────────── */}
<SwarmStatusBanner
isLoading={swarmInfoQ.isLoading}
isError={swarmInfoQ.isError}
swarmInfo={swarmInfo}
/>
{/* ── Swarm overview cards ──────────────────────────────────────────── */}
{swarmOk && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "Total Nodes", value: swarmInfo.nodes, icon: Server, color: "text-cyan-400" },
{ label: "Managers", value: swarmInfo.managers, icon: Shield, color: "text-purple-400" },
{ label: "Services", value: services.length, icon: Layers, color: "text-green-400" },
{ label: "Running Tasks", value: runningTasks, icon: Activity, color: "text-yellow-400" },
{ label: "Total Nodes", value: swarmInfo.nodes, icon: Server, color: "text-cyan-400" },
{ label: "Managers", value: swarmInfo.managers, icon: Shield, color: "text-purple-400" },
{ label: "Services", value: services.length, icon: Layers, color: "text-green-400" },
{ label: "Running Tasks", value: runningTasks, icon: Activity, color: "text-yellow-400" },
].map((stat) => (
<Card key={stat.label} className="bg-card border-border/50">
<CardContent className="p-3">
@@ -808,7 +999,6 @@ export default function Nodes() {
{/* ── Tab content ───────────────────────────────────────────────────── */}
<AnimatePresence mode="wait">
{/* NODES tab */}
{activeTab === "nodes" && (
<motion.div key="nodes" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
@@ -820,15 +1010,14 @@ export default function Nodes() {
<div className="text-center py-12 text-muted-foreground">
<Server className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">No nodes found</p>
<p className="text-xs font-mono text-muted-foreground/60 mt-1">
{swarmInfoQ.isError ? "Cannot connect to Swarm — check gateway logs" : "Swarm has no registered nodes yet"}
</p>
</div>
) : (
<div className="space-y-3">
{nodes.map((node: NodeInfo) => (
<NodeCard
key={node.id}
node={node}
onRefresh={() => nodesQ.refetch()}
/>
<NodeCard key={node.id} node={node} onRefresh={() => nodesQ.refetch()} />
))}
</div>
)}
@@ -840,15 +1029,13 @@ export default function Nodes() {
<motion.div key="services" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<div className="flex justify-end mb-3">
<Button
variant="outline"
size="sm"
variant="outline" size="sm"
onClick={() => setShowDeploy(true)}
className="h-7 gap-1.5 text-xs text-cyan-400 border-cyan-500/40 hover:bg-cyan-500/10"
>
<Plus className="w-3.5 h-3.5" /> Deploy new service
</Button>
</div>
{servicesQ.isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm p-8 justify-center">
<Loader2 className="w-4 h-4 animate-spin" /> Loading services
@@ -857,10 +1044,8 @@ export default function Nodes() {
<div className="text-center py-12 text-muted-foreground">
<Layers className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm mb-2">No swarm services running</p>
<p className="text-xs font-mono mb-4">Deploy agents as services to scale them across nodes</p>
<Button
variant="outline"
size="sm"
variant="outline" size="sm"
onClick={() => setShowDeploy(true)}
className="gap-1.5 text-xs text-cyan-400 border-cyan-500/40 hover:bg-cyan-500/10"
>
@@ -888,6 +1073,7 @@ export default function Nodes() {
svc={svc}
onScale={handleScale}
onViewTasks={(id, name) => setTasksTarget({ id, name })}
onRemove={(id, name) => setRemoveTarget({ id, name })}
/>
))}
</tbody>
@@ -899,6 +1085,62 @@ export default function Nodes() {
</motion.div>
)}
{/* AGENTS tab */}
{activeTab === "agents" && (
<motion.div key="agents" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<div className="flex items-center justify-between mb-3">
<div className="text-[10px] font-mono text-muted-foreground flex items-center gap-2">
<Clock className="w-3 h-3" />
Auto-stop after 15 min idle (managed by SwarmManager)
</div>
<Button
variant="outline" size="sm"
onClick={() => setShowDeploy(true)}
className="h-7 gap-1.5 text-xs text-cyan-400 border-cyan-500/40 hover:bg-cyan-500/10"
>
<Plus className="w-3.5 h-3.5" /> New agent
</Button>
</div>
{agentsQ.isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm p-8 justify-center">
<Loader2 className="w-4 h-4 animate-spin" /> Loading agents
</div>
) : agents.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Bot className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm mb-1">No agent services deployed</p>
<p className="text-xs font-mono text-muted-foreground/60 mb-4">
Deploy agents as Swarm services to run them across nodes
</p>
<Button
variant="outline" size="sm"
onClick={() => setShowDeploy(true)}
className="gap-1.5 text-xs text-cyan-400 border-cyan-500/40 hover:bg-cyan-500/10"
>
<Rocket className="w-3.5 h-3.5" /> Deploy first agent
</Button>
</div>
) : (
<div className="space-y-2">
{/* Legend */}
<div className="flex items-center gap-4 px-1 pb-1 text-[9px] font-mono text-muted-foreground/60">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-400" />active</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-yellow-400" />idle 5-10m</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-400" />idle 10m+ stopping soon</span>
</div>
{agents.map((agent: AgentServiceInfo) => (
<AgentManagerRow
key={agent.id}
agent={agent}
onRefresh={() => { agentsQ.refetch(); servicesQ.refetch(); }}
/>
))}
</div>
)}
</motion.div>
)}
{/* SHELL tab */}
{activeTab === "shell" && (
<motion.div key="shell" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
@@ -908,7 +1150,6 @@ export default function Nodes() {
</AnimatePresence>
{/* ── Dialogs / drawers ─────────────────────────────────────────────── */}
{scaleTarget && (
<ScaleDialog
serviceId={scaleTarget.id}
@@ -917,7 +1158,14 @@ export default function Nodes() {
onConfirm={confirmScale}
/>
)}
{removeTarget && (
<RemoveConfirmDialog
serviceName={removeTarget.name}
onClose={() => setRemoveTarget(null)}
onConfirm={confirmRemove}
isPending={removeMutation.isPending}
/>
)}
{tasksTarget && (
<TasksDrawer
serviceId={tasksTarget.id}
@@ -925,11 +1173,10 @@ export default function Nodes() {
onClose={() => setTasksTarget(null)}
/>
)}
{showDeploy && (
<DeployAgentDialog
onClose={() => setShowDeploy(false)}
onSuccess={() => servicesQ.refetch()}
onSuccess={() => { servicesQ.refetch(); agentsQ.refetch(); }}
/>
)}
</div>