feat(gateways): add health history logging and timeline (#300)
This commit is contained in:
parent
0c209e4c21
commit
eaf0bb149e
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)`)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue