Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ export async function approveAllDevices(): Promise<ApproveAllResponse> {
});
}

export interface GatewayStatusResponse {
status: 'running' | 'starting' | 'stopped';
processId?: string;
error?: string;
}

export async function getGatewayStatus(): Promise<GatewayStatusResponse> {
return apiRequest<GatewayStatusResponse>('/gateway/status');
}

export interface RestartGatewayResponse {
success: boolean;
message?: string;
Expand Down
57 changes: 57 additions & 0 deletions src/client/pages/AdminPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,63 @@
color: var(--text-muted);
}

.section-title-row {
display: flex;
align-items: center;
gap: 0.75rem;
}

.gateway-status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.025em;
}

.gateway-status-badge .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}

.gateway-status-badge.running {
background-color: rgba(74, 222, 128, 0.15);
color: var(--success-color);
}

.gateway-status-badge.running .status-dot {
background-color: var(--success-color);
}

.gateway-status-badge.starting {
background-color: rgba(251, 191, 36, 0.15);
color: var(--warning-color);
}

.gateway-status-badge.starting .status-dot {
background-color: var(--warning-color);
animation: pulse-dot 1.5s ease-in-out infinite;
}

.gateway-status-badge.stopped {
background-color: rgba(239, 68, 68, 0.15);
color: var(--error-color);
}

.gateway-status-badge.stopped .status-dot {
background-color: var(--error-color);
}

@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}

/* Empty state */
.empty-state {
text-align: center;
Expand Down
61 changes: 53 additions & 8 deletions src/client/pages/AdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {
approveDevice,
approveAllDevices,
restartGateway,
getGatewayStatus,
getStorageStatus,
triggerSync,
AuthError,
type PendingDevice,
type PairedDevice,
type DeviceListResponse,
type StorageStatusResponse,
type GatewayStatusResponse,
} from '../api';
import './AdminPage.css';

Expand Down Expand Up @@ -49,6 +51,7 @@ export default function AdminPage() {
const [pending, setPending] = useState<PendingDevice[]>([]);
const [paired, setPaired] = useState<PairedDevice[]>([]);
const [storageStatus, setStorageStatus] = useState<StorageStatusResponse | null>(null);
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
Expand Down Expand Up @@ -88,10 +91,28 @@ export default function AdminPage() {
}
}, []);

const fetchGatewayStatus = useCallback(async () => {
try {
const status = await getGatewayStatus();
setGatewayStatus(status);
return status;
} catch (err) {
console.error('Failed to fetch gateway status:', err);
return null;
}
}, []);

useEffect(() => {
fetchDevices();
fetchStorageStatus();
}, [fetchDevices, fetchStorageStatus]);
fetchGatewayStatus();
}, [fetchDevices, fetchStorageStatus, fetchGatewayStatus]);

// Poll gateway status every 15s so the badge stays current
useEffect(() => {
const id = setInterval(fetchGatewayStatus, 15000);
return () => clearInterval(id);
}, [fetchGatewayStatus]);

const handleApprove = async (requestId: string) => {
setActionInProgress(requestId);
Expand Down Expand Up @@ -138,19 +159,31 @@ export default function AdminPage() {
}

setRestartInProgress(true);
setGatewayStatus((prev) => prev ? { ...prev, status: 'starting' } : { status: 'starting' });
try {
const result = await restartGateway();
if (result.success) {
setError(null);
// Show success message briefly
alert('Gateway restart initiated. Clients will reconnect automatically.');
// Poll for gateway status until running or timeout (60s)
const startTime = Date.now();
const poll = async () => {
const status = await fetchGatewayStatus();
if (status?.status === 'running' || Date.now() - startTime > 60000) {
setRestartInProgress(false);
return;
}
setTimeout(poll, 2000);
};
setTimeout(poll, 2000);
} else {
setError(result.error || 'Failed to restart gateway');
setRestartInProgress(false);
fetchGatewayStatus();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to restart gateway');
} finally {
setRestartInProgress(false);
fetchGatewayStatus();
}
};

Expand Down Expand Up @@ -220,7 +253,8 @@ export default function AdminPage() {
<button
className="btn btn-secondary btn-sm"
onClick={handleSync}
disabled={syncInProgress}
disabled={syncInProgress || gatewayStatus?.status !== 'running'}
title={gatewayStatus?.status !== 'running' ? 'Gateway must be running to backup' : undefined}
>
{syncInProgress && <ButtonSpinner />}
{syncInProgress ? 'Syncing...' : 'Backup Now'}
Expand All @@ -231,7 +265,17 @@ export default function AdminPage() {

<section className="devices-section gateway-section">
<div className="section-header">
<h2>Gateway Controls</h2>
<div className="section-title-row">
<h2>Gateway Controls</h2>
{gatewayStatus && (
<span className={`gateway-status-badge ${gatewayStatus.status}`}>
<span className="status-dot" />
{gatewayStatus.status === 'running' && 'Running'}
{gatewayStatus.status === 'starting' && 'Starting'}
{gatewayStatus.status === 'stopped' && 'Stopped'}
</span>
)}
</div>
<button
className="btn btn-danger"
onClick={handleRestartGateway}
Expand All @@ -242,8 +286,9 @@ export default function AdminPage() {
</button>
</div>
<p className="hint">
Restart the gateway to apply configuration changes or recover from errors. All connected
clients will be temporarily disconnected.
{gatewayStatus?.status === 'stopped'
? 'The gateway is not running. It starts automatically on deploy — use restart to recover from errors.'
: 'Restart the gateway to apply configuration changes or recover from errors. All connected clients will be temporarily disconnected.'}
</p>
</section>

Expand Down
150 changes: 150 additions & 0 deletions src/routes/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Hono } from 'hono';
import type { AppEnv } from '../types';
import { createMockEnv, createMockSandbox, suppressConsole } from '../test-utils';
import type { Process } from '@cloudflare/sandbox';

import { api } from './api';

function createFullMockProcess(overrides: Partial<Process> = {}): Process {
return {
id: 'test-id',
command: 'openclaw gateway',
status: 'running',
startTime: new Date(),
endTime: undefined,
exitCode: undefined,
waitForPort: vi.fn(),
kill: vi.fn(),
getLogs: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
...overrides,
} as Process;
}

/**
* Build a test app that sets up sandbox on context and mounts the api routes.
*/
function createTestApp(mockSandbox: ReturnType<typeof createMockSandbox>) {
const app = new Hono<AppEnv>();

// Middleware: inject sandbox into context (mirrors src/index.ts)
app.use('*', async (c, next) => {
c.set('sandbox', mockSandbox.sandbox);
await next();
});

// Mount the api routes
app.route('/api', api);

return app;
}

function makeRequest(app: Hono<AppEnv>, path: string, env: Record<string, unknown> = {}) {
const mockEnv = createMockEnv({ DEV_MODE: 'true', ...env });
return app.request(path, {}, mockEnv);
}

describe('GET /api/admin/gateway/status', () => {
beforeEach(() => {
suppressConsole();
});

it('returns stopped when no gateway process found', async () => {
const mockSandbox = createMockSandbox({ processes: [] });
const app = createTestApp(mockSandbox);

const res = await makeRequest(app, '/api/admin/gateway/status');
expect(res.status).toBe(200);

const body = await res.json();
expect(body.status).toBe('stopped');
expect(body.processId).toBeUndefined();
});

it('returns running when process exists and port responds', async () => {
const gatewayProcess = createFullMockProcess({
id: 'gw-123',
command: 'openclaw gateway --port 18789',
status: 'running',
});
const mockSandbox = createMockSandbox();
mockSandbox.listProcessesMock.mockResolvedValue([gatewayProcess]);
mockSandbox.containerFetchMock.mockResolvedValue(new Response('OK', { status: 200 }));

const app = createTestApp(mockSandbox);
const res = await makeRequest(app, '/api/admin/gateway/status');
expect(res.status).toBe(200);

const body = await res.json();
expect(body.status).toBe('running');
expect(body.processId).toBe('gw-123');
});

it('returns starting when process exists but port does not respond', async () => {
const gatewayProcess = createFullMockProcess({
id: 'gw-456',
command: '/usr/local/bin/start-openclaw.sh',
status: 'starting',
});
const mockSandbox = createMockSandbox();
mockSandbox.listProcessesMock.mockResolvedValue([gatewayProcess]);
mockSandbox.containerFetchMock.mockRejectedValue(new Error('Connection refused'));

const app = createTestApp(mockSandbox);
const res = await makeRequest(app, '/api/admin/gateway/status');
expect(res.status).toBe(200);

const body = await res.json();
expect(body.status).toBe('starting');
expect(body.processId).toBe('gw-456');
});

it('returns stopped when listProcesses fails gracefully', async () => {
const mockSandbox = createMockSandbox();
mockSandbox.listProcessesMock.mockRejectedValue(new Error('Sandbox unavailable'));
// findExistingMoltbotProcess catches listProcesses errors and returns null
const app = createTestApp(mockSandbox);
const res = await makeRequest(app, '/api/admin/gateway/status');
expect(res.status).toBe(200);

const body = await res.json();
expect(body.status).toBe('stopped');
});

it('returns running even when containerFetch returns a non-200 status', async () => {
const gatewayProcess = createFullMockProcess({
id: 'gw-789',
command: 'openclaw gateway',
status: 'running',
});
const mockSandbox = createMockSandbox();
mockSandbox.listProcessesMock.mockResolvedValue([gatewayProcess]);
// Gateway responds with 404 — still means port is up
mockSandbox.containerFetchMock.mockResolvedValue(new Response('Not Found', { status: 404 }));

const app = createTestApp(mockSandbox);
const res = await makeRequest(app, '/api/admin/gateway/status');
expect(res.status).toBe(200);

const body = await res.json();
expect(body.status).toBe('running');
expect(body.processId).toBe('gw-789');
});

it('ignores CLI command processes and returns stopped', async () => {
const cliProcess = createFullMockProcess({
id: 'cli-1',
command: 'openclaw devices list --json',
status: 'running',
});
const mockSandbox = createMockSandbox();
mockSandbox.listProcessesMock.mockResolvedValue([cliProcess]);

const app = createTestApp(mockSandbox);
const res = await makeRequest(app, '/api/admin/gateway/status');
expect(res.status).toBe(200);

const body = await res.json();
expect(body.status).toBe('stopped');
});
});
Loading