Skip to content

Commit 96a7f05

Browse files
committed
feat: add gateway fleet topology
1 parent 7b0602f commit 96a7f05

19 files changed

Lines changed: 979 additions & 57 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,14 @@ Operational docs: https://swarmclaw.ai/docs/observability
399399

400400
## Releases
401401

402+
### v1.8.12 Highlights
403+
404+
Gateway Fleet Command release: SwarmClaw now treats OpenClaw gateways as an operator surface instead of a background provider detail.
405+
406+
- **Fleet topology API.** Added gateway topology endpoints that collect OpenClaw nodes, node pairings, device pairings, sessions, presence, and best-effort RPC errors in one server-side snapshot.
407+
- **Provider console controls.** The Providers screen can refresh a whole gateway fleet or a single gateway topology, showing sessions, presence, pending pairings, and topology warnings alongside deploy and runtime health.
408+
- **Operations Pulse coverage.** Degraded gateways, stale topology, failed topology refreshes, and pending OpenClaw pairings now appear in the shared operator triage queue.
409+
402410
### v1.8.11 Highlights
403411

404412
DeepSeek tool-use hotfix for issue [#67](https://github.com/swarmclawai/swarmclaw/issues/67).

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@swarmclawai/swarmclaw",
3-
"version": "1.8.11",
3+
"version": "1.8.12",
44
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
55
"main": "electron-dist/main.js",
66
"license": "MIT",
@@ -86,7 +86,7 @@
8686
"cli": "node ./bin/swarmclaw.js",
8787
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
8888
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
89-
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
89+
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
9090
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
9191
"test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
9292
"test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { NextResponse } from 'next/server'
2+
3+
import { notFound } from '@/lib/server/collection-helpers'
4+
import { getOpenClawGatewayTopology } from '@/lib/server/gateways/gateway-topology'
5+
6+
export const dynamic = 'force-dynamic'
7+
8+
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9+
const { id } = await params
10+
const topology = await getOpenClawGatewayTopology(id)
11+
if (!topology) return notFound()
12+
return NextResponse.json(topology)
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextResponse } from 'next/server'
2+
3+
import { getOpenClawGatewayFleetTopology } from '@/lib/server/gateways/gateway-topology'
4+
5+
export const dynamic = 'force-dynamic'
6+
7+
export async function GET() {
8+
return NextResponse.json(await getOpenClawGatewayFleetTopology())
9+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import assert from 'node:assert/strict'
2+
import test from 'node:test'
3+
4+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5+
6+
test('gateway topology route returns 404 for unknown profiles', () => {
7+
const output = runWithTempDataDir<{ status: number; body: { error: string } }>(`
8+
const routeMod = await import('./src/app/api/gateways/[id]/topology/route')
9+
const route = routeMod.default || routeMod
10+
const response = await route.GET(
11+
new Request('http://local/api/gateways/missing/topology'),
12+
{ params: Promise.resolve({ id: 'missing' }) },
13+
)
14+
console.log(JSON.stringify({ status: response.status, body: await response.json() }))
15+
`, { prefix: 'swarmclaw-gateway-topology-route-test-' })
16+
17+
assert.equal(output.status, 404)
18+
assert.equal(output.body.error, 'Not found')
19+
})
20+
21+
test('gateway fleet route reports empty totals when no OpenClaw profiles exist', () => {
22+
const output = runWithTempDataDir<{
23+
status: number
24+
body: { gateways: unknown[]; totals: { gatewayCount: number; nodeCount: number; hasErrors: boolean } }
25+
}>(`
26+
const routeMod = await import('./src/app/api/gateways/fleet/route')
27+
const route = routeMod.default || routeMod
28+
const response = await route.GET(new Request('http://local/api/gateways/fleet'))
29+
console.log(JSON.stringify({ status: response.status, body: await response.json() }))
30+
`, { prefix: 'swarmclaw-gateway-fleet-route-test-' })
31+
32+
assert.equal(output.status, 200)
33+
assert.equal(output.body.gateways.length, 0)
34+
assert.equal(output.body.totals.gatewayCount, 0)
35+
assert.equal(output.body.totals.nodeCount, 0)
36+
assert.equal(output.body.totals.hasErrors, false)
37+
})

src/cli/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ const COMMAND_GROUPS = [
272272
cmd('update', 'PUT', '/gateways/:id', 'Update a gateway profile', { expectsJsonBody: true }),
273273
cmd('delete', 'DELETE', '/gateways/:id', 'Delete a gateway profile'),
274274
cmd('health', 'GET', '/gateways/:id/health', 'Run a gateway health check'),
275+
cmd('topology', 'GET', '/gateways/:id/topology', 'Refresh and return one gateway topology snapshot'),
276+
cmd('fleet', 'GET', '/gateways/fleet', 'Refresh and return fleet-wide gateway topology'),
275277
],
276278
},
277279
{

src/cli/spec.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ const COMMAND_GROUPS = {
220220
update: { description: 'Update a gateway profile', method: 'PUT', path: '/gateways/:id', params: ['id'] },
221221
delete: { description: 'Delete a gateway profile', method: 'DELETE', path: '/gateways/:id', params: ['id'] },
222222
health: { description: 'Run a gateway health check', method: 'GET', path: '/gateways/:id/health', params: ['id'] },
223+
topology: { description: 'Refresh and return one gateway topology snapshot', method: 'GET', path: '/gateways/:id/topology', params: ['id'] },
224+
fleet: { description: 'Refresh and return fleet-wide gateway topology', method: 'GET', path: '/gateways/fleet' },
223225
},
224226
},
225227
logs: {

src/components/gateways/gateway-sheet.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { useAppStore } from '@/stores/use-app-store'
77
import { toast } from 'sonner'
88
import type {
99
OpenClawDevicePairRequest,
10+
OpenClawGatewayPresenceEntry,
11+
OpenClawGatewayRpcError,
12+
OpenClawGatewaySession,
1013
OpenClawNode,
1114
OpenClawNodePairRequest,
1215
OpenClawPairedDevice,
@@ -76,6 +79,9 @@ export function GatewaySheet() {
7679
const [nodePairings, setNodePairings] = useState<OpenClawNodePairRequest[]>([])
7780
const [devicePairings, setDevicePairings] = useState<OpenClawDevicePairRequest[]>([])
7881
const [pairedDevices, setPairedDevices] = useState<OpenClawPairedDevice[]>([])
82+
const [gatewaySessions, setGatewaySessions] = useState<OpenClawGatewaySession[]>([])
83+
const [gatewayPresence, setGatewayPresence] = useState<OpenClawGatewayPresenceEntry[]>([])
84+
const [gatewayTopologyErrors, setGatewayTopologyErrors] = useState<OpenClawGatewayRpcError[]>([])
7985
const [invokeNodeId, setInvokeNodeId] = useState('')
8086
const [invokeCommand, setInvokeCommand] = useState('')
8187
const [invokeParamsText, setInvokeParamsText] = useState('{}')
@@ -113,6 +119,9 @@ export function GatewaySheet() {
113119
setNodePairings([])
114120
setDevicePairings([])
115121
setPairedDevices([])
122+
setGatewaySessions([])
123+
setGatewayPresence([])
124+
setGatewayTopologyErrors([])
116125
setInvokeNodeId('')
117126
setInvokeCommand('')
118127
setInvokeParamsText('{}')
@@ -130,6 +139,9 @@ export function GatewaySheet() {
130139
setNodePairings(result.nodePairings)
131140
setDevicePairings(result.devicePairings)
132141
setPairedDevices(result.pairedDevices)
142+
setGatewaySessions(result.sessions)
143+
setGatewayPresence(result.presence)
144+
setGatewayTopologyErrors(result.errors)
133145
if (result.nodes[0]) {
134146
setInvokeNodeId((current) => current || result.nodes[0].nodeId)
135147
setInvokeCommand((current) => current || result.nodes[0].commands?.[0] || '')
@@ -532,6 +544,37 @@ export function GatewaySheet() {
532544
</div>
533545
)}
534546

547+
<div className="mb-4 grid grid-cols-2 gap-2 md:grid-cols-4">
548+
<div className="rounded-[12px] border border-white/[0.06] bg-white/[0.025] px-3 py-2">
549+
<div className="text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/55">Nodes</div>
550+
<div className="mt-1 font-display text-[18px] font-700 text-text">{nodes.filter((node) => node.connected).length}/{nodes.length}</div>
551+
</div>
552+
<div className="rounded-[12px] border border-white/[0.06] bg-white/[0.025] px-3 py-2">
553+
<div className="text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/55">Pairings</div>
554+
<div className="mt-1 font-display text-[18px] font-700 text-amber-300">{nodePairings.length + devicePairings.length}</div>
555+
</div>
556+
<div className="rounded-[12px] border border-white/[0.06] bg-white/[0.025] px-3 py-2">
557+
<div className="text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/55">Sessions</div>
558+
<div className="mt-1 font-display text-[18px] font-700 text-text">{gatewaySessions.length}</div>
559+
</div>
560+
<div className={`rounded-[12px] border px-3 py-2 ${
561+
gatewayTopologyErrors.length > 0
562+
? 'border-rose-400/20 bg-rose-400/[0.06]'
563+
: 'border-white/[0.06] bg-white/[0.025]'
564+
}`}>
565+
<div className="text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/55">Presence</div>
566+
<div className={gatewayTopologyErrors.length > 0 ? 'mt-1 font-display text-[18px] font-700 text-rose-200' : 'mt-1 font-display text-[18px] font-700 text-text'}>
567+
{gatewayPresence.length}
568+
</div>
569+
</div>
570+
</div>
571+
572+
{gatewayTopologyErrors.length > 0 && (
573+
<div className="mb-4 rounded-[12px] border border-rose-400/20 bg-rose-400/[0.06] px-3 py-2 text-[12px] text-rose-200">
574+
{gatewayTopologyErrors[0]?.method}: {gatewayTopologyErrors[0]?.message}
575+
</div>
576+
)}
577+
535578
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
536579
<div className="space-y-4">
537580
<div className="rounded-[14px] border border-white/[0.06] bg-surface p-4">

src/components/operations/operations-pulse-panel.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useCallback, useEffect, useMemo, useState } from 'react'
44
import { useRouter } from 'next/navigation'
5-
import { AlertTriangle, CheckCircle2, Clock, PlugZap, RefreshCw } from 'lucide-react'
5+
import { AlertTriangle, CheckCircle2, Clock, PlugZap, RadioTower, RefreshCw } from 'lucide-react'
66
import { api } from '@/lib/app/api-client'
77
import { cn } from '@/lib/utils'
88
import type { OperationPulse, OperationPulseAction, OperationPulseRange, OperationPulseSeverity } from '@/types'
@@ -39,6 +39,7 @@ function Kpi({ label, value, danger = false }: { label: string; value: number; d
3939
function actionIcon(action: OperationPulseAction) {
4040
if (action.severity === 'high') return <AlertTriangle size={15} />
4141
if (action.kind === 'connector') return <PlugZap size={15} />
42+
if (action.kind === 'gateway') return <RadioTower size={15} />
4243
if (action.kind === 'mission') return <Clock size={15} />
4344
return <CheckCircle2 size={15} />
4445
}
@@ -82,6 +83,7 @@ export function OperationsPulsePanel({
8283
return pulse.kpis.failedRuns === 0
8384
&& pulse.kpis.pendingApprovals === 0
8485
&& pulse.kpis.connectorAttention === 0
86+
&& pulse.kpis.gatewayAttention === 0
8587
&& pulse.kpis.budgetWarnings === 0
8688
}, [pulse])
8789

@@ -92,7 +94,7 @@ export function OperationsPulsePanel({
9294
<div className="text-[10px] font-700 uppercase tracking-[0.16em] text-accent-bright/70">Operations Pulse</div>
9395
<h2 className="mt-1 font-display text-[16px] font-700 tracking-normal text-text">What needs operator attention next</h2>
9496
<p className="mt-1 max-w-[680px] text-[12px] leading-relaxed text-text-3/68">
95-
Missions, runs, approvals, connector readiness, and budget pressure rolled into one triage queue.
97+
Missions, runs, approvals, connector readiness, OpenClaw gateways, and budget pressure rolled into one triage queue.
9698
</p>
9799
</div>
98100
<div className="flex flex-wrap items-center gap-2">
@@ -130,12 +132,13 @@ export function OperationsPulsePanel({
130132
</div>
131133
) : (
132134
<>
133-
<div className={cn('mt-4 grid gap-2', compact ? 'grid-cols-2 md:grid-cols-3 xl:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6')}>
135+
<div className={cn('mt-4 grid gap-2', compact ? 'grid-cols-2 md:grid-cols-4 xl:grid-cols-7' : 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-7')}>
134136
<Kpi label="Missions" value={pulse.kpis.activeMissions} />
135137
<Kpi label="Running" value={pulse.kpis.runningRuns} />
136138
<Kpi label="Failed" value={pulse.kpis.failedRuns} danger />
137139
<Kpi label="Approvals" value={pulse.kpis.pendingApprovals} />
138140
<Kpi label="Connectors" value={pulse.kpis.connectorAttention} danger />
141+
<Kpi label="Gateways" value={pulse.kpis.gatewayAttention} danger />
139142
<Kpi label="Budgets" value={pulse.kpis.budgetWarnings} />
140143
</div>
141144

0 commit comments

Comments
 (0)