mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
feat: add agent healthcheck
This commit is contained in:
@@ -66,6 +66,14 @@ export async function POST(
|
|||||||
}))
|
}))
|
||||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(drizzleDb.schemas.healthcheckLog)
|
||||||
|
.values({
|
||||||
|
kind: "agent",
|
||||||
|
status: "success",
|
||||||
|
objectId: agentId,
|
||||||
|
date: lastContact
|
||||||
|
})
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
agent: {
|
agent: {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {Separator} from "@/components/ui/separator";
|
|||||||
import {Badge} from "@/components/ui/badge";
|
import {Badge} from "@/components/ui/badge";
|
||||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||||
import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card";
|
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 = {
|
type AgentContentPageProps = {
|
||||||
edgeKey: string;
|
edgeKey: string;
|
||||||
@@ -32,7 +34,8 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
|||||||
return result?.data;
|
return result?.data;
|
||||||
},
|
},
|
||||||
initialData: {
|
initialData: {
|
||||||
data: initialAgent
|
data: initialAgent,
|
||||||
|
health: []
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
@@ -40,11 +43,12 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
|||||||
});
|
});
|
||||||
|
|
||||||
const agent = data?.data ?? initialAgent;
|
const agent = data?.data ?? initialAgent;
|
||||||
|
const agentHealthLogs: HealthcheckLog[] = data?.health ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
|
<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">
|
<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>
|
<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"/>
|
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
|
||||||
@@ -55,7 +59,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<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>
|
<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"/>
|
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
|
||||||
@@ -67,6 +71,8 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HealthCheckGraph logs={agentHealthLogs}/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
|
<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">
|
<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>
|
||||||
|
)
|
||||||
+3
-1
@@ -15,6 +15,7 @@ import * as notificationLog from "./schema/11_notification-log";
|
|||||||
import * as storageChannel from "./schema/12_storage-channel";
|
import * as storageChannel from "./schema/12_storage-channel";
|
||||||
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
||||||
import * as backupStorage from "@/db/schema/14_storage-backup";
|
import * as backupStorage from "@/db/schema/14_storage-backup";
|
||||||
|
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
||||||
|
|
||||||
|
|
||||||
import {Pool} from "pg";
|
import {Pool} from "pg";
|
||||||
@@ -46,7 +47,8 @@ export const schemas = {
|
|||||||
...notificationLog,
|
...notificationLog,
|
||||||
...storageChannel,
|
...storageChannel,
|
||||||
...storagePolicy,
|
...storagePolicy,
|
||||||
...backupStorage
|
...backupStorage,
|
||||||
|
...healthcheckLog
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = drizzle({
|
export const db = drizzle({
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TYPE "public"."healthcheck_status" AS ENUM('success', 'failed');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."healthcheck_kind" AS ENUM('database', 'agent');--> statement-breakpoint
|
||||||
|
CREATE TABLE "healthcheck_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"kind" "healthcheck_kind" NOT NULL,
|
||||||
|
"date" timestamp,
|
||||||
|
"status" "healthcheck_status",
|
||||||
|
"object_id" uuid,
|
||||||
|
"updated_at" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "healthcheck_log" ALTER COLUMN "date" SET NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "healthcheck_log" ALTER COLUMN "status" SET NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "healthcheck_log" ALTER COLUMN "object_id" SET NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -281,6 +281,20 @@
|
|||||||
"when": 1773659096498,
|
"when": 1773659096498,
|
||||||
"tag": "0039_conscious_solo",
|
"tag": "0039_conscious_solo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 40,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774375027038,
|
||||||
|
"tag": "0040_quick_lester",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774378412843,
|
||||||
|
"tag": "0041_spooky_radioactive_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {pgTable, uuid, timestamp, pgEnum} from 'drizzle-orm/pg-core';
|
||||||
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const healthcheckKindEnum = pgEnum('healthcheck_kind', ['database', 'agent']);
|
||||||
|
export const healthCheckStatusEnum = pgEnum('healthcheck_status', ['success', 'failed']);
|
||||||
|
|
||||||
|
|
||||||
|
export const healthcheckLog = pgTable('healthcheck_log', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
kind: healthcheckKindEnum('kind').notNull(),
|
||||||
|
date: timestamp("date").notNull(),
|
||||||
|
status: healthCheckStatusEnum("status").notNull(),
|
||||||
|
objectId: uuid('object_id').notNull(),
|
||||||
|
...timestamps
|
||||||
|
});
|
||||||
|
|
||||||
|
export const healthcheckLogSchema = createSelectSchema(healthcheckLog);
|
||||||
|
export type HealthcheckLog = z.infer<typeof healthcheckLogSchema>;
|
||||||
|
|
||||||
|
export type HealthcheckKind = (typeof healthcheckKindEnum.enumValues)[number];
|
||||||
|
export type HealthcheckStatus = (typeof healthCheckStatusEnum.enumValues)[number];
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import {ActionError, userAction} from "@/lib/safe-actions/actions";
|
import {ActionError, userAction} from "@/lib/safe-actions/actions";
|
||||||
import {AgentSchema} from "@/features/agents/agents.schema";
|
import {AgentSchema} from "@/features/agents/agents.schema";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {eq, and, ne, count} from "drizzle-orm";
|
import {eq, and, ne, count, gte} from "drizzle-orm";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {slugify} from "@/utils/slugify";
|
import {slugify} from "@/utils/slugify";
|
||||||
@@ -54,7 +54,25 @@ export const getAgentAction = userAction.schema(z.string()).action(async ({parse
|
|||||||
databases: true
|
databases: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: agent,
|
data: agent,
|
||||||
|
health: agent ? await getLast24hLogs({ id: agent.id }) : []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export async function getLast24hLogs({id}: { id: string }) {
|
||||||
|
const now = new Date()
|
||||||
|
const since = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(drizzleDb.schemas.healthcheckLog)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
|
||||||
|
gte(drizzleDb.schemas.healthcheckLog.date, since)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user