-
-
-
Managed Databases
-
- Resources currently connected to this agent.
-
+
+
+
+
Managed Databases
+
+ Resources currently connected to this agent.
+
+
+
+
-
-
-
)}
)
diff --git a/src/components/wrappers/dashboard/database/backup/actions/get-data.action.ts b/src/components/wrappers/dashboard/database/backup/actions/get-data.action.ts
index f706c620..85b2c076 100644
--- a/src/components/wrappers/dashboard/database/backup/actions/get-data.action.ts
+++ b/src/components/wrappers/dashboard/database/backup/actions/get-data.action.ts
@@ -7,6 +7,7 @@ import * as drizzleDb from "@/db";
import {BackupWith, Restoration} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
+import {getHealthLast12hLogs} from "@/db/services/healthcheck";
export const getDatabaseDataAction = userAction
.schema(
@@ -50,7 +51,9 @@ export const getDatabaseDataAction = userAction
const successfulBackups = backups.filter(b => b.status === "success").length;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
+ // @ts-ignore
let activeOrganizationChannels = [];
+ // @ts-ignore
let activeOrganizationStorageChannels = [];
if (database?.project?.organizationId) {
@@ -61,16 +64,20 @@ export const getDatabaseDataAction = userAction
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
}
+
return {
database,
backups,
restorations,
+ // @ts-ignore
activeOrganizationChannels,
+ // @ts-ignore
activeOrganizationStorageChannels,
stats: {
totalBackups,
availableBackups,
successRate
- }
+ },
+ health: database ? await getHealthLast12hLogs({ id: database.id }) : []
};
});
\ No newline at end of file
diff --git a/src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx b/src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx
index f2da6250..3e994e74 100644
--- a/src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx
+++ b/src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx
@@ -37,7 +37,6 @@ import {backupOnly} from "@/components/wrappers/dashboard/projects/database/data
type ChannelPoliciesFormProps = {
onSuccess?: () => void;
channels: NotificationChannel[] | StorageChannel[];
- organizationId: string;
database: DatabaseWith;
kind: ChannelKind
};
@@ -46,7 +45,6 @@ type ChannelPoliciesFormProps = {
export const ChannelPoliciesForm = ({
database,
channels,
- organizationId,
onSuccess,
kind
}: ChannelPoliciesFormProps) => {
diff --git a/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx b/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx
index d1cfc9ae..a8de8e43 100644
--- a/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx
+++ b/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx
@@ -65,7 +65,6 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
setOpen(false)}
diff --git a/src/components/wrappers/dashboard/database/channels-policy/policy.schema.ts b/src/components/wrappers/dashboard/database/channels-policy/policy.schema.ts
index a43e2704..98f7b6d2 100644
--- a/src/components/wrappers/dashboard/database/channels-policy/policy.schema.ts
+++ b/src/components/wrappers/dashboard/database/channels-policy/policy.schema.ts
@@ -3,7 +3,7 @@ import {z} from "zod";
export const PolicySchema = z.object({
channelId: z.string().min(1, "Please select channel"),
eventKinds: z.array(z.enum([
- 'error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report'
+ 'error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report', 'error_health_database'
]))
.optional(),
enabled: z.boolean().default(true),
@@ -17,16 +17,16 @@ export const PoliciesSchema = z.object({
export type PoliciesType = z.infer;
export type PolicyType = z.infer;
-
-export const EVENT_KIND_OPTIONS = [
- {label: "Error Backup", value: "error_backup"},
- {label: "Error Restore", value: "error_restore"},
- {label: "Success Restore", value: "success_restore"},
- {label: "Success Backup", value: "success_backup"},
- // {label: "Weekly Report", value: "weekly_report"},
-];
-
export const EVENT_KIND_BACKUP_ONLY_OPTIONS = [
{label: "Error Backup", value: "error_backup"},
{label: "Success Backup", value: "success_backup"},
-];
\ No newline at end of file
+ {label: "Health Ping Fail", value: "error_health_database"},
+];
+
+export const EVENT_KIND_OPTIONS = [
+ ...EVENT_KIND_BACKUP_ONLY_OPTIONS,
+ {label: "Error Restore", value: "error_restore"},
+ {label: "Success Restore", value: "success_restore"},
+ // {label: "Weekly Report", value: "weekly_report"},
+];
+
diff --git a/src/components/wrappers/dashboard/database/health/health-modal.tsx b/src/components/wrappers/dashboard/database/health/health-modal.tsx
new file mode 100644
index 00000000..25fccccb
--- /dev/null
+++ b/src/components/wrappers/dashboard/database/health/health-modal.tsx
@@ -0,0 +1,43 @@
+"use client"
+
+import {useState} from "react";
+import {Button} from "@/components/ui/button";
+import {DatabaseWith} from "@/db/schema/07_database";
+import {HeartPulse} from "lucide-react";
+import {
+ Sheet,
+ SheetContent,
+ SheetTitle,
+ SheetTrigger
+} from "@/components/ui/sheet";
+import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
+import {HealthCheckGraph} from "@/components/wrappers/dashboard/health/heath-grid";
+
+type HealthModalProps = {
+ database: DatabaseWith,
+ healthLogs: HealthcheckLog[]
+}
+
+export const HealthModal = ({database, healthLogs}: HealthModalProps) => {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+ setOpen(true)}>
+
+
+
+
+
+
+ Database Health Status
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/wrappers/dashboard/health/heath-grid.tsx b/src/components/wrappers/dashboard/health/heath-grid.tsx
new file mode 100644
index 00000000..50f9e157
--- /dev/null
+++ b/src/components/wrappers/dashboard/health/heath-grid.tsx
@@ -0,0 +1,204 @@
+"use client"
+
+import {useMemo} from "react"
+import {Card} from "@/components/ui/card"
+import {HealthcheckLog} from "@/db/schema/15_healthcheck-log"
+import {useIsMobile} from "@/hooks/use-mobile";
+
+type HealthStatus = "healthy" | "degraded" | "down" | "unknown"
+
+interface HealthCheckData {
+ timestamp: Date
+ status: HealthStatus
+}
+
+interface Props {
+ logs: HealthcheckLog[]
+}
+
+const INTERVAL_MINUTES = 10
+const WINDOW_HOURS = 12
+
+function roundDateToInterval(date: Date, intervalMinutes: number): Date {
+ const ms = intervalMinutes * 60 * 1000
+ return new Date(Math.floor(date.getTime() / ms) * ms)
+}
+
+function buildTimeSeries(logs: HealthcheckLog[]): HealthCheckData[] {
+ const intervalMs = INTERVAL_MINUTES * 60 * 1000
+ const now = new Date()
+ const roundedNow = roundDateToInterval(now, INTERVAL_MINUTES)
+
+ const buckets = (WINDOW_HOURS * 60) / INTERVAL_MINUTES
+ 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 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 formatTime(date: Date): string {
+ return date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ })
+}
+
+
+export const HealthCheckGraph = ({logs}: Props) => {
+ const data = useMemo(() => {
+ return buildTimeSeries(logs)
+ }, [logs])
+
+ const isMobile = useIsMobile()
+
+
+ const hourLabels = useMemo(() => {
+ if (data.length === 0) return []
+
+
+ return data
+ .map((item, index) => ({ item, index }))
+ .filter(({ item }) => {
+ const hours = item.timestamp.getHours()
+ const minutes = item.timestamp.getMinutes()
+ if (isMobile) {
+ return minutes === 0 && hours % 3 === 0
+ } else {
+ return minutes === 0
+ }
+ })
+ .map(({ item, index }) => ({
+ hour: formatTime(item.timestamp),
+ index,
+ }))
+ }, [data])
+
+ const healthyCount = data.filter((d) => d.status === "healthy").length
+
+ const uptimePercent =
+ data.length > 0
+ ? ((healthyCount / data.length) * 100).toFixed(1)
+ : "0.0"
+
+ return (
+
+
+
+
+
+
Health
+
+ Last 12 hours • {INTERVAL_MINUTES} minute intervals
+
+
+
+
+ {uptimePercent}%
+
+
Uptime
+
+
+
+
+
+ {hourLabels.map((label, i) => {
+ const left = (label.index / (data.length - 1)) * 100
+
+ return (
+
+ {label.hour}
+
+ )
+ })}
+
+
+
+
+ {data.map((item, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const Legend = ({color, label}: { color: string; label: string }) => (
+
+)
\ No newline at end of file
diff --git a/src/components/wrappers/dashboard/projects/database/database-content.tsx b/src/components/wrappers/dashboard/projects/database/database-content.tsx
index 22f0272b..e6f7f659 100644
--- a/src/components/wrappers/dashboard/projects/database/database-content.tsx
+++ b/src/components/wrappers/dashboard/projects/database/database-content.tsx
@@ -20,6 +20,8 @@ import { ChannelPoliciesModal } from "@/components/wrappers/dashboard/database/c
import { HardDrive, Megaphone } from "lucide-react";
import { ImportModal } from "@/components/wrappers/dashboard/database/import/import-modal";
import { BackupButton } from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
+import {HealthModal} from "@/components/wrappers/dashboard/database/health/health-modal";
+import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
export type DatabaseContentProps = {
settings: Setting;
@@ -34,6 +36,7 @@ export type DatabaseContentProps = {
organizationId: string;
activeOrganizationChannels: any[];
activeOrganizationStorageChannels: any[];
+ databaseHealthLogs: HealthcheckLog[]
};
export const DatabaseContent = (props: DatabaseContentProps) => {
@@ -64,6 +67,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
availableBackups: props.availableBackups,
successRate: props.successRate,
},
+ health: props.databaseHealthLogs
},
staleTime: 0,
gcTime: 0,
@@ -118,10 +122,13 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
organizationId={props.organizationId}
/>
+
+
+