From 1580b7abff4d434baf550e1057a23bad76cdd1c2 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 10 Jul 2026 07:02:22 +0200 Subject: [PATCH 01/20] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20batch=20status=20hi?= =?UTF-8?q?stories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 80 ++++++++++++++++++- .../routers/site/getBatchedStatusHistory.ts | 62 ++++++++++++++ src/lib/queries.ts | 22 +++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 server/routers/site/getBatchedStatusHistory.ts diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 7c5b5c370..e3b13270b 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { db, logsDb, statusHistory } from "@server/db"; -import { and, eq, gte, lt, asc, desc } from "drizzle-orm"; +import { and, eq, gte, lt, asc, desc, inArray } from "drizzle-orm"; import { regionalCache as cache } from "#dynamic/lib/cache"; const STATUS_HISTORY_CACHE_TTL = 60; // seconds @@ -273,3 +273,81 @@ export function computeBuckets( return { buckets, totalDowntime }; } + +export interface BatchedStatusHistoryResponse { + entityType: string; + entityIds: number[]; + days: StatusHistoryDayBucket[]; + overallUptimePercent: number; + totalDowntimeSeconds: number; +} + +export async function getBatchedStatusHistory( + entityType: string, + entityIds: number[], + days: number +): Promise { + // const cacheKey = statusHistoryCacheKey(entityType, entityId, days); + // const cached = await cache.get(cacheKey); + // if (cached !== undefined) { + // return cached; + // } + + // Anchor to UTC midnight so the query window aligns with stable calendar days + const utcToday = new Date(); + utcToday.setUTCHours(0, 0, 0, 0); + const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + const startSec = todayMidnightSec - days * 86400; + + const events = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + inArray(statusHistory.entityId, entityIds), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + // Fetch the last known state before the window so that entities that + // haven't changed status recently still show the correct status rather + // than appearing as "no_data". + const [lastKnownEvent] = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + inArray(statusHistory.entityId, entityIds), + lt(statusHistory.timestamp, startSec) + ) + ) + .orderBy(desc(statusHistory.timestamp)) + .limit(1); + + const priorStatus = lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + events, + days, + priorStatus + ); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100) + : 100; + + const result: BatchedStatusHistoryResponse = { + entityType, + entityIds, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }; + + // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); + return result; +} diff --git a/server/routers/site/getBatchedStatusHistory.ts b/server/routers/site/getBatchedStatusHistory.ts new file mode 100644 index 000000000..97ed8a9ac --- /dev/null +++ b/server/routers/site/getBatchedStatusHistory.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +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"; +import { + getCachedStatusHistory, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; + +const siteParamsSchema = z.object({ + siteId: z.string().transform((v) => parseInt(v, 10)) +}); + +export async function getBatchedSiteStatusHistory( + 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 = statusHistoryQuerySchema.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 data = await getCachedStatusHistory(entityType, entityId, days); + + return response(res, { + data, + 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") + ); + } +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8fdb1c531..19b28e2d9 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -640,6 +640,28 @@ export const orgQueries = { }; } }), + batchedSiteStatusHistory: ({ + siteIds, + days = 90 + }: { + siteIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "SITE_STATUS_HISTORY", + "BATCHED", + siteIds, + days + ] as const, + queryFn: async ({ signal, meta }) => { + // TODO + // const res = await meta!.api.get< + // AxiosResponse + // >(`/site/${siteId}/status-history?days=${days}`, { signal }); + // return res.data.data; + } + }), siteStatusHistory: ({ siteId, days = 90 From 9cc3190e3a75ece1c3fd01bc895d33d1ecd9c7bb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 20 Jul 2026 20:37:52 +0100 Subject: [PATCH 02/20] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20batched=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 87 ++++++++++++------- .../routers/site/getBatchedStatusHistory.ts | 42 ++++++--- 2 files changed, 85 insertions(+), 44 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index a0ede99a6..315c776c2 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -264,9 +264,7 @@ export function computeBuckets( // Shift by the client's offset before formatting so the label reflects // their local calendar date rather than the UTC date of dayStartSec - const dateStr = new Date( - (dayStartSec + tzOffsetMinutes * 60) * 1000 - ) + const dateStr = new Date((dayStartSec + tzOffsetMinutes * 60) * 1000) .toISOString() .slice(0, 10); @@ -305,13 +303,10 @@ export function computeBuckets( return { buckets, totalDowntime }; } -export interface BatchedStatusHistoryResponse { - entityType: string; - entityIds: number[]; - days: StatusHistoryDayBucket[]; - overallUptimePercent: number; - totalDowntimeSeconds: number; -} +export type BatchedStatusHistoryResponse = Record< + string, + StatusHistoryResponse +>; export async function getBatchedStatusHistory( entityType: string, @@ -345,8 +340,8 @@ export async function getBatchedStatusHistory( // Fetch the last known state before the window so that entities that // haven't changed status recently still show the correct status rather // than appearing as "no_data". - const [lastKnownEvent] = await logsDb - .select() + const lastKnownEvents = await logsDb + .selectDistinctOn([statusHistory.entityId, statusHistory.timestamp]) .from(statusHistory) .where( and( @@ -355,29 +350,57 @@ export async function getBatchedStatusHistory( lt(statusHistory.timestamp, startSec) ) ) - .orderBy(desc(statusHistory.timestamp)) - .limit(1); + .orderBy(desc(statusHistory.timestamp)); - const priorStatus = lastKnownEvent?.status ?? null; + const eventStatusMap: Record< + number, + { + events: typeof events; + lastKnownEvent: (typeof lastKnownEvents)[number] | null; + } + > = {}; - const { buckets, totalDowntime } = computeBuckets( - events, - days, - priorStatus - ); - const totalWindow = days * 86400; - const overallUptime = - totalWindow > 0 - ? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100) - : 100; + for (const event of events) { + if (!eventStatusMap[event.entityId]) { + eventStatusMap[event.entityId] = { + events: [], + lastKnownEvent: + lastKnownEvents.find( + (ev) => ev.entityId === event.entityId + ) ?? null + }; + } + eventStatusMap[event.entityId].events.push(event); + } - const result: BatchedStatusHistoryResponse = { - entityType, - entityIds, - days: buckets, - overallUptimePercent: Math.round(overallUptime * 100) / 100, - totalDowntimeSeconds: totalDowntime - }; + const result: BatchedStatusHistoryResponse = {}; + + for (const entityId in eventStatusMap) { + const event = eventStatusMap[Number(entityId)]; + const priorStatus = event.lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + event.events, + days, + priorStatus + ); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max( + 0, + ((totalWindow - totalDowntime) / totalWindow) * 100 + ) + : 100; + + result[entityId] = { + entityType, + entityId: Number(entityId), + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }; + } // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; diff --git a/server/routers/site/getBatchedStatusHistory.ts b/server/routers/site/getBatchedStatusHistory.ts index 97ed8a9ac..507a556d4 100644 --- a/server/routers/site/getBatchedStatusHistory.ts +++ b/server/routers/site/getBatchedStatusHistory.ts @@ -11,8 +11,35 @@ import { StatusHistoryResponse } from "@server/lib/statusHistory"; -const siteParamsSchema = z.object({ - siteId: z.string().transform((v) => parseInt(v, 10)) +const siteIdParamsSchema = z.object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)), + siteIds: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive())) + .openapi({ + description: "Filter by siteIds (repeat query param)" + }) }); export async function getBatchedSiteStatusHistory( @@ -21,16 +48,7 @@ export async function getBatchedSiteStatusHistory( 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 = statusHistoryQuerySchema.safeParse(req.query); + const parsedQuery = siteIdParamsSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( From 23181f4019b4ac852b55af3eaba162f058861b59 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 21 Jul 2026 19:57:09 +0100 Subject: [PATCH 03/20] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20batched=20site=20st?= =?UTF-8?q?atus=20histories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 34 ++++++++++---- server/routers/external.ts | 7 +++ .../routers/site/getBatchedStatusHistory.ts | 29 ++++++------ server/routers/site/index.ts | 1 + src/components/SitesTable.tsx | 34 +++++++++----- src/components/UptimeMiniBar.tsx | 45 +++++++++++-------- src/lib/queries.ts | 31 +++++++++---- 7 files changed, 122 insertions(+), 59 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 315c776c2..f116d74e2 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -37,10 +37,11 @@ export async function getCachedStatusHistory( tzOffsetMinutes ); const cached = await cache.get(cacheKey); - if (cached !== undefined) { - return cached; - } + // if (cached !== undefined) { + // return cached; + // } + console.time(`[getCachedStatusHistory/${entityType}=${entityId}]`); // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query // window aligns with stable calendar days for the requesting client const todayMidnightSec = localMidnightSec(tzOffsetMinutes); @@ -76,12 +77,14 @@ export async function getCachedStatusHistory( const priorStatus = lastKnownEvent?.status ?? null; + console.time(`[computeBuckets/${entityType}=${entityId}]`); const { buckets, totalDowntime } = computeBuckets( events, days, priorStatus, tzOffsetMinutes ); + console.timeEnd(`[computeBuckets/${entityType}=${entityId}]`); const totalWindow = days * 86400; const overallUptime = totalWindow > 0 @@ -96,6 +99,7 @@ export async function getCachedStatusHistory( totalDowntimeSeconds: totalDowntime }; + console.timeEnd(`[getCachedStatusHistory/${entityType}=${entityId}]`); await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; } @@ -299,7 +303,6 @@ export function computeBuckets( status }); } - return { buckets, totalDowntime }; } @@ -311,7 +314,8 @@ export type BatchedStatusHistoryResponse = Record< export async function getBatchedStatusHistory( entityType: string, entityIds: number[], - days: number + days: number, + tzOffsetMinutes: number = 0 ): Promise { // const cacheKey = statusHistoryCacheKey(entityType, entityId, days); // const cached = await cache.get(cacheKey); @@ -319,10 +323,13 @@ export async function getBatchedStatusHistory( // return cached; // } - // Anchor to UTC midnight so the query window aligns with stable calendar days - const utcToday = new Date(); - utcToday.setUTCHours(0, 0, 0, 0); - const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + console.time( + `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` + ); + + // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query + // window aligns with stable calendar days for the requesting client + const todayMidnightSec = localMidnightSec(tzOffsetMinutes); const startSec = todayMidnightSec - days * 86400; const events = await logsDb @@ -337,6 +344,10 @@ export async function getBatchedStatusHistory( ) .orderBy(asc(statusHistory.timestamp)); + console.log({ + events + }); + // Fetch the last known state before the window so that entities that // haven't changed status recently still show the correct status rather // than appearing as "no_data". @@ -375,6 +386,7 @@ export async function getBatchedStatusHistory( const result: BatchedStatusHistoryResponse = {}; + console.time(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`); for (const entityId in eventStatusMap) { const event = eventStatusMap[Number(entityId)]; const priorStatus = event.lastKnownEvent?.status ?? null; @@ -401,7 +413,11 @@ export async function getBatchedStatusHistory( totalDowntimeSeconds: totalDowntime }; } + console.timeEnd(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`); + console.timeEnd( + `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` + ); // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; } diff --git a/server/routers/external.ts b/server/routers/external.ts index 62ca15316..e6d487f56 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -319,6 +319,13 @@ authenticated.get( site.getSiteStatusHistory ); +authenticated.get( + "/org/:orgId/site-status-histories", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listSites), + site.getBatchedSiteStatusHistory +); + // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", diff --git a/server/routers/site/getBatchedStatusHistory.ts b/server/routers/site/getBatchedStatusHistory.ts index 507a556d4..f266c2840 100644 --- a/server/routers/site/getBatchedStatusHistory.ts +++ b/server/routers/site/getBatchedStatusHistory.ts @@ -1,15 +1,14 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; 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"; import { - getCachedStatusHistory, - statusHistoryQuerySchema, - StatusHistoryResponse + getBatchedStatusHistory, + type BatchedStatusHistoryResponse } from "@server/lib/statusHistory"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const siteIdParamsSchema = z.object({ days: z @@ -59,12 +58,16 @@ export async function getBatchedSiteStatusHistory( } const entityType = "site"; - const entityId = parsedParams.data.siteId; - const { days } = parsedQuery.data; + const { days, siteIds, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getBatchedStatusHistory( + entityType, + siteIds, + days, + tzOffsetMinutes + ); - return response(res, { + return response(res, { data, success: true, error: false, diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index c0c399789..7d5bcb698 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -1,5 +1,6 @@ export * from "./getSite"; export * from "./getStatusHistory"; +export * from "./getBatchedStatusHistory"; export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 95d63cf9a..1f745269c 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,7 +1,7 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import UptimeMiniBar from "@app/components/UptimeMiniBar"; +import { UptimeMiniBar } from "@app/components/UptimeMiniBar"; import { Credenza, @@ -56,8 +56,9 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { useQuery } from "@tanstack/react-query"; -import { productUpdatesQueries } from "@app/lib/queries"; +import { orgQueries, productUpdatesQueries } from "@app/lib/queries"; import semver from "semver"; +import { durationToMs } from "@app/lib/durationToMs"; export type SiteRow = { id: number; @@ -89,6 +90,8 @@ type SitesTableProps = { rowCount: number; }; +const SITE_STATUS_HISTORY_DAYS = 30; + export default function SitesTable({ sites, orgId, @@ -112,7 +115,14 @@ export default function SitesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - const { isPaidUser } = usePaidStatus(); + const statusHistoryQuery = useQuery({ + ...orgQueries.batchedSiteStatusHistory({ + orgId, + siteIds: sites.map((s) => s.id), + days: SITE_STATUS_HISTORY_DAYS + }), + staleTime: durationToMs(5, "seconds") + }); const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -296,7 +306,14 @@ export default function SitesTable({ if (originalRow.type == "local") { return -; } - return ; + const data = statusHistoryQuery.data?.[row.original.id]; + return ( + + ); } }, { @@ -361,12 +378,9 @@ export default function SitesTable({ let updateAvailable = Boolean( latestNewtVersion && - originalRow.newtVersion && - semver.valid(originalRow.newtVersion) && - semver.lt( - originalRow.newtVersion, - latestNewtVersion - ) + originalRow.newtVersion && + semver.valid(originalRow.newtVersion) && + semver.lt(originalRow.newtVersion, latestNewtVersion) ); if (originalRow.type === "newt") { diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index b7e684c8b..454f44d66 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -1,15 +1,14 @@ "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"; +import { orgQueries } from "@app/lib/queries"; +import type { StatusHistoryResponse } from "@server/lib/statusHistory"; +import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; function formatDuration(seconds: number): string { @@ -46,21 +45,16 @@ type UptimeMiniBarProps = { days?: number; }; -export default function UptimeMiniBar({ +export default function UptimeMiniBarWrapper({ orgId, siteId, resourceId, healthCheckId, days = 30 }: UptimeMiniBarProps) { - const t = useTranslations(); - const api = createApiClient(useEnvContext()); - const siteQuery = useQuery({ ...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }), - enabled: siteId != null, - meta: { api }, - staleTime: 5 * 60 * 1000 + enabled: siteId != null }); const hcQuery = useQuery({ @@ -69,16 +63,12 @@ export default function UptimeMiniBar({ healthCheckId: healthCheckId ?? 0, days }), - enabled: healthCheckId != null && siteId == null && resourceId == null, - meta: { api }, - staleTime: 5 * 60 * 1000 + enabled: healthCheckId != null && siteId == null && resourceId == null }); const resourceQuery = useQuery({ ...orgQueries.resourceStatusHistory({ resourceId, days }), - enabled: resourceId != null && siteId == null && healthCheckId == null, - meta: { api }, - staleTime: 5 * 60 * 1000 + enabled: resourceId != null && siteId == null && healthCheckId == null }); const { data, isLoading } = @@ -88,6 +78,22 @@ export default function UptimeMiniBar({ ? resourceQuery : hcQuery; + return ; +} + +type UptimeMiniBarUIProps = { + data?: StatusHistoryResponse; + isLoading?: boolean; + days?: number; +}; + +export function UptimeMiniBar({ + data, + isLoading, + days = 30 +}: UptimeMiniBarUIProps) { + const t = useTranslations(); + if (isLoading) { return (
@@ -138,7 +144,8 @@ export default function UptimeMiniBar({ {formatDate(day.date)}
- {day.status === "no_data" || day.status === "unknown" + {day.status === "no_data" || + day.status === "unknown" ? t("uptimeNoData") : `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
@@ -159,4 +166,4 @@ export default function UptimeMiniBar({ ); -} \ No newline at end of file +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index b552fd26c..a56271991 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -47,7 +47,10 @@ import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -import { StatusHistoryResponse } from "@server/lib/statusHistory"; +import { + StatusHistoryResponse, + type BatchedStatusHistoryResponse +} from "@server/lib/statusHistory"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { @@ -642,24 +645,33 @@ export const orgQueries = { }), batchedSiteStatusHistory: ({ siteIds, + orgId, days = 90 }: { + orgId: string; siteIds: number[]; days?: number; }) => queryOptions({ queryKey: [ - "SITE_STATUS_HISTORY", - "BATCHED", + "ORG", + orgId, + "BATCHED_SITE_STATUS_HISTORY", siteIds, days ] as const, queryFn: async ({ signal, meta }) => { - // TODO - // const res = await meta!.api.get< - // AxiosResponse - // >(`/site/${siteId}/status-history?days=${days}`, { signal }); - // return res.data.data; + const sp = new URLSearchParams([ + ["days", days.toString()], + ...siteIds.map((id) => ["siteIds", id.toString()]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/site-status-histories?${sp.toString()}`, { + signal + }); + return res.data.data; } }), siteStatusHistory: ({ @@ -671,6 +683,7 @@ export const orgQueries = { }) => queryOptions({ queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const, + staleTime: durationToMs(5, "seconds"), queryFn: async ({ signal, meta }) => { const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< @@ -692,6 +705,7 @@ export const orgQueries = { }) => queryOptions({ queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const, + staleTime: durationToMs(5, "seconds"), queryFn: async ({ signal, meta }) => { const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< @@ -714,6 +728,7 @@ export const orgQueries = { days?: number; }) => queryOptions({ + staleTime: durationToMs(5, "seconds"), queryKey: [ "HC_STATUS_HISTORY", orgId, From 70bddba55b126de0fba0a60f4cbea1b7bb97f225 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 21 Jul 2026 20:29:16 +0100 Subject: [PATCH 04/20] =?UTF-8?q?=E2=9C=A8=20Make=20batched=20status=20his?= =?UTF-8?q?tory=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index f116d74e2..da579d3d3 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -37,9 +37,9 @@ export async function getCachedStatusHistory( tzOffsetMinutes ); const cached = await cache.get(cacheKey); - // if (cached !== undefined) { - // return cached; - // } + if (cached !== undefined) { + return cached; + } console.time(`[getCachedStatusHistory/${entityType}=${entityId}]`); // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query @@ -344,10 +344,6 @@ export async function getBatchedStatusHistory( ) .orderBy(asc(statusHistory.timestamp)); - console.log({ - events - }); - // Fetch the last known state before the window so that entities that // haven't changed status recently still show the correct status rather // than appearing as "no_data". @@ -371,17 +367,12 @@ export async function getBatchedStatusHistory( } > = {}; - for (const event of events) { - if (!eventStatusMap[event.entityId]) { - eventStatusMap[event.entityId] = { - events: [], - lastKnownEvent: - lastKnownEvents.find( - (ev) => ev.entityId === event.entityId - ) ?? null - }; - } - eventStatusMap[event.entityId].events.push(event); + for (const entityId of entityIds) { + eventStatusMap[entityId] = { + events: events.filter((ev) => ev.entityId === entityId), + lastKnownEvent: + lastKnownEvents.find((ev) => ev.entityId === entityId) ?? null + }; } const result: BatchedStatusHistoryResponse = {}; From 9867d3c876cda24fff3a3678f6d9391edc5cfa8c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 21 Jul 2026 20:31:57 +0100 Subject: [PATCH 05/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20`const`=20instead=20?= =?UTF-8?q?of=20`let`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 1f745269c..9c5a2b634 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -376,7 +376,7 @@ export default function SitesTable({ cell: ({ row }) => { const originalRow = row.original; - let updateAvailable = Boolean( + const updateAvailable = Boolean( latestNewtVersion && originalRow.newtVersion && semver.valid(originalRow.newtVersion) && From a33de8268b87ae21c4a35cb87fecba81d5afa43f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 19:32:53 +0100 Subject: [PATCH 06/20] =?UTF-8?q?=F0=9F=90=9B=20Get=20last=20known=20event?= =?UTF-8?q?s=20working=20with=20sqlite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index da579d3d3..ecb3f369e 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { db, logsDb, statusHistory } from "@server/db"; -import { and, eq, gte, lt, asc, desc, inArray } from "drizzle-orm"; +import { and, eq, gte, lt, asc, desc, inArray, max, sql } from "drizzle-orm"; import { regionalCache as cache } from "#dynamic/lib/cache"; const STATUS_HISTORY_CACHE_TTL = 60; // seconds @@ -347,8 +347,26 @@ export async function getBatchedStatusHistory( // Fetch the last known state before the window so that entities that // haven't changed status recently still show the correct status rather // than appearing as "no_data". - const lastKnownEvents = await logsDb - .selectDistinctOn([statusHistory.entityId, statusHistory.timestamp]) + + /** + * If we used only postgres, we would have used `SELECT DISTINCT ON` to get the + * latest event for each `entityId`, + * but it doesn't work on SQLite, so instead we use a subquery, + * the `ROW_NUMBER() OVER PARTITION` allows to assign a number + * to each row ordered by the timestamp, the number 1 is the first one appearing in + * the specified order, then the next and more, we only want the highest timestamp, + * so we get for `row_number=1` + */ + const lastKnowEventsSub = logsDb + .select({ + entityId: statusHistory.entityId, + status: statusHistory.status, + timestamp: statusHistory.timestamp, + row_number: + sql`ROW_NUMBER() OVER (PARTITION BY ${statusHistory.entityId} ORDER BY ${statusHistory.timestamp} DESC)`.as( + "row_number" + ) + }) .from(statusHistory) .where( and( @@ -357,7 +375,16 @@ export async function getBatchedStatusHistory( lt(statusHistory.timestamp, startSec) ) ) - .orderBy(desc(statusHistory.timestamp)); + .as("sub"); + + const lastKnownEvents = await logsDb + .select({ + entityId: lastKnowEventsSub.entityId, + status: lastKnowEventsSub.status, + timestamp: lastKnowEventsSub.timestamp + }) + .from(lastKnowEventsSub) + .where(eq(lastKnowEventsSub.row_number, 1)); const eventStatusMap: Record< number, From b1558b09b1708cff195c40f5fbabc73d77a2a3f0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 19:33:00 +0100 Subject: [PATCH 07/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 9c5a2b634..5fdaf05a0 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -52,13 +52,12 @@ import { } from "./ui/controlled-data-table"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { durationToMs } from "@app/lib/durationToMs"; +import { orgQueries, productUpdatesQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; -import { useQuery } from "@tanstack/react-query"; -import { orgQueries, productUpdatesQueries } from "@app/lib/queries"; -import semver from "semver"; -import { durationToMs } from "@app/lib/durationToMs"; export type SiteRow = { id: number; From 262f7bd0903b3be9bef0e03b9920d30e63a30804 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 20:57:32 +0100 Subject: [PATCH 08/20] =?UTF-8?q?=E2=9C=A8=20resource=20status=20histories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 10 +-- server/routers/external.ts | 7 ++ .../resource/getBatchedStatusHistory.ts | 83 +++++++++++++++++++ server/routers/resource/index.ts | 1 + src/components/PublicResourcesTable.tsx | 36 +++++++- src/components/SitesTable.tsx | 2 +- src/lib/queries.ts | 43 +++++++++- 7 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 server/routers/resource/getBatchedStatusHistory.ts diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index ecb3f369e..0ec338d93 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -317,12 +317,6 @@ export async function getBatchedStatusHistory( days: number, tzOffsetMinutes: number = 0 ): Promise { - // const cacheKey = statusHistoryCacheKey(entityType, entityId, days); - // const cached = await cache.get(cacheKey); - // if (cached !== undefined) { - // return cached; - // } - console.time( `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` ); @@ -412,7 +406,8 @@ export async function getBatchedStatusHistory( const { buckets, totalDowntime } = computeBuckets( event.events, days, - priorStatus + priorStatus, + tzOffsetMinutes ); const totalWindow = days * 86400; const overallUptime = @@ -436,6 +431,5 @@ export async function getBatchedStatusHistory( console.timeEnd( `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` ); - // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; } diff --git a/server/routers/external.ts b/server/routers/external.ts index e6d487f56..fc3170365 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -476,6 +476,13 @@ authenticated.get( resource.getResourceStatusHistory ); +authenticated.get( + "/org/:orgId/resource-status-histories", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listResources), + resource.getBatchedResourceStatusHistory +); + authenticated.get( "/org/:orgId/resources", verifyOrgAccess, diff --git a/server/routers/resource/getBatchedStatusHistory.ts b/server/routers/resource/getBatchedStatusHistory.ts new file mode 100644 index 000000000..870f20287 --- /dev/null +++ b/server/routers/resource/getBatchedStatusHistory.ts @@ -0,0 +1,83 @@ +import response from "@server/lib/response"; +import { + getBatchedStatusHistory, + type BatchedStatusHistoryResponse +} from "@server/lib/statusHistory"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const resourceIdParamsSchema = z.object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)), + resourceIds: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive())) + .openapi({ + description: "Filter by resourceIds (repeat query param)" + }) +}); + +export async function getBatchedResourceStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = resourceIdParamsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "resource"; + const { days, resourceIds, tzOffsetMinutes } = parsedQuery.data; + + const data = await getBatchedStatusHistory( + entityType, + resourceIds, + days, + tzOffsetMinutes + ); + + return response(res, { + data, + 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") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 83dbdea2a..709bf7340 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -33,4 +33,5 @@ export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; export * from "./getStatusHistory"; +export * from "./getBatchedStatusHistory"; export * from "./getResourcePolicies"; diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 369fe0601..0b2e167fe 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -31,10 +31,13 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { durationToMs } from "@app/lib/durationToMs"; +import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UpdateResourceResponse } from "@server/routers/resource"; +import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { @@ -69,7 +72,7 @@ import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import UptimeMiniBar from "./UptimeMiniBar"; +import { UptimeMiniBar } from "./UptimeMiniBar"; import { type SelectedLabel } from "./labels-selector"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { useLocalLabels } from "@app/hooks/useLocalLabels"; @@ -127,6 +130,8 @@ const booleanSearchFilterSchema = z .optional() .catch(undefined); +const RESOURCE_STATUS_HISTORY_DAYS = 30; + export default function PublicResourcesTable({ resources, orgId, @@ -155,6 +160,21 @@ export default function PublicResourcesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + // Only http resources show an uptime bar, so don't ask for the others + const statusHistoryResourceIds = useMemo( + () => resources.filter((r) => r.mode === "http").map((r) => r.id), + [resources] + ); + + const statusHistoryQuery = useQuery({ + ...orgQueries.batchedResourceStatusHistory({ + orgId, + resourceIds: statusHistoryResourceIds, + days: RESOURCE_STATUS_HISTORY_DAYS + }), + enabled: statusHistoryResourceIds.length > 0 + }); + const refreshData = () => { startTransition(() => { try { @@ -407,7 +427,11 @@ export default function PublicResourcesTable({ return -; } return ( - + ); } }, @@ -625,7 +649,13 @@ export default function PublicResourcesTable({ ]; return cols; - }, [orgId, t, searchParams]); + }, [ + orgId, + t, + searchParams, + statusHistoryQuery.data, + statusHistoryQuery.isLoading + ]); function handleFilterChange( column: string, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5fdaf05a0..199810eb3 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -120,7 +120,7 @@ export default function SitesTable({ siteIds: sites.map((s) => s.id), days: SITE_STATUS_HISTORY_DAYS }), - staleTime: durationToMs(5, "seconds") + enabled: sites.length > 0 }); const api = createApiClient(useEnvContext()); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index a56271991..18568f721 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -661,8 +661,12 @@ export const orgQueries = { days ] as const, queryFn: async ({ signal, meta }) => { + // Negated because getTimezoneOffset() returns UTC - local, + // while the API expects minutes to add to UTC to get local + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const sp = new URLSearchParams([ ["days", days.toString()], + ["tzOffsetMinutes", tzOffsetMinutes.toString()], ...siteIds.map((id) => ["siteIds", id.toString()]) ]); @@ -672,7 +676,44 @@ export const orgQueries = { signal }); return res.data.data; - } + }, + staleTime: durationToMs(5, "seconds") + }), + batchedResourceStatusHistory: ({ + resourceIds, + orgId, + days = 90 + }: { + orgId: string; + resourceIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "BATCHED_RESOURCE_STATUS_HISTORY", + resourceIds, + days + ] as const, + queryFn: async ({ signal, meta }) => { + // Negated because getTimezoneOffset() returns UTC - local, + // while the API expects minutes to add to UTC to get local + const tzOffsetMinutes = -new Date().getTimezoneOffset(); + const sp = new URLSearchParams([ + ["days", days.toString()], + ["tzOffsetMinutes", tzOffsetMinutes.toString()], + ...resourceIds.map((id) => ["resourceIds", id.toString()]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/resource-status-histories?${sp.toString()}`, { + signal + }); + return res.data.data; + }, + staleTime: durationToMs(5, "seconds") }), siteStatusHistory: ({ siteId, From b0ff7d2707a523200f445e18757abd99e392449b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 21:06:40 +0100 Subject: [PATCH 09/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20batch=20healthcheck?= =?UTF-8?q?=20status=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/private/routers/external.ts | 8 ++ .../healthChecks/getBatchedStatusHistory.ts | 96 +++++++++++++++++++ server/private/routers/healthChecks/index.ts | 1 + src/components/HealthChecksTable.tsx | 30 +++++- src/lib/queries.ts | 40 ++++++++ 5 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 server/private/routers/healthChecks/getBatchedStatusHistory.ts diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 75a5b8d48..d2dc7bec1 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -853,6 +853,14 @@ authenticated.get( healthChecks.getHealthCheckStatusHistory ); +authenticated.get( + "/org/:orgId/health-check-status-histories", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getTarget), + healthChecks.getBatchedHealthCheckStatusHistory +); + authenticated.get( "/client/:clientId/verify-associations-cache", verifyClientAccess, diff --git a/server/private/routers/healthChecks/getBatchedStatusHistory.ts b/server/private/routers/healthChecks/getBatchedStatusHistory.ts new file mode 100644 index 000000000..38c4903ec --- /dev/null +++ b/server/private/routers/healthChecks/getBatchedStatusHistory.ts @@ -0,0 +1,96 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import response from "@server/lib/response"; +import { + getBatchedStatusHistory, + type BatchedStatusHistoryResponse +} from "@server/lib/statusHistory"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const healthCheckIdParamsSchema = z.object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)), + healthCheckIds: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive())) + .openapi({ + description: "Filter by healthCheckIds (repeat query param)" + }) +}); + +export async function getBatchedHealthCheckStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = healthCheckIdParamsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "health_check"; + const { days, healthCheckIds, tzOffsetMinutes } = parsedQuery.data; + + const data = await getBatchedStatusHistory( + entityType, + healthCheckIds, + days, + tzOffsetMinutes + ); + + return response(res, { + data, + 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") + ); + } +} diff --git a/server/private/routers/healthChecks/index.ts b/server/private/routers/healthChecks/index.ts index 665ae5cca..a2e01c2ae 100644 --- a/server/private/routers/healthChecks/index.ts +++ b/server/private/routers/healthChecks/index.ts @@ -16,3 +16,4 @@ export * from "./createHealthCheck"; export * from "./updateHealthCheck"; export * from "./deleteHealthCheck"; export * from "./getStatusHistory"; +export * from "./getBatchedStatusHistory"; diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 02e725fed..b4f273e5a 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -1,6 +1,6 @@ "use client"; -import UptimeMiniBar from "@app/components/UptimeMiniBar"; +import { UptimeMiniBar } from "@app/components/UptimeMiniBar"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import HealthCheckCredenza, { @@ -51,6 +51,8 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; type StandaloneHealthChecksTableProps = { orgId: string; @@ -81,6 +83,8 @@ function formatTarget(row: HealthCheckRow): string { return `${scheme}://${host}${port}${path}`; } +const HEALTH_CHECK_STATUS_HISTORY_DAYS = 30; + export default function HealthChecksTable({ orgId, healthChecks, @@ -157,6 +161,20 @@ export default function HealthChecksTable({ const rows = healthChecks; + const healthCheckIds = useMemo( + () => rows.map((r) => r.targetHealthCheckId), + [rows] + ); + + const statusHistoryQuery = useQuery({ + ...orgQueries.batchedHealthCheckStatusHistory({ + orgId, + healthCheckIds, + days: HEALTH_CHECK_STATUS_HISTORY_DAYS + }), + enabled: healthCheckIds.length > 0 + }); + function refreshList() { startRefresh(() => { router.refresh(); @@ -547,9 +565,13 @@ export default function HealthChecksTable({ cell: ({ row }) => { return ( ); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 18568f721..8007fd2f1 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -679,6 +679,46 @@ export const orgQueries = { }, staleTime: durationToMs(5, "seconds") }), + batchedHealthCheckStatusHistory: ({ + healthCheckIds, + orgId, + days = 90 + }: { + orgId: string; + healthCheckIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "BATCHED_HEALTH_CHECK_STATUS_HISTORY", + healthCheckIds, + days + ] as const, + queryFn: async ({ signal, meta }) => { + // Negated because getTimezoneOffset() returns UTC - local, + // while the API expects minutes to add to UTC to get local + const tzOffsetMinutes = -new Date().getTimezoneOffset(); + const sp = new URLSearchParams([ + ["days", days.toString()], + ["tzOffsetMinutes", tzOffsetMinutes.toString()], + ...healthCheckIds.map((id) => [ + "healthCheckIds", + id.toString() + ]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >( + `/org/${orgId}/health-check-status-histories?${sp.toString()}`, + { signal } + ); + return res.data.data; + }, + staleTime: durationToMs(5, "seconds") + }), batchedResourceStatusHistory: ({ resourceIds, orgId, From 58004a8ec914c89b55db0fa7bfeff4d366bc3828 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 21:25:31 +0100 Subject: [PATCH 10/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20little=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SitesTable.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 199810eb3..2c4ecd1e5 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -114,13 +114,15 @@ export default function SitesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + const siteIds = sites.map((s) => s.id); + const statusHistoryQuery = useQuery({ ...orgQueries.batchedSiteStatusHistory({ orgId, - siteIds: sites.map((s) => s.id), + siteIds, days: SITE_STATUS_HISTORY_DAYS }), - enabled: sites.length > 0 + enabled: siteIds.length > 0 }); const api = createApiClient(useEnvContext()); From b3880e5c02ead9359e6d419953891287953c00bb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 22 Jul 2026 21:34:13 +0100 Subject: [PATCH 11/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/certificates/restartCertificate.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/server/private/routers/certificates/restartCertificate.ts b/server/private/routers/certificates/restartCertificate.ts index 9c3cbb8cc..05614b078 100644 --- a/server/private/routers/certificates/restartCertificate.ts +++ b/server/private/routers/certificates/restartCertificate.ts @@ -11,18 +11,16 @@ * This file is not licensed under the AGPLv3. */ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; import { certificates, db } from "@server/db"; -import { sites } from "@server/db"; -import { eq, and } 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 stoi from "@server/lib/stoi"; +import { registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; const restartCertificateParamsSchema = z.strictObject({ certId: z.coerce.number().int().positive(), From 832e382888448ba2758feb554284e3178c2338fd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 23 Jul 2026 22:00:31 +0100 Subject: [PATCH 12/20] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20batch=20certificate?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- .../certificates/getBatchedCertificates.ts | 220 ++++++++++++++++++ 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 server/private/routers/certificates/getBatchedCertificates.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 09e12e6d0..17a58d94d 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -18,7 +18,7 @@ export const domains = pgTable("domains", { domainId: varchar("domainId").primaryKey(), baseDomain: varchar("baseDomain").notNull(), configManaged: boolean("configManaged").notNull().default(false), - type: varchar("type"), // "ns", "cname", "wildcard" + type: varchar("type").$type<"ns" | "cname" | "wildcard">(), verified: boolean("verified").notNull().default(false), failed: boolean("failed").notNull().default(false), tries: integer("tries").notNull().default(0), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 61f3c2d6e..d0db18555 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -15,7 +15,7 @@ export const domains = sqliteTable("domains", { configManaged: integer("configManaged", { mode: "boolean" }) .notNull() .default(false), - type: text("type"), // "ns", "cname", "wildcard" + type: text("type").$type<"ns" | "cname" | "wildcard">(), verified: integer("verified", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false), tries: integer("tries").notNull().default(0), diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts new file mode 100644 index 000000000..6962b9c53 --- /dev/null +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -0,0 +1,220 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { certificates, db, domains } from "@server/db"; +import { eq, and, or, like, inArray } 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"; +import { registry } from "@server/openApi"; +import { GetCertificateResponse } from "@server/routers/certificates/types"; + +const getCertificateSchema = z.strictObject({ + domains: z.preprocess( + (val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (Array.isArray(val)) { + return val; + } + // the array is returned as this + if (typeof val === "string") { + return val.split(","); + } + return undefined; + }, + z.array(z.string().min(1).max(255)) + ), + orgId: z.string() +}); + +async function query(domainId: string, domain: string) { + const [domainRecord] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + + if (!domainRecord) { + throw new Error(`Domain with ID ${domainId} not found`); + } + + const domainType = domainRecord.type; + + let existing: any[] = []; + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + + existing = await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + or( + eq(certificates.domain, domain), + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) + ) + ) + ); + } else { + // For non-NS domains, we only match exact domain names + existing = await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .where( + and( + eq(certificates.domainId, domainId), + eq(certificates.domain, domain) // exact match for non-NS domains + ) + ); + } + + return existing.length > 0 ? { ...existing[0], domainType } : null; +} + +async function query2(domainList: string[]) { + // Try to get CNAME certificates first + let existingCertificates = await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .innerJoin(domains, eq(certificates.domainId, domains.domainId)) + .where(and(inArray(certificates.domain, domainList))); + + // All non resolved domain certificates might be `ns` or `wildcard` + const nonAvailableCertificates = existingCertificates + .filter((cert) => !domainList.includes(cert.domain)) + .map((cert) => cert.domain); + + if (nonAvailableCertificates.length > 0) { + const wildcardPrefixedDomains = nonAvailableCertificates.map( + (domain) => { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + return { + domainLevelDown, + wildcardPrefixed + }; + } + ); + + // Need to map the certificates to each domain + await db + .select({ + certId: certificates.certId, + domain: certificates.domain, + wildcard: certificates.wildcard, + status: certificates.status, + expiresAt: certificates.expiresAt, + lastRenewalAttempt: certificates.lastRenewalAttempt, + createdAt: certificates.createdAt, + updatedAt: certificates.updatedAt, + errorMessage: certificates.errorMessage, + renewalCount: certificates.renewalCount + }) + .from(certificates) + .where( + or( + eq(certificates.domain, domain), + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) + ) + ); + } + + return; +} + +export async function getBatchedCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getCertificateSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { domains } = parsedParams.data; + + const cert = await query(domains); + + return response(res, { + data: cert, + success: true, + error: false, + message: "Certificates retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} From e128b8c282fffbc2cbe89fc09bff71d92ea23576 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 24 Jul 2026 19:07:37 +0100 Subject: [PATCH 13/20] =?UTF-8?q?=E2=9C=A8=20get=20batched=20certificates?= =?UTF-8?q?=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../certificates/getBatchedCertificates.ts | 116 ++++++++++++------ server/private/routers/certificates/index.ts | 1 + server/private/routers/external.ts | 7 ++ server/routers/certificates/types.ts | 11 +- 4 files changed, 98 insertions(+), 37 deletions(-) diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts index 6962b9c53..ba51b77b5 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -12,7 +12,7 @@ */ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { certificates, db, domains } from "@server/db"; +import { certificates, db, domains, orgDomains } from "@server/db"; import { eq, and, or, like, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -20,9 +20,16 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { registry } from "@server/openApi"; -import { GetCertificateResponse } from "@server/routers/certificates/types"; +import { + GetCertificateResponse, + type GetBatchedCertificateResponse +} from "@server/routers/certificates/types"; -const getCertificateSchema = z.strictObject({ +const getCertificateParamSchema = z.strictObject({ + orgId: z.string() +}); + +const getCertificateQuerySchema = z.object({ domains: z.preprocess( (val) => { if (val === undefined || val === null || val === "") { @@ -38,11 +45,10 @@ const getCertificateSchema = z.strictObject({ return undefined; }, z.array(z.string().min(1).max(255)) - ), - orgId: z.string() + ) }); -async function query(domainId: string, domain: string) { +async function query1(domainId: string, domain: string) { const [domainRecord] = await db .select() .from(domains) @@ -116,7 +122,7 @@ async function query(domainId: string, domain: string) { return existing.length > 0 ? { ...existing[0], domainType } : null; } -async function query2(domainList: string[]) { +async function query(orgId: string, domainList: string[]) { // Try to get CNAME certificates first let existingCertificates = await db .select({ @@ -129,31 +135,39 @@ async function query2(domainList: string[]) { createdAt: certificates.createdAt, updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, - renewalCount: certificates.renewalCount + renewalCount: certificates.renewalCount, + domainType: domains.type }) .from(certificates) .innerJoin(domains, eq(certificates.domainId, domains.domainId)) + .innerJoin( + orgDomains, + and( + eq(domains.domainId, orgDomains.domainId), + eq(orgDomains.orgId, orgId) + ) + ) .where(and(inArray(certificates.domain, domainList))); - // All non resolved domain certificates might be `ns` or `wildcard` + // All non resolved domain certificates might be `ns` or `wildcard`, + // which means exact domain certificates do not const nonAvailableCertificates = existingCertificates .filter((cert) => !domainList.includes(cert.domain)) .map((cert) => cert.domain); if (nonAvailableCertificates.length > 0) { - const wildcardPrefixedDomains = nonAvailableCertificates.map( - (domain) => { - const domainLevelDown = domain.split(".").slice(1).join("."); - const wildcardPrefixed = `*.${domainLevelDown}`; - return { - domainLevelDown, - wildcardPrefixed - }; - } - ); + const domainLevelDownSet = new Set(); + const wildcardDomainSet = new Set(); + + for (const domain of nonAvailableCertificates) { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + domainLevelDownSet.add(domainLevelDown); + wildcardDomainSet.add(wildcardPrefixed); + } // Need to map the certificates to each domain - await db + const wildcardCertificates = await db .select({ certId: certificates.certId, domain: certificates.domain, @@ -164,33 +178,54 @@ async function query2(domainList: string[]) { createdAt: certificates.createdAt, updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, - renewalCount: certificates.renewalCount + renewalCount: certificates.renewalCount, + domainType: domains.type }) .from(certificates) + .innerJoin(domains, eq(certificates.domainId, domains.domainId)) + .innerJoin( + orgDomains, + and( + eq(domains.domainId, orgDomains.domainId), + eq(orgDomains.orgId, orgId) + ) + ) .where( - or( - eq(certificates.domain, domain), - and( - eq(certificates.wildcard, true), - or( - eq(certificates.domain, domainLevelDown), - eq(certificates.domain, wildcardPrefixed) - ) + and( + eq(certificates.wildcard, true), + or( + inArray(certificates.domain, [...domainLevelDownSet]), + inArray(certificates.domain, [...wildcardDomainSet]) ) ) ); + + existingCertificates.push(...wildcardCertificates); } - return; + const certificateMap: Record = {}; + for (const domain of domainList) { + const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + certificateMap[domain] = + existingCertificates.find( + (cert) => + cert.domain === domain || + cert.domain === domainLevelDown || + cert.domain === wildcardPrefixed + ) ?? null; + } + + return certificateMap; } -export async function getBatchedCertificate( +export async function getBatchedCertificates( req: Request, res: Response, next: NextFunction ): Promise { try { - const parsedParams = getCertificateSchema.safeParse(req.params); + const parsedParams = getCertificateParamSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( @@ -200,11 +235,22 @@ export async function getBatchedCertificate( ); } - const { domains } = parsedParams.data; + const { orgId } = parsedParams.data; + const parsedQuery = getCertificateQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } - const cert = await query(domains); + const { domains } = parsedQuery.data; - return response(res, { + const cert = await query(orgId, domains); + + return response(res, { data: cert, success: true, error: false, diff --git a/server/private/routers/certificates/index.ts b/server/private/routers/certificates/index.ts index 18b942d5c..54b11aa1e 100644 --- a/server/private/routers/certificates/index.ts +++ b/server/private/routers/certificates/index.ts @@ -14,3 +14,4 @@ export * from "./getCertificate"; export * from "./restartCertificate"; export * from "./syncCertToNewts"; +export * from "./getBatchedCertificates"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index d2dc7bec1..fab026418 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -175,6 +175,13 @@ authenticated.get( certificates.getCertificate ); +authenticated.get( + "/org/:orgId/batched-certificates", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getCertificate), + certificates.getBatchedCertificates +); + authenticated.post( "/org/:orgId/certificate/:certId/restart", verifyValidLicense, diff --git a/server/routers/certificates/types.ts b/server/routers/certificates/types.ts index e6aeecdf8..55fe92183 100644 --- a/server/routers/certificates/types.ts +++ b/server/routers/certificates/types.ts @@ -1,9 +1,11 @@ +import type { Domain } from "@server/db"; + export type GetCertificateResponse = { certId: number; domain: string; domainId: string; wildcard: boolean; - domainType: string; + domainType: Domain["type"]; status: string; // pending, requested, valid, expired, failed expiresAt: string | null; lastRenewalAttempt: Date | null; @@ -11,4 +13,9 @@ export type GetCertificateResponse = { updatedAt: number; errorMessage?: string | null; renewalCount: number; -}; \ No newline at end of file +}; + +export type GetBatchedCertificateResponse = Record< + string, + GetCertificateResponse | null +>; From 6e29ebd649e44e4122692b26511f4f390ea64430 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 24 Jul 2026 20:58:47 +0100 Subject: [PATCH 14/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20getbatche?= =?UTF-8?q?dcertificate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../certificates/getBatchedCertificates.ts | 92 ++----------------- 1 file changed, 8 insertions(+), 84 deletions(-) diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts index ba51b77b5..7149de036 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -10,20 +10,16 @@ * * This file is not licensed under the AGPLv3. */ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; import { certificates, db, domains, orgDomains } from "@server/db"; -import { eq, and, or, like, inArray } 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 { type GetBatchedCertificateResponse } from "@server/routers/certificates/types"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, inArray, or } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { registry } from "@server/openApi"; -import { - GetCertificateResponse, - type GetBatchedCertificateResponse -} from "@server/routers/certificates/types"; const getCertificateParamSchema = z.strictObject({ orgId: z.string() @@ -48,80 +44,6 @@ const getCertificateQuerySchema = z.object({ ) }); -async function query1(domainId: string, domain: string) { - const [domainRecord] = await db - .select() - .from(domains) - .where(eq(domains.domainId, domainId)) - .limit(1); - - if (!domainRecord) { - throw new Error(`Domain with ID ${domainId} not found`); - } - - const domainType = domainRecord.type; - - let existing: any[] = []; - if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { - const domainLevelDown = domain.split(".").slice(1).join("."); - const wildcardPrefixed = `*.${domainLevelDown}`; - - existing = await db - .select({ - certId: certificates.certId, - domain: certificates.domain, - wildcard: certificates.wildcard, - status: certificates.status, - expiresAt: certificates.expiresAt, - lastRenewalAttempt: certificates.lastRenewalAttempt, - createdAt: certificates.createdAt, - updatedAt: certificates.updatedAt, - errorMessage: certificates.errorMessage, - renewalCount: certificates.renewalCount - }) - .from(certificates) - .where( - and( - eq(certificates.domainId, domainId), - or( - eq(certificates.domain, domain), - and( - eq(certificates.wildcard, true), - or( - eq(certificates.domain, domainLevelDown), - eq(certificates.domain, wildcardPrefixed) - ) - ) - ) - ) - ); - } else { - // For non-NS domains, we only match exact domain names - existing = await db - .select({ - certId: certificates.certId, - domain: certificates.domain, - wildcard: certificates.wildcard, - status: certificates.status, - expiresAt: certificates.expiresAt, - lastRenewalAttempt: certificates.lastRenewalAttempt, - createdAt: certificates.createdAt, - updatedAt: certificates.updatedAt, - errorMessage: certificates.errorMessage, - renewalCount: certificates.renewalCount - }) - .from(certificates) - .where( - and( - eq(certificates.domainId, domainId), - eq(certificates.domain, domain) // exact match for non-NS domains - ) - ); - } - - return existing.length > 0 ? { ...existing[0], domainType } : null; -} - async function query(orgId: string, domainList: string[]) { // Try to get CNAME certificates first let existingCertificates = await db @@ -136,6 +58,7 @@ async function query(orgId: string, domainList: string[]) { updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, renewalCount: certificates.renewalCount, + domainId: domains.domainId, domainType: domains.type }) .from(certificates) @@ -179,6 +102,7 @@ async function query(orgId: string, domainList: string[]) { updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, renewalCount: certificates.renewalCount, + domainId: domains.domainId, domainType: domains.type }) .from(certificates) From 762c79511b372edc9bcd258fc32af320d6f3c0f3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 24 Jul 2026 20:59:29 +0100 Subject: [PATCH 15/20] =?UTF-8?q?=F0=9F=9A=A7=20Add=20batchedCertificate?= =?UTF-8?q?=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PublicResourcesTable.tsx | 14 +++++++ src/lib/queries.ts | 54 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 0b2e167fe..86c149df4 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -166,6 +166,12 @@ export default function PublicResourcesTable({ [resources] ); + // Domain list + const resourceDomains = useMemo( + () => resources.map((r) => r.fullDomain).filter(Boolean) as string[], + [resources] + ); + const statusHistoryQuery = useQuery({ ...orgQueries.batchedResourceStatusHistory({ orgId, @@ -175,6 +181,14 @@ export default function PublicResourcesTable({ enabled: statusHistoryResourceIds.length > 0 }); + const domainCertificatesQuery = useQuery({ + ...orgQueries.batchedDomainCertificates({ + orgId, + domains: resourceDomains + }), + enabled: resourceDomains.length > 0 + }); + const refreshData = () => { startTransition(() => { try { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8007fd2f1..c52dff981 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -69,6 +69,7 @@ import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getRe import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource"; import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; +import type { GetCertificateResponse } from "@server/routers/certificates/types"; export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; @@ -719,6 +720,31 @@ export const orgQueries = { }, staleTime: durationToMs(5, "seconds") }), + batchedDomainCertificates: ({ + domains, + orgId + }: { + orgId: string; + domains: string[]; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "BATCHED_CERTIFICATES", domains] as const, + queryFn: async ({ signal, meta }) => { + // Negated because getTimezoneOffset() returns UTC - local, + // while the API expects minutes to add to UTC to get local + const sp = new URLSearchParams([ + ...domains.map((domain) => ["domains", domain.toString()]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/batched-certificates?${sp.toString()}`, { + signal + }); + return res.data.data; + }, + staleTime: durationToMs(5, "seconds") + }), batchedResourceStatusHistory: ({ resourceIds, orgId, @@ -1355,6 +1381,34 @@ export const approvalQueries = { }; export const domainQueries = { + getCertificate: ({ + orgId, + domainId, + domain + }: { + orgId: string; + domainId: string; + domain: string; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "DOMAIN", + domainId, + "CERTIFICATE" + ] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/certificate/${domainId}/${domain}`, { + signal + }); + if (res.status === 404) return null; + return res.data.data; + }, + staleTime: durationToMs(1, "minutes") + }), getDomain: ({ orgId, domainId }: { orgId: string; domainId: string }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAIN", domainId] as const, From 49854f31a51374318a72035193cc523c9615108c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 24 Jul 2026 21:03:52 +0100 Subject: [PATCH 16/20] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20refactor=20`useCert?= =?UTF-8?q?ificate`=20to=20use=20tanstack=20qeury?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResourceAccessCertIndicator.tsx | 1 + src/hooks/useCertificate.ts | 93 +++++++++++-------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/components/ResourceAccessCertIndicator.tsx b/src/components/ResourceAccessCertIndicator.tsx index 40ddfbfab..4067f80ae 100644 --- a/src/components/ResourceAccessCertIndicator.tsx +++ b/src/components/ResourceAccessCertIndicator.tsx @@ -22,6 +22,7 @@ type ResourceAccessCertIndicatorProps = { orgId: string; domainId: string; fullDomain: string; + initialCertValue?: any; }; function getStatusColor(status: string) { diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index cd0802ef0..0a555c8fc 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -5,6 +5,8 @@ import { AxiosResponse } from "axios"; import { GetCertificateResponse } from "@server/routers/certificates/types"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useQuery } from "@tanstack/react-query"; +import { domainQueries } from "@app/lib/queries"; type UseCertificateProps = { orgId: string; @@ -13,6 +15,7 @@ type UseCertificateProps = { autoFetch?: boolean; polling?: boolean; pollingInterval?: number; + initialCertValue?: GetCertificateResponse | null; }; type UseCertificateReturn = { @@ -22,51 +25,63 @@ type UseCertificateReturn = { refreshing: boolean; fetchCert: (showLoading?: boolean) => Promise; refreshCert: () => Promise; - clearCert: () => void; + // clearCert: () => void; }; export function useCertificate({ orgId, domainId, fullDomain, + initialCertValue = null, autoFetch = true, polling = false, pollingInterval = 5000 }: UseCertificateProps): UseCertificateReturn { const api = createApiClient(useEnvContext()); - const [cert, setCert] = useState(null); - const [certLoading, setCertLoading] = useState(false); + // const [cert, setCert] = useState( + // initialCertValue + // ); + // const [certLoading, setCertLoading] = useState(false); const [certError, setCertError] = useState(null); const [refreshing, setRefreshing] = useState(false); - const fetchCert = useCallback( - async (showLoading = true) => { - if (!orgId || !domainId || !fullDomain) return; + const query = useQuery({ + ...domainQueries.getCertificate({ + orgId, + domainId, + domain: fullDomain + }), + initialData: initialCertValue + }); - if (showLoading) { - setCertLoading(true); - } - try { - const res = await api.get< - AxiosResponse - >(`/org/${orgId}/certificate/${domainId}/${fullDomain}`); - const certData = res.data.data; - if (certData) { - setCertError(null); - setCert(certData); - } - } catch (error: any) { - console.error("Failed to fetch certificate:", error); - setCertError("Failed"); - } finally { - if (showLoading) { - setCertLoading(false); - } - } - }, - [api, orgId, domainId, fullDomain] - ); + // const fetchCert = useCallback( + // async (showLoading = true) => { + // if (!orgId || !domainId || !fullDomain) return; + + // if (showLoading) { + // setCertLoading(true); + // } + // try { + // const res = await api.get< + // AxiosResponse + // >(`/org/${orgId}/certificate/${domainId}/${fullDomain}`); + // const certData = res.data.data; + // if (certData) { + // setCertError(null); + // setCert(certData); + // } + // } catch (error: any) { + // console.error("Failed to fetch certificate:", error); + // setCertError("Failed"); + // } finally { + // if (showLoading) { + // setCertLoading(false); + // } + // } + // }, + // [api, orgId, domainId, fullDomain] + // ); const refreshCert = useCallback(async () => { if (!cert) return; @@ -96,11 +111,11 @@ export function useCertificate({ }, []); // Auto-fetch on mount if enabled - useEffect(() => { - if (autoFetch && orgId && domainId && fullDomain) { - fetchCert(); - } - }, [autoFetch, orgId, domainId, fullDomain, fetchCert]); + // useEffect(() => { + // if (autoFetch && orgId && domainId && fullDomain) { + // fetchCert(); + // } + // }, [autoFetch, orgId, domainId, fullDomain, fetchCert]); useEffect(() => { if (!polling || !orgId || !domainId || !fullDomain) return; @@ -132,12 +147,12 @@ export function useCertificate({ }, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]); return { - cert, - certLoading, + cert: query.data, + certLoading: query.isLoading, certError, refreshing, - fetchCert, - refreshCert, - clearCert + fetchCert: query.refetch, + refreshCert + // clearCert }; } From 10773432bb37861c0703fc5c3178af4e28a05871 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 28 Jul 2026 20:59:42 +0100 Subject: [PATCH 17/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Batch=20certificate?= =?UTF-8?q?=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/resources/public/page.tsx | 56 +++--- src/components/AuthPageSettings.tsx | 1 - src/components/CertificateStatus.tsx | 3 - src/components/PublicResourcesTable.tsx | 51 ++--- .../ResourceAccessCertIndicator.tsx | 13 +- src/components/ResourceInfoBox.tsx | 1 - src/components/SiteResourceInfoBox.tsx | 1 - src/hooks/useCertificate.ts | 179 +++++++----------- src/lib/queries.ts | 32 +++- 9 files changed, 151 insertions(+), 186 deletions(-) diff --git a/src/app/[orgId]/settings/resources/public/page.tsx b/src/app/[orgId]/settings/resources/public/page.tsx index f8cc85dae..d7fd80d4a 100644 --- a/src/app/[orgId]/settings/resources/public/page.tsx +++ b/src/app/[orgId]/settings/resources/public/page.tsx @@ -5,6 +5,8 @@ import PublicResourcesBanner from "@app/components/PublicResourcesBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import OrgProvider from "@app/providers/OrgProvider"; +import { build } from "@server/build"; +import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import type { GetOrgResponse } from "@server/routers/org"; import type { ListResourcesResponse } from "@server/routers/resource"; import { GetSiteResponse } from "@server/routers/site/getSite"; @@ -60,29 +62,7 @@ export default async function ProxyResourcesPage( searchParams.get("siteId") ?? undefined ); - let initialFilterSite: { - siteId: number; - name: string; - type: string; - } | null = null; - if (siteIdParam) { - try { - const siteRes = await internal.get( - `/site/${siteIdParam}`, - await authCookieHeader() - ); - const s = (siteRes.data as ResponseT).data; - if (s && s.orgId === params.orgId) { - initialFilterSite = { - siteId: s.siteId, - name: s.name, - type: s.type - }; - } - } catch { - // leave null - } - } + let org = null; try { @@ -140,6 +120,34 @@ export default async function ProxyResourcesPage( health: (resource.health as ResourceRow["health"]) ?? undefined }; }); + // Prefetched in one batched call so the table doesn't fire a separate + // certificate request per visible row once it mounts on the client. + const certDomains = Array.from( + new Set( + resourceRows + .filter((r) => r.ssl && r.fullDomain) + .map((r) => r.fullDomain as string) + ) + ); + + let initialCertificates: GetBatchedCertificateResponse | undefined; + if (build !== "oss" && certDomains.length > 0) { + try { + const certSearchParams = new URLSearchParams( + certDomains.map((domain) => ["domains", domain]) + ); + const certRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/batched-certificates?${certSearchParams.toString()}`, + await authCookieHeader() + ); + initialCertificates = certRes.data.data; + } catch { + // leave undefined so each row falls back to fetching its own + } + } + return ( <> diff --git a/src/components/AuthPageSettings.tsx b/src/components/AuthPageSettings.tsx index edd1e3a19..d6375c7b5 100644 --- a/src/components/AuthPageSettings.tsx +++ b/src/components/AuthPageSettings.tsx @@ -412,7 +412,6 @@ function AuthPageSettings({ fullDomain={ loginPage.fullDomain } - autoFetch={true} showLabel={true} polling={true} /> diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index 788c39c21..9b1f52c03 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -187,7 +187,6 @@ type CertificateStatusProps = { orgId: string; domainId: string; fullDomain: string; - autoFetch?: boolean; showLabel?: boolean; className?: string; onRefresh?: () => void; @@ -199,7 +198,6 @@ export default function CertificateStatus({ orgId, domainId, fullDomain, - autoFetch = true, showLabel = true, className = "", onRefresh, @@ -210,7 +208,6 @@ export default function CertificateStatus({ orgId, domainId, fullDomain, - autoFetch, polling, pollingInterval }); diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 86c149df4..a5ada101d 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -7,8 +7,7 @@ import { ResourceSitesStatusCell, type ResourceSiteRow } from "@app/components/ResourceSitesStatusCell"; -import { Selectedsite, SitesSelector } from "@app/components/site-selector"; -import { Badge } from "@app/components/ui/badge"; +import { Selectedsite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { @@ -18,25 +17,18 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; -import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; -import { durationToMs } from "@app/lib/durationToMs"; import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UpdateResourceResponse } from "@server/routers/resource"; +import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; @@ -48,9 +40,7 @@ import { ChevronDown, ChevronsUpDownIcon, Clock, - Funnel, MoreHorizontal, - PlusIcon, ShieldCheck, ShieldOff, XCircle @@ -60,7 +50,6 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, - useEffect, useMemo, useOptimistic, useRef, @@ -71,15 +60,11 @@ import { import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; +import { LabelsTableCell } from "./LabelsTableCell"; +import { SitesColumnFilterButton } from "./SitesColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { UptimeMiniBar } from "./UptimeMiniBar"; -import { type SelectedLabel } from "./labels-selector"; -import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { LabelsTableCell } from "./LabelsTableCell"; -import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; -import { refresh } from "next/cache"; -import { SitesColumnFilterButton } from "./SitesColumnFilterButton"; export type TargetHealth = { targetId: number; @@ -123,6 +108,8 @@ type ProxyResourcesTableProps = { pagination: PaginationState; rowCount: number; initialFilterSite?: Selectedsite | null; + /** Certificates prefetched on the server, keyed by full domain. */ + initialCertificates?: GetBatchedCertificateResponse; }; const booleanSearchFilterSchema = z @@ -137,7 +124,7 @@ export default function PublicResourcesTable({ orgId, pagination, rowCount, - initialFilterSite = null + initialCertificates }: ProxyResourcesTableProps) { const router = useRouter(); const { @@ -166,12 +153,6 @@ export default function PublicResourcesTable({ [resources] ); - // Domain list - const resourceDomains = useMemo( - () => resources.map((r) => r.fullDomain).filter(Boolean) as string[], - [resources] - ); - const statusHistoryQuery = useQuery({ ...orgQueries.batchedResourceStatusHistory({ orgId, @@ -181,14 +162,6 @@ export default function PublicResourcesTable({ enabled: statusHistoryResourceIds.length > 0 }); - const domainCertificatesQuery = useQuery({ - ...orgQueries.batchedDomainCertificates({ - orgId, - domains: resourceDomains - }), - enabled: resourceDomains.length > 0 - }); - const refreshData = () => { startTransition(() => { try { @@ -499,6 +472,9 @@ export default function PublicResourcesTable({ orgId={resourceRow.orgId} domainId={domainId} fullDomain={certHostname} + initialCertValue={ + initialCertificates?.[certHostname] + } /> ) : null}
@@ -668,7 +644,8 @@ export default function PublicResourcesTable({ t, searchParams, statusHistoryQuery.data, - statusHistoryQuery.isLoading + statusHistoryQuery.isLoading, + initialCertificates ]); function handleFilterChange( diff --git a/src/components/ResourceAccessCertIndicator.tsx b/src/components/ResourceAccessCertIndicator.tsx index 4067f80ae..9459add3a 100644 --- a/src/components/ResourceAccessCertIndicator.tsx +++ b/src/components/ResourceAccessCertIndicator.tsx @@ -7,6 +7,7 @@ import { PopoverContent } from "@app/components/ui/popover"; import { useCertificate } from "@app/hooks/useCertificate"; +import type { GetCertificateResponse } from "@server/routers/certificates/types"; import { cn } from "@app/lib/cn"; import { FileBadge } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -22,7 +23,7 @@ type ResourceAccessCertIndicatorProps = { orgId: string; domainId: string; fullDomain: string; - initialCertValue?: any; + initialCertValue?: GetCertificateResponse | null; }; function getStatusColor(status: string) { @@ -44,7 +45,8 @@ function getStatusColor(status: string) { export function ResourceAccessCertIndicator({ orgId, domainId, - fullDomain + fullDomain, + initialCertValue }: ResourceAccessCertIndicatorProps) { const t = useTranslations(); const [open, setOpen] = useState(false); @@ -54,16 +56,19 @@ export function ResourceAccessCertIndicator({ orgId, domainId, fullDomain, - autoFetch: true, + initialCertValue, polling: open, pollingInterval: 5000 }); const { cert, certLoading, certError, refreshing, fetchCert } = certificate; + // `polling` only schedules on predefined intervals (1 second), + // so the first request would be a full interval away if open = true (which set polling = true). + // So we fetch immediately so the popover opens with fresh data. useEffect(() => { if (!open) return; - void fetchCert(false); + void fetchCert(); }, [open, fetchCert]); const clearCloseTimer = useCallback(() => { diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index f459d2c38..642ba8e12 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -180,7 +180,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { orgId={resource.orgId} domainId={resource.domainId!} fullDomain={resource.fullDomain!} - autoFetch={true} showLabel={false} polling={true} /> diff --git a/src/components/SiteResourceInfoBox.tsx b/src/components/SiteResourceInfoBox.tsx index f57acbe99..15457a189 100644 --- a/src/components/SiteResourceInfoBox.tsx +++ b/src/components/SiteResourceInfoBox.tsx @@ -182,7 +182,6 @@ export function SiteResourceInfoSections({ orgId={siteResource.orgId} domainId={siteResource.domainId!} fullDomain={siteResource.fullDomain!} - autoFetch={true} showLabel={false} polling={true} /> diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index 0a555c8fc..99e78f321 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -1,18 +1,17 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; -import { AxiosResponse } from "axios"; +import { useCallback } from "react"; import { GetCertificateResponse } from "@server/routers/certificates/types"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { domainQueries } from "@app/lib/queries"; +import { durationToMs } from "@app/lib/durationToMs"; type UseCertificateProps = { orgId: string; domainId: string; fullDomain: string; - autoFetch?: boolean; polling?: boolean; pollingInterval?: number; initialCertValue?: GetCertificateResponse | null; @@ -23,135 +22,103 @@ type UseCertificateReturn = { certLoading: boolean; certError: string | null; refreshing: boolean; - fetchCert: (showLoading?: boolean) => Promise; + fetchCert: () => Promise; refreshCert: () => Promise; // clearCert: () => void; }; +const POLL_JITTER_MS = durationToMs(1, "seconds"); + export function useCertificate({ orgId, domainId, fullDomain, - initialCertValue = null, - autoFetch = true, + initialCertValue, polling = false, pollingInterval = 5000 }: UseCertificateProps): UseCertificateReturn { const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); - // const [cert, setCert] = useState( - // initialCertValue - // ); - // const [certLoading, setCertLoading] = useState(false); - const [certError, setCertError] = useState(null); - const [refreshing, setRefreshing] = useState(false); - - const query = useQuery({ - ...domainQueries.getCertificate({ - orgId, - domainId, - domain: fullDomain - }), - initialData: initialCertValue + const certQuery = domainQueries.getCertificate({ + orgId, + domainId, + domain: fullDomain }); - // const fetchCert = useCallback( - // async (showLoading = true) => { - // if (!orgId || !domainId || !fullDomain) return; + const { data, isLoading, isError, refetch } = useQuery({ + ...certQuery, + enabled: Boolean(orgId && domainId && fullDomain), + // Add a small random offset to each tick. Without it, every row in a + // table would poll at the exact same instant, hitting the server in + // bursts instead of spreading the requests out. + refetchInterval: polling + ? () => + Math.max( + 1000, + Math.round( + pollingInterval + + (Math.random() * 2 - 1) * POLL_JITTER_MS + ) + ) + : false, + // An explicit `null` is a real answer ("this domain has no certificate") + // and is seeded like any other, so a server-prefetched row doesn't + // refetch on mount. Only an omitted prop leaves the cache empty. + ...(initialCertValue !== undefined && { initialData: initialCertValue }) + }); - // if (showLoading) { - // setCertLoading(true); - // } - // try { - // const res = await api.get< - // AxiosResponse - // >(`/org/${orgId}/certificate/${domainId}/${fullDomain}`); - // const certData = res.data.data; - // if (certData) { - // setCertError(null); - // setCert(certData); - // } - // } catch (error: any) { - // console.error("Failed to fetch certificate:", error); - // setCertError("Failed"); - // } finally { - // if (showLoading) { - // setCertLoading(false); - // } - // } - // }, - // [api, orgId, domainId, fullDomain] - // ); + const cert = data ?? null; + const restartCert = useMutation({ + mutationFn: async (certId: number) => { + await api.post(`/org/${orgId}/certificate/${certId}/restart`, {}); + }, + onSuccess: () => { + // the backend flips the status asynchronously; reflect it optimistically + setTimeout(() => { + queryClient.setQueryData(certQuery.queryKey, (prev) => + prev ? { ...prev, status: "pending" } : prev + ); + }, 500); + }, + onError: (error) => { + console.error("Failed to restart certificate:", error); + } + }); + + const fetchCert = useCallback(async () => { + await refetch(); + }, [refetch]); + + const { mutateAsync: restartCertAsync } = restartCert; const refreshCert = useCallback(async () => { if (!cert) return; - setRefreshing(true); - setCertError(null); try { - await api.post( - `/org/${orgId}/certificate/${cert.certId}/restart`, - {} - ); - // Update status to pending - setTimeout(() => { - setCert({ ...cert, status: "pending" }); - }, 500); - } catch (error: any) { - console.error("Failed to restart certificate:", error); - setCertError("Failed to restart"); - } finally { - setRefreshing(false); + await restartCertAsync(cert.certId); + } catch { + // surfaced through certError } - }, [api, orgId, cert]); + }, [cert, restartCertAsync]); - const clearCert = useCallback(() => { - setCert(null); - setCertError(null); - }, []); + // const clearCert = useCallback(() => { + // queryClient.removeQueries({ queryKey: certQuery.queryKey }); + // }, [queryClient, certQuery.queryKey]); - // Auto-fetch on mount if enabled - // useEffect(() => { - // if (autoFetch && orgId && domainId && fullDomain) { - // fetchCert(); - // } - // }, [autoFetch, orgId, domainId, fullDomain, fetchCert]); - - useEffect(() => { - if (!polling || !orgId || !domainId || !fullDomain) return; - - const POLL_JITTER_MS = 1000; - let cancelled = false; - let timeoutId: ReturnType; - - const scheduleNext = () => { - const jitter = (Math.random() * 2 - 1) * POLL_JITTER_MS; - const delayMs = Math.max( - 1000, - Math.round(pollingInterval + jitter) - ); - - timeoutId = setTimeout(() => { - if (cancelled) return; - void fetchCert(false); - scheduleNext(); - }, delayMs); - }; - - scheduleNext(); - - return () => { - cancelled = true; - clearTimeout(timeoutId); - }; - }, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]); + let certError: string | null = null; + if (restartCert.isError) { + certError = "Failed to restart"; + } else if (isError) { + certError = "Failed"; + } return { - cert: query.data, - certLoading: query.isLoading, + cert, + certLoading: isLoading, certError, - refreshing, - fetchCert: query.refetch, + refreshing: restartCert.isPending, + fetchCert, refreshCert // clearCert }; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index c52dff981..f51ca0ac0 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -41,7 +41,7 @@ import { keepPreviousData, queryOptions } from "@tanstack/react-query"; -import type { AxiosResponse } from "axios"; +import { isAxiosError, type AxiosResponse } from "axios"; import z, { meta } from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; @@ -1396,17 +1396,31 @@ export const domainQueries = { orgId, "DOMAIN", domainId, - "CERTIFICATE" + "CERTIFICATE", + domain ] as const, queryFn: async ({ signal, meta }) => { - const res = await meta!.api.get< - AxiosResponse - >(`/org/${orgId}/certificate/${domainId}/${domain}`, { - signal - }); - if (res.status === 404) return null; - return res.data.data; + try { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/certificate/${domainId}/${domain}`, { + signal + }); + return res.data.data; + } catch (error) { + // the endpoint 404s when the domain has no certificate yet + if (isAxiosError(error) && error.response?.status === 404) { + return null; + } + throw error; + } }, + retry: (failureCount, error) => + isAxiosError(error) && + error.response != null && + error.response.status < 500 + ? false + : failureCount < 2, staleTime: durationToMs(1, "minutes") }), getDomain: ({ orgId, domainId }: { orgId: string; domainId: string }) => From ac79621cae5fb7b897d93bc63fabb3ee76e45064 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 29 Jul 2026 19:05:33 +0100 Subject: [PATCH 18/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Adjust=20the=20polli?= =?UTF-8?q?ng=20interval=20to=20`30s`=20for=20valid=20certs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCertificate.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index 99e78f321..a71c3ef70 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -28,6 +28,8 @@ type UseCertificateReturn = { }; const POLL_JITTER_MS = durationToMs(1, "seconds"); +/** A valid cert isn't going anywhere soon, so check on it far less often. */ +const VALID_POLL_INTERVAL_MS = durationToMs(30, "seconds"); export function useCertificate({ orgId, @@ -35,7 +37,7 @@ export function useCertificate({ fullDomain, initialCertValue, polling = false, - pollingInterval = 5000 + pollingInterval = durationToMs(5, "seconds") }: UseCertificateProps): UseCertificateReturn { const api = createApiClient(useEnvContext()); const queryClient = useQueryClient(); @@ -49,19 +51,19 @@ export function useCertificate({ const { data, isLoading, isError, refetch } = useQuery({ ...certQuery, enabled: Boolean(orgId && domainId && fullDomain), - // Add a small random offset to each tick. Without it, every row in a - // table would poll at the exact same instant, hitting the server in - // bursts instead of spreading the requests out. - refetchInterval: polling - ? () => - Math.max( - 1000, - Math.round( - pollingInterval + - (Math.random() * 2 - 1) * POLL_JITTER_MS - ) - ) - : false, + refetchInterval: (query) => { + if (!polling) return false; + const interval = + query.state.data?.status === "valid" + ? VALID_POLL_INTERVAL_MS + : pollingInterval; + // Add a small random offset to each tick. Without it, every row in + // a table would poll at the exact same instant, hitting the server + // in bursts instead of spreading the requests out. + // the requests will be jittered by less or more than 1s + const jitter = (Math.random() * 2 - 1) * POLL_JITTER_MS; + return Math.max(1000, interval + jitter); + }, // An explicit `null` is a real answer ("this domain has no certificate") // and is seeded like any other, so a server-prefetched row doesn't // refetch on mount. Only an omitted prop leaves the cache empty. From 5cbb767e5f9e5653844931354a62a3eb967980a9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 29 Jul 2026 19:11:03 +0100 Subject: [PATCH 19/20] =?UTF-8?q?=E2=9C=A8=20Set=20default=20certificates?= =?UTF-8?q?=20for=20private=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/resources/private/page.tsx | 39 +++++++++++++++++++ src/components/PrivateResourcesTable.tsx | 11 +++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 348340aa9..cbd9ec7ef 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -6,6 +6,8 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; +import { build } from "@server/build"; +import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { AxiosResponse } from "axios"; import type { Metadata } from "next"; @@ -99,6 +101,42 @@ export default async function ClientResourcesPage( }; } ); + + // Prefetched in one batched call so the table doesn't fire a separate + // certificate request per visible row once it mounts on the client. + const certDomains = Array.from( + new Set( + internalResourceRows + .filter( + (r) => + r.mode === "http" && + !r.alias && + r.ssl && + r.domainId && + r.fullDomain + ) + .map((r) => r.fullDomain as string) + ) + ); + + let initialCertificates: GetBatchedCertificateResponse | undefined; + if (build !== "oss" && certDomains.length > 0) { + try { + const certSearchParams = new URLSearchParams( + certDomains.map((domain) => ["domains", domain]) + ); + const certRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/batched-certificates?${certSearchParams.toString()}`, + await authCookieHeader() + ); + initialCertificates = certRes.data.data; + } catch { + // leave undefined so each row falls back to fetching its own + } + } + return ( <> diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 6c2999624..5ee23f424 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -37,6 +37,7 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import { UpdateSiteResourceResponse } from "@server/routers/siteResource"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; @@ -136,13 +137,16 @@ type ClientResourcesTableProps = { orgId: string; pagination: PaginationState; rowCount: number; + /** Certificates prefetched on the server, keyed by full domain. */ + initialCertificates?: GetBatchedCertificateResponse; }; export default function PrivateResourcesTable({ internalResources, orgId, pagination, - rowCount + rowCount, + initialCertificates }: ClientResourcesTableProps) { const router = useRouter(); const { @@ -432,6 +436,9 @@ export default function PrivateResourcesTable({ orgId={resourceRow.orgId} domainId={domainId} fullDomain={fullDomain} + initialCertValue={ + initialCertificates?.[fullDomain] + } /> ) : null}
@@ -585,7 +592,7 @@ export default function PrivateResourcesTable({ ]; return cols; - }, [orgId, t, searchParams]); + }, [orgId, t, searchParams, initialCertificates]); function handleFilterChange( column: string, From bc267b71071963645120eead845793514e78a07b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 29 Jul 2026 19:11:16 +0100 Subject: [PATCH 20/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CertificateStatus.tsx | 6 +++--- src/components/PublicResourcesTable.tsx | 2 -- src/components/ResourceAccessCertIndicator.tsx | 3 ++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index 9b1f52c03..88da20ff0 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -5,6 +5,7 @@ import { FileBadge, RotateCw } from "lucide-react"; import { useCertificate } from "@app/hooks/useCertificate"; import type { GetCertificateResponse } from "@server/routers/certificates/types"; import { useTranslations } from "next-intl"; +import { durationToMs } from "@app/lib/durationToMs"; export type CertificateStatusContentProps = { cert: GetCertificateResponse | null; @@ -32,8 +33,7 @@ export function CertificateStatusContent({ const labelClass = "inline-flex shrink-0 items-center self-center text-sm font-medium leading-normal"; - const valueClass = - "inline-flex items-center gap-2 text-sm leading-normal"; + const valueClass = "inline-flex items-center gap-2 text-sm leading-normal"; const handleRefresh = async () => { await refreshCert(); @@ -202,7 +202,7 @@ export default function CertificateStatus({ className = "", onRefresh, polling = false, - pollingInterval = 5000 + pollingInterval = durationToMs(5, "seconds") }: CertificateStatusProps) { const hook = useCertificate({ orgId, diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index a5ada101d..09f451a69 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -142,8 +142,6 @@ export default function PublicResourcesTable({ const [selectedResource, setSelectedResource] = useState(); - const { isPaidUser } = usePaidStatus(); - const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); diff --git a/src/components/ResourceAccessCertIndicator.tsx b/src/components/ResourceAccessCertIndicator.tsx index 9459add3a..4fc9c4c2d 100644 --- a/src/components/ResourceAccessCertIndicator.tsx +++ b/src/components/ResourceAccessCertIndicator.tsx @@ -18,6 +18,7 @@ import { useState, type ReactNode } from "react"; +import { durationToMs } from "@app/lib/durationToMs"; type ResourceAccessCertIndicatorProps = { orgId: string; @@ -58,7 +59,7 @@ export function ResourceAccessCertIndicator({ fullDomain, initialCertValue, polling: open, - pollingInterval: 5000 + pollingInterval: durationToMs(5, "seconds") }); const { cert, certLoading, certError, refreshing, fetchCert } = certificate;