Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ AUTH_PASSKEY_ENABLED=true

# Retention
RETENTION_CRON="* * * * *"

# Health Ping (check database connectivity)
HEALTH_PING_CRON="* * * * *"
Comment on lines +62 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

HEALTH_PING_CRON example conflicts with the runtime default cadence.

Line 63 sets a 1-minute schedule, but runtime default is 30 seconds. This mismatch will alter health-failure detection sensitivity depending on deployment source.

🛠️ Proposed fix
 # Health Ping (check database connectivity)
-HEALTH_PING_CRON="* * * * *"
+HEALTH_PING_CRON="*/30 * * * * *"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Health Ping (check database connectivity)
HEALTH_PING_CRON="* * * * *"
# Health Ping (check database connectivity)
HEALTH_PING_CRON="*/30 * * * * *"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example around lines 62 - 63, The HEALTH_PING_CRON example currently
shows a 1-minute schedule but the service runtime uses a 30-second default;
update the HEALTH_PING_CRON example value to match the runtime default (30s) and
clarify format expectations: change the value for HEALTH_PING_CRON to the
runtime-compatible 30‑second expression and add a short comment noting that the
cron expression must include seconds if the runtime expects a 6-field cron
(e.g., the runtime's 30s cron format).

64 changes: 64 additions & 0 deletions app/(customer)/dashboard/(admin)/health/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageDescription, PageHeader, PageTitle} from "@/features/layout/page";
import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation";
import {listOrganizations} from "@/lib/auth/auth";
import {getDatabasesWithHealth, getHealthPingFailures} from "@/db/services/health-ping";
import {getAllHealthDashboardPreferences} from "@/db/services/health-dashboard-preference";
import {HealthStatusList} from "@/components/wrappers/dashboard/health/health-status-list";
import {Metadata} from "next";

export const metadata: Metadata = {
title: "Health Status",
};

export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
const organizations = await listOrganizations();

if (!user || !organizations) notFound();

const organizationIds = organizations.map((org) => org.id);
const databases = await getDatabasesWithHealth(organizationIds);
const databaseIds = databases.map((db) => db.id);

const [failedPings, preferences] = await Promise.all([
getHealthPingFailures(databaseIds, 180),
getAllHealthDashboardPreferences(user.id),
]);
Comment on lines +25 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Aggregate failure history on the server.

This page fetches raw health_ping_fail events for a 180-day window and materializes every timestamp. With the 30-second cron in this PR, one day of outage is 2,880 rows per database, so a few prolonged failures will make this page very heavy. Have getHealthPingFailures return per-database/per-day counts instead; app/(customer)/dashboard/home/page.tsx can consume the same shape.

Also applies to: 42-45

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(customer)/dashboard/(admin)/health/page.tsx around lines 25 - 28, The
page is loading raw health_ping_fail rows for a 180-day window causing huge
payloads; change getHealthPingFailures to aggregate on the server and return
per-database, per-day counts (e.g., [{ databaseId, date, failCount }, ...]) for
the requested window (databaseIds, days) instead of materialized timestamps, and
update the callers in app/(customer)/dashboard/(admin)/health/page.tsx and
app/(customer)/dashboard/home/page.tsx to consume that aggregated shape; ensure
the aggregation groups by database ID and date (UTC) and still respects the same
date range parameter (180 days) and any pagination/filtering semantics.


const prefsMap: Record<string, boolean> = {};
for (const pref of preferences) {
prefsMap[pref.databaseId] = pref.visible;
}

const serializedDatabases = databases.map((db) => ({
id: db.id,
name: db.name,
dbms: db.dbms,
lastContact: db.lastContact,
}));

const serializedFailures = failedPings.map((f) => ({
databaseId: f.databaseId,
timestamp: f.timestamp,
}));

