feat(gateways): add health history logging and timeline (#300)

This commit is contained in:
nyk 2026-03-12 18:16:15 +07:00 committed by GitHub
parent 0c209e4c21
commit eaf0bb149e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 170 additions and 5 deletions

View File

@ -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<number, GatewayHistory> = {}
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 })
}

View File

@ -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,
})
}
}

View File

@ -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<number | null>(null)
const [healthByGatewayId, setHealthByGatewayId] = useState<Map<number, GatewayHealthProbe>>(new Map())
const [historyByGatewayId, setHistoryByGatewayId] = useState<Record<number, GatewayHistory>>({})
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<number, GatewayHistory> = {}
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<string, string> = {
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}
</div>
)}
<div className="flex flex-wrap items-center gap-2 mt-3 text-2xs text-muted-foreground">
{timelineEntries.length > 0 ? (
<div className="flex items-center gap-0.5">
{timelineEntries.map((entry) => (
<span
key={`${entry.probed_at}-${entry.status}`}
className={`w-2.5 h-2.5 rounded-full ${statusColors[entry.status] || statusColors.unknown}`}
title={`${entry.status} ${entry.latency != null ? `(${entry.latency}ms)` : '(n/a)'} @ ${new Date(entry.probed_at * 1000).toLocaleTimeString()}${entry.error ? `${entry.error}` : ''}`}
/>
))}
</div>
) : (
<span className="text-2xs text-muted-foreground">No history yet</span>
)}
<span title="Green = online; amber = error; red = offline" className="text-2xs text-muted-foreground">
Color key
</span>
{latestEntry?.latency != null && (
<span className="text-2xs font-medium">Last latency: {latestEntry.latency}ms</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
<Button

View File

@ -1245,6 +1245,23 @@ const migrations: Migration[] = [
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_expires_at ON agent_api_keys(expires_at)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_revoked_at ON agent_api_keys(revoked_at)`)
}
},
{
id: '041_gateway_health_logs',
up(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS gateway_health_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gateway_id INTEGER NOT NULL,
status TEXT NOT NULL,
latency INTEGER,
probed_at INTEGER NOT NULL DEFAULT (unixepoch()),
error TEXT
)
`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_gateway_health_logs_gateway_id ON gateway_health_logs(gateway_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_gateway_health_logs_probed_at ON gateway_health_logs(probed_at)`)
}
}
]

View File

@ -101,6 +101,17 @@ CREATE TABLE IF NOT EXISTS quality_reviews (
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
-- Gateway health logs (captured each time MC probes a gateway)
CREATE TABLE IF NOT EXISTS gateway_health_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gateway_id INTEGER NOT NULL,
status TEXT NOT NULL,
latency INTEGER,
probed_at INTEGER NOT NULL DEFAULT (unixepoch()),
error TEXT
);
-- Indexes for better performance
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to);
@ -118,5 +129,7 @@ CREATE INDEX IF NOT EXISTS idx_task_subscriptions_agent_name ON task_subscriptio
CREATE INDEX IF NOT EXISTS idx_standup_reports_created_at ON standup_reports(created_at);
CREATE INDEX IF NOT EXISTS idx_quality_reviews_task_id ON quality_reviews(task_id);
CREATE INDEX IF NOT EXISTS idx_quality_reviews_reviewer ON quality_reviews(reviewer);
CREATE INDEX IF NOT EXISTS idx_gateway_health_logs_gateway_id ON gateway_health_logs(gateway_id);
CREATE INDEX IF NOT EXISTS idx_gateway_health_logs_probed_at ON gateway_health_logs(probed_at);
-- Sample data intentionally omitted - seed in dev scripts if needed.