diff --git a/messages/en-US.json b/messages/en-US.json index 1b7e446fb..eb4d3ae3c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Create Admin Account", "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", "certificateStatus": "Certificate", + "certificateStatusAutoRefreshHint": "Status refreshes automatically.", "loading": "Loading", "loadingAnalytics": "Loading Analytics", "restart": "Restart", diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index 5d727d905..0bbc8aa66 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -120,6 +120,7 @@ export default async function ProxyResourcesPage( : "not_protected", enabled: resource.enabled, domainId: resource.domainId || undefined, + fullDomain: resource.fullDomain ?? null, ssl: resource.ssl, targets: resource.targets?.map((target) => ({ targetId: target.targetId, diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index 0d4fa9026..cc22b1e88 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -1,10 +1,187 @@ "use client"; import { Button } from "@/components/ui/button"; -import { CheckCircle2, Clock, Loader2, RotateCw, XCircle } from "lucide-react"; +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"; +export type CertificateStatusContentProps = { + cert: GetCertificateResponse | null; + certLoading: boolean; + certError: string | null; + refreshing: boolean; + refreshCert: () => Promise; + showLabel?: boolean; + className?: string; + onRefresh?: () => void; +}; + +/** Presentation-only certificate row (shared hook state possible via props). */ +export function CertificateStatusContent({ + cert, + certLoading, + certError, + refreshing, + refreshCert, + showLabel = true, + className = "", + onRefresh +}: CertificateStatusContentProps) { + const t = useTranslations(); + + const labelClass = + "inline-flex shrink-0 items-center self-center text-sm font-medium leading-none"; + const valueClass = "inline-flex items-center gap-2 text-sm leading-none"; + + const handleRefresh = async () => { + await refreshCert(); + onRefresh?.(); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case "valid": + return "text-green-500"; + case "pending": + case "requested": + return "text-yellow-500"; + case "expired": + case "failed": + return "text-red-500"; + default: + return "text-muted-foreground"; + } + }; + + const shouldShowRefreshButton = (status: string, updatedAt: number) => { + return ( + status === "failed" || + status === "expired" || + (status === "requested" && + updatedAt && + new Date(updatedAt * 1000).getTime() < + Date.now() - 5 * 60 * 1000) + ); + }; + + if (certLoading) { + return ( +
+ {showLabel && ( + + {t("certificateStatus")}: + + )} + + + {t("loading")} + +
+ ); + } + + if (certError) { + return ( +
+ {showLabel && ( + + {t("certificateStatus")}: + + )} + + + {certError} + +
+ ); + } + + if (!cert) { + return ( +
+ {showLabel && ( + + {t("certificateStatus")}: + + )} + + + {t("none", { defaultValue: "None" })} + +
+ ); + } + + const isPending = cert.status === "pending"; + const disableRestartButton = cert.domainType === "wildcard"; + + return ( +
+ {showLabel && ( + {t("certificateStatus")}: + )} + {isPending && !disableRestartButton ? ( + + ) : ( + + + {cert.status.charAt(0).toUpperCase() + cert.status.slice(1)} + {shouldShowRefreshButton(cert.status, cert.updatedAt) && + !disableRestartButton ? ( + + ) : null} + + )} +
+ ); +} + type CertificateStatusProps = { orgId: string; domainId: string; @@ -28,174 +205,25 @@ export default function CertificateStatus({ polling = false, pollingInterval = 5000 }: CertificateStatusProps) { - const t = useTranslations(); - const { cert, certLoading, certError, refreshing, refreshCert } = - useCertificate({ - orgId, - domainId, - fullDomain, - autoFetch, - polling, - pollingInterval - }); - - const handleRefresh = async () => { - await refreshCert(); - onRefresh?.(); - }; - - const getStatusColor = (status: string) => { - switch (status) { - case "valid": - return "text-green-500"; - case "pending": - case "requested": - return "text-yellow-500"; - case "expired": - case "failed": - return "text-red-500"; - default: - return "text-muted-foreground"; - } - }; - - const getStatusIcon = (status: string) => { - switch (status) { - case "valid": - return CheckCircle2; - case "pending": - case "requested": - return Clock; - case "expired": - case "failed": - return XCircle; - default: - return Clock; - } - }; - - const shouldShowRefreshButton = (status: string, updatedAt: number) => { - return ( - status === "failed" || - status === "expired" || - (status === "requested" && - updatedAt && - new Date(updatedAt * 1000).getTime() < - Date.now() - 5 * 60 * 1000) - ); - }; - - if (certLoading) { - return ( -
- {showLabel && ( - - {t("certificateStatus")}: - - )} - - - {t("loading")} - -
- ); - } - - if (certError) { - return ( -
- {showLabel && ( - - {t("certificateStatus")}: - - )} - - - {certError} - -
- ); - } - - if (!cert) { - return ( -
- {showLabel && ( - - {t("certificateStatus")}: - - )} - - - {t("none", { defaultValue: "None" })} - -
- ); - } - - const isPending = cert.status === "pending"; - const disableRestartButton = cert.domainType === "wildcard"; - const StatusIcon = getStatusIcon(cert.status); + const hook = useCertificate({ + orgId, + domainId, + fullDomain, + autoFetch, + polling, + pollingInterval + }); return ( -
- {showLabel && ( - - {t("certificateStatus")}: - - )} - {isPending && !disableRestartButton ? ( - - ) : ( - - - - {cert.status.charAt(0).toUpperCase() + - cert.status.slice(1)} - {shouldShowRefreshButton(cert.status, cert.updatedAt) && - !disableRestartButton ? ( - - ) : null} - - - )} -
+ ); } diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 9d2c46106..e8ff8ff62 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -51,7 +51,7 @@ import { ResourceSitesStatusCell, type ResourceSiteRow } from "@app/components/ResourceSitesStatusCell"; -import { PrivateResourceCertAccessIndicator } from "@app/components/PrivateResourceCertAccessIndicator"; +import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; export type InternalResourceSiteRow = ResourceSiteRow; @@ -453,6 +453,13 @@ export default function ClientResourcesTable({ return (
+ {did ? ( + + ) : null}
- {did ? ( - - ) : null}
); } diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 384ad35c6..f65ec0b0c 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -64,6 +64,7 @@ import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; import UptimeMiniBar from "./UptimeMiniBar"; +import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; export type TargetHealth = { targetId: number; @@ -86,6 +87,8 @@ export type ResourceRow = { proxyPort: number | null; enabled: boolean; domainId?: string; + /** Hostname for certificate API (without scheme); distinct from `domain` URL shown in Access column */ + fullDomain?: string | null; ssl: boolean; targetHost?: string; targetPort?: number; @@ -266,7 +269,7 @@ export default function ProxyResourcesTable({ {overallStatus === "healthy" && - t("resourcesTableHealthy")} + t("resourcesTableHealthy")} {overallStatus === "degraded" && t("resourcesTableDegraded")} {overallStatus === "unhealthy" && @@ -488,7 +491,12 @@ export default function ProxyResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - return ; + return ( + + ); }, sortingFn: (rowA, rowB) => { const statusA = rowA.original.health; @@ -520,24 +528,51 @@ export default function ProxyResourcesTable({ header: () => {t("access")}, cell: ({ row }) => { const resourceRow = row.original; - return ( -
- {!resourceRow.http ? ( + + if (!resourceRow.http) { + return ( +
- ) : !resourceRow.domainId ? ( +
+ ); + } + + if (!resourceRow.domainId) { + return ( +
- ) : ( +
+ ); + } + + const domainId = resourceRow.domainId; + const certHostname = resourceRow.fullDomain; + const showHttpsCertIndicator = + resourceRow.ssl && + certHostname != null && + certHostname !== ""; + + return ( +
+ {showHttpsCertIndicator ? ( + + ) : null} +
- )} +
); } diff --git a/src/components/PrivateResourceCertAccessIndicator.tsx b/src/components/ResourceAccessCertIndicator.tsx similarity index 55% rename from src/components/PrivateResourceCertAccessIndicator.tsx rename to src/components/ResourceAccessCertIndicator.tsx index 8b06721a5..40ddfbfab 100644 --- a/src/components/PrivateResourceCertAccessIndicator.tsx +++ b/src/components/ResourceAccessCertIndicator.tsx @@ -1,6 +1,6 @@ "use client"; -import CertificateStatus from "@app/components/CertificateStatus"; +import { CertificateStatusContent } from "@app/components/CertificateStatus"; import { Popover, PopoverAnchor, @@ -8,11 +8,17 @@ import { } from "@app/components/ui/popover"; import { useCertificate } from "@app/hooks/useCertificate"; import { cn } from "@app/lib/cn"; -import { CheckCircle2, Clock, XCircle } from "lucide-react"; +import { FileBadge } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode +} from "react"; -type PrivateResourceCertAccessIndicatorProps = { +type ResourceAccessCertIndicatorProps = { orgId: string; domainId: string; fullDomain: string; @@ -33,37 +39,32 @@ function getStatusColor(status: string) { } } -function getStatusIcon(status: string) { - switch (status) { - case "valid": - return CheckCircle2; - case "pending": - case "requested": - return Clock; - case "expired": - case "failed": - return XCircle; - default: - return Clock; - } -} - -export function PrivateResourceCertAccessIndicator({ +/** Compact cert icon + hover popover with full certificate status (shared by proxy and client resource tables). */ +export function ResourceAccessCertIndicator({ orgId, domainId, fullDomain -}: PrivateResourceCertAccessIndicatorProps) { +}: ResourceAccessCertIndicatorProps) { const t = useTranslations(); const [open, setOpen] = useState(false); const closeTimerRef = useRef | null>(null); - const { cert, certLoading, certError } = useCertificate({ + const certificate = useCertificate({ orgId, domainId, fullDomain, - autoFetch: true + autoFetch: true, + polling: open, + pollingInterval: 5000 }); + const { cert, certLoading, certError, refreshing, fetchCert } = certificate; + + useEffect(() => { + if (!open) return; + void fetchCert(false); + }, [open, fetchCert]); + const clearCloseTimer = useCallback(() => { if (closeTimerRef.current != null) { clearTimeout(closeTimerRef.current); @@ -85,24 +86,46 @@ export function PrivateResourceCertAccessIndicator({ return () => clearCloseTimer(); }, [clearCloseTimer]); + let triggerBody: ReactNode; if (certLoading) { - return ( + triggerBody = (
); - } - - let TriggerIcon = Clock; - let triggerIconClass = "text-muted-foreground"; - if (certError) { - TriggerIcon = XCircle; - triggerIconClass = "text-red-500"; + } else if (refreshing) { + triggerBody = ( + + ); + } else if (certError) { + triggerBody = ( + + ); } else if (cert) { - TriggerIcon = getStatusIcon(cert.status); - triggerIconClass = getStatusColor(cert.status); + triggerBody = ( + + ); + } else { + triggerBody = ( + + ); } return ( @@ -125,10 +148,7 @@ export function PrivateResourceCertAccessIndicator({ aria-haspopup="dialog" aria-label={t("certificateStatus")} > - + {triggerBody} e.preventDefault()} > - +
+ +

+ {t("certificateStatusAutoRefreshHint")} +

+
); diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index 217359f98..cd0802ef0 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -20,7 +20,7 @@ type UseCertificateReturn = { certLoading: boolean; certError: string | null; refreshing: boolean; - fetchCert: () => Promise; + fetchCert: (showLoading?: boolean) => Promise; refreshCert: () => Promise; clearCert: () => void; }; @@ -102,15 +102,33 @@ export function useCertificate({ } }, [autoFetch, orgId, domainId, fullDomain, fetchCert]); - // Polling effect useEffect(() => { if (!polling || !orgId || !domainId || !fullDomain) return; - const interval = setInterval(() => { - fetchCert(false); // Don't show loading for polling - }, pollingInterval); + const POLL_JITTER_MS = 1000; + let cancelled = false; + let timeoutId: ReturnType; - return () => clearInterval(interval); + 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 {