From 10773432bb37861c0703fc5c3178af4e28a05871 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 28 Jul 2026 20:59:42 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Batch=20certificate=20quer?= =?UTF-8?q?ies?= 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 }) =>