Add uptime tracking

This commit is contained in:
Owen
2026-04-16 18:25:25 -07:00
parent d6c15c8b81
commit c1782a2650
14 changed files with 794 additions and 4 deletions

View File

@@ -1159,3 +1159,19 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker typeof roundTripMessageTracker
>; >;
export type Network = InferSelectModel<typeof networks>; export type Network = InferSelectModel<typeof networks>;
export const statusHistory = pgTable("statusHistory", {
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull(),
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export type StatusHistory = InferSelectModel<typeof statusHistory>;

View File

@@ -1181,6 +1181,20 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", {
}) })
}); });
export const statusHistory = sqliteTable("statusHistory", {
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull(), // unix epoch seconds
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", { export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
messageId: integer("messageId").primaryKey({ autoIncrement: true }), messageId: integer("messageId").primaryKey({ autoIncrement: true }),
wsClientId: text("clientId"), wsClientId: text("clientId"),
@@ -1258,3 +1272,4 @@ export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
export type RoundTripMessageTracker = InferSelectModel< export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker typeof roundTripMessageTracker
>; >;
export type StatusHistory = InferSelectModel<typeof statusHistory>;

View File

@@ -285,6 +285,20 @@ authenticated.get(
site.listContainers site.listContainers
); );
authenticated.get(
"/site/:siteId/status-history",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.getSite),
site.getSiteStatusHistory
);
authenticated.get(
"/target/:targetId/health-check/status-history",
verifyTargetAccess,
verifyUserHasAction(ActionsEnum.getTarget),
site.getHealthCheckStatusHistory
);
// Site Resource endpoints // Site Resource endpoints
authenticated.put( authenticated.put(
"/org/:orgId/site-resource", "/org/:orgId/site-resource",

View File

@@ -1,4 +1,4 @@
import { db, newts, sites, targetHealthCheck, targets } from "@server/db"; import { db, newts, sites, targetHealthCheck, targets, statusHistory } from "@server/db";
import { import {
hasActiveConnections, hasActiveConnections,
} from "#dynamic/routers/ws"; } from "#dynamic/routers/ws";
@@ -77,6 +77,14 @@ export const startNewtOfflineChecker = (): void => {
.set({ online: false }) .set({ online: false })
.where(eq(sites.siteId, staleSite.siteId)); .where(eq(sites.siteId, staleSite.siteId));
await db.insert(statusHistory).values({
entityType: "site",
entityId: staleSite.siteId,
orgId: staleSite.orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
const healthChecksOnSite = await db const healthChecksOnSite = await db
.select() .select()
.from(targetHealthCheck) .from(targetHealthCheck)
@@ -147,6 +155,14 @@ export const startNewtOfflineChecker = (): void => {
.set({ online: false }) .set({ online: false })
.where(eq(sites.siteId, site.siteId)); .where(eq(sites.siteId, site.siteId));
await db.insert(statusHistory).values({
entityType: "site",
entityId: site.siteId,
orgId: site.orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name); await fireSiteOfflineAlert(site.orgId, site.siteId, site.name);
} else if ( } else if (
lastBandwidthUpdate >= wireguardOfflineThreshold && lastBandwidthUpdate >= wireguardOfflineThreshold &&
@@ -161,6 +177,14 @@ export const startNewtOfflineChecker = (): void => {
.set({ online: true }) .set({ online: true })
.where(eq(sites.siteId, site.siteId)); .where(eq(sites.siteId, site.siteId));
await db.insert(statusHistory).values({
entityType: "site",
entityId: site.siteId,
orgId: site.orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name); await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
} }
} }

View File

@@ -1,5 +1,5 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { sites, clients, olms } from "@server/db"; import { sites, clients, olms, statusHistory } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts"; import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
@@ -147,6 +147,13 @@ async function flushSitePingsToDb(): Promise<void> {
}, "flushSitePingsToDb"); }, "flushSitePingsToDb");
for (const site of newlyOnlineSites) { for (const site of newlyOnlineSites) {
await db.insert(statusHistory).values({
entityType: "site",
entityId: site.siteId,
orgId: site.orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name); await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
} }
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,305 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const siteParamsSchema = z.object({
siteId: z.string().transform((v) => parseInt(v, 10)),
});
const healthCheckParamsSchema = z.object({
targetHealthCheckId: z.string().transform((v) => parseInt(v, 10)),
});
const querySchema = z
.object({
days: z
.string()
.optional()
.transform((v) => (v ? parseInt(v, 10) : 90)),
})
.pipe(
z.object({
days: z.number().int().min(1).max(365),
})
);
export interface DayBucket {
date: string; // ISO date "YYYY-MM-DD"
uptimePercent: number; // 0-100
totalDowntimeSeconds: number;
downtimeWindows: { start: number; end: number | null; status: string }[];
status: "good" | "degraded" | "bad" | "no_data";
}
export interface StatusHistoryResponse {
entityType: string;
entityId: number;
days: DayBucket[];
overallUptimePercent: number;
totalDowntimeSeconds: number;
}
function computeBuckets(
events: { entityType: string; entityId: number; orgId: string; status: string; timestamp: number; id: number }[],
days: number
): { buckets: DayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000);
const buckets: DayBucket[] = [];
let totalDowntime = 0;
for (let d = 0; d < days; d++) {
const dayStartSec = nowSec - (days - d) * 86400;
const dayEndSec = dayStartSec + 86400;
const dayEvents = events.filter(
(e) => e.timestamp >= dayStartSec && e.timestamp < dayEndSec
);
// Determine the status at the start of this day (last event before dayStart)
const lastBeforeDay = [...events]
.filter((e) => e.timestamp < dayStartSec)
.at(-1);
let currentStatus = lastBeforeDay?.status ?? null;
const windows: { start: number; end: number | null; status: string }[] = [];
let dayDowntime = 0;
let windowStart = dayStartSec;
let windowStatus = currentStatus;
for (const evt of dayEvents) {
if (windowStatus !== null && windowStatus !== evt.status) {
const windowEnd = evt.timestamp;
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy" ||
windowStatus === "unknown";
if (isDown) {
dayDowntime += windowEnd - windowStart;
windows.push({
start: windowStart,
end: windowEnd,
status: windowStatus,
});
}
}
windowStart = evt.timestamp;
windowStatus = evt.status;
}
// Close the final window at the end of the day (or now if day hasn't ended)
if (windowStatus !== null) {
const finalEnd = Math.min(dayEndSec, nowSec);
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy" ||
windowStatus === "unknown";
if (isDown && finalEnd > windowStart) {
dayDowntime += finalEnd - windowStart;
windows.push({
start: windowStart,
end: finalEnd,
status: windowStatus,
});
}
}
totalDowntime += dayDowntime;
const effectiveDayLength = Math.max(
0,
Math.min(dayEndSec, nowSec) - dayStartSec
);
const uptimePct =
effectiveDayLength > 0
? Math.max(
0,
((effectiveDayLength - dayDowntime) /
effectiveDayLength) *
100
)
: 100;
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
let status: DayBucket["status"] = "no_data";
if (currentStatus !== null || dayEvents.length > 0) {
if (uptimePct >= 99) status = "good";
else if (uptimePct >= 50) status = "degraded";
else status = "bad";
}
buckets.push({
date: dateStr,
uptimePercent: Math.round(uptimePct * 100) / 100,
totalDowntimeSeconds: dayDowntime,
downtimeWindows: windows,
status,
});
}
return { buckets, totalDowntime };
}
export async function getSiteStatusHistory(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = siteParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const entityType = "site";
const entityId = parsedParams.data.siteId;
const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await db
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(
0,
((totalWindow - totalDowntime) / totalWindow) * 100
)
: 100;
return response<StatusHistoryResponse>(res, {
data: {
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime,
},
success: true,
error: false,
message: "Status history retrieved successfully",
status: HttpCode.OK,
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred"
)
);
}
}
export async function getHealthCheckStatusHistory(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = healthCheckParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const entityType = "healthCheck";
const entityId = parsedParams.data.targetHealthCheckId;
const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await db
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(
0,
((totalWindow - totalDowntime) / totalWindow) * 100
)
: 100;
return response<StatusHistoryResponse>(res, {
data: {
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime,
},
success: true,
error: false,
message: "Status history retrieved successfully",
status: HttpCode.OK,
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred"
)
);
}
}

