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": "Рестарт", 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", diff --git a/messages/de-DE.json b/messages/de-DE.json index 30dd1509f..74247798f 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", @@ -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", 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/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", 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", 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", 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": "재시작", 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", 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", 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", 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", 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": "Перезагрузка", 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", 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": "重启", diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index a9e818986..23b24b50b 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -250,10 +250,31 @@ function extractFirstCert(pemBundle: string): string | null { return match ? match[0] : null; } -async function syncAcmeCerts( - acmeJsonPath: string, - resolver: string -): Promise { +/** + * 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): Promise { let raw: string; try { raw = fs.readFileSync(acmeJsonPath, "utf8"); @@ -270,23 +291,41 @@ 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) { - const domain = cert.domain?.main; - const wildcard = domain.startsWith("*."); + // 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); + } + } - if (!domain) { + for (const cert of allCerts) { + const domain = cert?.domain?.main; + + 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 +333,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 +352,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() @@ -327,9 +406,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 @@ -355,18 +434,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( @@ -468,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(), 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) { 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/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/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, diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts index 3262308f8..df22faa2d 100644 --- a/server/setup/scriptsPg/1.18.0.ts +++ b/server/setup/scriptsPg/1.18.0.ts @@ -545,6 +545,72 @@ export default async function migration() { throw e; } + // Recompute resource health by aggregating across the resource's targets' + // target health checks, then update the resources.health column to match. + try { + const resourceTargetHealthQuery = await db.execute( + sql`SELECT + r."resourceId" AS "resourceId", + thc."hcHealth" AS "hcHealth" + FROM "resources" r + LEFT JOIN "targets" t ON t."resourceId" = r."resourceId" + LEFT JOIN "targetHealthCheck" thc ON thc."targetId" = t."targetId"` + ); + const resourceTargetHealthRows = + resourceTargetHealthQuery.rows as { + resourceId: number; + hcHealth: string | null; + }[]; + + const resourceHealthMap = new Map< + number, + { hasHealthy: boolean; hasUnhealthy: boolean; hasUnknown: boolean } + >(); + for (const row of resourceTargetHealthRows) { + const entry = resourceHealthMap.get(row.resourceId) ?? { + hasHealthy: false, + hasUnhealthy: false, + hasUnknown: false + }; + const status = row.hcHealth ?? "unknown"; + if (status === "healthy") entry.hasHealthy = true; + else if (status === "unhealthy") entry.hasUnhealthy = true; + else entry.hasUnknown = true; + resourceHealthMap.set(row.resourceId, entry); + } + + let updatedResourceCount = 0; + for (const [resourceId, flags] of resourceHealthMap.entries()) { + let aggregated: "healthy" | "unhealthy" | "degraded" | "unknown"; + if (flags.hasHealthy && flags.hasUnhealthy) { + aggregated = "degraded"; + } else if (flags.hasHealthy) { + aggregated = "healthy"; + } else if (flags.hasUnhealthy) { + aggregated = "unhealthy"; + } else { + aggregated = "unknown"; + } + + await db.execute(sql` + UPDATE "resources" + SET "health" = ${aggregated} + WHERE "resourceId" = ${resourceId} + `); + updatedResourceCount++; + } + + console.log( + `Recomputed health for ${updatedResourceCount} resource(s) based on target health checks` + ); + } catch (e) { + console.error( + "Error while recomputing resource health from target health checks:", + e + ); + throw e; + } + // Seed statusHistory for all existing health checks try { const healthChecksQuery = await db.execute( diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts index 5f1718e7a..49ee8c450 100644 --- a/server/setup/scriptsSqlite/1.18.0.ts +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -255,7 +255,7 @@ export default async function migration() { ).run(); db.prepare( ` - INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", NULL, NULL, "niceId", "name", 0, "mode", NULL, "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", NULL, NULL, NULL FROM 'siteResources'; + INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", NULL, NULL, "niceId", "name", 0, "mode", NULL, "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", COALESCE("tcpPortRangeString", '*'), COALESCE("udpPortRangeString", '*'), COALESCE("disableIcmp", 0), "authDaemonPort", "authDaemonMode", NULL, NULL, NULL FROM 'siteResources'; ` ).run(); db.prepare( @@ -509,6 +509,70 @@ export default async function migration() { `Seeded statusHistory for ${allResources.length} resource(s)` ); + // Recompute resource health by aggregating across the resource's + // targets' target health checks, then update resources.health. + const resourceTargetHealthRows = db + .prepare( + `SELECT + r."resourceId" AS "resourceId", + thc."hcHealth" AS "hcHealth" + FROM 'resources' r + LEFT JOIN 'targets' t ON t."resourceId" = r."resourceId" + LEFT JOIN 'targetHealthCheck' thc ON thc."targetId" = t."targetId"` + ) + .all() as { + resourceId: number; + hcHealth: string | null; + }[]; + + const resourceHealthMap = new Map< + number, + { + hasHealthy: boolean; + hasUnhealthy: boolean; + hasUnknown: boolean; + } + >(); + for (const row of resourceTargetHealthRows) { + const entry = resourceHealthMap.get(row.resourceId) ?? { + hasHealthy: false, + hasUnhealthy: false, + hasUnknown: false + }; + const status = row.hcHealth ?? "unknown"; + if (status === "healthy") entry.hasHealthy = true; + else if (status === "unhealthy") entry.hasUnhealthy = true; + else entry.hasUnknown = true; + resourceHealthMap.set(row.resourceId, entry); + } + + const updateResourceHealth = db.prepare( + `UPDATE 'resources' SET "health" = ? WHERE "resourceId" = ?` + ); + const recomputeResourceHealth = db.transaction(() => { + for (const [resourceId, flags] of resourceHealthMap.entries()) { + let aggregated: + | "healthy" + | "unhealthy" + | "degraded" + | "unknown"; + if (flags.hasHealthy && flags.hasUnhealthy) { + aggregated = "degraded"; + } else if (flags.hasHealthy) { + aggregated = "healthy"; + } else if (flags.hasUnhealthy) { + aggregated = "unhealthy"; + } else { + aggregated = "unknown"; + } + updateResourceHealth.run(aggregated, resourceId); + } + }); + recomputeResourceHealth(); + console.log( + `Recomputed health for ${resourceHealthMap.size} resource(s) based on target health checks` + ); + // Seed statusHistory for all existing health checks const allHealthChecks = db .prepare( 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/app/layout.tsx b/src/app/layout.tsx index 9cf66dd28..d04cc0b6e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,7 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; import { TailwindIndicator } from "@app/components/TailwindIndicator"; import { ViewportHeightFix } from "@app/components/ViewportHeightFix"; import StoreInternalRedirect from "@app/components/StoreInternalRedirect"; -import { Inter, Mona_Sans } from "next/font/google"; +import localFont from "next/font/local"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -32,12 +32,30 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic"; -const inter = Inter({ - subsets: ["latin"] -}); - -const monaSans = Mona_Sans({ - subsets: ["latin"] +const monaSans = localFont({ + src: [ + { + path: "../fonts/mona-sans/MonaSans-Regular.woff2", + weight: "400", + style: "normal" + }, + { + path: "../fonts/mona-sans/MonaSans-Medium.woff2", + weight: "500", + style: "normal" + }, + { + path: "../fonts/mona-sans/MonaSans-SemiBold.woff2", + weight: "600", + style: "normal" + }, + { + path: "../fonts/mona-sans/MonaSans-Bold.woff2", + weight: "700", + style: "normal" + } + ], + display: "swap" }); const fontClassName = monaSans.className; 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..cc22b1e88 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -1,43 +1,38 @@ "use client"; import { Button } from "@/components/ui/button"; -import { Loader2, RotateCw } 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"; -type CertificateStatusProps = { - orgId: string; - domainId: string; - fullDomain: string; - autoFetch?: boolean; +export type CertificateStatusContentProps = { + cert: GetCertificateResponse | null; + certLoading: boolean; + certError: string | null; + refreshing: boolean; + refreshCert: () => Promise; showLabel?: boolean; className?: string; onRefresh?: () => void; - polling?: boolean; - pollingInterval?: number; }; -export default function CertificateStatus({ - orgId, - domainId, - fullDomain, - autoFetch = true, +/** Presentation-only certificate row (shared hook state possible via props). */ +export function CertificateStatusContent({ + cert, + certLoading, + certError, + refreshing, + refreshCert, showLabel = true, className = "", - onRefresh, - polling = false, - pollingInterval = 5000 -}: CertificateStatusProps) { + onRefresh +}: CertificateStatusContentProps) { const t = useTranslations(); - const { cert, certLoading, certError, refreshing, refreshCert } = - useCertificate({ - orgId, - domainId, - fullDomain, - autoFetch, - polling, - pollingInterval - }); + + 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(); @@ -74,13 +69,13 @@ export default function CertificateStatus({ return (
{showLabel && ( - + {t("certificateStatus")}: )} - - + {t("loading")} @@ -93,11 +88,17 @@ export default function CertificateStatus({ return (
{showLabel && ( - + {t("certificateStatus")}: )} - {certError} + + + {certError} +
); } @@ -106,11 +107,15 @@ export default function CertificateStatus({ return (
{showLabel && ( - + {t("certificateStatus")}: )} - + + {t("none", { defaultValue: "None" })}
@@ -123,50 +128,102 @@ export default function CertificateStatus({ return (
{showLabel && ( - - {t("certificateStatus")}: - + {t("certificateStatus")}: )} - {isPending ? ( + {isPending && !disableRestartButton ? ( ) : ( - - - {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) && + !disableRestartButton ? ( + + ) : null} )}
); } + +type CertificateStatusProps = { + orgId: string; + domainId: string; + fullDomain: string; + autoFetch?: boolean; + showLabel?: boolean; + className?: string; + onRefresh?: () => void; + polling?: boolean; + pollingInterval?: number; +}; + +export default function CertificateStatus({ + orgId, + domainId, + fullDomain, + autoFetch = true, + showLabel = true, + className = "", + onRefresh, + polling = false, + pollingInterval = 5000 +}: CertificateStatusProps) { + const hook = useCertificate({ + orgId, + domainId, + fullDomain, + autoFetch, + polling, + pollingInterval + }); + + return ( + + ); +} diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5c5906ad5..e8ff8ff62 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -51,6 +51,7 @@ import { ResourceSitesStatusCell, type ResourceSiteRow } from "@app/components/ResourceSitesStatusCell"; +import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; 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/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index c0b78018c..0360a15e7 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -485,6 +485,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { onSelectSite={(site) => { setSelectedSite(site); }} + filterTypes={["newt"]} /> diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 76a614207..62e4fc611 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -55,6 +55,7 @@ import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; import { SwitchInput } from "@app/components/SwitchInput"; import CertificateStatus from "@app/components/CertificateStatus"; +import { build } from "@server/build"; // --- Helpers (shared) --- @@ -156,7 +157,7 @@ export type InternalResourceData = { const tagSchema = z.object({ id: z.string(), text: z.string() }); function buildSelectedSitesForResource( - resource: InternalResourceData, + resource: InternalResourceData ): Selectedsite[] { return resource.siteIds.map((siteId, idx) => ({ name: resource.siteNames[idx] ?? "", @@ -609,9 +610,7 @@ export function InternalResourceForm({ users: [], clients: [] }); - setSelectedSites( - buildSelectedSitesForResource(resource) - ); + setSelectedSites(buildSelectedSitesForResource(resource)); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) ); @@ -800,7 +799,9 @@ export function InternalResourceForm({ ); field.onChange( sites.map( - (s) => + ( + s + ) => s.siteId ) ); @@ -822,15 +823,21 @@ export function InternalResourceForm({ [ { value: "host", - label: t(modeHostKey) + label: t( + modeHostKey + ) }, { value: "cidr", - label: t(modeCidrKey) + label: t( + modeCidrKey + ) }, { value: "http", - label: t(modeHttpKey) + label: t( + modeHttpKey + ) } ]; return ( @@ -839,7 +846,9 @@ export function InternalResourceForm({ {t(modeLabelKey)} - options={modeOptions} + options={ + modeOptions + } value={field.value} onChange={ field.onChange @@ -899,7 +908,9 @@ export function InternalResourceForm({ field.value ?? "http" } - disabled={httpSectionDisabled} + disabled={ + httpSectionDisabled + } > @@ -940,7 +951,10 @@ export function InternalResourceForm({ @@ -996,7 +1010,9 @@ export function InternalResourceForm({ field.value ?? "" } - disabled={httpSectionDisabled} + disabled={ + httpSectionDisabled + } onChange={(e) => { const raw = e.target @@ -1031,7 +1047,9 @@ export function InternalResourceForm({
{isHttpMode && ( - + )} {isHttpMode ? ( @@ -1044,55 +1062,61 @@ export function InternalResourceForm({ {t(httpConfigurationDescriptionKey)} -
- { - if (res === null) { + > + { + if (res === null) { + form.setValue( + "httpConfigSubdomain", + null + ); + form.setValue( + "httpConfigDomainId", + null + ); + form.setValue( + "httpConfigFullDomain", + null + ); + return; + } form.setValue( "httpConfigSubdomain", - null + res.subdomain ?? null ); form.setValue( "httpConfigDomainId", - null + res.domainId ); form.setValue( "httpConfigFullDomain", - null + res.fullDomain ); - return; - } - form.setValue( - "httpConfigSubdomain", - res.subdomain ?? null - ); - form.setValue( - "httpConfigDomainId", - res.domainId - ); - form.setValue( - "httpConfigFullDomain", - res.fullDomain - ); - }} - /> + }} + />
@@ -1120,15 +1148,22 @@ export function InternalResourceForm({ {variant === "edit" && resource?.domainId && httpConfigFullDomain && + httpConfigDomainId === + resource.domainId && + httpConfigFullDomain === + resource.fullDomain && + build != "oss" && form.watch("ssl") && ( -
- +
+ {t("certificateStatus")}: {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/ResourceAccessCertIndicator.tsx b/src/components/ResourceAccessCertIndicator.tsx new file mode 100644 index 000000000..40ddfbfab --- /dev/null +++ b/src/components/ResourceAccessCertIndicator.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { CertificateStatusContent } from "@app/components/CertificateStatus"; +import { + Popover, + PopoverAnchor, + PopoverContent +} from "@app/components/ui/popover"; +import { useCertificate } from "@app/hooks/useCertificate"; +import { cn } from "@app/lib/cn"; +import { FileBadge } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode +} from "react"; + +type ResourceAccessCertIndicatorProps = { + 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"; + } +} + +/** Compact cert icon + hover popover with full certificate status (shared by proxy and client resource tables). */ +export function ResourceAccessCertIndicator({ + orgId, + domainId, + fullDomain +}: ResourceAccessCertIndicatorProps) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const closeTimerRef = useRef | null>(null); + + const certificate = useCertificate({ + orgId, + domainId, + fullDomain, + 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); + 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]); + + let triggerBody: ReactNode; + if (certLoading) { + triggerBody = ( +
+ ); + } else if (refreshing) { + triggerBody = ( + + ); + } else if (certError) { + triggerBody = ( + + ); + } else if (cert) { + triggerBody = ( + + ); + } else { + triggerBody = ( + + ); + } + + return ( + + + + + e.preventDefault()} + > +
+ +

+ {t("certificateStatusAutoRefreshHint")} +

+
+
+
+ ); +} diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index ca5ded4c5..7ddb06ddd 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")} @@ -61,12 +69,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { authInfo.whitelist || authInfo.headerAuth ? (
- + {t("protected")}
) : ( -
- +
+ {t("notProtected")}
)} @@ -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,8 +186,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {t("resourcesTableUnhealthy")}
)} - {(!resource.health || resource.health === "unknown") && ( -
+ {(!resource.health || + resource.health === "unknown") && ( +
{t("resourcesTableUnknown")}
diff --git a/src/components/ResourceSitesStatusCell.tsx b/src/components/ResourceSitesStatusCell.tsx index 3c940c6b0..4560c2f26 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 "border border-muted-foreground/50 bg-transparent"; } } @@ -84,6 +95,7 @@ export function ResourceSitesStatusCell({ {resourceSites.map((site) => { const isOnline = site.online; + const hasKnownStatus = typeof isOnline === "boolean"; return ( @@ -106,12 +120,16 @@ export function ResourceSitesStatusCell({ - {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/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"} 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 && ( + + )}
))} diff --git a/src/fonts/mona-sans/MonaSans-Bold.ttf b/src/fonts/mona-sans/MonaSans-Bold.ttf new file mode 100644 index 000000000..e0591eb20 Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-Bold.ttf differ diff --git a/src/fonts/mona-sans/MonaSans-Bold.woff2 b/src/fonts/mona-sans/MonaSans-Bold.woff2 new file mode 100644 index 000000000..1f86963d9 Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-Bold.woff2 differ diff --git a/src/fonts/mona-sans/MonaSans-Italic-VariableFont_wdth,wght.ttf b/src/fonts/mona-sans/MonaSans-Italic-VariableFont_wdth,wght.ttf new file mode 100644 index 000000000..00bc96dea Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-Italic-VariableFont_wdth,wght.ttf differ diff --git a/src/fonts/mona-sans/MonaSans-Medium.ttf b/src/fonts/mona-sans/MonaSans-Medium.ttf new file mode 100644 index 000000000..c10690aae Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-Medium.ttf differ diff --git a/src/fonts/mona-sans/MonaSans-Medium.woff2 b/src/fonts/mona-sans/MonaSans-Medium.woff2 new file mode 100644 index 000000000..24fa40811 Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-Medium.woff2 differ diff --git a/src/fonts/mona-sans/MonaSans-Regular.ttf b/src/fonts/mona-sans/MonaSans-Regular.ttf new file mode 100644 index 000000000..91fbf9e22 Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-Regular.ttf differ diff --git a/src/fonts/mona-sans/MonaSans-Regular.woff2 b/src/fonts/mona-sans/MonaSans-Regular.woff2 new file mode 100644 index 000000000..69ee83758 Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-Regular.woff2 differ diff --git a/src/fonts/mona-sans/MonaSans-SemiBold.ttf b/src/fonts/mona-sans/MonaSans-SemiBold.ttf new file mode 100644 index 000000000..092959a3a Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-SemiBold.ttf differ diff --git a/src/fonts/mona-sans/MonaSans-SemiBold.woff2 b/src/fonts/mona-sans/MonaSans-SemiBold.woff2 new file mode 100644 index 000000000..82fbcedcf Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-SemiBold.woff2 differ diff --git a/src/fonts/mona-sans/MonaSans-VariableFont_wdth,wght.ttf b/src/fonts/mona-sans/MonaSans-VariableFont_wdth,wght.ttf new file mode 100644 index 000000000..a49f373ec Binary files /dev/null and b/src/fonts/mona-sans/MonaSans-VariableFont_wdth,wght.ttf differ diff --git a/src/fonts/mona-sans/MonaSansVF[wdth,wght,opsz,ital].woff2 b/src/fonts/mona-sans/MonaSansVF[wdth,wght,opsz,ital].woff2 new file mode 100644 index 000000000..576f498c1 Binary files /dev/null and b/src/fonts/mona-sans/MonaSansVF[wdth,wght,opsz,ital].woff2 differ 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 {