From eaf0bb149e0530271aae5a8125c7a2da459d645f Mon Sep 17 00:00:00 2001 From: nyk <93952610+0xNyk@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:16:15 +0700 Subject: [PATCH] feat(gateways): add health history logging and timeline (#300) --- src/app/api/gateways/health/history/route.ts | 63 +++++++++++++++++++ src/app/api/gateways/health/route.ts | 20 +++++- src/components/panels/multi-gateway-panel.tsx | 62 +++++++++++++++++- src/lib/migrations.ts | 17 +++++ src/lib/schema.sql | 13 ++++ 5 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 src/app/api/gateways/health/history/route.ts diff --git a/src/app/api/gateways/health/history/route.ts b/src/app/api/gateways/health/history/route.ts new file mode 100644 index 0000000..dcfce12 --- /dev/null +++ b/src/app/api/gateways/health/history/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server" +import { requireRole } from "@/lib/auth" +import { getDatabase } from "@/lib/db" + +interface GatewayHealthLogRow { + gateway_id: number + gateway_name: string | null + status: string + latency: number | null + probed_at: number + error: string | null +} + +interface GatewayHistoryEntry { + status: string + latency: number | null + probed_at: number + error: string | null +} + +interface GatewayHistory { + gatewayId: number + name: string | null + entries: GatewayHistoryEntry[] +} + +export async function GET(request: NextRequest) { + const auth = requireRole(request, "viewer") + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const db = getDatabase() + const rows = db.prepare(` + SELECT l.gateway_id, g.name AS gateway_name, l.status, l.latency, l.probed_at, l.error + FROM gateway_health_logs l + LEFT JOIN gateways g ON g.id = l.gateway_id + ORDER BY l.probed_at DESC + LIMIT 100 + `).all() as GatewayHealthLogRow[] + + const historyMap: Record = {} + + for (const row of rows) { + const entry: GatewayHistoryEntry = { + status: row.status, + latency: row.latency, + probed_at: row.probed_at, + error: row.error, + } + + if (!historyMap[row.gateway_id]) { + historyMap[row.gateway_id] = { + gatewayId: row.gateway_id, + name: row.gateway_name, + entries: [], + } + } + + historyMap[row.gateway_id].entries.push(entry) + } + + const history = Object.values(historyMap) + return NextResponse.json({ history }) +} diff --git a/src/app/api/gateways/health/route.ts b/src/app/api/gateways/health/route.ts index 6afaeca..df2cad0 100644 --- a/src/app/api/gateways/health/route.ts +++ b/src/app/api/gateways/health/route.ts @@ -162,18 +162,26 @@ export async function POST(request: NextRequest) { const updateOfflineStmt = db.prepare( "UPDATE gateways SET status = ?, latency = NULL, updated_at = (unixepoch()) WHERE id = ?" ) + const insertLogStmt = db.prepare( + "INSERT INTO gateway_health_logs (gateway_id, status, latency, probed_at, error) VALUES (?, ?, ?, ?, ?)" + ) const results: HealthResult[] = [] for (const gw of gateways) { + const probedAt = Math.floor(Date.now() / 1000) const probeUrl = buildGatewayProbeUrl(gw.host, gw.port) if (!probeUrl) { - results.push({ id: gw.id, name: gw.name, status: 'error', latency: null, agents: [], sessions_count: 0, error: 'Invalid gateway address' }) + const error = 'Invalid gateway address' + insertLogStmt.run(gw.id, 'error', null, probedAt, error) + results.push({ id: gw.id, name: gw.name, status: 'error', latency: null, agents: [], sessions_count: 0, error }) continue } if (isBlockedUrl(probeUrl, configuredHosts)) { - results.push({ id: gw.id, name: gw.name, status: 'error', latency: null, agents: [], sessions_count: 0, error: 'Blocked URL' }) + const error = 'Blocked URL' + insertLogStmt.run(gw.id, 'error', null, probedAt, error) + results.push({ id: gw.id, name: gw.name, status: 'error', latency: null, agents: [], sessions_count: 0, error }) continue } @@ -194,6 +202,9 @@ export async function POST(request: NextRequest) { ? 'OpenClaw 2026.3.2+ defaults tools.profile=messaging; Mission Control should enforce coding profile when spawning.' : undefined + const errorMessage = res.ok ? null : `HTTP ${res.status}` + insertLogStmt.run(gw.id, status, latency, probedAt, errorMessage) + results.push({ id: gw.id, name: gw.name, @@ -203,8 +214,11 @@ export async function POST(request: NextRequest) { sessions_count: 0, gateway_version: gatewayVersion, compatibility_warning: compatibilityWarning, + ...(errorMessage ? { error: errorMessage } : {}), }) } catch (err: any) { + const errorMessage = err.name === "AbortError" ? "timeout" : (err.message || "connection failed") + insertLogStmt.run(gw.id, "offline", null, probedAt, errorMessage) results.push({ id: gw.id, name: gw.name, @@ -212,7 +226,7 @@ export async function POST(request: NextRequest) { latency: null, agents: [], sessions_count: 0, - error: err.name === "AbortError" ? "timeout" : (err.message || "connection failed"), + error: errorMessage, }) } } diff --git a/src/components/panels/multi-gateway-panel.tsx b/src/components/panels/multi-gateway-panel.tsx index bc824cc..d4c9e2e 100644 --- a/src/components/panels/multi-gateway-panel.tsx +++ b/src/components/panels/multi-gateway-panel.tsx @@ -47,6 +47,19 @@ interface GatewayHealthProbe { error?: string } +interface GatewayHealthLogEntry { + status: string + latency: number | null + probed_at: number + error: string | null +} + +interface GatewayHistory { + gatewayId: number + name: string | null + entries: GatewayHealthLogEntry[] +} + interface DiscoveredGateway { user: string port: number @@ -64,6 +77,7 @@ export function MultiGatewayPanel() { const [showAdd, setShowAdd] = useState(false) const [probing, setProbing] = useState(null) const [healthByGatewayId, setHealthByGatewayId] = useState>(new Map()) + const [historyByGatewayId, setHistoryByGatewayId] = useState>({}) const { connection } = useMissionControl() const { connect } = useWebSocket() @@ -92,7 +106,21 @@ export function MultiGatewayPanel() { } catch { /* ignore */ } }, []) - useEffect(() => { fetchGateways(); fetchDirectConnections(); fetchDiscovered() }, [fetchGateways, fetchDirectConnections, fetchDiscovered]) + const fetchHistory = useCallback(async () => { + try { + const res = await fetch('/api/gateways/health/history') + const data = await res.json() + const map: Record = {} + for (const entry of data.history || []) { + map[entry.gatewayId] = entry + } + setHistoryByGatewayId(map) + } catch { + setHistoryByGatewayId({}) + } + }, []) + + useEffect(() => { fetchGateways(); fetchDirectConnections(); fetchDiscovered(); fetchHistory() }, [fetchGateways, fetchDirectConnections, fetchDiscovered, fetchHistory]) const gatewayMatchesConnection = useCallback((gw: Gateway): boolean => { const url = connection.url @@ -126,6 +154,7 @@ export function MultiGatewayPanel() { body: JSON.stringify({ id: gw.id, is_primary: 1 }), }) fetchGateways() + fetchHistory() } const deleteGateway = async (id: number) => { @@ -135,6 +164,7 @@ export function MultiGatewayPanel() { body: JSON.stringify({ id }), }) fetchGateways() + fetchHistory() } const connectTo = async (gw: Gateway) => { @@ -171,6 +201,7 @@ export function MultiGatewayPanel() { setHealthByGatewayId(mapped) } catch { /* ignore */ } fetchGateways() + fetchHistory() } const probeGateway = async (gw: Gateway) => { @@ -255,6 +286,7 @@ export function MultiGatewayPanel() { key={gw.id} gateway={gw} health={healthByGatewayId.get(gw.id)} + historyEntries={historyByGatewayId[gw.id]?.entries || []} isProbing={probing === gw.id} isCurrentlyConnected={gatewayMatchesConnection(gw)} onSetPrimary={() => setPrimary(gw)} @@ -413,9 +445,10 @@ export function MultiGatewayPanel() { ) } -function GatewayCard({ gateway, health, isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: { +function GatewayCard({ gateway, health, historyEntries = [], isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: { gateway: Gateway health?: GatewayHealthProbe + historyEntries?: GatewayHealthLogEntry[] isProbing: boolean isCurrentlyConnected: boolean onSetPrimary: () => void @@ -426,10 +459,14 @@ function GatewayCard({ gateway, health, isProbing, isCurrentlyConnected, onSetPr const statusColors: Record = { online: 'bg-green-500', offline: 'bg-red-500', + error: 'bg-amber-500', timeout: 'bg-amber-500', unknown: 'bg-muted-foreground/30', } + const timelineEntries = historyEntries.length > 0 ? [...historyEntries].slice(0, 10).reverse() : [] + const latestEntry = historyEntries[0] + const lastSeen = gateway.last_seen ? new Date(gateway.last_seen * 1000).toLocaleString() : 'Never probed' @@ -471,6 +508,27 @@ function GatewayCard({ gateway, health, isProbing, isCurrentlyConnected, onSetPr {compatibilityWarning} )} +
+ {timelineEntries.length > 0 ? ( +
+ {timelineEntries.map((entry) => ( + + ))} +
+ ) : ( + No history yet + )} + + Color key + + {latestEntry?.latency != null && ( + Last latency: {latestEntry.latency}ms + )} +