mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-04 19:51:29 +02:00
♻️ Batch certificate queries
This commit is contained in:
@@ -5,6 +5,8 @@ import PublicResourcesBanner from "@app/components/PublicResourcesBanner";
|
|||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
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 { GetOrgResponse } from "@server/routers/org";
|
||||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||||
import { GetSiteResponse } from "@server/routers/site/getSite";
|
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||||
@@ -60,29 +62,7 @@ export default async function ProxyResourcesPage(
|
|||||||
searchParams.get("siteId") ?? undefined
|
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;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
@@ -140,6 +120,34 @@ export default async function ProxyResourcesPage(
|
|||||||
health: (resource.health as ResourceRow["health"]) ?? undefined
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
@@ -158,7 +166,7 @@ export default async function ProxyResourcesPage(
|
|||||||
pageIndex: pagination.page - 1,
|
pageIndex: pagination.page - 1,
|
||||||
pageSize: pagination.pageSize
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
initialFilterSite={initialFilterSite}
|
initialCertificates={initialCertificates}
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -412,7 +412,6 @@ function AuthPageSettings({
|
|||||||
fullDomain={
|
fullDomain={
|
||||||
loginPage.fullDomain
|
loginPage.fullDomain
|
||||||
}
|
}
|
||||||
autoFetch={true}
|
|
||||||
showLabel={true}
|
showLabel={true}
|
||||||
polling={true}
|
polling={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ type CertificateStatusProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
domainId: string;
|
domainId: string;
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
autoFetch?: boolean;
|
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
@@ -199,7 +198,6 @@ export default function CertificateStatus({
|
|||||||
orgId,
|
orgId,
|
||||||
domainId,
|
domainId,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
autoFetch = true,
|
|
||||||
showLabel = true,
|
showLabel = true,
|
||||||
className = "",
|
className = "",
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -210,7 +208,6 @@ export default function CertificateStatus({
|
|||||||
orgId,
|
orgId,
|
||||||
domainId,
|
domainId,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
autoFetch,
|
|
||||||
polling,
|
polling,
|
||||||
pollingInterval
|
pollingInterval
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
ResourceSitesStatusCell,
|
ResourceSitesStatusCell,
|
||||||
type ResourceSiteRow
|
type ResourceSiteRow
|
||||||
} from "@app/components/ResourceSitesStatusCell";
|
} from "@app/components/ResourceSitesStatusCell";
|
||||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
import { Selectedsite } from "@app/components/site-selector";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import {
|
import {
|
||||||
@@ -18,25 +17,18 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
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 { Switch } from "@app/components/ui/switch";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
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 { orgQueries } from "@app/lib/queries";
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
@@ -48,9 +40,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
Clock,
|
Clock,
|
||||||
Funnel,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PlusIcon,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
XCircle
|
XCircle
|
||||||
@@ -60,7 +50,6 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
startTransition,
|
startTransition,
|
||||||
useEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useOptimistic,
|
useOptimistic,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -71,15 +60,11 @@ import {
|
|||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
|
import { LabelsTableCell } from "./LabelsTableCell";
|
||||||
|
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
import { UptimeMiniBar } from "./UptimeMiniBar";
|
import { UptimeMiniBar } from "./UptimeMiniBar";
|
||||||
import { type SelectedLabel } from "./labels-selector";
|
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
|
||||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
|
||||||
import { LabelsTableCell } from "./LabelsTableCell";
|
|
||||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
|
||||||
import { refresh } from "next/cache";
|
|
||||||
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
|
|
||||||
|
|
||||||
export type TargetHealth = {
|
export type TargetHealth = {
|
||||||
targetId: number;
|
targetId: number;
|
||||||
@@ -123,6 +108,8 @@ type ProxyResourcesTableProps = {
|
|||||||
pagination: PaginationState;
|
pagination: PaginationState;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
initialFilterSite?: Selectedsite | null;
|
initialFilterSite?: Selectedsite | null;
|
||||||
|
/** Certificates prefetched on the server, keyed by full domain. */
|
||||||
|
initialCertificates?: GetBatchedCertificateResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
const booleanSearchFilterSchema = z
|
const booleanSearchFilterSchema = z
|
||||||
@@ -137,7 +124,7 @@ export default function PublicResourcesTable({
|
|||||||
orgId,
|
orgId,
|
||||||
pagination,
|
pagination,
|
||||||
rowCount,
|
rowCount,
|
||||||
initialFilterSite = null
|
initialCertificates
|
||||||
}: ProxyResourcesTableProps) {
|
}: ProxyResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
@@ -166,12 +153,6 @@ export default function PublicResourcesTable({
|
|||||||
[resources]
|
[resources]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Domain list
|
|
||||||
const resourceDomains = useMemo(
|
|
||||||
() => resources.map((r) => r.fullDomain).filter(Boolean) as string[],
|
|
||||||
[resources]
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusHistoryQuery = useQuery({
|
const statusHistoryQuery = useQuery({
|
||||||
...orgQueries.batchedResourceStatusHistory({
|
...orgQueries.batchedResourceStatusHistory({
|
||||||
orgId,
|
orgId,
|
||||||
@@ -181,14 +162,6 @@ export default function PublicResourcesTable({
|
|||||||
enabled: statusHistoryResourceIds.length > 0
|
enabled: statusHistoryResourceIds.length > 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const domainCertificatesQuery = useQuery({
|
|
||||||
...orgQueries.batchedDomainCertificates({
|
|
||||||
orgId,
|
|
||||||
domains: resourceDomains
|
|
||||||
}),
|
|
||||||
enabled: resourceDomains.length > 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
try {
|
try {
|
||||||
@@ -499,6 +472,9 @@ export default function PublicResourcesTable({
|
|||||||
orgId={resourceRow.orgId}
|
orgId={resourceRow.orgId}
|
||||||
domainId={domainId}
|
domainId={domainId}
|
||||||
fullDomain={certHostname}
|
fullDomain={certHostname}
|
||||||
|
initialCertValue={
|
||||||
|
initialCertificates?.[certHostname]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="">
|
<div className="">
|
||||||
@@ -668,7 +644,8 @@ export default function PublicResourcesTable({
|
|||||||
t,
|
t,
|
||||||
searchParams,
|
searchParams,
|
||||||
statusHistoryQuery.data,
|
statusHistoryQuery.data,
|
||||||
statusHistoryQuery.isLoading
|
statusHistoryQuery.isLoading,
|
||||||
|
initialCertificates
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PopoverContent
|
PopoverContent
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { useCertificate } from "@app/hooks/useCertificate";
|
import { useCertificate } from "@app/hooks/useCertificate";
|
||||||
|
import type { GetCertificateResponse } from "@server/routers/certificates/types";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { FileBadge } from "lucide-react";
|
import { FileBadge } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -22,7 +23,7 @@ type ResourceAccessCertIndicatorProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
domainId: string;
|
domainId: string;
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
initialCertValue?: any;
|
initialCertValue?: GetCertificateResponse | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStatusColor(status: string) {
|
function getStatusColor(status: string) {
|
||||||
@@ -44,7 +45,8 @@ function getStatusColor(status: string) {
|
|||||||
export function ResourceAccessCertIndicator({
|
export function ResourceAccessCertIndicator({
|
||||||
orgId,
|
orgId,
|
||||||
domainId,
|
domainId,
|
||||||
fullDomain
|
fullDomain,
|
||||||
|
initialCertValue
|
||||||
}: ResourceAccessCertIndicatorProps) {
|
}: ResourceAccessCertIndicatorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -54,16 +56,19 @@ export function ResourceAccessCertIndicator({
|
|||||||
orgId,
|
orgId,
|
||||||
domainId,
|
domainId,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
autoFetch: true,
|
initialCertValue,
|
||||||
polling: open,
|
polling: open,
|
||||||
pollingInterval: 5000
|
pollingInterval: 5000
|
||||||
});
|
});
|
||||||
|
|
||||||
const { cert, certLoading, certError, refreshing, fetchCert } = certificate;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
void fetchCert(false);
|
void fetchCert();
|
||||||
}, [open, fetchCert]);
|
}, [open, fetchCert]);
|
||||||
|
|
||||||
const clearCloseTimer = useCallback(() => {
|
const clearCloseTimer = useCallback(() => {
|
||||||
|
|||||||
@@ -180,7 +180,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
orgId={resource.orgId}
|
orgId={resource.orgId}
|
||||||
domainId={resource.domainId!}
|
domainId={resource.domainId!}
|
||||||
fullDomain={resource.fullDomain!}
|
fullDomain={resource.fullDomain!}
|
||||||
autoFetch={true}
|
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
polling={true}
|
polling={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ export function SiteResourceInfoSections({
|
|||||||
orgId={siteResource.orgId}
|
orgId={siteResource.orgId}
|
||||||
domainId={siteResource.domainId!}
|
domainId={siteResource.domainId!}
|
||||||
fullDomain={siteResource.fullDomain!}
|
fullDomain={siteResource.fullDomain!}
|
||||||
autoFetch={true}
|
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
polling={true}
|
polling={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+73
-106
@@ -1,18 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useCallback } from "react";
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { GetCertificateResponse } from "@server/routers/certificates/types";
|
import { GetCertificateResponse } from "@server/routers/certificates/types";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
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 { domainQueries } from "@app/lib/queries";
|
||||||
|
import { durationToMs } from "@app/lib/durationToMs";
|
||||||
|
|
||||||
type UseCertificateProps = {
|
type UseCertificateProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
domainId: string;
|
domainId: string;
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
autoFetch?: boolean;
|
|
||||||
polling?: boolean;
|
polling?: boolean;
|
||||||
pollingInterval?: number;
|
pollingInterval?: number;
|
||||||
initialCertValue?: GetCertificateResponse | null;
|
initialCertValue?: GetCertificateResponse | null;
|
||||||
@@ -23,135 +22,103 @@ type UseCertificateReturn = {
|
|||||||
certLoading: boolean;
|
certLoading: boolean;
|
||||||
certError: string | null;
|
certError: string | null;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
fetchCert: (showLoading?: boolean) => Promise<void>;
|
fetchCert: () => Promise<void>;
|
||||||
refreshCert: () => Promise<void>;
|
refreshCert: () => Promise<void>;
|
||||||
// clearCert: () => void;
|
// clearCert: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const POLL_JITTER_MS = durationToMs(1, "seconds");
|
||||||
|
|
||||||
export function useCertificate({
|
export function useCertificate({
|
||||||
orgId,
|
orgId,
|
||||||
domainId,
|
domainId,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
initialCertValue = null,
|
initialCertValue,
|
||||||
autoFetch = true,
|
|
||||||
polling = false,
|
polling = false,
|
||||||
pollingInterval = 5000
|
pollingInterval = 5000
|
||||||
}: UseCertificateProps): UseCertificateReturn {
|
}: UseCertificateProps): UseCertificateReturn {
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// const [cert, setCert] = useState<GetCertificateResponse | null>(
|
const certQuery = domainQueries.getCertificate({
|
||||||
// initialCertValue
|
orgId,
|
||||||
// );
|
domainId,
|
||||||
// const [certLoading, setCertLoading] = useState(false);
|
domain: fullDomain
|
||||||
const [certError, setCertError] = useState<string | null>(null);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
...domainQueries.getCertificate({
|
|
||||||
orgId,
|
|
||||||
domainId,
|
|
||||||
domain: fullDomain
|
|
||||||
}),
|
|
||||||
initialData: initialCertValue
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// const fetchCert = useCallback(
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
// async (showLoading = true) => {
|
...certQuery,
|
||||||
// if (!orgId || !domainId || !fullDomain) return;
|
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) {
|
const cert = data ?? null;
|
||||||
// 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 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 () => {
|
const refreshCert = useCallback(async () => {
|
||||||
if (!cert) return;
|
if (!cert) return;
|
||||||
|
|
||||||
setRefreshing(true);
|
|
||||||
setCertError(null);
|
|
||||||
try {
|
try {
|
||||||
await api.post(
|
await restartCertAsync(cert.certId);
|
||||||
`/org/${orgId}/certificate/${cert.certId}/restart`,
|
} catch {
|
||||||
{}
|
// surfaced through certError
|
||||||
);
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}, [api, orgId, cert]);
|
}, [cert, restartCertAsync]);
|
||||||
|
|
||||||
const clearCert = useCallback(() => {
|
// const clearCert = useCallback(() => {
|
||||||
setCert(null);
|
// queryClient.removeQueries({ queryKey: certQuery.queryKey });
|
||||||
setCertError(null);
|
// }, [queryClient, certQuery.queryKey]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-fetch on mount if enabled
|
let certError: string | null = null;
|
||||||
// useEffect(() => {
|
if (restartCert.isError) {
|
||||||
// if (autoFetch && orgId && domainId && fullDomain) {
|
certError = "Failed to restart";
|
||||||
// fetchCert();
|
} else if (isError) {
|
||||||
// }
|
certError = "Failed";
|
||||||
// }, [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]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cert: query.data,
|
cert,
|
||||||
certLoading: query.isLoading,
|
certLoading: isLoading,
|
||||||
certError,
|
certError,
|
||||||
refreshing,
|
refreshing: restartCert.isPending,
|
||||||
fetchCert: query.refetch,
|
fetchCert,
|
||||||
refreshCert
|
refreshCert
|
||||||
// clearCert
|
// clearCert
|
||||||
};
|
};
|
||||||
|
|||||||
+23
-9
@@ -41,7 +41,7 @@ import {
|
|||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
queryOptions
|
queryOptions
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import type { AxiosResponse } from "axios";
|
import { isAxiosError, type AxiosResponse } from "axios";
|
||||||
import z, { meta } from "zod";
|
import z, { meta } from "zod";
|
||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
@@ -1396,17 +1396,31 @@ export const domainQueries = {
|
|||||||
orgId,
|
orgId,
|
||||||
"DOMAIN",
|
"DOMAIN",
|
||||||
domainId,
|
domainId,
|
||||||
"CERTIFICATE"
|
"CERTIFICATE",
|
||||||
|
domain
|
||||||
] as const,
|
] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
try {
|
||||||
AxiosResponse<GetCertificateResponse | null>
|
const res = await meta!.api.get<
|
||||||
>(`/org/${orgId}/certificate/${domainId}/${domain}`, {
|
AxiosResponse<GetCertificateResponse | null>
|
||||||
signal
|
>(`/org/${orgId}/certificate/${domainId}/${domain}`, {
|
||||||
});
|
signal
|
||||||
if (res.status === 404) return null;
|
});
|
||||||
return res.data.data;
|
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")
|
staleTime: durationToMs(1, "minutes")
|
||||||
}),
|
}),
|
||||||
getDomain: ({ orgId, domainId }: { orgId: string; domainId: string }) =>
|
getDomain: ({ orgId, domainId }: { orgId: string; domainId: string }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user