mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 04:32:53 +00:00
Add uptime tracking
This commit is contained in:
@@ -1159,3 +1159,19 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
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>;
|
||||
|
||||
@@ -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", {
|
||||
messageId: integer("messageId").primaryKey({ autoIncrement: true }),
|
||||
wsClientId: text("clientId"),
|
||||
@@ -1258,3 +1272,4 @@ export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||
export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
|
||||
@@ -285,6 +285,20 @@ authenticated.get(
|
||||
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
|
||||
authenticated.put(
|
||||
"/org/:orgId/site-resource",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, newts, sites, targetHealthCheck, targets } from "@server/db";
|
||||
import { db, newts, sites, targetHealthCheck, targets, statusHistory } from "@server/db";
|
||||
import {
|
||||
hasActiveConnections,
|
||||
} from "#dynamic/routers/ws";
|
||||
@@ -77,6 +77,14 @@ export const startNewtOfflineChecker = (): void => {
|
||||
.set({ online: false })
|
||||
.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
|
||||
.select()
|
||||
.from(targetHealthCheck)
|
||||
@@ -147,6 +155,14 @@ export const startNewtOfflineChecker = (): void => {
|
||||
.set({ online: false })
|
||||
.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);
|
||||
} else if (
|
||||
lastBandwidthUpdate >= wireguardOfflineThreshold &&
|
||||
@@ -161,6 +177,14 @@ export const startNewtOfflineChecker = (): void => {
|
||||
.set({ online: true })
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 logger from "@server/logger";
|
||||
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
|
||||
@@ -147,6 +147,13 @@ async function flushSitePingsToDb(): Promise<void> {
|
||||
}, "flushSitePingsToDb");
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
305
server/routers/site/getStatusHistory.ts
Normal file
305
server/routers/site/getStatusHistory.ts
Normal 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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./getSite";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./createSite";
|
||||
export * from "./deleteSite";
|
||||
export * from "./updateSite";
|
||||
|
||||
@@ -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 { Newt } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -137,6 +137,15 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
.where(eq(targetHealthCheck.targetId, targetIdNum))
|
||||
.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
|
||||
if (healthStatus.status === "unhealthy") {
|
||||
await fireHealthCheckHealthyAlert(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import UptimeBar from "@app/components/UptimeBar";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -223,6 +225,19 @@ export default function GeneralPage() {
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { build } from "@server/build";
|
||||
import type { StatusHistoryResponse } from "@server/routers/site/getStatusHistory";
|
||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||
import type { ListClientsResponse } from "@server/routers/client";
|
||||
import type { ListDomainsResponse } from "@server/routers/domain";
|
||||
@@ -304,7 +305,30 @@ export const orgQueries = {
|
||||
>(`/org/${orgId}/health-checks`, { signal });
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user