feat: add agent healthcheck

This commit is contained in:
charles-gauthereau
2026-03-24 20:06:43 +01:00
parent 26eff02943
commit 0cc0ba989c
11 changed files with 5321 additions and 5 deletions
@@ -17,6 +17,8 @@ import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card";
import {HealthCheckGraph} from "@/components/wrappers/dashboard/health/heath-grid";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
type AgentContentPageProps = {
edgeKey: string;
@@ -32,7 +34,8 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
return result?.data;
},
initialData: {
data: initialAgent
data: initialAgent,
health: []
},
staleTime: 0,
gcTime: 0,
@@ -40,11 +43,12 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
});
const agent = data?.data ?? initialAgent;
const agentHealthLogs: HealthcheckLog[] = data?.health ?? [];
return (
<div className="space-y-10">
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
<Card className="w-full sm:w-auto flex-1 transition-all border-border/50 bg-card ">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
@@ -55,7 +59,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
<Card className="w-full sm:w-auto flex-1 transition-all border-border/50 bg-card ">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
@@ -67,6 +71,8 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
</Card>
</div>
<HealthCheckGraph logs={agentHealthLogs}/>
<div className="space-y-6">
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
<AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
@@ -0,0 +1,204 @@
"use client"
import { useMemo } from "react"
import { Card } from "@/components/ui/card"
import { HealthcheckLog } from "@/db/schema/15_healthcheck-log"
type HealthStatus = "healthy" | "degraded" | "down" | "unknown"
interface HealthCheckData {
timestamp: Date
status: HealthStatus
}
interface Props {
logs: HealthcheckLog[]
intervalMinutes?: 10 | 20 | 30
}
function normalizeInterval(interval?: number): 10 | 20 | 30 {
if (interval === 20) return 20
if (interval === 30) return 30
return 10
}
function roundDateToInterval(date: Date, intervalMinutes: number): Date {
const ms = intervalMinutes * 60 * 1000
return new Date(Math.floor(date.getTime() / ms) * ms)
}
function getOldestLog(logs: HealthcheckLog[]): HealthcheckLog {
console.log(logs)
const validLogs = logs.filter(l => l.date)
return validLogs.reduce((oldest, current) =>
new Date(current.date!) < new Date(oldest.date!) ? current : oldest
)
}
function buildTimeSeries(
logs: HealthcheckLog[],
intervalMinutes: 10 | 20 | 30
): HealthCheckData[] {
const intervalMs = intervalMinutes * 60 * 1000
const now = new Date()
const roundedNow = roundDateToInterval(now, intervalMinutes)
const buckets = (24 * 60) / intervalMinutes
const data: HealthCheckData[] = []
const oldestLog = logs.length > 0 ? getOldestLog(logs) : null
for (let i = buckets - 1; i >= 0; i--) {
const start = new Date(roundedNow.getTime() - i * intervalMs)
const end = new Date(start.getTime() + intervalMs)
const bucketLogs = logs.filter(
(l) =>
l.date &&
new Date(l.date) >= start &&
new Date(l.date) < end
)
let status: HealthStatus = "unknown"
if (!oldestLog || new Date(oldestLog.date!) > start) {
status = "unknown"
} else if (new Date(oldestLog.date!) < start) {
status = "down"
}
if (bucketLogs.length > 0) {
const hasFailure = bucketLogs.some((l) => l.status === "failed")
const hasSuccess = bucketLogs.some((l) => l.status === "success")
if (hasFailure && hasSuccess) {
status = "degraded"
} else if (hasFailure) {
status = "down"
} else if (hasSuccess) {
status = "healthy"
}
}
data.push({ timestamp: start, status })
}
return data
}
function getStatusColor(status: HealthStatus): string {
switch (status) {
case "healthy":
return "bg-emerald-500"
case "degraded":
return "bg-emerald-700"
case "down":
return "bg-red-500"
case "unknown":
return "bg-zinc-700"
}
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
}
export const HealthCheckGraph = ({
logs,
intervalMinutes = 10,
}: Props) => {
const normalizedInterval = normalizeInterval(intervalMinutes)
const data = useMemo(() => {
return buildTimeSeries(logs, normalizedInterval)
}, [logs, normalizedInterval])
const hourLabels = useMemo(() => {
if (data.length === 0) return []
const step = Math.floor(120 / normalizedInterval) // label toutes les 2h
const labels: { hour: string; index: number }[] = []
for (let i = 0; i < data.length; i += step) {
labels.push({
hour: formatTime(data[i].timestamp),
index: i,
})
}
return labels
}, [data, normalizedInterval])
const healthyCount = data.filter((d) => d.status === "healthy").length
const uptimePercent =
data.length > 0
? ((healthyCount / data.length) * 100).toFixed(1)
: "0.0"
return (
<div className="flex items-center justify-center">
<div className="w-full">
<Card className="h-full flex flex-col p-4 border-border/50 bg-card gap-0">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">Health</h2>
<p className="text-zinc-500 text-sm">
Last 24 hours {normalizedInterval} minute intervals
</p>
</div>
<div className="text-right">
<p className="text-emerald-400 text-2xl font-bold">
{uptimePercent}%
</p>
<p className="text-zinc-500 text-sm">Uptime</p>
</div>
</div>
<div className="flex mb-1 text-xs text-zinc-500">
{hourLabels.map((label, i) => (
<div
key={i}
className="flex-1 text-center first:text-left last:text-right"
>
{label.hour}
</div>
))}
</div>
<div className="flex gap-[2px]">
{data.map((item, index) => (
<div
key={index}
className={`flex-1 h-8 rounded-sm ${getStatusColor(item.status)} hover:ring-2 hover:ring-zinc-400 transition-all cursor-pointer`}
title={`${formatTime(item.timestamp)} - ${item.status}`}
/>
))}
</div>
<div className="flex items-center justify-end gap-4 mt-4 text-xs text-zinc-500">
<Legend color="bg-zinc-700" label="Unknown" />
<Legend color="bg-red-500" label="Down" />
<Legend color="bg-emerald-700" label="Degraded" />
<Legend color="bg-emerald-500" label="Healthy" />
</div>
</Card>
</div>
</div>
)
}
const Legend = ({ color, label }: { color: string; label: string }) => (
<div className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded-sm ${color}`} />
<span>{label}</span>
</div>
)