From df7b80d39a6416e32481f41fc74c5a7f5f8187af Mon Sep 17 00:00:00 2001 From: Isaac Rowntree Date: Mon, 9 Feb 2026 10:52:13 +1100 Subject: [PATCH] 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 --- src/client/api.ts | 10 +++ src/client/pages/AdminPage.css | 57 +++++++++++++ src/client/pages/AdminPage.tsx | 61 ++++++++++++-- src/routes/api.test.ts | 150 +++++++++++++++++++++++++++++++++ src/routes/api.ts | 32 ++++++- 5 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 src/routes/api.test.ts diff --git a/src/client/api.ts b/src/client/api.ts index 317542891..5c8f10726 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -101,6 +101,16 @@ export async function approveAllDevices(): Promise { }); } +export interface GatewayStatusResponse { + status: 'running' | 'starting' | 'stopped'; + processId?: string; + error?: string; +} + +export async function getGatewayStatus(): Promise { + return apiRequest('/gateway/status'); +} + export interface RestartGatewayResponse { success: boolean; message?: string; diff --git a/src/client/pages/AdminPage.css b/src/client/pages/AdminPage.css index b81ff5c4e..a722db374 100644 --- a/src/client/pages/AdminPage.css +++ b/src/client/pages/AdminPage.css @@ -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; diff --git a/src/client/pages/AdminPage.tsx b/src/client/pages/AdminPage.tsx index cfe725fce..695f10a84 100644 --- a/src/client/pages/AdminPage.tsx +++ b/src/client/pages/AdminPage.tsx @@ -4,6 +4,7 @@ import { approveDevice, approveAllDevices, restartGateway, + getGatewayStatus, getStorageStatus, triggerSync, AuthError, @@ -11,6 +12,7 @@ import { type PairedDevice, type DeviceListResponse, type StorageStatusResponse, + type GatewayStatusResponse, } from '../api'; import './AdminPage.css'; @@ -49,6 +51,7 @@ export default function AdminPage() { const [pending, setPending] = useState([]); const [paired, setPaired] = useState([]); const [storageStatus, setStorageStatus] = useState(null); + const [gatewayStatus, setGatewayStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [actionInProgress, setActionInProgress] = useState(null); @@ -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); @@ -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(); } }; @@ -220,7 +253,8 @@ export default function AdminPage() {