Skip to content

Commit f946e07

Browse files
isaacrowntreeclaude
andcommitted
feat: add gateway status indicator and controls to admin UI (#213)
Add GET /api/admin/gateway/status endpoint that checks process status and port responsiveness to determine running/starting/stopped state. Admin UI changes: - Status badge next to "Gateway Controls" (green/yellow/red dot) - Always "Restart Gateway" (gateway auto-starts on deploy) - Post-restart polling until gateway is running or 60s timeout - Background polling every 15s to keep status badge current - "Backup Now" disabled when gateway not running Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6510afd commit f946e07

5 files changed

Lines changed: 301 additions & 8 deletions

File tree

src/client/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ export async function approveAllDevices(): Promise<ApproveAllResponse> {
101101
});
102102
}
103103

104+
export interface GatewayStatusResponse {
105+
status: 'running' | 'starting' | 'stopped';
106+
processId?: string;
107+
error?: string;
108+
}
109+
110+
export async function getGatewayStatus(): Promise<GatewayStatusResponse> {
111+
return apiRequest<GatewayStatusResponse>('/gateway/status');
112+
}
113+
104114
export interface RestartGatewayResponse {
105115
success: boolean;
106116
message?: string;

src/client/pages/AdminPage.css

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,63 @@
161161
color: var(--text-muted);
162162
}
163163

164+
.section-title-row {
165+
display: flex;
166+
align-items: center;
167+
gap: 0.75rem;
168+
}
169+
170+
.gateway-status-badge {
171+
display: inline-flex;
172+
align-items: center;
173+
gap: 0.375rem;
174+
font-size: 0.75rem;
175+
font-weight: 500;
176+
padding: 0.25rem 0.625rem;
177+
border-radius: 9999px;
178+
text-transform: uppercase;
179+
letter-spacing: 0.025em;
180+
}
181+
182+
.gateway-status-badge .status-dot {
183+
width: 8px;
184+
height: 8px;
185+
border-radius: 50%;
186+
}
187+
188+
.gateway-status-badge.running {
189+
background-color: rgba(74, 222, 128, 0.15);
190+
color: var(--success-color);
191+
}
192+
193+
.gateway-status-badge.running .status-dot {
194+
background-color: var(--success-color);
195+
}
196+
197+
.gateway-status-badge.starting {
198+
background-color: rgba(251, 191, 36, 0.15);
199+
color: var(--warning-color);
200+
}
201+
202+
.gateway-status-badge.starting .status-dot {
203+
background-color: var(--warning-color);
204+
animation: pulse-dot 1.5s ease-in-out infinite;
205+
}
206+
207+
.gateway-status-badge.stopped {
208+
background-color: rgba(239, 68, 68, 0.15);
209+
color: var(--error-color);
210+
}
211+
212+
.gateway-status-badge.stopped .status-dot {
213+
background-color: var(--error-color);
214+
}
215+
216+
@keyframes pulse-dot {
217+
0%, 100% { opacity: 1; }
218+
50% { opacity: 0.3; }
219+
}
220+
164221
/* Empty state */
165222
.empty-state {
166223
text-align: center;

src/client/pages/AdminPage.tsx

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import {
44
approveDevice,
55
approveAllDevices,
66
restartGateway,
7+
getGatewayStatus,
78
getStorageStatus,
89
triggerSync,
910
AuthError,
1011
type PendingDevice,
1112
type PairedDevice,
1213
type DeviceListResponse,
1314
type StorageStatusResponse,
15+
type GatewayStatusResponse,
1416
} from '../api';
1517
import './AdminPage.css';
1618

@@ -49,6 +51,7 @@ export default function AdminPage() {
4951
const [pending, setPending] = useState<PendingDevice[]>([]);
5052
const [paired, setPaired] = useState<PairedDevice[]>([]);
5153
const [storageStatus, setStorageStatus] = useState<StorageStatusResponse | null>(null);
54+
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatusResponse | null>(null);
5255
const [loading, setLoading] = useState(true);
5356
const [error, setError] = useState<string | null>(null);
5457
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
@@ -88,10 +91,28 @@ export default function AdminPage() {
8891
}
8992
}, []);
9093

94+
const fetchGatewayStatus = useCallback(async () => {
95+
try {
96+
const status = await getGatewayStatus();
97+
setGatewayStatus(status);
98+
return status;
99+
} catch (err) {
100+
console.error('Failed to fetch gateway status:', err);
101+
return null;
102+
}
103+
}, []);
104+
91105
useEffect(() => {
92106
fetchDevices();
93107
fetchStorageStatus();
94-
}, [fetchDevices, fetchStorageStatus]);
108+
fetchGatewayStatus();
109+
}, [fetchDevices, fetchStorageStatus, fetchGatewayStatus]);
110+
111+
// Poll gateway status every 15s so the badge stays current
112+
useEffect(() => {
113+
const id = setInterval(fetchGatewayStatus, 15000);
114+
return () => clearInterval(id);
115+
}, [fetchGatewayStatus]);
95116

