♻️ Batch certificate queries

This commit is contained in:
Fred KISSIE
2026-07-28 20:59:42 +01:00
parent 49854f31a5
commit 10773432bb
9 changed files with 151 additions and 186 deletions
@@ -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<GetSiteResponse>).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<GetBatchedCertificateResponse>
>(
`/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 (
<>
<SettingsSectionTitle
@@ -158,7 +166,7 @@ export default async function ProxyResourcesPage(
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialFilterSite={initialFilterSite}
initialCertificates={initialCertificates}
/>
</OrgProvider>
</>
-1
View File
@@ -412,7 +412,6 @@ function AuthPageSettings({
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
-3
View File
@@ -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
});
+14 -37
View File
@@ -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}
<div className="">
@@ -668,7 +644,8 @@ export default function PublicResourcesTable({
t,
searchParams,
statusHistoryQuery.data,
statusHistoryQuery.isLoading
statusHistoryQuery.isLoading,
initialCertificates
]);
function handleFilterChange(
@@ -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(() => {
-1
View File
@@ -180,7 +180,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
orgId={resource.orgId}
domainId={resource.domainId!}
fullDomain={resource.fullDomain!}
autoFetch={true}
showLabel={false}
polling={true}
/>
-1
View File
@@ -182,7 +182,6 @@ export function SiteResourceInfoSections({
orgId={siteResource.orgId}
domainId={siteResource.domainId!}
fullDomain={siteResource.fullDomain!}
autoFetch={true}
showLabel={false}
polling={true}
/>
+73 -106
View File
@@ -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<void>;
fetchCert: () => Promise<void>;
refreshCert: () => Promise<void>;
// 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<GetCertificateResponse | null>(
// initialCertValue
// );
// const [certLoading, setCertLoading] = useState(false);
const [certError, setCertError] = useState<string | null>(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<GetCertificateResponse>
// >(`/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<typeof setTimeout>;
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
};
+23 -9
View File
@@ -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<GetCertificateResponse | null>
>(`/org/${orgId}/certificate/${domainId}/${domain}`, {
signal
});
if (res.status === 404) return null;
return res.data.data;
try {
const res = await meta!.api.get<
AxiosResponse<GetCertificateResponse | null>
>(`/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 }) =>