View File

@@ -1,4 +1,5 @@
export * from "./getSite"; export * from "./getSite";
export * from "./getStatusHistory";
export * from "./createSite"; export * from "./createSite";
export * from "./deleteSite"; export * from "./deleteSite";
export * from "./updateSite"; export * from "./updateSite";

View File

@@ -1,4 +1,4 @@
import { db, targets, resources, sites, targetHealthCheck } from "@server/db"; import { db, targets, resources, sites, targetHealthCheck, statusHistory } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { Newt } from "@server/db"; import { Newt } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
@@ -137,6 +137,15 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
.where(eq(targetHealthCheck.targetId, targetIdNum)) .where(eq(targetHealthCheck.targetId, targetIdNum))
.execute(); .execute();
// Log the state change to status history
await db.insert(statusHistory).values({
entityType: "healthCheck",
entityId: targetCheck.targetHealthCheckId,
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
status: healthStatus.status,
timestamp: Math.floor(Date.now() / 1000),
}).execute();
// because we are checking above if there was a change we can fire the alert here because it changed // because we are checking above if there was a change we can fire the alert here because it changed
if (healthStatus.status === "unhealthy") { if (healthStatus.status === "unhealthy") {
await fireHealthCheckHealthyAlert( await fireHealthCheckHealthyAlert(

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import UptimeBar from "@app/components/UptimeBar";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -223,6 +225,19 @@ export default function GeneralPage() {
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last 90 days.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{site?.siteId && (
<UptimeBar siteId={site.siteId} days={90} />
)}
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer> </SettingsContainer>
); );
} }

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import UptimeMiniBar from "@app/components/UptimeMiniBar";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import HealthCheckCredenza, { import HealthCheckCredenza, {
HealthCheckRow 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", accessorKey: "hcEnabled",
friendlyName: t("alertingColumnEnabled"), friendlyName: t("alertingColumnEnabled"),

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import UptimeMiniBar from "@app/components/UptimeMiniBar";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; 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", accessorKey: "mbIn",
friendlyName: t("dataIn"), friendlyName: t("dataIn"),

View 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>
);
}

View 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>
);
}