96117
const handleApprove = async (requestId: string) => {
97118
setActionInProgress(requestId);
@@ -138,19 +159,31 @@ export default function AdminPage() {
138159
}
139160

140161
setRestartInProgress(true);
162+
setGatewayStatus((prev) => prev ? { ...prev, status: 'starting' } : { status: 'starting' });
141163
try {
142164
const result = await restartGateway();
143165
if (result.success) {
144166
setError(null);
145-
// Show success message briefly
146-
alert('Gateway restart initiated. Clients will reconnect automatically.');
167+
// Poll for gateway status until running or timeout (60s)
168+
const startTime = Date.now();
169+
const poll = async () => {
170+
const status = await fetchGatewayStatus();
171+
if (status?.status === 'running' || Date.now() - startTime > 60000) {
172+
setRestartInProgress(false);
173+
return;
174+
}
175+
setTimeout(poll, 2000);
176+
};
177+
setTimeout(poll, 2000);
147178
} else {
148179
setError(result.error || 'Failed to restart gateway');
180+
setRestartInProgress(false);
181+
fetchGatewayStatus();
149182
}
150183
} catch (err) {
151184
setError(err instanceof Error ? err.message : 'Failed to restart gateway');
152-
} finally {
153185
setRestartInProgress(false);
186+
fetchGatewayStatus();
154187
}
155188
};
156189

