mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-04 19:44:47 +00:00
Add uptime tracking
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import HealthCheckCredenza, {
|
||||
HealthCheckRow
|
||||
@@ -219,6 +221,16 @@ export default function HealthChecksTable({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
friendlyName: "Uptime",
|
||||
header: () => <span className="p-3">Uptime (30d)</span>,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<UptimeMiniBar targetId={row.original.targetHealthCheckId} days={30} />
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "hcEnabled",
|
||||
friendlyName: t("alertingColumnEnabled"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
||||
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
@@ -222,6 +223,17 @@ export default function SitesTable({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
friendlyName: "Uptime",
|
||||
header: () => <span className="p-3">Uptime (30d)</span>,
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
return (
|
||||
<UptimeMiniBar siteId={originalRow.id} days={30} />
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
friendlyName: t("dataIn"),
|
||||
|
||||
208
src/components/UptimeBar.tsx
Normal file
208
src/components/UptimeBar.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds === 0) return "0s";
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
if (h > 0) return s > 0 ? `${h}h ${m}m ${s}s` : `${h}h ${m}m`;
|
||||
if (m > 0 && s > 0) return `${m}m ${s}s`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr + "T00:00:00").toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric"
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
const barColorClass: Record<string, string> = {
|
||||
good: "bg-green-500",
|
||||
degraded: "bg-yellow-500",
|
||||
bad: "bg-red-500",
|
||||
no_data: "bg-zinc-700"
|
||||
};
|
||||
|
||||
type UptimeBarProps = {
|
||||
siteId?: number;
|
||||
targetId?: number;
|
||||
days?: number;
|
||||
title?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function UptimeBar({
|
||||
siteId,
|
||||
targetId,
|
||||
days = 90,
|
||||
title,
|
||||
className
|
||||
}: UptimeBarProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const siteQuery = useQuery({
|
||||
...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }),
|
||||
enabled: siteId != null,
|
||||
meta: { api }
|
||||
});
|
||||
|
||||
const hcQuery = useQuery({
|
||||
...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }),
|
||||
enabled: targetId != null && siteId == null,
|
||||
meta: { api }
|
||||
});
|
||||
|
||||
const { data, isLoading } = siteId != null ? siteQuery : hcQuery;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{title && (
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 h-8">
|
||||
{Array.from({ length: days }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-sm bg-zinc-800 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
{title && (
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm ml-auto">
|
||||
{!allNoData && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
{data.overallUptimePercent.toFixed(2)}%
|
||||
</span>{" "}
|
||||
uptime
|
||||
</span>
|
||||
{data.totalDowntimeSeconds > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDuration(
|
||||
data.totalDowntimeSeconds
|
||||
)}
|
||||
</span>{" "}
|
||||
downtime
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{allNoData && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
No data available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar row */}
|
||||
<div className="flex gap-0.5 h-8">
|
||||
{data.days.map((day, i) => (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 rounded-sm cursor-default transition-opacity hover:opacity-80",
|
||||
barColorClass[day.status]
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-[220px] p-3 space-y-1"
|
||||
>
|
||||
<div className="font-semibold text-xs">
|
||||
{formatDate(day.date)}
|
||||
</div>
|
||||
{day.status !== "no_data" && (
|
||||
<div className="text-xs text-primary-foreground/80">
|
||||
Uptime:{" "}
|
||||
<span className="font-medium text-primary-foreground">
|
||||
{day.uptimePercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{day.totalDowntimeSeconds > 0 && (
|
||||
<div className="text-xs text-primary-foreground/80">
|
||||
Downtime:{" "}
|
||||
<span className="font-medium text-primary-foreground">
|
||||
{formatDuration(
|
||||
day.totalDowntimeSeconds
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{day.downtimeWindows.length > 0 && (
|
||||
<div className="pt-1 space-y-0.5 border-t border-primary-foreground/20">
|
||||
{day.downtimeWindows.map((w, wi) => (
|
||||
<div
|
||||
key={wi}
|
||||
className="text-xs text-primary-foreground/70"
|
||||
>
|
||||
{formatTime(w.start)}
|
||||
{w.end
|
||||
? ` – ${formatTime(w.end)}`
|
||||
: " – ongoing"}{" "}
|
||||
<span className="capitalize">
|
||||
({w.status})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{day.status === "no_data" && (
|
||||
<div className="text-xs text-primary-foreground/60">
|
||||
No monitoring data
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Date labels */}
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{days} days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/components/UptimeMiniBar.tsx
Normal file
128
src/components/UptimeMiniBar.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds === 0) return "0s";
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0 && s > 0) return `${m}m ${s}s`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr + "T00:00:00").toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
});
|
||||
}
|
||||
|
||||
const barColorClass: Record<string, string> = {
|
||||
good: "bg-green-500",
|
||||
degraded: "bg-yellow-500",
|
||||
bad: "bg-red-500",
|
||||
no_data: "bg-zinc-700"
|
||||
};
|
||||
|
||||
type UptimeMiniBarProps = {
|
||||
siteId?: number;
|
||||
targetId?: number;
|
||||
days?: number;
|
||||
};
|
||||
|
||||
export default function UptimeMiniBar({
|
||||
siteId,
|
||||
targetId,
|
||||
days = 30
|
||||
}: UptimeMiniBarProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const siteQuery = useQuery({
|
||||
...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }),
|
||||
enabled: siteId != null,
|
||||
meta: { api }
|
||||
});
|
||||
|
||||
const hcQuery = useQuery({
|
||||
...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }),
|
||||
enabled: targetId != null && siteId == null,
|
||||
meta: { api }
|
||||
});
|
||||
|
||||
const { data, isLoading } = siteId != null ? siteQuery : hcQuery;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-px h-5 w-24">
|
||||
{Array.from({ length: days }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[2px] bg-zinc-800 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-12">—</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex gap-px h-5"
|
||||
style={{ width: `${days * 5}px` }}
|
||||
>
|
||||
{data.days.map((day, i) => (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 rounded-[2px] cursor-default transition-opacity hover:opacity-75",
|
||||
barColorClass[day.status]
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="p-2 space-y-0.5">
|
||||
<div className="font-semibold text-xs">
|
||||
{formatDate(day.date)}
|
||||
</div>
|
||||
<div className="text-xs text-primary-foreground/80">
|
||||
{day.status === "no_data"
|
||||
? "No data"
|
||||
: `${day.uptimePercent.toFixed(1)}% uptime`}
|
||||
</div>
|
||||
{day.totalDowntimeSeconds > 0 && (
|
||||
<div className="text-xs text-primary-foreground/70">
|
||||
Down:{" "}
|
||||
{formatDuration(day.totalDowntimeSeconds)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{allNoData
|
||||
? "No data"
|
||||
: `${data.overallUptimePercent.toFixed(1)}%`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user