return (
<Page>
<PageHeader>
<PageTitle>Health Status</PageTitle>
</PageHeader>
<PageDescription>
Monitor the health of your agents and databases. Toggle the switch to pin a chart to your main dashboard.
</PageDescription>
<PageContent>
<HealthStatusList
databases={serializedDatabases}
failedPings={serializedFailures}
preferences={prefsMap}
/>
</PageContent>
</Page>
);
}
39 changes: 39 additions & 0 deletions app/(customer)/dashboard/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {asc, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next";
import {getHealthDashboardPreferences} from "@/db/services/health-dashboard-preference";
import {getHealthPingFailures} from "@/db/services/health-ping";
import {HealthPingChart} from "@/components/wrappers/dashboard/health/health-ping-chart";
import {Badge} from "@/components/ui/badge";

export const metadata: Metadata = {
title: "Home",
Expand Down Expand Up @@ -50,6 +54,15 @@ export default async function RoutePage(props: PageParams<{}>) {

const availableBackups = backupsEvolution.filter(backup => backup.deletedAt == null);

// Health dashboard: fetch pinned databases and their failure data
const pinnedPrefs = await getHealthDashboardPreferences(user.id);
const pinnedDbIds = pinnedPrefs.map((p) => p.databaseId);
const pinnedDatabases = databasesOfAllProjects.filter((db) => pinnedDbIds.includes(db.id));

let pinnedFailures: {databaseId: string; databaseName: string; timestamp: Date}[] = [];
if (pinnedDbIds.length > 0) {
pinnedFailures = await getHealthPingFailures(pinnedDbIds, 180);
}

return (
<Page>
Expand Down Expand Up @@ -124,6 +137,32 @@ export default async function RoutePage(props: PageParams<{}>) {
</CardContent>
</Card>
</div>
{pinnedDatabases.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Health Monitoring</h2>
<Badge variant="secondary" className="text-xs">Agent Health</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{pinnedDatabases.map((database) => {
const dbFailures = pinnedFailures
.filter((f) => f.databaseId === database.id)
.map((f) => ({timestamp: f.timestamp}));

return (
<HealthPingChart
key={database.id}
databaseName={database.name}
databaseId={database.id}
dbms={database.dbms}
lastContact={database.lastContact}
failedPings={dbFailures}
/>
);
})}
</div>
</div>
)}
{/*Do not delete*/}
{/*<div className="flex flex-1 flex-col gap-4">*/}
{/* <div className="grid auto-rows-min gap-4 md:grid-cols-3">*/}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
Layers,
ChartArea,
ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen,
HeartPulse
} from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
import {authClient} from "@/lib/auth/auth-client";
Expand Down Expand Up @@ -62,6 +63,12 @@ export const SidebarMenuCustomMain = () => {
details: true,
type: "item"
},
{
title: "Health Status",
url: "/health",
icon: HeartPulse,
type: "item"
},
{
title: "Notifications",
url: "/notifications",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', 'health_ping_fail'
]))
.optional(),
enabled: z.boolean().default(true),
Expand All @@ -24,6 +24,7 @@ export const EVENT_KIND_OPTIONS = [
{label: "Success Restore", value: "success_restore"},
{label: "Success Backup", value: "success_backup"},
// {label: "Weekly Report", value: "weekly_report"},
{label: "Health Ping Fail", value: "health_ping_fail"},
];

export const EVENT_KIND_BACKUP_ONLY_OPTIONS = [
Expand Down
178 changes: 178 additions & 0 deletions src/components/wrappers/dashboard/health/health-ping-chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"use client";

import {useMemo} from "react";
import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";

export type HealthPingChartProps = {
databaseName: string;
databaseId: string;
dbms: string;
lastContact: Date | null;
failedPings: {timestamp: Date}[];
days?: number;
};

type CellData = {
date: Date;
dayLabel: string;
failures: number;
isFuture: boolean;
isToday: boolean;
};

function buildGrid(pastDays: number, futureDays: number, failedPings: {timestamp: Date}[]): CellData[] {
const now = new Date();
now.setHours(0, 0, 0, 0);
const cells: CellData[] = [];

const failuresByDay = new Map<string, number>();
for (const ping of failedPings) {
const d = new Date(ping.timestamp);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
failuresByDay.set(key, (failuresByDay.get(key) || 0) + 1);
}

for (let i = pastDays; i >= -futureDays; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);

const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
const isToday = i === 0;
const isFuture = i < 0;
const failures = isFuture ? 0 : (failuresByDay.get(key) || 0);

const dayLabel = date.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
});

cells.push({date, dayLabel, failures, isFuture, isToday});
}

return cells;
}

function getCellColor(cell: CellData): string {
if (cell.isFuture) return "bg-muted";
if (cell.failures > 0) {
if (cell.failures >= 10) return "bg-red-600";
if (cell.failures >= 5) return "bg-red-500";
return "bg-red-400";
}
return "bg-green-500";
}

