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/lib/statusHistory.ts b/server/lib/statusHistory.ts index d11ea88ef..0ec338d93 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, max, sql } from "drizzle-orm"; import { regionalCache as cache } from "#dynamic/lib/cache"; const STATUS_HISTORY_CACHE_TTL = 60; // seconds @@ -41,6 +41,7 @@ export async function getCachedStatusHistory( 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; } @@ -264,9 +268,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); @@ -301,6 +303,133 @@ export function computeBuckets( status }); } - return { buckets, totalDowntime }; } + +export type BatchedStatusHistoryResponse = Record< + string, + StatusHistoryResponse +>; + +export async function getBatchedStatusHistory( + entityType: string, + entityIds: number[], + days: number, + tzOffsetMinutes: number = 0 +): Promise { + 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 + .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". + + /** + * 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( + eq(statusHistory.entityType, entityType), + inArray(statusHistory.entityId, entityIds), + lt(statusHistory.timestamp, startSec) + ) + ) + .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, + { + events: typeof events; + lastKnownEvent: (typeof lastKnownEvents)[number] | null; + } + > = {}; + + 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 = {}; + + console.time(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`); + for (const entityId in eventStatusMap) { + const event = eventStatusMap[Number(entityId)]; + const priorStatus = event.lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + event.events, + days, + priorStatus, + tzOffsetMinutes + ); + 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 + }; + } + console.timeEnd(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`); + + console.timeEnd( + `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` + ); + return result; +} diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts new file mode 100644 index 000000000..7149de036 --- /dev/null +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -0,0 +1,190 @@ +/* + * 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 { certificates, db, domains, orgDomains } from "@server/db"; +import response from "@server/lib/response"; +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"; + +const getCertificateParamSchema = z.strictObject({ + orgId: z.string() +}); + +const getCertificateQuerySchema = z.object({ + 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)) + ) +}); + +async function query(orgId: string, 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, + domainId: domains.domainId, + 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`, + // 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 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 + const wildcardCertificates = 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, + domainId: domains.domainId, + 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( + eq(certificates.wildcard, true), + or( + inArray(certificates.domain, [...domainLevelDownSet]), + inArray(certificates.domain, [...wildcardDomainSet]) + ) + ) + ); + + existingCertificates.push(...wildcardCertificates); + } + + 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 getBatchedCertificates( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getCertificateParamSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const parsedQuery = getCertificateQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { domains } = parsedQuery.data; + + const cert = await query(orgId, 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") + ); + } +} 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/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(), diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 75a5b8d48..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, @@ -853,6 +860,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/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 +>; diff --git a/server/routers/external.ts b/server/routers/external.ts index 1dd156b58..ffe0a809b 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", @@ -469,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/server/routers/site/getBatchedStatusHistory.ts b/server/routers/site/getBatchedStatusHistory.ts new file mode 100644 index 000000000..f266c2840 --- /dev/null +++ b/server/routers/site/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 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( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = siteIdParamsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "site"; + const { days, siteIds, tzOffsetMinutes } = parsedQuery.data; + + const data = await getBatchedStatusHistory( + entityType, + siteIds, + 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/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/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/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..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(); @@ -187,7 +187,6 @@ type CertificateStatusProps = { orgId: string; domainId: string; fullDomain: string; - autoFetch?: boolean; showLabel?: boolean; className?: string; onRefresh?: () => void; @@ -199,18 +198,16 @@ export default function CertificateStatus({ orgId, domainId, fullDomain, - autoFetch = true, showLabel = true, className = "", onRefresh, polling = false, - pollingInterval = 5000 + pollingInterval = durationToMs(5, "seconds") }: CertificateStatusProps) { const hook = useCertificate({ orgId, domainId, fullDomain, - autoFetch, polling, pollingInterval }); 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/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, diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 369fe0601..09f451a69 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,23 +17,19 @@ 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 { 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"; import { @@ -45,9 +40,7 @@ import { ChevronDown, ChevronsUpDownIcon, Clock, - Funnel, MoreHorizontal, - PlusIcon, ShieldCheck, ShieldOff, XCircle @@ -57,7 +50,6 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, - useEffect, useMemo, useOptimistic, useRef, @@ -68,15 +60,11 @@ import { 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 { 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"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { UptimeMiniBar } from "./UptimeMiniBar"; export type TargetHealth = { targetId: number; @@ -120,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 @@ -127,12 +117,14 @@ const booleanSearchFilterSchema = z .optional() .catch(undefined); +const RESOURCE_STATUS_HISTORY_DAYS = 30; + export default function PublicResourcesTable({ resources, orgId, pagination, rowCount, - initialFilterSite = null + initialCertificates }: ProxyResourcesTableProps) { const router = useRouter(); const { @@ -150,11 +142,24 @@ export default function PublicResourcesTable({ const [selectedResource, setSelectedResource] = useState(); - const { isPaidUser } = usePaidStatus(); - 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 +412,11 @@ export default function PublicResourcesTable({ return -; } return ( - + ); } }, @@ -461,6 +470,9 @@ export default function PublicResourcesTable({ orgId={resourceRow.orgId} domainId={domainId} fullDomain={certHostname} + initialCertValue={ + initialCertificates?.[certHostname] + } /> ) : null}
@@ -625,7 +637,14 @@ export default function PublicResourcesTable({ ]; return cols; - }, [orgId, t, searchParams]); + }, [ + orgId, + t, + searchParams, + statusHistoryQuery.data, + statusHistoryQuery.isLoading, + initialCertificates + ]); function handleFilterChange( column: string, diff --git a/src/components/ResourceAccessCertIndicator.tsx b/src/components/ResourceAccessCertIndicator.tsx index 40ddfbfab..4fc9c4c2d 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"; @@ -17,11 +18,13 @@ import { useState, type ReactNode } from "react"; +import { durationToMs } from "@app/lib/durationToMs"; type ResourceAccessCertIndicatorProps = { orgId: string; domainId: string; fullDomain: string; + initialCertValue?: GetCertificateResponse | null; }; function getStatusColor(status: string) { @@ -43,7 +46,8 @@ function getStatusColor(status: string) { export function ResourceAccessCertIndicator({ orgId, domainId, - fullDomain + fullDomain, + initialCertValue }: ResourceAccessCertIndicatorProps) { const t = useTranslations(); const [open, setOpen] = useState(false); @@ -53,16 +57,19 @@ export function ResourceAccessCertIndicator({ orgId, domainId, fullDomain, - autoFetch: true, + initialCertValue, polling: open, - pollingInterval: 5000 + pollingInterval: durationToMs(5, "seconds") }); 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/components/SitesTable.tsx b/src/components/SitesTable.tsx index 95d63cf9a..2c4ecd1e5 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, @@ -52,12 +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 { productUpdatesQueries } from "@app/lib/queries"; -import semver from "semver"; export type SiteRow = { id: number; @@ -89,6 +89,8 @@ type SitesTableProps = { rowCount: number; }; +const SITE_STATUS_HISTORY_DAYS = 30; + export default function SitesTable({ sites, orgId, @@ -112,7 +114,16 @@ export default function SitesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - const { isPaidUser } = usePaidStatus(); + const siteIds = sites.map((s) => s.id); + + const statusHistoryQuery = useQuery({ + ...orgQueries.batchedSiteStatusHistory({ + orgId, + siteIds, + days: SITE_STATUS_HISTORY_DAYS + }), + enabled: siteIds.length > 0 + }); const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -296,7 +307,14 @@ export default function SitesTable({ if (originalRow.type == "local") { return -; } - return ; + const data = statusHistoryQuery.data?.[row.original.id]; + return ( + + ); } }, { @@ -359,14 +377,11 @@ export default function SitesTable({ cell: ({ row }) => { const originalRow = row.original; - let updateAvailable = Boolean( + const 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/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index cd0802ef0..a71c3ef70 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -1,18 +1,20 @@ "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 { 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; }; type UseCertificateReturn = { @@ -20,124 +22,106 @@ type UseCertificateReturn = { certLoading: boolean; certError: string | null; refreshing: boolean; - fetchCert: (showLoading?: boolean) => Promise; + fetchCert: () => Promise; refreshCert: () => Promise; - clearCert: () => void; + // clearCert: () => void; }; +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, domainId, fullDomain, - autoFetch = true, + initialCertValue, polling = false, - pollingInterval = 5000 + pollingInterval = durationToMs(5, "seconds") }: UseCertificateProps): UseCertificateReturn { const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); - const [cert, setCert] = useState(null); - const [certLoading, setCertLoading] = useState(false); - const [certError, setCertError] = useState(null); - const [refreshing, setRefreshing] = useState(false); + const certQuery = domainQueries.getCertificate({ + orgId, + domainId, + domain: 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); - } - } + const { data, isLoading, isError, refetch } = useQuery({ + ...certQuery, + enabled: Boolean(orgId && domainId && fullDomain), + 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); }, - [api, orgId, domainId, fullDomain] - ); + // 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 }) + }); + 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, - certLoading, + certLoading: isLoading, certError, - refreshing, + refreshing: restartCert.isPending, fetchCert, - refreshCert, - clearCert + refreshCert + // clearCert }; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 3194e5651..f51ca0ac0 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -41,13 +41,16 @@ 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"; 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 { @@ -66,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"; @@ -640,6 +644,143 @@ export const orgQueries = { }; } }), + batchedSiteStatusHistory: ({ + siteIds, + orgId, + days = 90 + }: { + orgId: string; + siteIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "BATCHED_SITE_STATUS_HISTORY", + siteIds, + 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()]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/site-status-histories?${sp.toString()}`, { + signal + }); + return res.data.data; + }, + 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") + }), + 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, + 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, days = 90 @@ -649,6 +790,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< @@ -670,6 +812,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< @@ -692,6 +835,7 @@ export const orgQueries = { days?: number; }) => queryOptions({ + staleTime: durationToMs(5, "seconds"), queryKey: [ "HC_STATUS_HISTORY", orgId, @@ -1237,6 +1381,48 @@ export const approvalQueries = { }; export const domainQueries = { + getCertificate: ({ + orgId, + domainId, + domain + }: { + orgId: string; + domainId: string; + domain: string; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "DOMAIN", + domainId, + "CERTIFICATE", + domain + ] as const, + queryFn: async ({ signal, meta }) => { + 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 }) => queryOptions({ queryKey: ["ORG", orgId, "DOMAIN", domainId] as const,