@@ -220,7 +253,8 @@ export default function AdminPage() {
220253
<button
221254
className="btn btn-secondary btn-sm"
222255
onClick={handleSync}
223-
disabled={syncInProgress}
256+
disabled={syncInProgress || gatewayStatus?.status !== 'running'}
257+
title={gatewayStatus?.status !== 'running' ? 'Gateway must be running to backup' : undefined}
224258
>
225259
{syncInProgress && <ButtonSpinner />}
226260
{syncInProgress ? 'Syncing...' : 'Backup Now'}
@@ -231,7 +265,17 @@ export default function AdminPage() {
231265

232266
<section className="devices-section gateway-section">
233267
<div className="section-header">
234-
<h2>Gateway Controls</h2>
268+
<div className="section-title-row">
269+
<h2>Gateway Controls</h2>
270+
{gatewayStatus && (
271+
<span className={`gateway-status-badge ${gatewayStatus.status}`}>
272+
<span className="status-dot" />
273+
{gatewayStatus.status === 'running' && 'Running'}
274+
{gatewayStatus.status === 'starting' && 'Starting'}
275+
{gatewayStatus.status === 'stopped' && 'Stopped'}
276+
</span>
277+
)}
278+
</div>
235279
<button
236280
className="btn btn-danger"
237281
onClick={handleRestartGateway}
@@ -242,8 +286,9 @@ export default function AdminPage() {
242286
</button>
243287
</div>
244288
<p className="hint">
245-
Restart the gateway to apply configuration changes or recover from errors. All connected
246-
clients will be temporarily disconnected.
289+
{gatewayStatus?.status === 'stopped'
290+
? 'The gateway is not running. It starts automatically on deploy — use restart to recover from errors.'
291+
: 'Restart the gateway to apply configuration changes or recover from errors. All connected clients will be temporarily disconnected.'}
247292
</p>
248293
</section>
249294

src/routes/api.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { Hono } from 'hono';
3+
import type { AppEnv } from '../types';
4+
import { createMockEnv, createMockSandbox, suppressConsole } from '../test-utils';
5+
import type { Process } from '@cloudflare/sandbox';
6+
7+
import { api } from './api';
8+
9+
function createFullMockProcess(overrides: Partial<Process> = {}): Process {
10+
return {
11+
id: 'test-id',
12+
command: 'openclaw gateway',
13+
status: 'running',
14+
startTime: new Date(),
15+
endTime: undefined,
16+
exitCode: undefined,
17+
waitForPort: vi.fn(),
18+
kill: vi.fn(),
19+
getLogs: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
20+
...overrides,
21+
} as Process;
22+
}
23+
24+
/**
25+
* Build a test app that sets up sandbox on context and mounts the api routes.
26+
*/
27+
function createTestApp(mockSandbox: ReturnType<typeof createMockSandbox>) {
28+
const app = new Hono<AppEnv>();
29+
30+
// Middleware: inject sandbox into context (mirrors src/index.ts)
31+
app.use('*', async (c, next) => {
32+
c.set('sandbox', mockSandbox.sandbox);
33+
await next();
34+
});
35+
36+
// Mount the api routes
37+
app.route('/api', api);
38+
39+
return app;
40+
}
41+
42+
function makeRequest(app: Hono<AppEnv>, path: string, env: Record<string, unknown> = {}) {
43+
const mockEnv = createMockEnv({ DEV_MODE: 'true', ...env });
44+
return app.request(path, {}, mockEnv);
45+
}
46+
47+
describe('GET /api/admin/gateway/status', () => {
48+
beforeEach(() => {
49+
suppressConsole();
50+
});
51+
52+
it('returns stopped when no gateway process found', async () => {
53+
const mockSandbox = createMockSandbox({ processes: [] });
54+
const app = createTestApp(mockSandbox);
55+
56+
const res = await makeRequest(app, '/api/admin/gateway/status');
57+
expect(res.status).toBe(200);
58+
59+
const body = await res.json();
60+
expect(body.status).toBe('stopped');
61+
expect(body.processId).toBeUndefined();
62+
});
63+
64+
it('returns running when process exists and port responds', async () => {
65+
const gatewayProcess = createFullMockProcess({
66+
id: 'gw-123',
67+
command: 'openclaw gateway --port 18789',
68+
status: 'running',
69+
});
70+
const mockSandbox = createMockSandbox();
71+
mockSandbox.listProcessesMock.mockResolvedValue([gatewayProcess]);
72+
mockSandbox.containerFetchMock.mockResolvedValue(new Response('OK', { status: 200 }));
73+
74+
const app = createTestApp(mockSandbox);
75+
const res = await makeRequest(app, '/api/admin/gateway/status');
76+
expect(res.status).toBe(200);
77+
78+
const body = await res.json();
79+
expect(body.status).toBe('running');
80+
expect(body.processId).toBe('gw-123');
81+
});
82+
83+
it('returns starting when process exists but port does not respond', async () => {
84+
const gatewayProcess = createFullMockProcess({
85+
id: 'gw-456',
86+
command: '/usr/local/bin/start-openclaw.sh',
87+
status: 'starting',
88+
});
89+
const mockSandbox = createMockSandbox();
90+
mockSandbox.listProcessesMock.mockResolvedValue([gatewayProcess]);
91+
mockSandbox.containerFetchMock.mockRejectedValue(new Error('Connection refused'));
92+
93+
const app = createTestApp(mockSandbox);
94+
const res = await makeRequest(app, '/api/admin/gateway/status');
95+
expect(res.status).toBe(200);
96+
97+
const body = await res.json();
98+
expect(body.status).toBe('starting');
99+
expect(body.processId).toBe('gw-456');
100+
});
101+
102+
it('returns stopped when listProcesses fails gracefully', async () => {
103+
const mockSandbox = createMockSandbox();
104+
mockSandbox.listProcessesMock.mockRejectedValue(new Error('Sandbox unavailable'));
105+
// findExistingMoltbotProcess catches listProcesses errors and returns null
106+
const app = createTestApp(mockSandbox);
107+
const res = await makeRequest(app, '/api/admin/gateway/status');
108+
expect(res.status).toBe(200);
109+
110+
const body = await res.json();
111+
expect(body.status).toBe('stopped');
112+
});
113+
114+
it('returns running even when containerFetch returns a non-200 status', async () => {
115+
const gatewayProcess = createFullMockProcess({
116+
id: 'gw-789',
117+
command: 'openclaw gateway',
118+
status: 'running',
119+
});
120+
const mockSandbox = createMockSandbox();
121+
mockSandbox.listProcessesMock.mockResolvedValue([gatewayProcess]);
122+
// Gateway responds with 404 — still means port is up
123+
mockSandbox.containerFetchMock.mockResolvedValue(new Response('Not Found', { status: 404 }));
124+
125+
const app = createTestApp(mockSandbox);
126+
const res = await makeRequest(app, '/api/admin/gateway/status');
127+
expect(res.status).toBe(200);
128+
129+
const body = await res.json();
130+
expect(body.status).toBe('running');
131+
expect(body.processId).toBe('gw-789');
132+
});
133+
134+
it('ignores CLI command processes and returns stopped', async () => {
135+
const cliProcess = createFullMockProcess({
136+
id: 'cli-1',
137+
command: 'openclaw devices list --json',
138+
status: 'running',
139+
});
140+
const mockSandbox = createMockSandbox();
141+
mockSandbox.listProcessesMock.mockResolvedValue([cliProcess]);
142+
143+
const app = createTestApp(mockSandbox);
144+
const res = await makeRequest(app, '/api/admin/gateway/status');
145+
expect(res.status).toBe(200);
146+
147+
const body = await res.json();
148+
expect(body.status).toBe('stopped');
149+
});
150+
});

0 commit comments

Comments
 (0)