From 8ed9adbfae876eccd233b4ae320ce780abfc9703 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 28 Apr 2026 16:21:16 -0700 Subject: [PATCH 01/34] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 30dd1509f..c69483a68 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1432,7 +1432,7 @@ "alertingTriggerHcToggle": "Gesundheits-Check-Status ändern", "alertingTriggerResourceHealthy": "Ressource gesund", "alertingTriggerResourceUnhealthy": "Ressource ungesund", - "alertingTriggerResourceDegraded": "Resource degraded", + "alertingTriggerResourceDegraded": "Ressource verschlechtert", "alertingSearchHealthChecks": "Gesundheits-Checks suchen…", "alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.", "alertingTriggerResourceToggle": "Ressourcenstatus ändern", From 8c645315f3081b815bacb0ebb1365a052682aaf1 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 10:59:36 -0700 Subject: [PATCH 02/34] Handle when siteIds is not provided --- server/routers/siteResource/createSiteResource.ts | 15 +++++++++++++-- server/routers/siteResource/updateSiteResource.ts | 15 +++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index b3238796d..0da48d160 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -46,7 +46,7 @@ const createSiteResourceSchema = z mode: z.enum(["host", "cidr", "http"]), ssl: z.boolean().optional(), // only used for http mode scheme: z.enum(["http", "https"]).optional(), - siteIds: z.array(z.int()), + siteIds: z.array(z.int()).optional(), siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided // proxyPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(), @@ -133,6 +133,17 @@ const createSiteResourceSchema = z message: "HTTP mode requires scheme (http or https) and a valid destination port" } + ) + .refine( + (data) => { + return ( + (data.siteIds !== undefined && data.siteIds.length > 0) || + data.siteId !== undefined + ); + }, + { + message: "At least one of siteIds or siteId must be provided" + } ); export type CreateSiteResourceBody = z.infer; @@ -188,7 +199,7 @@ export async function createSiteResource( const { name, niceId, - siteIds: siteIdsInput, + siteIds: siteIdsInput = [], siteId, mode, scheme, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 462cb1c2b..d0efa0cf4 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -43,7 +43,7 @@ const updateSiteResourceParamsSchema = z.strictObject({ const updateSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255).optional(), - siteIds: z.array(z.int()), + siteIds: z.array(z.int()).optional(), siteId: z.int().positive().optional(), // niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(), niceId: z @@ -143,6 +143,17 @@ const updateSiteResourceSchema = z message: "HTTP mode requires scheme (http or https) and a valid destination port" } + ) + .refine( + (data) => { + return ( + (data.siteIds !== undefined && data.siteIds.length > 0) || + data.siteId !== undefined + ); + }, + { + message: "At least one of siteIds or siteId must be provided" + } ); export type UpdateSiteResourceBody = z.infer; @@ -197,7 +208,7 @@ export async function updateSiteResource( const { siteResourceId } = parsedParams.data; const { name, - siteIds: siteIdsInput, // because it can change + siteIds: siteIdsInput = [], // because it can change siteId, niceId, mode, From 5c31d35e28d7e9d1cbf79b0da55ca5baae5cbaa7 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 10:59:49 -0700 Subject: [PATCH 03/34] Handle sans in the acme.json --- server/private/lib/acmeCertSync.ts | 99 ++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index a9e818986..bb96de3d6 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -250,6 +250,30 @@ function extractFirstCert(pemBundle: string): string | null { return match ? match[0] : null; } +/** + * Determine whether an ACME cert entry represents a wildcard cert by checking + * both the primary domain (`main`) and the SANs. Some ACME clients (notably + * Traefik) store the bare apex in `main` and only put the wildcard form in + * `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]). + */ +function detectWildcard( + main: string, + sans: string[] | undefined +): { wildcard: boolean; wildcardSan: string | null } { + if (main.startsWith("*.")) { + return { wildcard: true, wildcardSan: null }; + } + if (Array.isArray(sans)) { + for (const san of sans) { + if (typeof san !== "string") continue; + if (san === `*.${main}` || san.startsWith("*.")) { + return { wildcard: true, wildcardSan: san }; + } + } + } + return { wildcard: false, wildcardSan: null }; +} + async function syncAcmeCerts( acmeJsonPath: string, resolver: string @@ -279,14 +303,15 @@ async function syncAcmeCerts( } for (const cert of resolverData.Certificates) { - const domain = cert.domain?.main; - const wildcard = domain.startsWith("*."); + const domain = cert?.domain?.main; - if (!domain) { + if (!domain || typeof domain !== "string") { logger.debug(`acmeCertSync: skipping cert with missing domain`); continue; } + const { wildcard } = detectWildcard(domain, cert.domain?.sans); + if (!cert.certificate || !cert.key) { logger.debug( `acmeCertSync: skipping cert for ${domain} - empty certificate or key field` @@ -294,10 +319,17 @@ async function syncAcmeCerts( continue; } - const certPem = Buffer.from(cert.certificate, "base64").toString( - "utf8" - ); - const keyPem = Buffer.from(cert.key, "base64").toString("utf8"); + let certPem: string; + let keyPem: string; + try { + certPem = Buffer.from(cert.certificate, "base64").toString("utf8"); + keyPem = Buffer.from(cert.key, "base64").toString("utf8"); + } catch (err) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}` + ); + continue; + } if (!certPem.trim() || !keyPem.trim()) { logger.debug( @@ -306,6 +338,39 @@ async function syncAcmeCerts( continue; } + // Validate that the decoded data actually parses as a real X.509 cert + // before we touch the database. This prevents importing partially-written + // or corrupted entries from acme.json. + const firstCertPemForValidation = extractFirstCert(certPem); + if (!firstCertPemForValidation) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - no PEM certificate block found` + ); + continue; + } + + let validatedX509: crypto.X509Certificate; + try { + validatedX509 = new crypto.X509Certificate( + firstCertPemForValidation + ); + } catch (err) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}` + ); + continue; + } + + // Sanity-check the private key parses too + try { + crypto.createPrivateKey(keyPem); + } catch (err) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}` + ); + continue; + } + // Check if cert already exists in DB const existing = await db .select() @@ -355,18 +420,16 @@ async function syncAcmeCerts( } } - // Parse cert expiry from the first cert in the PEM bundle + // Parse cert expiry from the validated X.509 certificate let expiresAt: number | null = null; - const firstCertPem = extractFirstCert(certPem); - if (firstCertPem) { - try { - const x509 = new crypto.X509Certificate(firstCertPem); - expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000); - } catch (err) { - logger.debug( - `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` - ); - } + try { + expiresAt = Math.floor( + new Date(validatedX509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); } const encryptedCert = encrypt( From d45b727dcad094be6905b41f3ffbe3a38e9f13d6 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 11:06:14 -0700 Subject: [PATCH 04/34] Dont show cert status because not saved yet --- src/components/InternalResourceForm.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 76a614207..79017ca48 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1120,6 +1120,8 @@ export function InternalResourceForm({ {variant === "edit" && resource?.domainId && httpConfigFullDomain && + httpConfigDomainId === resource.domainId && + httpConfigFullDomain === resource.fullDomain && form.watch("ssl") && (
From b39a2bcfb179bc26a5bf10545ddf95462aa983f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 11:25:43 -0700 Subject: [PATCH 05/34] Quiet logs --- server/private/lib/acmeCertSync.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index bb96de3d6..570ccfd75 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -392,9 +392,9 @@ async function syncAcmeCerts( config.getRawConfig().server.secret! ); if (storedCertPem === certPem) { - logger.debug( - `acmeCertSync: cert for ${domain} is unchanged, skipping` - ); + // logger.debug( + // `acmeCertSync: cert for ${domain} is unchanged, skipping` + // ); continue; } // Cert has changed; capture old values so we can send a correct From b8ca0499af6bd6e795b949bf1f6f78b0a895e9bb Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 11:28:17 -0700 Subject: [PATCH 06/34] Dont show the cert box oss and dont check license --- server/private/routers/external.ts | 1 - src/components/ResourceInfoBox.tsx | 20 +++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 159ee2449..a2667daa1 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -165,7 +165,6 @@ authenticated.get( authenticated.get( "/org/:orgId/certificate/:domainId/:domain", - verifyValidLicense, verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.getCertificate), diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index ca5ded4c5..de600a237 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -1,7 +1,15 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { ShieldCheck, ShieldOff, Eye, EyeOff, CheckCircle2, XCircle, Clock } from "lucide-react"; +import { + ShieldCheck, + ShieldOff, + Eye, + EyeOff, + CheckCircle2, + XCircle, + Clock +} from "lucide-react"; import { useResourceContext } from "@app/hooks/useResourceContext"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { @@ -13,7 +21,7 @@ import { import { useTranslations } from "next-intl"; import CertificateStatus from "@app/components/CertificateStatus"; import { toUnicode } from "punycode"; -import { useEnvContext } from "@app/hooks/useEnvContext"; +import { build } from "@server/build"; type ResourceInfoBoxType = {}; @@ -28,7 +36,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {/* 4 cols because of the certs */} - + {t("identifier")} @@ -137,7 +145,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {/* Certificate Status Column */} {resource.http && resource.domainId && - resource.fullDomain && ( + resource.fullDomain && + build != "oss" && ( {t("certificateStatus", { @@ -177,7 +186,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {t("resourcesTableUnhealthy")}
)} - {(!resource.health || resource.health === "unknown") && ( + {(!resource.health || + resource.health === "unknown") && (
{t("resourcesTableUnknown")} From 1eac7741a50fc0d8b8614f71c8be0ad70a14d8be Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 11:34:10 -0700 Subject: [PATCH 07/34] Show the certs elsewhere when required --- src/components/AuthPageSettings.tsx | 9 +- src/components/CertificateStatus.tsx | 11 +- src/components/InternalResourceForm.tsx | 147 +++++++++++++++--------- 3 files changed, 102 insertions(+), 65 deletions(-) diff --git a/src/components/AuthPageSettings.tsx b/src/components/AuthPageSettings.tsx index 1e825fa1e..edd1e3a19 100644 --- a/src/components/AuthPageSettings.tsx +++ b/src/components/AuthPageSettings.tsx @@ -399,11 +399,10 @@ function AuthPageSettings({
)} - {env.flags.usePangolinDns && - (build === "enterprise" || - !isPaidUser( - tierMatrix.loginPageDomain - )) && + {build !== "oss" && (build === "enterprise" || + !isPaidUser( + tierMatrix.loginPageDomain + )) && loginPage?.domainId && loginPage?.fullDomain && !hasUnsavedChanges && ( diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index 9b08aedfe..660fad62b 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -138,7 +138,8 @@ export default function CertificateStatus({ })} > - {cert.status.charAt(0).toUpperCase() + cert.status.slice(1)} + {cert.status.charAt(0).toUpperCase() + + cert.status.slice(1)} @@ -147,8 +148,12 @@ export default function CertificateStatus({ ) : ( - {cert.status.charAt(0).toUpperCase() + cert.status.slice(1)} - {shouldShowRefreshButton(cert.status, cert.updatedAt) && ( + {cert.status.charAt(0).toUpperCase() + + cert.status.slice(1)} + {shouldShowRefreshButton( + cert.status, + cert.updatedAt + ) && ( ) : ( - - + + + {cert.status.charAt(0).toUpperCase() + cert.status.slice(1)} - {shouldShowRefreshButton( - cert.status, - cert.updatedAt - ) && ( + {shouldShowRefreshButton(cert.status, cert.updatedAt) && + !disableRestartButton ? ( - )} + ) : null} )} diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 56bca891f..62e4fc611 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1154,8 +1154,8 @@ export function InternalResourceForm({ resource.fullDomain && build != "oss" && form.watch("ssl") && ( -
- +
+ {t("certificateStatus")}: - + {t("protected")}
) : ( -
- +
+ {t("notProtected")}
)} From a029b107aed3d6c2bcc0ee88dbe1a23aa2e76318 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Apr 2026 12:35:08 -0700 Subject: [PATCH 13/34] dont show site online status for local sites --- server/routers/resource/listResources.ts | 18 +++++------- server/routers/site/listSites.ts | 19 +++++++++--- src/components/ResourceSitesStatusCell.tsx | 34 +++++++++++++++++----- src/components/SitesTable.tsx | 2 +- src/components/multi-site-selector.tsx | 12 ++++---- src/components/site-selector.tsx | 12 ++++---- 6 files changed, 64 insertions(+), 33 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 1ca0ef918..16a82e400 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -152,7 +152,7 @@ export type ResourceWithTargets = { siteId: number; siteName: string; siteNiceId: string; - online: boolean; + online?: boolean; // undefined for local sites }>; }; @@ -383,12 +383,8 @@ export async function listResources( .select({ resourceId: targets.resourceId }) .from(targets) .innerJoin(sites, eq(targets.siteId, sites.siteId)) - .where( - and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)) - ); - conditions.push( - inArray(resources.resourceId, resourcesWithSite) - ); + .where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))); + conditions.push(inArray(resources.resourceId, resourcesWithSite)); } const baseQuery = queryResourcesBase().where(and(...conditions)); @@ -426,7 +422,8 @@ export async function listResources( hcEnabled: targetHealthCheck.hcEnabled, siteName: sites.name, siteNiceId: sites.niceId, - siteOnline: sites.online + siteOnline: sites.online, + siteType: sites.type }) .from(targets) .where(inArray(targets.resourceId, resourceIdList)) @@ -481,18 +478,19 @@ export async function listResources( siteId: number; siteName: string; siteNiceId: string; - online: boolean; + online?: boolean; } >(); for (const t of raw) { if (typeof t.siteId !== "number" || siteById.has(t.siteId)) { continue; } + const isLocal = t.siteType === "local"; siteById.set(t.siteId, { siteId: t.siteId, siteName: t.siteName ?? "", siteNiceId: t.siteNiceId ?? "", - online: Boolean(t.siteOnline) + online: isLocal ? undefined : Boolean(t.siteOnline) }); } entry.sites = Array.from(siteById.values()); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 10f5ac0f1..fc4ea5be1 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -31,7 +31,9 @@ let staleNewtVersion: string | null = null; async function getLatestNewtVersion(): Promise { try { - const cachedVersion = await cache.get("cache:latestNewtVersion"); + const cachedVersion = await cache.get( + "cache:latestNewtVersion" + ); if (cachedVersion) { return cachedVersion; } @@ -226,7 +228,10 @@ function querySitesBase() { ); } -type SiteWithUpdateAvailable = Awaited>[0] & { +type SiteRowBase = Awaited>[0]; + +type SiteWithUpdateAvailable = Omit & { + online?: SiteRowBase["online"]; // undefined for local sites newtUpdateAvailable?: boolean; }; @@ -338,7 +343,9 @@ export async function listSites( // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count( - querySitesBase().where(and(...conditions)).as("filtered_sites") + querySitesBase() + .where(and(...conditions)) + .as("filtered_sites") ); const siteListQuery = baseQuery @@ -397,9 +404,13 @@ export async function listSites( ); } + const sitesPayload = sitesWithUpdates.map((site) => + site.type === "local" ? { ...site, online: undefined } : site + ); + return response(res, { data: { - sites: sitesWithUpdates, + sites: sitesPayload, pagination: { total: totalCount, pageSize, diff --git a/src/components/ResourceSitesStatusCell.tsx b/src/components/ResourceSitesStatusCell.tsx index 3c940c6b0..8a33c284d 100644 --- a/src/components/ResourceSitesStatusCell.tsx +++ b/src/components/ResourceSitesStatusCell.tsx @@ -16,10 +16,10 @@ export type ResourceSiteRow = { siteId: number; siteName: string; siteNiceId: string; - online: boolean; + online?: boolean | null; }; -type AggregateSitesStatus = "allOnline" | "partial" | "allOffline"; +type AggregateSitesStatus = "allOnline" | "partial" | "allOffline" | "unknown"; function aggregateSitesStatus( resourceSites: ResourceSiteRow[] @@ -27,8 +27,17 @@ function aggregateSitesStatus( if (resourceSites.length === 0) { return "allOffline"; } - const onlineCount = resourceSites.filter((rs) => rs.online).length; - if (onlineCount === resourceSites.length) return "allOnline"; + + const knownStatuses = resourceSites + .map((rs) => rs.online) + .filter((status): status is boolean => typeof status === "boolean"); + + if (knownStatuses.length === 0) { + return "unknown"; + } + + const onlineCount = knownStatuses.filter(Boolean).length; + if (onlineCount === knownStatuses.length) return "allOnline"; if (onlineCount > 0) return "partial"; return "allOffline"; } @@ -40,8 +49,10 @@ function aggregateStatusDotClass(status: AggregateSitesStatus): string { case "partial": return "bg-yellow-500"; case "allOffline": - default: return "bg-neutral-500"; + case "unknown": + default: + return "bg-transparent"; } } @@ -84,6 +95,7 @@ export function ResourceSitesStatusCell({ {resourceSites.map((site) => { const isOnline = site.online; + const hasKnownStatus = typeof isOnline === "boolean"; return ( - {isOnline ? t("online") : t("offline")} + {!hasKnownStatus + ? t("resourcesTableUnknown") + : isOnline + ? t("online") + : t("offline")} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 4ab35359e..c29314874 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -60,7 +60,7 @@ export type SiteRow = { type: "newt" | "wireguard" | "local"; newtVersion?: string; newtUpdateAvailable?: boolean; - online: boolean; + online?: boolean | null; address?: string; exitNodeName?: string; exitNodeEndpoint?: string; diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index c06f6c74a..76255e824 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -111,11 +111,13 @@ export function MultiSitesSelector({ {site.name} - + {site.online != null && ( + + )}
))} diff --git a/src/components/site-selector.tsx b/src/components/site-selector.tsx index 1c6302d98..778a6fcf6 100644 --- a/src/components/site-selector.tsx +++ b/src/components/site-selector.tsx @@ -124,11 +124,13 @@ export function SitesSelector({ {site.name} - + {site.online != null && ( + + )}
))} From a16f805709ae99705a504f29a553728275f80a02 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Apr 2026 12:36:47 -0700 Subject: [PATCH 14/34] fix style for unknown status --- src/components/ResourceInfoBox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index cd8e6ec8b..7ddb06ddd 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -188,7 +188,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { )} {(!resource.health || resource.health === "unknown") && ( -
+
{t("resourcesTableUnknown")}
From 227501d8f8b52ab891ada10f9911ce144236050a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Apr 2026 12:39:08 -0700 Subject: [PATCH 15/34] fix rounded buttons in target input --- src/components/resource-target-address-item.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index c801844ce..08ce40dd3 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -104,7 +104,7 @@ export function ResourceTargetAddressItem({ role="combobox" className={cn( "w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent", - "rounded-l-md rounded-r-xs", + "", !proxyTarget.siteId && "text-muted-foreground" )} > @@ -142,7 +142,7 @@ export function ResourceTargetAddressItem({ }) } > - + {proxyTarget.method || "http"} From d3870f492002f367ba3a236a8848b63c1b23051b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Apr 2026 13:05:26 -0700 Subject: [PATCH 16/34] cert status in priv resources table first pass --- src/components/ClientResourcesTable.tsx | 33 ++- .../PrivateResourceCertAccessIndicator.tsx | 269 ++++++++++++++++++ 2 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 src/components/PrivateResourceCertAccessIndicator.tsx diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5c5906ad5..2bb0c6f27 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -51,6 +51,7 @@ import { ResourceSitesStatusCell, type ResourceSiteRow } from "@app/components/ResourceSitesStatusCell"; +import { PrivateResourceCertAccessIndicator } from "@app/components/PrivateResourceCertAccessIndicator"; export type InternalResourceSiteRow = ResourceSiteRow; @@ -440,13 +441,33 @@ export default function ClientResourcesTable({ ); } if (resourceRow.mode === "http") { - const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`; + const domainId = resourceRow.domainId; + const fullDomain = resourceRow.fullDomain; + const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`; + const did = + resourceRow.ssl && + domainId != null && + domainId !== "" && + fullDomain != null && + fullDomain !== ""; + return ( - +
+ {did ? ( + + ) : null} +
+ +
+
); } return -; diff --git a/src/components/PrivateResourceCertAccessIndicator.tsx b/src/components/PrivateResourceCertAccessIndicator.tsx new file mode 100644 index 000000000..92748abc4 --- /dev/null +++ b/src/components/PrivateResourceCertAccessIndicator.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverAnchor, + PopoverContent +} from "@app/components/ui/popover"; +import { useCertificate } from "@app/hooks/useCertificate"; +import { cn } from "@app/lib/cn"; +import { + CheckCircle2, + Clock, + RotateCw, + XCircle +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useRef, useState } from "react"; + +type PrivateResourceCertAccessIndicatorProps = { + orgId: string; + domainId: string; + fullDomain: string; +}; + +function 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"; + } +} + +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; + } +} + +function shouldShowRefreshButton(status: string, updatedAt: number) { + return ( + status === "failed" || + status === "expired" || + (status === "requested" && + updatedAt && + new Date(updatedAt * 1000).getTime() < Date.now() - 5 * 60 * 1000) + ); +} + +export function PrivateResourceCertAccessIndicator({ + orgId, + domainId, + fullDomain +}: PrivateResourceCertAccessIndicatorProps) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const closeTimerRef = useRef | null>(null); + + const { cert, certLoading, certError, refreshing, refreshCert } = + useCertificate({ + orgId, + domainId, + fullDomain, + autoFetch: true + }); + + const clearCloseTimer = useCallback(() => { + if (closeTimerRef.current != null) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + const scheduleClose = useCallback(() => { + clearCloseTimer(); + closeTimerRef.current = setTimeout(() => setOpen(false), 280); + }, [clearCloseTimer]); + + const handleEnterOpen = useCallback(() => { + clearCloseTimer(); + setOpen(true); + }, [clearCloseTimer]); + + useEffect(() => { + return () => clearCloseTimer(); + }, [clearCloseTimer]); + + const handleRefresh = async () => { + await refreshCert(); + }; + + if (certLoading) { + return ( +
+ ); + } + + const isPending = cert?.status === "pending"; + const disableWildcard = cert?.domainType === "wildcard"; + + let TriggerIcon = Clock; + let triggerIconClass = "text-muted-foreground"; + if (certError) { + TriggerIcon = XCircle; + triggerIconClass = "text-red-500"; + } else if (cert) { + TriggerIcon = getStatusIcon(cert.status); + triggerIconClass = getStatusColor(cert.status); + } + + return ( + + + + + e.preventDefault()} + > +
+
+ {t("certificateStatus")} +
+ {certError ? ( + + + {certError} + + ) : !cert ? ( + + + {t("none", { defaultValue: "None" })} + + ) : ( + <> + {isPending && !disableWildcard ? ( + + ) : ( + + + {(() => { + const StatusIcon = getStatusIcon( + cert.status + ); + return ( + + ); + })()} + {cert.status.charAt(0).toUpperCase() + + cert.status.slice(1)} + {shouldShowRefreshButton( + cert.status, + cert.updatedAt + ) && !disableWildcard ? ( + + ) : null} + + + )} + {cert.errorMessage && + (cert.status === "failed" || + cert.status === "expired") ? ( +

+ {cert.errorMessage} +

+ ) : null} + {cert.expiresAt && cert.status === "valid" ? ( +

+ {t("expiresAt")}:{" "} + {new Date( + cert.expiresAt + ).toLocaleDateString()} +

+ ) : null} + + )} +
+
+
+ ); +} From e173f59d89357dce6841674a5212fb19533f1080 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Apr 2026 13:44:35 -0700 Subject: [PATCH 17/34] visual improvements --- src/components/ClientResourcesTable.tsx | 14 +- .../PrivateResourceCertAccessIndicator.tsx | 148 ++---------------- src/components/ResourceSitesStatusCell.tsx | 8 +- 3 files changed, 27 insertions(+), 143 deletions(-) diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 2bb0c6f27..9d2c46106 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -453,6 +453,13 @@ export default function ClientResourcesTable({ return (
+
+ +
{did ? ( ) : null} -
- -
); } diff --git a/src/components/PrivateResourceCertAccessIndicator.tsx b/src/components/PrivateResourceCertAccessIndicator.tsx index 92748abc4..8b06721a5 100644 --- a/src/components/PrivateResourceCertAccessIndicator.tsx +++ b/src/components/PrivateResourceCertAccessIndicator.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button } from "@app/components/ui/button"; +import CertificateStatus from "@app/components/CertificateStatus"; import { Popover, PopoverAnchor, @@ -8,12 +8,7 @@ import { } from "@app/components/ui/popover"; import { useCertificate } from "@app/hooks/useCertificate"; import { cn } from "@app/lib/cn"; -import { - CheckCircle2, - Clock, - RotateCw, - XCircle -} from "lucide-react"; +import { CheckCircle2, Clock, XCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -53,16 +48,6 @@ function getStatusIcon(status: string) { } } -function shouldShowRefreshButton(status: string, updatedAt: number) { - return ( - status === "failed" || - status === "expired" || - (status === "requested" && - updatedAt && - new Date(updatedAt * 1000).getTime() < Date.now() - 5 * 60 * 1000) - ); -} - export function PrivateResourceCertAccessIndicator({ orgId, domainId, @@ -72,13 +57,12 @@ export function PrivateResourceCertAccessIndicator({ const [open, setOpen] = useState(false); const closeTimerRef = useRef | null>(null); - const { cert, certLoading, certError, refreshing, refreshCert } = - useCertificate({ - orgId, - domainId, - fullDomain, - autoFetch: true - }); + const { cert, certLoading, certError } = useCertificate({ + orgId, + domainId, + fullDomain, + autoFetch: true + }); const clearCloseTimer = useCallback(() => { if (closeTimerRef.current != null) { @@ -101,10 +85,6 @@ export function PrivateResourceCertAccessIndicator({ return () => clearCloseTimer(); }, [clearCloseTimer]); - const handleRefresh = async () => { - await refreshCert(); - }; - if (certLoading) { return (
e.preventDefault()} > -
-
- {t("certificateStatus")} -
- {certError ? ( - - - {certError} - - ) : !cert ? ( - - - {t("none", { defaultValue: "None" })} - - ) : ( - <> - {isPending && !disableWildcard ? ( - - ) : ( - - - {(() => { - const StatusIcon = getStatusIcon( - cert.status - ); - return ( - - ); - })()} - {cert.status.charAt(0).toUpperCase() + - cert.status.slice(1)} - {shouldShowRefreshButton( - cert.status, - cert.updatedAt - ) && !disableWildcard ? ( - - ) : null} - - - )} - {cert.errorMessage && - (cert.status === "failed" || - cert.status === "expired") ? ( -

- {cert.errorMessage} -

- ) : null} - {cert.expiresAt && cert.status === "valid" ? ( -

- {t("expiresAt")}:{" "} - {new Date( - cert.expiresAt - ).toLocaleDateString()} -

- ) : null} - - )} -
+
); diff --git a/src/components/ResourceSitesStatusCell.tsx b/src/components/ResourceSitesStatusCell.tsx index 8a33c284d..4560c2f26 100644 --- a/src/components/ResourceSitesStatusCell.tsx +++ b/src/components/ResourceSitesStatusCell.tsx @@ -52,7 +52,7 @@ function aggregateStatusDotClass(status: AggregateSitesStatus): string { return "bg-neutral-500"; case "unknown": default: - return "bg-transparent"; + return "border border-muted-foreground/50 bg-transparent"; } } @@ -107,10 +107,10 @@ export function ResourceSitesStatusCell({ className={cn( "h-2 w-2 shrink-0 rounded-full", !hasKnownStatus - ? "bg-transparent" + ? "border border-muted-foreground/50 bg-transparent" : isOnline - ? "bg-green-500" - : "bg-neutral-500" + ? "bg-green-500" + : "bg-neutral-500" )} /> From 2ffe254879e5561eefd492a7c91f7c30b1f55f68 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 14:08:42 -0700 Subject: [PATCH 18/34] Dont include site resources on the cloud --- .../private/lib/traefik/getTraefikConfig.ts | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 6427bec4f..481192fb5 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -277,37 +277,37 @@ export async function getTraefikConfig( }); }); - // Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge - const siteResourcesWithFullDomain = await db - .select({ - siteResourceId: siteResources.siteResourceId, - fullDomain: siteResources.fullDomain, - mode: siteResources.mode - }) - .from(siteResources) - .innerJoin( - siteNetworks, - eq(siteResources.networkId, siteNetworks.networkId) - ) - .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) - .where( - and( - eq(siteResources.enabled, true), - isNotNull(siteResources.fullDomain), - eq(siteResources.mode, "http"), - eq(siteResources.ssl, true), - or( - eq(sites.exitNodeId, exitNodeId), - and( - isNull(sites.exitNodeId), - sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, - eq(sites.type, "local"), - sql`(${build != "saas" ? 1 : 0} = 1)` - ) - ), - inArray(sites.type, siteTypes) + let siteResourcesWithFullDomain: { + siteResourceId: number; + fullDomain: string | null; + mode: "http" | "host" | "cidr"; + }[] = []; + if (build == "enterprise") { + // we dont want to do this on the cloud + // Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge + siteResourcesWithFullDomain = await db + .select({ + siteResourceId: siteResources.siteResourceId, + fullDomain: siteResources.fullDomain, + mode: siteResources.mode + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) ) - ); + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where( + and( + eq(siteResources.enabled, true), + isNotNull(siteResources.fullDomain), + eq(siteResources.mode, "http"), + eq(siteResources.ssl, true), + eq(sites.exitNodeId, exitNodeId), + inArray(sites.type, siteTypes) + ) + ); + } let validCerts: CertificateResult[] = []; if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { From a55842ffff8ae3c2bd974ca198ee0cc867a53830 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 14:29:15 -0700 Subject: [PATCH 19/34] Scrape certs from ALL resolvers --- server/private/lib/acmeCertSync.ts | 41 ++++++++++++++++++---------- server/private/lib/readConfigFile.ts | 1 - 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 570ccfd75..23b24b50b 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -274,10 +274,7 @@ function detectWildcard( return { wildcard: false, wildcardSan: null }; } -async function syncAcmeCerts( - acmeJsonPath: string, - resolver: string -): Promise { +async function syncAcmeCerts(acmeJsonPath: string): Promise { let raw: string; try { raw = fs.readFileSync(acmeJsonPath, "utf8"); @@ -294,15 +291,32 @@ async function syncAcmeCerts( return; } - const resolverData = acmeJson[resolver]; - if (!resolverData || !Array.isArray(resolverData.Certificates)) { - logger.debug( - `acmeCertSync: no certificates found for resolver "${resolver}"` - ); + const resolvers = Object.keys(acmeJson || {}); + if (resolvers.length === 0) { + logger.debug(`acmeCertSync: no resolvers found in acme.json`); return; } - for (const cert of resolverData.Certificates) { + // Collect certificates from every resolver. If the same domain appears in + // multiple resolvers, the last one wins (resolvers iterated in object order). + const allCerts: AcmeCert[] = []; + for (const resolver of resolvers) { + const resolverData = acmeJson[resolver]; + if (!resolverData || !Array.isArray(resolverData.Certificates)) { + logger.debug( + `acmeCertSync: no certificates found for resolver "${resolver}"` + ); + continue; + } + logger.debug( + `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` + ); + for (const cert of resolverData.Certificates) { + allCerts.push(cert); + } + } + + for (const cert of allCerts) { const domain = cert?.domain?.main; if (!domain || typeof domain !== "string") { @@ -531,20 +545,19 @@ export function initAcmeCertSync(): void { const acmeJsonPath = privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; - const resolver = privateConfigData.acme?.resolver ?? "letsencrypt"; const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; logger.debug( - `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms` + `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms` ); // Run immediately on init, then on the configured interval - syncAcmeCerts(acmeJsonPath, resolver).catch((err) => { + syncAcmeCerts(acmeJsonPath).catch((err) => { logger.error(`acmeCertSync: error during initial sync: ${err}`); }); setInterval(() => { - syncAcmeCerts(acmeJsonPath, resolver).catch((err) => { + syncAcmeCerts(acmeJsonPath).catch((err) => { logger.error(`acmeCertSync: error during sync: ${err}`); }); }, intervalMs); diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index c9cb1535a..056624159 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -102,7 +102,6 @@ export const privateConfigSchema = z.object({ .string() .optional() .default("config/letsencrypt/acme.json"), - resolver: z.string().optional().default("letsencrypt"), sync_interval_ms: z.number().optional().default(5000) }) .optional(), From 698cd868a845c0f203d6239a9dc7f988d70ccdcd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 29 Apr 2026 14:47:26 -0700 Subject: [PATCH 20/34] show cert status in public reosurces table --- messages/en-US.json | 1 + .../[orgId]/settings/resources/proxy/page.tsx | 1 + src/components/CertificateStatus.tsx | 364 ++++++++++-------- src/components/ClientResourcesTable.tsx | 16 +- src/components/ProxyResourcesTable.tsx | 51 ++- ...or.tsx => ResourceAccessCertIndicator.tsx} | 116 +++--- src/hooks/useCertificate.ts | 30 +- 7 files changed, 344 insertions(+), 235 deletions(-) rename src/components/{PrivateResourceCertAccessIndicator.tsx => ResourceAccessCertIndicator.tsx} (55%) 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 { From 143f362a48337d584d2089c0d90798d65cce9e10 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:09 -0700 Subject: [PATCH 21/34] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index ff9cca43d..9dbb4797e 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Créer un compte administrateur", "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.", "certificateStatus": "Certificat", + "certificateStatusAutoRefreshHint": "L'état se rafraîchit automatiquement.", "loading": "Chargement", "loadingAnalytics": "Chargement de l'analyse", "restart": "Redémarrer", From d83318cbfc1b4ddb0012589b400706930b9b6463 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:11 -0700 Subject: [PATCH 22/34] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index eb129ee32..0b743adbc 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Създаване на админ акаунт", "setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.", "certificateStatus": "Сертификат", + "certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.", "loading": "Зареждане", "loadingAnalytics": "Зареждане на анализи", "restart": "Рестарт", From 6c93aca444f4a3d24a358bbaa8a57cbf4c8bbbb4 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:13 -0700 Subject: [PATCH 23/34] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 181bff665..309d52a90 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Vytvořit účet správce", "setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.", "certificateStatus": "Certifikát", + "certificateStatusAutoRefreshHint": "Stav se automaticky obnovuje.", "loading": "Načítání", "loadingAnalytics": "Načítání analytiky", "restart": "Restartovat", @@ -3167,7 +3168,7 @@ "publicIpEndpoint": "Koncový bod", "lastTriggeredAt": "Poslední spouštěč", "reject": "Odmítnout", - "uptimeDaysAgo": "{count} days ago", + "uptimeDaysAgo": "Před {count} dny", "uptimeToday": "Dnes", "uptimeNoDataAvailable": "Dostupná žádná data", "uptimeSuffix": "doba dostupnosti", From 6284930fce0276bd224199eccacbf572e2ae829d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:15 -0700 Subject: [PATCH 24/34] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/de-DE.json b/messages/de-DE.json index c69483a68..74247798f 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Admin-Konto erstellen", "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.", "certificateStatus": "Zertifikat", + "certificateStatusAutoRefreshHint": "Der Status wird automatisch aktualisiert.", "loading": "Laden", "loadingAnalytics": "Analytik wird geladen", "restart": "Neustart", From 175f10a51decfdef443b00a25a6e43f8f67ddcd3 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:16 -0700 Subject: [PATCH 25/34] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/it-IT.json b/messages/it-IT.json index 4c5ba6f12..9e810c259 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Crea Account Admin", "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.", "certificateStatus": "Certificato", + "certificateStatusAutoRefreshHint": "Lo stato si aggiorna automaticamente.", "loading": "Caricamento", "loadingAnalytics": "Caricamento Delle Analisi", "restart": "Riavvia", From faa2e97530537b615667e5dc173abbcf24c23f54 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:18 -0700 Subject: [PATCH 26/34] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index d6936cecf..e98fc65fa 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "관리자 계정 생성", "setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.", "certificateStatus": "인증서", + "certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.", "loading": "로딩 중", "loadingAnalytics": "분석 로딩 중", "restart": "재시작", From 98dfd05f065682b2e4ac724c675253665484e9f0 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:20 -0700 Subject: [PATCH 27/34] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 6dc3cd158..09096c424 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Maak een beheeraccount aan", "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.", "certificateStatus": "Certificaat", + "certificateStatusAutoRefreshHint": "Status ververst automatisch.", "loading": "Bezig met laden", "loadingAnalytics": "Laden van Analytics", "restart": "Herstarten", From 3c265ee57747510cd06e1154240e5aec5b43041f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:22 -0700 Subject: [PATCH 28/34] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 9d9968001..38bbea59a 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Utwórz konto administratora", "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.", "certificateStatus": "Certyfikat", + "certificateStatusAutoRefreshHint": "Status odświeża się automatycznie.", "loading": "Ładowanie", "loadingAnalytics": "Ładowanie Analityki", "restart": "Uruchom ponownie", From c02c3eaa4ad5b54dd29da33b039136f906b86867 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:23 -0700 Subject: [PATCH 29/34] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 6d0d751a4..2cd442720 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Criar Conta de Administrador", "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.", "certificateStatus": "Certificado", + "certificateStatusAutoRefreshHint": "Status atualiza automaticamente.", "loading": "Carregando", "loadingAnalytics": "Carregando Analytics", "restart": "Reiniciar", From 1c57473b6d33b93fedafeb62894bd71257264b81 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:25 -0700 Subject: [PATCH 30/34] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 6490040cb..4899a3f97 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Создать учётную запись администратора", "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", "certificateStatus": "Сертификат", + "certificateStatusAutoRefreshHint": "Статус обновляется автоматически.", "loading": "Загрузка", "loadingAnalytics": "Загрузка аналитики", "restart": "Перезагрузка", From b36927c7a0007da507c4bf8cc4d8c9b9212e664e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:27 -0700 Subject: [PATCH 31/34] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 2676d2fba..4d36aebba 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Yönetici Hesabı Oluştur", "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.", "certificateStatus": "Sertifika", + "certificateStatusAutoRefreshHint": "Durum otomatik olarak yenilenir.", "loading": "Yükleniyor", "loadingAnalytics": "Analiz Yükleniyor", "restart": "Yeniden Başlat", From 994fb456c202668e3dc7cf120cdb74488596a6f6 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:29 -0700 Subject: [PATCH 32/34] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 58ee66d44..aa7ad9ed0 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "创建管理员帐户", "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", "certificateStatus": "证书", + "certificateStatusAutoRefreshHint": "状态自动刷新。", "loading": "加载中", "loadingAnalytics": "加载分析", "restart": "重启", From b53cc397befe96fd2ab1e2d445d971ccff807c00 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:30 -0700 Subject: [PATCH 33/34] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index e44022572..d6c674801 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Opprett administratorkonto", "setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.", "certificateStatus": "Sertifikat", + "certificateStatusAutoRefreshHint": "Status oppdateres automatisk.", "loading": "Laster inn", "loadingAnalytics": "Laster inn analyser", "restart": "Start på nytt", From b9f3f90de676d0bf2c29bbb2c1b939d86083378f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 29 Apr 2026 14:54:32 -0700 Subject: [PATCH 34/34] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/es-ES.json b/messages/es-ES.json index cf8c64f96..ea5e33b25 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1597,6 +1597,7 @@ "createAdminAccount": "Crear cuenta de administrador", "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.", "certificateStatus": "Certificado", + "certificateStatusAutoRefreshHint": "El estado se actualiza automáticamente.", "loading": "Cargando", "loadingAnalytics": "Cargando analíticas", "restart": "Reiniciar",