diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b091eb20b..bc9a72c09 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1159,3 +1159,19 @@ export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; export type Network = InferSelectModel; + +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; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f30331d64..d7c947347 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -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; export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; +export type StatusHistory = InferSelectModel; diff --git a/server/routers/external.ts b/server/routers/external.ts index d7729bca5..7f9f2bdc4 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -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", diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts index 4d5fd5de4..426d80323 100644 --- a/server/routers/newt/offlineChecker.ts +++ b/server/routers/newt/offlineChecker.ts @@ -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); } } diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index 56429372d..b63bf97d3 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -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 { }, "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) { diff --git a/server/routers/site/getStatusHistory.ts b/server/routers/site/getStatusHistory.ts new file mode 100644 index 000000000..d17fa83a9 --- /dev/null +++ b/server/routers/site/getStatusHistory.ts @@ -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 { + 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(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 { + 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(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" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 3edf67c14..00fdeda91 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -1,4 +1,5 @@ export * from "./getSite"; +export * from "./getStatusHistory"; export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 87f47c17b..cc290c131 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -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( diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 71dc32e70..93114c5b2 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -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() { + + + Uptime + + Site availability over the last 90 days. + + + + {site?.siteId && ( + + )} + + ); } diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index ed5296c82..81299fcb4 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -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: () => Uptime (30d), + cell: ({ row }) => { + return ( + + ); + } + }, { accessorKey: "hcEnabled", friendlyName: t("alertingColumnEnabled"), diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 7fa635b87..68fbc0cac 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -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: () => Uptime (30d), + cell: ({ row }) => { + const originalRow = row.original; + return ( + + ); + } + }, { accessorKey: "mbIn", friendlyName: t("dataIn"), diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx new file mode 100644 index 000000000..be88a4a21 --- /dev/null +++ b/src/components/UptimeBar.tsx @@ -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 = { + 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 ( +
+ {title && ( +
{title}
+ )} +
+ {Array.from({ length: days }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (!data) return null; + + const allNoData = data.days.every((d) => d.status === "no_data"); + + return ( +
+ {/* Header row */} +
+ {title && ( + {title} + )} +
+ {!allNoData && ( + <> + + + {data.overallUptimePercent.toFixed(2)}% + {" "} + uptime + + {data.totalDowntimeSeconds > 0 && ( + + + {formatDuration( + data.totalDowntimeSeconds + )} + {" "} + downtime + + )} + + )} + {allNoData && ( + + No data available + + )} +
+
+ + {/* Bar row */} +
+ {data.days.map((day, i) => ( + + +
+ + +
+ {formatDate(day.date)} +
+ {day.status !== "no_data" && ( +
+ Uptime:{" "} + + {day.uptimePercent.toFixed(1)}% + +
+ )} + {day.totalDowntimeSeconds > 0 && ( +
+ Downtime:{" "} + + {formatDuration( + day.totalDowntimeSeconds + )} + +
+ )} + {day.downtimeWindows.length > 0 && ( +
+ {day.downtimeWindows.map((w, wi) => ( +
+ {formatTime(w.start)} + {w.end + ? ` – ${formatTime(w.end)}` + : " – ongoing"}{" "} + + ({w.status}) + +
+ ))} +
+ )} + {day.status === "no_data" && ( +
+ No monitoring data +
+ )} +
+ + ))} +
+ + {/* Date labels */} +
+ {days} days ago + Today +
+
+ ); +} \ No newline at end of file diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx new file mode 100644 index 000000000..b92a9d765 --- /dev/null +++ b/src/components/UptimeMiniBar.tsx @@ -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 = { + 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 ( +
+
+ {Array.from({ length: days }).map((_, i) => ( +
+ ))} +
+ +
+ ); + } + + if (!data) return null; + + const allNoData = data.days.every((d) => d.status === "no_data"); + + return ( +
+
+ {data.days.map((day, i) => ( + + +
+ + +
+ {formatDate(day.date)} +
+
+ {day.status === "no_data" + ? "No data" + : `${day.uptimePercent.toFixed(1)}% uptime`} +
+ {day.totalDowntimeSeconds > 0 && ( +
+ Down:{" "} + {formatDuration(day.totalDowntimeSeconds)} +
+ )} +
+ + ))} +
+ + {allNoData + ? "No data" + : `${data.overallUptimePercent.toFixed(1)}%`} + +
+ ); +} \ No newline at end of file diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f658f9fee..5e1d6ea38 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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 + >(`/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 + >(`/target/${targetId}/health-check/status-history?days=${days}`, { signal }); + return res.data.data; + }, + refetchInterval: 60_000, + }), }; export const logAnalyticsFiltersSchema = z.object({