View File

@@ -1,4 +1,5 @@
import { build } from "@server/build"; import { build } from "@server/build";
import type { StatusHistoryResponse } from "@server/routers/site/getStatusHistory";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
import type { ListClientsResponse } from "@server/routers/client"; import type { ListClientsResponse } from "@server/routers/client";
import type { ListDomainsResponse } from "@server/routers/domain"; import type { ListDomainsResponse } from "@server/routers/domain";
@@ -304,7 +305,30 @@ export const orgQueries = {
>(`/org/${orgId}/health-checks`, { signal }); >(`/org/${orgId}/health-checks`, { signal });
return res.data.data.healthChecks; return res.data.data.healthChecks;
} }
}) }),
siteStatusHistory: ({ siteId, days = 90 }: { siteId: number; days?: number }) =>
queryOptions({
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse>
>(`/site/${siteId}/status-history?days=${days}`, { signal });
return res.data.data;
},
refetchInterval: 60_000,
}),
healthCheckStatusHistory: ({ targetId, days = 90 }: { targetId: number; days?: number }) =>
queryOptions({
queryKey: ["HC_STATUS_HISTORY", targetId, days] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse>
>(`/target/${targetId}/health-check/status-history?days=${days}`, { signal });
return res.data.data;
},
refetchInterval: 60_000,
}),
}; };
export const logAnalyticsFiltersSchema = z.object({ export const logAnalyticsFiltersSchema = z.object({