export const HealthPingChart = ({
databaseName,
databaseId,
dbms,
lastContact,
failedPings,
days = 90,
}: HealthPingChartProps) => {
Comment on lines +69 to +76
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Unused databaseId prop.

The databaseId prop is destructured but never used in the component. Either remove it from the props or use it (e.g., for analytics, linking, or as a key).

 export const HealthPingChart = ({
     databaseName,
-    databaseId,
     dbms,
     lastContact,
     failedPings,
     days = 90,
 }: HealthPingChartProps) => {

If it's intentionally kept for future use, consider prefixing with underscore: _databaseId.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/wrappers/dashboard/health/health-ping-chart.tsx` around lines
69 - 76, The HealthPingChart component destructures databaseId but doesn't use
it; either remove databaseId from the HealthPingChart parameter list and the
corresponding HealthPingChartProps definition, or keep it and use it (for
example as a key on the root element, in analytics calls, or to build links) —
if you intend to reserve it for future use, rename it to _databaseId to silence
unused-var warnings; update the HealthPingChartProps type accordingly and ensure
any callers of HealthPingChart are adjusted to match the prop change.

const cells = useMemo(() => buildGrid(days, days, failedPings), [days, failedPings]);

const weeks: (CellData | null)[][] = useMemo(() => {
const result: (CellData | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) {
const week: (CellData | null)[] = cells.slice(i, i + 7);
while (week.length < 7) {
week.push(null);
}
result.push(week);
}
return result;
}, [cells]);

const pastCells = cells.filter((c) => !c.isFuture);
const totalFailures = failedPings.length;
const healthyDays = pastCells.filter((c) => c.failures === 0).length;

return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-3">
<ConnectionIndicator date={lastContact}/>
<div>
<CardTitle className="text-sm font-medium">{databaseName}</CardTitle>
<p className="text-xs text-muted-foreground uppercase">{dbms}</p>
</div>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">
{healthyDays}/{pastCells.length} healthy days
</p>
{totalFailures > 0 && (
<p className="text-xs text-red-500">{totalFailures} failure(s)</p>
)}
</div>
</CardHeader>
<CardContent>
<div className="flex gap-0.5 overflow-x-auto pb-2">
{weeks.map((week, weekIdx) => (
<div key={weekIdx} className="flex flex-col gap-0.5">
{week.map((cell, dayIdx) => (
cell === null ? (
<div
key={`${weekIdx}-${dayIdx}`}
className="w-3 h-3 rounded-full bg-muted"
/>
) : cell.isFuture ? (
<Tooltip key={`${weekIdx}-${dayIdx}`}>
<TooltipTrigger asChild>
<div className="w-3 h-3 rounded-full bg-muted cursor-default"/>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{cell.dayLabel}</p>
<p>Forecast</p>
</TooltipContent>
</Tooltip>
) : (
<Tooltip key={`${weekIdx}-${dayIdx}`}>
<TooltipTrigger asChild>
<div
className={cn(
"w-3 h-3 rounded-full cursor-default transition-colors",
getCellColor(cell),
cell.isToday && "ring-2 ring-foreground/50"
)}
/>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">
{cell.dayLabel}
{cell.isToday && " (Today)"}
</p>
<p>
{cell.failures === 0
? "Healthy"
: `${cell.failures} failure(s)`}
</p>
</TooltipContent>
</Tooltip>
)
))}
</div>
))}
</div>
Comment on lines +114 to +161
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Accessibility: chart lacks screen reader support.

The contribution grid uses <div> elements without ARIA attributes. Screen reader users cannot understand the health data visualization. Consider:

  1. Adding role="img" to the chart container with an aria-label summarizing the data.
  2. Or using a visually hidden summary text.
♿ Suggested accessibility improvement
 <CardContent>
-    <div className="flex gap-0.5 overflow-x-auto pb-2">
+    <div 
+        className="flex gap-0.5 overflow-x-auto pb-2"
+        role="img"
+        aria-label={`Health chart showing ${healthyDays} healthy days out of ${pastCells.length} past days with ${totalFailures} total failures`}
+    >
         {weeks.map((week, weekIdx) => (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/wrappers/dashboard/health/health-ping-chart.tsx` around lines
114 - 161, The chart is not accessible to screen readers; update the top-level
chart container (the div wrapping weeks in health-ping-chart.tsx — the one with
className "flex gap-0.5 overflow-x-auto pb-2") to include role="img" and an
aria-label that summarizes the data (derive a brief summary from the existing
weeks/cell data such as total days, total failures or latest status using the
weeks array and cell.{dayLabel,failures,isToday,isFuture}); keep the individual
visual cells (rendered from weeks.map) decorative by adding aria-hidden="true"
to those cell divs (the null/future/normal cell branches) and ensure
Tooltip/TooltipContent remains keyboard accessible for detailed info or also
include a visually hidden summary element (sr-only) that exposes per-day details
if you prefer that approach instead of a single aria-label.

<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>Less</span>
<div className="flex gap-0.5">
<div className="w-3 h-3 rounded-full bg-green-500"/>
<div className="w-3 h-3 rounded-full bg-red-400"/>
<div className="w-3 h-3 rounded-full bg-red-500"/>
<div className="w-3 h-3 rounded-full bg-red-600"/>
</div>
<span>More</span>
<span className="ml-2">|</span>
<div className="w-3 h-3 rounded-full bg-muted"/>
<span>Forecast</span>
</div>
</CardContent>
</Card>
);
};
Loading
Loading