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:
@@ -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 & 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>
|
||||
|
||||
53
docker/Dockerfile.agent
Normal file
53
docker/Dockerfile.agent
Normal file
@@ -0,0 +1,53 @@
|
||||
# ── GoClaw Agent Container ────────────────────────────────────────────────────
|
||||
#
|
||||
# Autonomous agent microservice that:
|
||||
# 1. Exposes a lightweight HTTP API (port 8080) for receiving tasks
|
||||
# 2. Has access to the Swarm overlay network (goclaw-net)
|
||||
# 3. Connects to the shared MySQL database for persistence
|
||||
# 4. Calls the LLM API via the GoClaw Gateway
|
||||
# 5. Auto-registers itself with the orchestrator on startup
|
||||
#
|
||||
# Build: docker build -f docker/Dockerfile.agent -t goclaw-agent:latest .
|
||||
# Deploy: docker service create --name goclaw-agent-NAME \
|
||||
# --network goclaw-net \
|
||||
# -e AGENT_ID=NAME \
|
||||
# -e GATEWAY_URL=http://goclaw-gateway:18789 \
|
||||
# -e DATABASE_URL=mysql://... \
|
||||
# goclaw-agent:latest
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Stage 1: Build Go agent binary ───────────────────────────────────────────
|
||||
FROM golang:1.23-alpine AS builder
|
||||
WORKDIR /src
|
||||
|
||||
# Copy gateway module (agent reuses gateway internals)
|
||||
COPY gateway/go.mod gateway/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY gateway/ ./
|
||||
|
||||
# Build the agent server binary
|
||||
RUN go build -o /agent-server ./cmd/agent/...
|
||||
|
||||
# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates curl wget tzdata
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /agent-server ./agent-server
|
||||
|
||||
# Default environment (override at deploy time)
|
||||
ENV AGENT_ID=default-agent \
|
||||
AGENT_PORT=8080 \
|
||||
GATEWAY_URL=http://goclaw-gateway:18789 \
|
||||
LLM_BASE_URL=https://ollama.com/v1 \
|
||||
LLM_API_KEY="" \
|
||||
DATABASE_URL="" \
|
||||
IDLE_TIMEOUT_MINUTES=15
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8080/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/agent-server"]
|
||||
270
gateway/cmd/agent/main.go
Normal file
270
gateway/cmd/agent/main.go
Normal file
@@ -0,0 +1,270 @@
|
||||
// GoClaw Agent Server — autonomous agent microservice
|
||||
//
|
||||
// Each agent runs as an independent container in the Docker Swarm overlay
|
||||
// network. It exposes an HTTP API that the GoClaw Orchestrator can reach
|
||||
// via the Swarm DNS name (e.g. http://goclaw-agent-researcher:8080).
|
||||
//
|
||||
// The agent:
|
||||
// - Receives task requests from the orchestrator
|
||||
// - Calls the LLM via the centrally-managed GoClaw Gateway
|
||||
// - Reads/writes shared state in the MySQL database
|
||||
// - Reports its last-activity time so the SwarmManager can auto-stop it
|
||||
// - Gracefully shuts down after IdleTimeout with no requests
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type AgentConfig struct {
|
||||
AgentID string
|
||||
Port string
|
||||
GatewayURL string
|
||||
LLMURL string
|
||||
LLMAPIKey string
|
||||
DatabaseURL string
|
||||
IdleTimeoutMinutes int
|
||||
}
|
||||
|
||||
func loadConfig() AgentConfig {
|
||||
idleMin := 15
|
||||
if v := os.Getenv("IDLE_TIMEOUT_MINUTES"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
idleMin = n
|
||||
}
|
||||
}
|
||||
port := os.Getenv("AGENT_PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
return AgentConfig{
|
||||
AgentID: getEnv("AGENT_ID", "unnamed-agent"),
|
||||
Port: port,
|
||||
GatewayURL: getEnv("GATEWAY_URL", "http://goclaw-gateway:18789"),
|
||||
LLMURL: getEnv("LLM_BASE_URL", "https://ollama.com/v1"),
|
||||
LLMAPIKey: os.Getenv("LLM_API_KEY"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
IdleTimeoutMinutes: idleMin,
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Agent struct {
|
||||
cfg AgentConfig
|
||||
lastActivity time.Time
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewAgent(cfg AgentConfig) *Agent {
|
||||
return &Agent{
|
||||
cfg: cfg,
|
||||
lastActivity: time.Now(),
|
||||
httpClient: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) touch() {
|
||||
a.lastActivity = time.Now()
|
||||
}
|
||||
|
||||
// ─── HTTP handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /health — liveness probe
|
||||
func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"agentId": a.cfg.AgentID,
|
||||
"lastActivity": a.lastActivity.Format(time.RFC3339),
|
||||
"idleMinutes": time.Since(a.lastActivity).Minutes(),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /task — receive a task from the orchestrator
|
||||
// Body: { "sessionId": "abc", "messages": [...], "model": "qwen2.5:7b", "maxIter": 5 }
|
||||
func (a *Agent) handleTask(w http.ResponseWriter, r *http.Request) {
|
||||
a.touch()
|
||||
var body struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Messages json.RawMessage `json:"messages"`
|
||||
Model string `json:"model"`
|
||||
MaxIter int `json:"maxIter"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respondError(w, 400, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Forward the task to the GoClaw Gateway orchestrator
|
||||
gatewayURL := a.cfg.GatewayURL + "/api/orchestrator/chat"
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"messages": body.Messages,
|
||||
"model": body.Model,
|
||||
"maxIter": body.MaxIter,
|
||||
})
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), "POST", gatewayURL, strings.NewReader(string(reqBody)))
|
||||
if err != nil {
|
||||
respondError(w, 500, "request build error: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
respondError(w, 502, "gateway error: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
respondError(w, 502, "gateway response error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
a.touch()
|
||||
respond(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"agentId": a.cfg.AgentID,
|
||||
"sessionId": body.SessionID,
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /info — agent metadata
|
||||
func (a *Agent) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
hostname, _ := os.Hostname()
|
||||
respond(w, 200, map[string]any{
|
||||
"agentId": a.cfg.AgentID,
|
||||
"hostname": hostname,
|
||||
"gatewayUrl": a.cfg.GatewayURL,
|
||||
"idleTimeout": a.cfg.IdleTimeoutMinutes,
|
||||
"lastActivity": a.lastActivity.Format(time.RFC3339),
|
||||
"idleMinutes": time.Since(a.lastActivity).Minutes(),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Idle watchdog ────────────────────────────────────────────────────────────
|
||||
|
||||
func (a *Agent) runIdleWatchdog(cancel context.CancelFunc) {
|
||||
threshold := time.Duration(a.cfg.IdleTimeoutMinutes) * time.Minute
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
idle := time.Since(a.lastActivity)
|
||||
if idle >= threshold {
|
||||
log.Printf("[Agent %s] Idle for %.1f min — requesting self-stop via gateway",
|
||||
a.cfg.AgentID, idle.Minutes())
|
||||
a.selfStop()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// selfStop asks the GoClaw Gateway to scale this service to 0.
|
||||
func (a *Agent) selfStop() {
|
||||
url := fmt.Sprintf("%s/api/swarm/agents/%s/stop", a.cfg.GatewayURL, a.cfg.AgentID)
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
if err != nil {
|
||||
log.Printf("[Agent %s] selfStop error building request: %v", a.cfg.AgentID, err)
|
||||
return
|
||||
}
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[Agent %s] selfStop error: %v", a.cfg.AgentID, err)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
log.Printf("[Agent %s] selfStop response %d: %s", a.cfg.AgentID, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func respond(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func respondError(w http.ResponseWriter, status int, msg string) {
|
||||
respond(w, status, map[string]any{"error": msg})
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
|
||||
cfg := loadConfig()
|
||||
agent := NewAgent(cfg)
|
||||
|
||||
log.Printf("[Agent] %s starting on port %s (idle timeout: %d min)",
|
||||
cfg.AgentID, cfg.Port, cfg.IdleTimeoutMinutes)
|
||||
log.Printf("[Agent] Gateway: %s", cfg.GatewayURL)
|
||||
|
||||
// ── HTTP server ──────────────────────────────────────────────────────────
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", agent.handleHealth)
|
||||
mux.HandleFunc("POST /task", agent.handleTask)
|
||||
mux.HandleFunc("GET /info", agent.handleInfo)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 150 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// ── Idle watchdog ────────────────────────────────────────────────────────
|
||||
go agent.runIdleWatchdog(cancel)
|
||||
|
||||
// ── Graceful shutdown ────────────────────────────────────────────────────
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
log.Printf("[Agent %s] Listening on :%s", cfg.AgentID, cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[Agent %s] Server error: %v", cfg.AgentID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-quit:
|
||||
log.Printf("[Agent %s] Signal received — shutting down", cfg.AgentID)
|
||||
case <-ctx.Done():
|
||||
log.Printf("[Agent %s] Context cancelled — shutting down", cfg.AgentID)
|
||||
}
|
||||
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutCancel()
|
||||
if err := srv.Shutdown(shutCtx); err != nil {
|
||||
log.Printf("[Agent %s] Shutdown error: %v", cfg.AgentID, err)
|
||||
}
|
||||
log.Printf("[Agent %s] Stopped.", cfg.AgentID)
|
||||
}
|
||||
@@ -135,12 +135,22 @@ func main() {
|
||||
r.Post("/swarm/nodes/{id}/availability", h.SwarmSetNodeAvailability)
|
||||
r.Get("/swarm/services", h.SwarmServices)
|
||||
r.Post("/swarm/services/create", h.SwarmCreateService)
|
||||
r.Delete("/swarm/services/{id}", h.SwarmRemoveService)
|
||||
r.Get("/swarm/services/{id}/tasks", h.SwarmServiceTasks)
|
||||
r.Post("/swarm/services/{id}/scale", h.SwarmScaleService)
|
||||
r.Get("/swarm/join-token", h.SwarmJoinToken)
|
||||
r.Post("/swarm/shell", h.SwarmShell)
|
||||
r.Get("/swarm/agents", h.SwarmListAgents)
|
||||
r.Post("/swarm/agents/{name}/start", h.SwarmStartAgent)
|
||||
r.Post("/swarm/agents/{name}/stop", h.SwarmStopAgent)
|
||||
})
|
||||
|
||||
// ── Swarm Manager: auto-stop idle agents after 15 min ────────────────────
|
||||
swarmMgr := api.NewSwarmManager(h, 60*time.Second)
|
||||
managerCtx, managerCancel := context.WithCancel(context.Background())
|
||||
go swarmMgr.Start(managerCtx)
|
||||
defer managerCancel()
|
||||
|
||||
// ── Start Server ─────────────────────────────────────────────────────────
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
|
||||
@@ -1223,7 +1223,7 @@ func (h *Handler) SwarmScaleService(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// POST /api/swarm/services/create
|
||||
// Deploy a new GoClaw agent as a Swarm service.
|
||||
// Body: { "name": "agent-researcher", "image": "goclaw-gateway:latest", "replicas": 2, "env": ["KEY=val"], "port": 0 }
|
||||
// Body: { "name": "agent-researcher", "image": "goclaw-gateway:latest", "replicas": 2, "env": ["KEY=val"], "port": 0, "networks": ["goclaw-net"] }
|
||||
func (h *Handler) SwarmCreateService(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
@@ -1231,6 +1231,7 @@ func (h *Handler) SwarmCreateService(w http.ResponseWriter, r *http.Request) {
|
||||
Replicas int `json:"replicas"`
|
||||
Env []string `json:"env"`
|
||||
Port int `json:"port"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" || body.Image == "" {
|
||||
respondError(w, http.StatusBadRequest, "name and image required")
|
||||
@@ -1239,7 +1240,14 @@ func (h *Handler) SwarmCreateService(w http.ResponseWriter, r *http.Request) {
|
||||
if body.Replicas <= 0 {
|
||||
body.Replicas = 1
|
||||
}
|
||||
svc, err := h.docker.CreateAgentService(body.Name, body.Image, body.Replicas, body.Env, body.Port)
|
||||
svc, err := h.docker.CreateAgentServiceFull(dockerclient.CreateAgentServiceOpts{
|
||||
Name: body.Name,
|
||||
Image: body.Image,
|
||||
Replicas: body.Replicas,
|
||||
Env: body.Env,
|
||||
Port: body.Port,
|
||||
Networks: body.Networks,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "create service: "+err.Error())
|
||||
return
|
||||
@@ -1251,6 +1259,76 @@ func (h *Handler) SwarmCreateService(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE /api/swarm/services/{id}
|
||||
// Remove (stop) a swarm service.
|
||||
func (h *Handler) SwarmRemoveService(w http.ResponseWriter, r *http.Request) {
|
||||
serviceID := r.PathValue("id")
|
||||
if serviceID == "" {
|
||||
respondError(w, http.StatusBadRequest, "service id required")
|
||||
return
|
||||
}
|
||||
if err := h.docker.RemoveService(serviceID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "remove service: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[Swarm] Removed service %s", serviceID)
|
||||
respond(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/swarm/agents
|
||||
// List all GoClaw agent services with idle time information.
|
||||
func (h *Handler) SwarmListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
services, err := h.docker.ListServices()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "list services: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
type AgentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
DesiredReplicas int `json:"desiredReplicas"`
|
||||
RunningTasks int `json:"runningTasks"`
|
||||
LastActivity time.Time `json:"lastActivity"`
|
||||
IdleMinutes float64 `json:"idleMinutes"`
|
||||
IsGoClaw bool `json:"isGoClaw"`
|
||||
}
|
||||
|
||||
var agents []AgentInfo
|
||||
for _, svc := range services {
|
||||
isGoClaw := svc.Spec.Labels["goclaw.agent"] == "true"
|
||||
desired := 0
|
||||
if svc.Spec.Mode.Replicated != nil {
|
||||
desired = svc.Spec.Mode.Replicated.Replicas
|
||||
}
|
||||
running := 0
|
||||
if svc.ServiceStatus != nil {
|
||||
running = svc.ServiceStatus.RunningTasks
|
||||
}
|
||||
lastActivity, _ := h.docker.GetServiceLastActivity(svc.ID)
|
||||
if lastActivity.IsZero() {
|
||||
lastActivity = svc.UpdatedAt
|
||||
}
|
||||
idle := time.Since(lastActivity).Minutes()
|
||||
agents = append(agents, AgentInfo{
|
||||
ID: svc.ID,
|
||||
Name: svc.Spec.Name,
|
||||
Image: svc.Spec.TaskTemplate.ContainerSpec.Image,
|
||||
DesiredReplicas: desired,
|
||||
RunningTasks: running,
|
||||
LastActivity: lastActivity,
|
||||
IdleMinutes: idle,
|
||||
IsGoClaw: isGoClaw,
|
||||
})
|
||||
}
|
||||
if agents == nil {
|
||||
agents = []AgentInfo{}
|
||||
}
|
||||
respond(w, http.StatusOK, map[string]any{"agents": agents, "count": len(agents)})
|
||||
}
|
||||
|
||||
|
||||
// POST /api/swarm/shell
|
||||
// Execute a shell command on the HOST system (via nsenter into PID 1).
|
||||
// Body: { "command": "docker ps" }
|
||||
|
||||
142
gateway/internal/api/swarm_manager.go
Normal file
142
gateway/internal/api/swarm_manager.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Package api – Swarm Agent Lifecycle Manager
|
||||
//
|
||||
// The SwarmManager runs as a background goroutine inside the GoClaw Gateway
|
||||
// (which is the Swarm manager node). It watches all agent services and
|
||||
// automatically scales them to 0 replicas after IdleTimeout minutes of no
|
||||
// activity. The orchestrator can call StartAgent / StopAgent via the REST API
|
||||
// to start/stop agents on demand.
|
||||
//
|
||||
// Start flow: POST /api/swarm/agents/{name}/start → scale to N replicas (default 1)
|
||||
// Stop flow: POST /api/swarm/agents/{name}/stop → scale to 0
|
||||
// Auto-stop: background loop checks every 60 s, scales idle agents to 0
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// IdleTimeout – how many minutes without any task updates before an agent
|
||||
// is automatically scaled to 0.
|
||||
defaultIdleTimeoutMinutes = 15
|
||||
)
|
||||
|
||||
// SwarmManager watches agent services and auto-scales them down after idle.
|
||||
type SwarmManager struct {
|
||||
handler *Handler
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewSwarmManager creates a manager that checks every checkInterval.
|
||||
func NewSwarmManager(h *Handler, checkInterval time.Duration) *SwarmManager {
|
||||
return &SwarmManager{
|
||||
handler: h,
|
||||
ticker: time.NewTicker(checkInterval),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the background loop. Call in a goroutine.
|
||||
func (m *SwarmManager) Start(ctx context.Context) {
|
||||
log.Printf("[SwarmManager] Started — idle timeout %d min, check every %s",
|
||||
defaultIdleTimeoutMinutes, m.ticker)
|
||||
defer m.ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-m.done:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.ticker.C:
|
||||
m.checkIdleAgents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals the background loop to exit.
|
||||
func (m *SwarmManager) Stop() {
|
||||
close(m.done)
|
||||
}
|
||||
|
||||
func (m *SwarmManager) checkIdleAgents() {
|
||||
services, err := m.handler.docker.ListServices()
|
||||
if err != nil {
|
||||
log.Printf("[SwarmManager] list services error: %v", err)
|
||||
return
|
||||
}
|
||||
idleThreshold := time.Duration(defaultIdleTimeoutMinutes) * time.Minute
|
||||
now := time.Now()
|
||||
for _, svc := range services {
|
||||
// Only manage services labelled as GoClaw agents
|
||||
if svc.Spec.Labels["goclaw.agent"] != "true" {
|
||||
continue
|
||||
}
|
||||
// Skip already-stopped services (0 desired replicas)
|
||||
desired := 0
|
||||
if svc.Spec.Mode.Replicated != nil {
|
||||
desired = svc.Spec.Mode.Replicated.Replicas
|
||||
}
|
||||
if desired == 0 {
|
||||
continue
|
||||
}
|
||||
// Check last activity time
|
||||
lastActivity, err := m.handler.docker.GetServiceLastActivity(svc.ID)
|
||||
if err != nil || lastActivity.IsZero() {
|
||||
lastActivity = svc.UpdatedAt
|
||||
}
|
||||
idle := now.Sub(lastActivity)
|
||||
if idle >= idleThreshold {
|
||||
log.Printf("[SwarmManager] Agent '%s' idle for %.1f min → scaling to 0",
|
||||
svc.Spec.Name, idle.Minutes())
|
||||
if err := m.handler.docker.ScaleService(svc.ID, 0); err != nil {
|
||||
log.Printf("[SwarmManager] scale-to-0 error for %s: %v", svc.Spec.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP Handlers for agent lifecycle ────────────────────────────────────────
|
||||
|
||||
// POST /api/swarm/agents/{name}/start
|
||||
// Start (scale-up) a named agent service. Body: { "replicas": 1 }
|
||||
func (h *Handler) SwarmStartAgent(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if name == "" {
|
||||
respondError(w, http.StatusBadRequest, "agent name required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Replicas int `json:"replicas"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Replicas <= 0 {
|
||||
body.Replicas = 1
|
||||
}
|
||||
if err := h.docker.ScaleService(name, body.Replicas); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "start agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[Swarm] Agent '%s' started with %d replica(s)", name, body.Replicas)
|
||||
respond(w, http.StatusOK, map[string]any{"ok": true, "name": name, "replicas": body.Replicas})
|
||||
}
|
||||
|
||||
// POST /api/swarm/agents/{name}/stop
|
||||
// Stop (scale-to-0) a named agent service.
|
||||
func (h *Handler) SwarmStopAgent(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if name == "" {
|
||||
respondError(w, http.StatusBadRequest, "agent name required")
|
||||
return
|
||||
}
|
||||
if err := h.docker.ScaleService(name, 0); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "stop agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[Swarm] Agent '%s' stopped (scaled to 0)", name)
|
||||
respond(w, http.StatusOK, map[string]any{"ok": true, "name": name, "replicas": 0})
|
||||
}
|
||||
@@ -158,11 +158,17 @@ type SwarmService struct {
|
||||
}
|
||||
|
||||
type ServiceSpec struct {
|
||||
Name string `json:"Name"`
|
||||
Mode ServiceMode `json:"Mode"`
|
||||
TaskTemplate TaskTemplate `json:"TaskTemplate"`
|
||||
EndpointSpec *EndpointSpec `json:"EndpointSpec,omitempty"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
Name string `json:"Name"`
|
||||
Mode ServiceMode `json:"Mode"`
|
||||
TaskTemplate TaskTemplate `json:"TaskTemplate"`
|
||||
EndpointSpec *EndpointSpec `json:"EndpointSpec,omitempty"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
Networks []NetworkAttachment `json:"Networks,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkAttachment struct {
|
||||
Target string `json:"Target"`
|
||||
Aliases []string `json:"Aliases,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceMode struct {
|
||||
@@ -443,34 +449,67 @@ func (c *DockerClient) ListAllTasks() ([]SwarmTask, error) {
|
||||
// CreateAgentService deploys a new swarm service for an AI agent.
|
||||
// image: container image, name: service name, replicas: initial count,
|
||||
// env: environment variables, port: optional published port (0 = none).
|
||||
// CreateAgentServiceOpts holds options for deploying an agent Swarm service.
|
||||
type CreateAgentServiceOpts struct {
|
||||
Name string
|
||||
Image string
|
||||
Replicas int
|
||||
Env []string
|
||||
Port int
|
||||
Networks []string // overlay network names/IDs to attach
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
func (c *DockerClient) CreateAgentService(name, image string, replicas int, env []string, port int) (*SwarmService, error) {
|
||||
return c.CreateAgentServiceFull(CreateAgentServiceOpts{
|
||||
Name: name,
|
||||
Image: image,
|
||||
Replicas: replicas,
|
||||
Env: env,
|
||||
Port: port,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *DockerClient) CreateAgentServiceFull(opts CreateAgentServiceOpts) (*SwarmService, error) {
|
||||
labels := map[string]string{
|
||||
"goclaw.agent": "true",
|
||||
"goclaw.name": opts.Name,
|
||||
}
|
||||
for k, v := range opts.Labels {
|
||||
labels[k] = v
|
||||
}
|
||||
spec := ServiceSpec{
|
||||
Name: name,
|
||||
Name: opts.Name,
|
||||
Mode: ServiceMode{
|
||||
Replicated: &ReplicatedService{Replicas: replicas},
|
||||
Replicated: &ReplicatedService{Replicas: opts.Replicas},
|
||||
},
|
||||
TaskTemplate: TaskTemplate{
|
||||
ContainerSpec: ContainerSpec{
|
||||
Image: image,
|
||||
Env: env,
|
||||
Image: opts.Image,
|
||||
Env: opts.Env,
|
||||
},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"goclaw.agent": "true",
|
||||
"goclaw.name": name,
|
||||
},
|
||||
Labels: labels,
|
||||
}
|
||||
if port > 0 {
|
||||
if opts.Port > 0 {
|
||||
spec.EndpointSpec = &EndpointSpec{
|
||||
Ports: []PortConfig{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
TargetPort: port,
|
||||
TargetPort: opts.Port,
|
||||
PublishMode: "ingress",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if len(opts.Networks) > 0 {
|
||||
for _, net := range opts.Networks {
|
||||
spec.Networks = append(spec.Networks, NetworkAttachment{
|
||||
Target: net,
|
||||
Aliases: []string{opts.Name},
|
||||
})
|
||||
}
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"ID"`
|
||||
}
|
||||
@@ -480,6 +519,40 @@ func (c *DockerClient) CreateAgentService(name, image string, replicas int, env
|
||||
return c.GetService(created.ID)
|
||||
}
|
||||
|
||||
// RemoveService removes a swarm service by ID or name.
|
||||
func (c *DockerClient) RemoveService(idOrName string) error {
|
||||
req, err := http.NewRequest(http.MethodDelete, c.baseURL+"/v1.44/services/"+urlEncode(idOrName), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker DELETE service %s: %w", idOrName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("docker DELETE service %s: status %d: %s", idOrName, resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServiceLastActivity returns the most recent task update time for a service.
|
||||
// Used to determine whether a service is idle.
|
||||
func (c *DockerClient) GetServiceLastActivity(serviceID string) (time.Time, error) {
|
||||
tasks, err := c.ListServiceTasks(serviceID)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
var latest time.Time
|
||||
for _, t := range tasks {
|
||||
if t.UpdatedAt.After(latest) {
|
||||
latest = t.UpdatedAt
|
||||
}
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
// ─── Methods: Containers ─────────────────────────────────────────────────────
|
||||
|
||||
func (c *DockerClient) ListContainers() ([]Container, error) {
|
||||
|
||||
@@ -703,7 +703,7 @@ export async function setNodeAvailability(nodeId: string, availability: "active"
|
||||
|
||||
/** Deploy a new agent as a Swarm service */
|
||||
export async function createAgentService(opts: {
|
||||
name: string; image: string; replicas: number; env?: string[]; port?: number;
|
||||
name: string; image: string; replicas: number; env?: string[]; port?: number; networks?: string[];
|
||||
}): Promise<{ ok: boolean; serviceId?: string; name?: string } | null> {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/create`, {
|
||||
@@ -716,3 +716,61 @@ export async function createAgentService(opts: {
|
||||
return res.json();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Remove (stop) a Swarm service by ID or name */
|
||||
export async function removeSwarmService(serviceId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/services/${encodeURIComponent(serviceId)}`, {
|
||||
method: "DELETE",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export interface SwarmAgentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
desiredReplicas: number;
|
||||
runningTasks: number;
|
||||
lastActivity: string;
|
||||
idleMinutes: number;
|
||||
isGoClaw: boolean;
|
||||
}
|
||||
|
||||
/** List all GoClaw agent services with idle time info */
|
||||
export async function listSwarmAgents(): Promise<{ agents: SwarmAgentInfo[]; count: number } | null> {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/agents`, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Start (scale-up) an agent service */
|
||||
export async function startSwarmAgent(name: string, replicas = 1): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/agents/${encodeURIComponent(name)}/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ replicas }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Stop (scale-to-0) an agent service */
|
||||
export async function stopSwarmAgent(name: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/agents/${encodeURIComponent(name)}/stop`, {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
addSwarmNodeLabel,
|
||||
setNodeAvailability,
|
||||
createAgentService,
|
||||
removeSwarmService,
|
||||
listSwarmAgents,
|
||||
startSwarmAgent,
|
||||
stopSwarmAgent,
|
||||
getOllamaModelInfo,
|
||||
} from "./gateway-proxy";
|
||||
|
||||
@@ -998,6 +1002,7 @@ export const appRouter = router({
|
||||
replicas: z.number().min(1).max(20).default(1),
|
||||
env: z.array(z.string()).optional(),
|
||||
port: z.number().optional(),
|
||||
networks: z.array(z.string()).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const result = await createAgentService(input);
|
||||
@@ -1005,6 +1010,44 @@ export const appRouter = router({
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Remove (stop and delete) a Swarm service.
|
||||
*/
|
||||
removeService: publicProcedure
|
||||
.input(z.object({ serviceId: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const ok = await removeSwarmService(input.serviceId);
|
||||
return { ok };
|
||||
}),
|
||||
|
||||
/**
|
||||
* List all GoClaw agent services with idle time info.
|
||||
*/
|
||||
listAgents: publicProcedure.query(async () => {
|
||||
const result = await listSwarmAgents();
|
||||
return result ?? { agents: [], count: 0 };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Start (scale-up) an agent service by name.
|
||||
*/
|
||||
startAgent: publicProcedure
|
||||
.input(z.object({ name: z.string().min(1), replicas: z.number().min(1).max(20).default(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const ok = await startSwarmAgent(input.name, input.replicas);
|
||||
return { ok };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Stop (scale-to-0) an agent service by name.
|
||||
*/
|
||||
stopAgent: publicProcedure
|
||||
.input(z.object({ name: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const ok = await stopSwarmAgent(input.name);
|
||||
return { ok };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get live container stats (CPU%, RAM) for all running containers.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user