Compare commits

..

42 Commits

Author SHA1 Message Date
Owen Schwartz
79541ec7b8 Merge pull request #2936 from fosrl/dev
1.18.1 patch over
2026-04-29 16:43:06 -07:00
Owen
81197f8a86 Update the database if the wildcard changes 2026-04-29 16:42:10 -07:00
miloschwartz
dcfc7822f4 hide cert in public resources col on oss 2026-04-29 16:03:59 -07:00
Owen Schwartz
269bd9aa0f Merge pull request #2934 from fosrl/dev
1.18.1-s.1
2026-04-29 15:18:28 -07:00
Owen
0a0817b860 Restrict alerting 2026-04-29 15:15:53 -07:00
Owen Schwartz
b7a903ab32 Merge pull request #2933 from fosrl/dev
1.18.1
2026-04-29 15:00:29 -07:00
Owen Schwartz
ab60438aa7 Merge pull request #2917 from fosrl/crowdin_dev
New Crowdin updates
2026-04-29 14:55:53 -07:00
Owen Schwartz
b9f3f90de6 New translations en-us.json (Spanish)
[ci skip]
2026-04-29 14:54:32 -07:00
Owen Schwartz
b53cc397be New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-04-29 14:54:30 -07:00
Owen Schwartz
994fb456c2 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-04-29 14:54:29 -07:00
Owen Schwartz
b36927c7a0 New translations en-us.json (Turkish)
[ci skip]
2026-04-29 14:54:27 -07:00
Owen Schwartz
1c57473b6d New translations en-us.json (Russian)
[ci skip]
2026-04-29 14:54:25 -07:00
Owen Schwartz
c02c3eaa4a New translations en-us.json (Portuguese)
[ci skip]
2026-04-29 14:54:23 -07:00
Owen Schwartz
3c265ee577 New translations en-us.json (Polish)
[ci skip]
2026-04-29 14:54:22 -07:00
Owen Schwartz
98dfd05f06 New translations en-us.json (Dutch)
[ci skip]
2026-04-29 14:54:20 -07:00
Owen Schwartz
faa2e97530 New translations en-us.json (Korean)
[ci skip]
2026-04-29 14:54:18 -07:00
Owen Schwartz
175f10a51d New translations en-us.json (Italian)
[ci skip]
2026-04-29 14:54:16 -07:00
Owen Schwartz
6284930fce New translations en-us.json (German)
[ci skip]
2026-04-29 14:54:15 -07:00
Owen Schwartz
6c93aca444 New translations en-us.json (Czech)
[ci skip]
2026-04-29 14:54:13 -07:00
Owen Schwartz
d83318cbfc New translations en-us.json (Bulgarian)
[ci skip]
2026-04-29 14:54:11 -07:00
Owen Schwartz
143f362a48 New translations en-us.json (French)
[ci skip]
2026-04-29 14:54:09 -07:00
miloschwartz
698cd868a8 show cert status in public reosurces table 2026-04-29 14:47:34 -07:00
Owen
a55842ffff Scrape certs from ALL resolvers 2026-04-29 14:29:15 -07:00
Owen
2ffe254879 Dont include site resources on the cloud 2026-04-29 14:08:42 -07:00
miloschwartz
e173f59d89 visual improvements 2026-04-29 13:44:35 -07:00
miloschwartz
d3870f4920 cert status in priv resources table first pass 2026-04-29 13:05:26 -07:00
miloschwartz
227501d8f8 fix rounded buttons in target input 2026-04-29 12:39:08 -07:00
miloschwartz
a16f805709 fix style for unknown status 2026-04-29 12:36:47 -07:00
miloschwartz
a029b107ae dont show site online status for local sites 2026-04-29 12:35:08 -07:00
miloschwartz
f03389a9a0 fix cert styling 2026-04-29 12:18:52 -07:00
Owen
78fff6bfde Filter to only allow newt sites 2026-04-29 12:18:28 -07:00
Owen
bc585c24fc Calculate actual resource status
Fixes #2930
2026-04-29 12:07:32 -07:00
miloschwartz
0f6c66dc67 use localfont and updated mona sans closes #2924 2026-04-29 11:58:06 -07:00
Owen
6be150bafe Handle possible not null for tcp, udp, and icmp
Fixes #2929
2026-04-29 11:42:18 -07:00
Owen
1eac7741a5 Show the certs elsewhere when required 2026-04-29 11:34:10 -07:00
Owen
b8ca0499af Dont show the cert box oss and dont check license 2026-04-29 11:28:30 -07:00
Owen
b39a2bcfb1 Quiet logs 2026-04-29 11:25:43 -07:00
Owen
d45b727dca Dont show cert status because not saved yet 2026-04-29 11:06:14 -07:00
Owen
5c31d35e28 Handle sans in the acme.json 2026-04-29 10:59:49 -07:00
Owen
8c645315f3 Handle when siteIds is not provided 2026-04-29 10:59:36 -07:00
Milo Schwartz
ab6377e086 Merge pull request #2923 from fosrl/miloschwartz-patch-2
Update README.md
2026-04-28 23:03:31 -07:00
Owen Schwartz
8ed9adbfae New translations en-us.json (German)
[ci skip]
2026-04-28 16:21:16 -07:00
53 changed files with 926 additions and 275 deletions

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Създаване на админ акаунт", "createAdminAccount": "Създаване на админ акаунт",
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.", "setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
"certificateStatus": "Сертификат", "certificateStatus": "Сертификат",
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
"loading": "Зареждане", "loading": "Зареждане",
"loadingAnalytics": "Зареждане на анализи", "loadingAnalytics": "Зареждане на анализи",
"restart": "Рестарт", "restart": "Рестарт",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Vytvořit účet správce", "createAdminAccount": "Vytvořit účet správce",
"setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.", "setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.",
"certificateStatus": "Certifikát", "certificateStatus": "Certifikát",
"certificateStatusAutoRefreshHint": "Stav se automaticky obnovuje.",
"loading": "Načítání", "loading": "Načítání",
"loadingAnalytics": "Načítání analytiky", "loadingAnalytics": "Načítání analytiky",
"restart": "Restartovat", "restart": "Restartovat",
@@ -3167,7 +3168,7 @@
"publicIpEndpoint": "Koncový bod", "publicIpEndpoint": "Koncový bod",
"lastTriggeredAt": "Poslední spouštěč", "lastTriggeredAt": "Poslední spouštěč",
"reject": "Odmítnout", "reject": "Odmítnout",
"uptimeDaysAgo": "{count} days ago", "uptimeDaysAgo": "Před {count} dny",
"uptimeToday": "Dnes", "uptimeToday": "Dnes",
"uptimeNoDataAvailable": "Dostupná žádná data", "uptimeNoDataAvailable": "Dostupná žádná data",
"uptimeSuffix": "doba dostupnosti", "uptimeSuffix": "doba dostupnosti",

View File

@@ -1432,7 +1432,7 @@
"alertingTriggerHcToggle": "Gesundheits-Check-Status ändern", "alertingTriggerHcToggle": "Gesundheits-Check-Status ändern",
"alertingTriggerResourceHealthy": "Ressource gesund", "alertingTriggerResourceHealthy": "Ressource gesund",
"alertingTriggerResourceUnhealthy": "Ressource ungesund", "alertingTriggerResourceUnhealthy": "Ressource ungesund",
"alertingTriggerResourceDegraded": "Resource degraded", "alertingTriggerResourceDegraded": "Ressource verschlechtert",
"alertingSearchHealthChecks": "Gesundheits-Checks suchen…", "alertingSearchHealthChecks": "Gesundheits-Checks suchen…",
"alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.", "alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.",
"alertingTriggerResourceToggle": "Ressourcenstatus ändern", "alertingTriggerResourceToggle": "Ressourcenstatus ändern",
@@ -1597,6 +1597,7 @@
"createAdminAccount": "Admin-Konto erstellen", "createAdminAccount": "Admin-Konto erstellen",
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.", "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.",
"certificateStatus": "Zertifikat", "certificateStatus": "Zertifikat",
"certificateStatusAutoRefreshHint": "Der Status wird automatisch aktualisiert.",
"loading": "Laden", "loading": "Laden",
"loadingAnalytics": "Analytik wird geladen", "loadingAnalytics": "Analytik wird geladen",
"restart": "Neustart", "restart": "Neustart",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Create Admin Account", "createAdminAccount": "Create Admin Account",
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.", "setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"certificateStatus": "Certificate", "certificateStatus": "Certificate",
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
"loading": "Loading", "loading": "Loading",
"loadingAnalytics": "Loading Analytics", "loadingAnalytics": "Loading Analytics",
"restart": "Restart", "restart": "Restart",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Crear cuenta de administrador", "createAdminAccount": "Crear cuenta de administrador",
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.", "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.",
"certificateStatus": "Certificado", "certificateStatus": "Certificado",
"certificateStatusAutoRefreshHint": "El estado se actualiza automáticamente.",
"loading": "Cargando", "loading": "Cargando",
"loadingAnalytics": "Cargando analíticas", "loadingAnalytics": "Cargando analíticas",
"restart": "Reiniciar", "restart": "Reiniciar",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Créer un compte administrateur", "createAdminAccount": "Créer un compte administrateur",
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.", "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.",
"certificateStatus": "Certificat", "certificateStatus": "Certificat",
"certificateStatusAutoRefreshHint": "L'état se rafraîchit automatiquement.",
"loading": "Chargement", "loading": "Chargement",
"loadingAnalytics": "Chargement de l'analyse", "loadingAnalytics": "Chargement de l'analyse",
"restart": "Redémarrer", "restart": "Redémarrer",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Crea Account Admin", "createAdminAccount": "Crea Account Admin",
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.", "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.",
"certificateStatus": "Certificato", "certificateStatus": "Certificato",
"certificateStatusAutoRefreshHint": "Lo stato si aggiorna automaticamente.",
"loading": "Caricamento", "loading": "Caricamento",
"loadingAnalytics": "Caricamento Delle Analisi", "loadingAnalytics": "Caricamento Delle Analisi",
"restart": "Riavvia", "restart": "Riavvia",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "관리자 계정 생성", "createAdminAccount": "관리자 계정 생성",
"setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.", "setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.",
"certificateStatus": "인증서", "certificateStatus": "인증서",
"certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.",
"loading": "로딩 중", "loading": "로딩 중",
"loadingAnalytics": "분석 로딩 중", "loadingAnalytics": "분석 로딩 중",
"restart": "재시작", "restart": "재시작",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Opprett administratorkonto", "createAdminAccount": "Opprett administratorkonto",
"setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.", "setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.",
"certificateStatus": "Sertifikat", "certificateStatus": "Sertifikat",
"certificateStatusAutoRefreshHint": "Status oppdateres automatisk.",
"loading": "Laster inn", "loading": "Laster inn",
"loadingAnalytics": "Laster inn analyser", "loadingAnalytics": "Laster inn analyser",
"restart": "Start på nytt", "restart": "Start på nytt",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Maak een beheeraccount aan", "createAdminAccount": "Maak een beheeraccount aan",
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.", "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.",
"certificateStatus": "Certificaat", "certificateStatus": "Certificaat",
"certificateStatusAutoRefreshHint": "Status ververst automatisch.",
"loading": "Bezig met laden", "loading": "Bezig met laden",
"loadingAnalytics": "Laden van Analytics", "loadingAnalytics": "Laden van Analytics",
"restart": "Herstarten", "restart": "Herstarten",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Utwórz konto administratora", "createAdminAccount": "Utwórz konto administratora",
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.", "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.",
"certificateStatus": "Certyfikat", "certificateStatus": "Certyfikat",
"certificateStatusAutoRefreshHint": "Status odświeża się automatycznie.",
"loading": "Ładowanie", "loading": "Ładowanie",
"loadingAnalytics": "Ładowanie Analityki", "loadingAnalytics": "Ładowanie Analityki",
"restart": "Uruchom ponownie", "restart": "Uruchom ponownie",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Criar Conta de Administrador", "createAdminAccount": "Criar Conta de Administrador",
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.", "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.",
"certificateStatus": "Certificado", "certificateStatus": "Certificado",
"certificateStatusAutoRefreshHint": "Status atualiza automaticamente.",
"loading": "Carregando", "loading": "Carregando",
"loadingAnalytics": "Carregando Analytics", "loadingAnalytics": "Carregando Analytics",
"restart": "Reiniciar", "restart": "Reiniciar",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Создать учётную запись администратора", "createAdminAccount": "Создать учётную запись администратора",
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
"certificateStatus": "Сертификат", "certificateStatus": "Сертификат",
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
"loading": "Загрузка", "loading": "Загрузка",
"loadingAnalytics": "Загрузка аналитики", "loadingAnalytics": "Загрузка аналитики",
"restart": "Перезагрузка", "restart": "Перезагрузка",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "Yönetici Hesabı Oluştur", "createAdminAccount": "Yönetici Hesabı Oluştur",
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.", "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.",
"certificateStatus": "Sertifika", "certificateStatus": "Sertifika",
"certificateStatusAutoRefreshHint": "Durum otomatik olarak yenilenir.",
"loading": "Yükleniyor", "loading": "Yükleniyor",
"loadingAnalytics": "Analiz Yükleniyor", "loadingAnalytics": "Analiz Yükleniyor",
"restart": "Yeniden Başlat", "restart": "Yeniden Başlat",

View File

@@ -1597,6 +1597,7 @@
"createAdminAccount": "创建管理员帐户", "createAdminAccount": "创建管理员帐户",
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
"certificateStatus": "证书", "certificateStatus": "证书",
"certificateStatusAutoRefreshHint": "状态自动刷新。",
"loading": "加载中", "loading": "加载中",
"loadingAnalytics": "加载分析", "loadingAnalytics": "加载分析",
"restart": "重启", "restart": "重启",

View File

@@ -64,7 +64,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.SIEM]: ["enterprise"], [TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"], [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"], [TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"] [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
}; };

View File

@@ -250,10 +250,31 @@ function extractFirstCert(pemBundle: string): string | null {
return match ? match[0] : null; return match ? match[0] : null;
} }
async function syncAcmeCerts( /**
acmeJsonPath: string, * Determine whether an ACME cert entry represents a wildcard cert by checking
resolver: string * both the primary domain (`main`) and the SANs. Some ACME clients (notably
): Promise<void> { * 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<void> {
let raw: string; let raw: string;
try { try {
raw = fs.readFileSync(acmeJsonPath, "utf8"); raw = fs.readFileSync(acmeJsonPath, "utf8");
@@ -270,23 +291,41 @@ async function syncAcmeCerts(
return; return;
} }
const resolverData = acmeJson[resolver]; const resolvers = Object.keys(acmeJson || {});
if (!resolverData || !Array.isArray(resolverData.Certificates)) { if (resolvers.length === 0) {
logger.debug( logger.debug(`acmeCertSync: no resolvers found in acme.json`);
`acmeCertSync: no certificates found for resolver "${resolver}"`
);
return; return;
} }
for (const cert of resolverData.Certificates) { // Collect certificates from every resolver. If the same domain appears in
const domain = cert.domain?.main; // multiple resolvers, the last one wins (resolvers iterated in object order).
const wildcard = domain.startsWith("*."); 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`); logger.debug(`acmeCertSync: skipping cert with missing domain`);
continue; continue;
} }
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
if (!cert.certificate || !cert.key) { if (!cert.certificate || !cert.key) {
logger.debug( logger.debug(
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field` `acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
@@ -294,10 +333,17 @@ async function syncAcmeCerts(
continue; continue;
} }
const certPem = Buffer.from(cert.certificate, "base64").toString( let certPem: string;
"utf8" let keyPem: string;
); try {
const keyPem = Buffer.from(cert.key, "base64").toString("utf8"); 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()) { if (!certPem.trim() || !keyPem.trim()) {
logger.debug( logger.debug(
@@ -306,6 +352,39 @@ async function syncAcmeCerts(
continue; 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 // Check if cert already exists in DB
const existing = await db const existing = await db
.select() .select()
@@ -326,10 +405,11 @@ async function syncAcmeCerts(
existing[0].certFile, existing[0].certFile,
config.getRawConfig().server.secret! config.getRawConfig().server.secret!
); );
if (storedCertPem === certPem) { const wildcardUnchanged = existing[0].wildcard === wildcard;
logger.debug( if (storedCertPem === certPem && wildcardUnchanged) {
`acmeCertSync: cert for ${domain} is unchanged, skipping` // logger.debug(
); // `acmeCertSync: cert for ${domain} is unchanged, skipping`
// );
continue; continue;
} }
// Cert has changed; capture old values so we can send a correct // Cert has changed; capture old values so we can send a correct
@@ -355,18 +435,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; let expiresAt: number | null = null;
const firstCertPem = extractFirstCert(certPem); try {
if (firstCertPem) { expiresAt = Math.floor(
try { new Date(validatedX509.validTo).getTime() / 1000
const x509 = new crypto.X509Certificate(firstCertPem); );
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000); } catch (err) {
} catch (err) { logger.debug(
logger.debug( `acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}` );
);
}
} }
const encryptedCert = encrypt( const encryptedCert = encrypt(
@@ -468,20 +546,19 @@ export function initAcmeCertSync(): void {
const acmeJsonPath = const acmeJsonPath =
privateConfigData.acme?.acme_json_path ?? privateConfigData.acme?.acme_json_path ??
"config/letsencrypt/acme.json"; "config/letsencrypt/acme.json";
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
logger.debug( 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 // 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}`); logger.error(`acmeCertSync: error during initial sync: ${err}`);
}); });
setInterval(() => { setInterval(() => {
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => { syncAcmeCerts(acmeJsonPath).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`); logger.error(`acmeCertSync: error during sync: ${err}`);
}); });
}, intervalMs); }, intervalMs);

View File

@@ -102,7 +102,6 @@ export const privateConfigSchema = z.object({
.string() .string()
.optional() .optional()
.default("config/letsencrypt/acme.json"), .default("config/letsencrypt/acme.json"),
resolver: z.string().optional().default("letsencrypt"),
sync_interval_ms: z.number().optional().default(5000) sync_interval_ms: z.number().optional().default(5000)
}) })
.optional(), .optional(),

View File

@@ -277,37 +277,37 @@ export async function getTraefikConfig(
}); });
}); });
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge let siteResourcesWithFullDomain: {
const siteResourcesWithFullDomain = await db siteResourceId: number;
.select({ fullDomain: string | null;
siteResourceId: siteResources.siteResourceId, mode: "http" | "host" | "cidr";
fullDomain: siteResources.fullDomain, }[] = [];
mode: siteResources.mode if (build == "enterprise") {
}) // we dont want to do this on the cloud
.from(siteResources) // Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
.innerJoin( siteResourcesWithFullDomain = await db
siteNetworks, .select({
eq(siteResources.networkId, siteNetworks.networkId) siteResourceId: siteResources.siteResourceId,
) fullDomain: siteResources.fullDomain,
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) mode: siteResources.mode
.where( })
and( .from(siteResources)
eq(siteResources.enabled, true), .innerJoin(
isNotNull(siteResources.fullDomain), siteNetworks,
eq(siteResources.mode, "http"), eq(siteResources.networkId, siteNetworks.networkId)
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)
) )
); .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[] = []; let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {

View File

@@ -165,7 +165,6 @@ authenticated.get(
authenticated.get( authenticated.get(
"/org/:orgId/certificate/:domainId/:domain", "/org/:orgId/certificate/:domainId/:domain",
verifyValidLicense,
verifyOrgAccess, verifyOrgAccess,
verifyCertificateAccess, verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.getCertificate), verifyUserHasAction(ActionsEnum.getCertificate),

View File

@@ -152,7 +152,7 @@ export type ResourceWithTargets = {
siteId: number; siteId: number;
siteName: string; siteName: string;
siteNiceId: string; siteNiceId: string;
online: boolean; online?: boolean; // undefined for local sites
}>; }>;
}; };
@@ -383,12 +383,8 @@ export async function listResources(
.select({ resourceId: targets.resourceId }) .select({ resourceId: targets.resourceId })
.from(targets) .from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId)) .innerJoin(sites, eq(targets.siteId, sites.siteId))
.where( .where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)) conditions.push(inArray(resources.resourceId, resourcesWithSite));
);
conditions.push(
inArray(resources.resourceId, resourcesWithSite)
);
} }
const baseQuery = queryResourcesBase().where(and(...conditions)); const baseQuery = queryResourcesBase().where(and(...conditions));
@@ -426,7 +422,8 @@ export async function listResources(
hcEnabled: targetHealthCheck.hcEnabled, hcEnabled: targetHealthCheck.hcEnabled,
siteName: sites.name, siteName: sites.name,
siteNiceId: sites.niceId, siteNiceId: sites.niceId,
siteOnline: sites.online siteOnline: sites.online,
siteType: sites.type
}) })
.from(targets) .from(targets)
.where(inArray(targets.resourceId, resourceIdList)) .where(inArray(targets.resourceId, resourceIdList))
@@ -481,18 +478,19 @@ export async function listResources(
siteId: number; siteId: number;
siteName: string; siteName: string;
siteNiceId: string; siteNiceId: string;
online: boolean; online?: boolean;
} }
>(); >();
for (const t of raw) { for (const t of raw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) { if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue; continue;
} }
const isLocal = t.siteType === "local";
siteById.set(t.siteId, { siteById.set(t.siteId, {
siteId: t.siteId, siteId: t.siteId,
siteName: t.siteName ?? "", siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "", siteNiceId: t.siteNiceId ?? "",
online: Boolean(t.siteOnline) online: isLocal ? undefined : Boolean(t.siteOnline)
}); });
} }
entry.sites = Array.from(siteById.values()); entry.sites = Array.from(siteById.values());

View File

@@ -31,7 +31,9 @@ let staleNewtVersion: string | null = null;
async function getLatestNewtVersion(): Promise<string | null> { async function getLatestNewtVersion(): Promise<string | null> {
try { try {
const cachedVersion = await cache.get<string>("cache:latestNewtVersion"); const cachedVersion = await cache.get<string>(
"cache:latestNewtVersion"
);
if (cachedVersion) { if (cachedVersion) {
return cachedVersion; return cachedVersion;
} }
@@ -226,7 +228,10 @@ function querySitesBase() {
); );
} }
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & { type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
online?: SiteRowBase["online"]; // undefined for local sites
newtUpdateAvailable?: boolean; newtUpdateAvailable?: boolean;
}; };
@@ -338,7 +343,9 @@ export async function listSites(
// we need to add `as` so that drizzle filters the result as a subquery // we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count( const countQuery = db.$count(
querySitesBase().where(and(...conditions)).as("filtered_sites") querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
); );
const siteListQuery = baseQuery 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<ListSitesResponse>(res, { return response<ListSitesResponse>(res, {
data: { data: {
sites: sitesWithUpdates, sites: sitesPayload,
pagination: { pagination: {
total: totalCount, total: totalCount,
pageSize, pageSize,

View File

@@ -46,7 +46,7 @@ const createSiteResourceSchema = z
mode: z.enum(["host", "cidr", "http"]), mode: z.enum(["host", "cidr", "http"]),
ssl: z.boolean().optional(), // only used for http mode ssl: z.boolean().optional(), // only used for http mode
scheme: z.enum(["http", "https"]).optional(), 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 siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(),
@@ -133,6 +133,17 @@ const createSiteResourceSchema = z
message: message:
"HTTP mode requires scheme (http or https) and a valid destination port" "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<typeof createSiteResourceSchema>; export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
@@ -188,7 +199,7 @@ export async function createSiteResource(
const { const {
name, name,
niceId, niceId,
siteIds: siteIdsInput, siteIds: siteIdsInput = [],
siteId, siteId,
mode, mode,
scheme, scheme,

View File

@@ -43,7 +43,7 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z const updateSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
siteIds: z.array(z.int()), siteIds: z.array(z.int()).optional(),
siteId: z.int().positive().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.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
niceId: z niceId: z
@@ -143,6 +143,17 @@ const updateSiteResourceSchema = z
message: message:
"HTTP mode requires scheme (http or https) and a valid destination port" "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<typeof updateSiteResourceSchema>; export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
@@ -197,7 +208,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const { const {
name, name,
siteIds: siteIdsInput, // because it can change siteIds: siteIdsInput = [], // because it can change
siteId, siteId,
niceId, niceId,
mode, mode,

View File

@@ -545,6 +545,72 @@ export default async function migration() {
throw e; 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 // Seed statusHistory for all existing health checks
try { try {
const healthChecksQuery = await db.execute( const healthChecksQuery = await db.execute(

View File

@@ -255,7 +255,7 @@ export default async function migration() {
).run(); ).run();
db.prepare( 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(); ).run();
db.prepare( db.prepare(
@@ -509,6 +509,70 @@ export default async function migration() {
`Seeded statusHistory for ${allResources.length} resource(s)` `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 // Seed statusHistory for all existing health checks
const allHealthChecks = db const allHealthChecks = db
.prepare( .prepare(

View File

@@ -120,6 +120,7 @@ export default async function ProxyResourcesPage(
: "not_protected", : "not_protected",
enabled: resource.enabled, enabled: resource.enabled,
domainId: resource.domainId || undefined, domainId: resource.domainId || undefined,
fullDomain: resource.fullDomain ?? null,
ssl: resource.ssl, ssl: resource.ssl,
targets: resource.targets?.map((target) => ({ targets: resource.targets?.map((target) => ({
targetId: target.targetId, targetId: target.targetId,

View File

@@ -23,7 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator"; import { TailwindIndicator } from "@app/components/TailwindIndicator";
import { ViewportHeightFix } from "@app/components/ViewportHeightFix"; import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
import StoreInternalRedirect from "@app/components/StoreInternalRedirect"; import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
import { Inter, Mona_Sans } from "next/font/google"; import localFont from "next/font/local";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -32,12 +32,30 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const inter = Inter({ const monaSans = localFont({
subsets: ["latin"] src: [
}); {
path: "../fonts/mona-sans/MonaSans-Regular.woff2",
const monaSans = Mona_Sans({ weight: "400",
subsets: ["latin"] 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; const fontClassName = monaSans.className;

View File

@@ -399,11 +399,10 @@ function AuthPageSettings({
</div> </div>
)} )}
{env.flags.usePangolinDns && {build !== "oss" && (build === "enterprise" ||
(build === "enterprise" || !isPaidUser(
!isPaidUser( tierMatrix.loginPageDomain
tierMatrix.loginPageDomain )) &&
)) &&
loginPage?.domainId && loginPage?.domainId &&
loginPage?.fullDomain && loginPage?.fullDomain &&
!hasUnsavedChanges && ( !hasUnsavedChanges && (

View File

@@ -1,43 +1,38 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; 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 { useCertificate } from "@app/hooks/useCertificate";
import type { GetCertificateResponse } from "@server/routers/certificates/types";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
type CertificateStatusProps = { export type CertificateStatusContentProps = {
orgId: string; cert: GetCertificateResponse | null;
domainId: string; certLoading: boolean;
fullDomain: string; certError: string | null;
autoFetch?: boolean; refreshing: boolean;
refreshCert: () => Promise<void>;
showLabel?: boolean; showLabel?: boolean;
className?: string; className?: string;
onRefresh?: () => void; onRefresh?: () => void;
polling?: boolean;
pollingInterval?: number;
}; };
export default function CertificateStatus({ /** Presentation-only certificate row (shared hook state possible via props). */
orgId, export function CertificateStatusContent({
domainId, cert,
fullDomain, certLoading,
autoFetch = true, certError,
refreshing,
refreshCert,
showLabel = true, showLabel = true,
className = "", className = "",
onRefresh, onRefresh
polling = false, }: CertificateStatusContentProps) {
pollingInterval = 5000
}: CertificateStatusProps) {
const t = useTranslations(); const t = useTranslations();
const { cert, certLoading, certError, refreshing, refreshCert } =
useCertificate({ const labelClass =
orgId, "inline-flex shrink-0 items-center self-center text-sm font-medium leading-none";
domainId, const valueClass = "inline-flex items-center gap-2 text-sm leading-none";
fullDomain,
autoFetch,
polling,
pollingInterval
});
const handleRefresh = async () => { const handleRefresh = async () => {
await refreshCert(); await refreshCert();
@@ -74,13 +69,13 @@ export default function CertificateStatus({
return ( return (
<div className={`flex items-center gap-2 ${className}`}> <div className={`flex items-center gap-2 ${className}`}>
{showLabel && ( {showLabel && (
<span className="text-sm font-medium"> <span className={labelClass}>
{t("certificateStatus")}: {t("certificateStatus")}:
</span> </span>
)} )}
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"> <span className={valueClass}>
<Loader2 <FileBadge
className="h-3.5 w-3.5 shrink-0 animate-spin" className="h-4 w-4 shrink-0 animate-pulse text-muted-foreground"
aria-hidden aria-hidden
/> />
{t("loading")} {t("loading")}
@@ -93,11 +88,17 @@ export default function CertificateStatus({
return ( return (
<div className={`flex items-center gap-2 ${className}`}> <div className={`flex items-center gap-2 ${className}`}>
{showLabel && ( {showLabel && (
<span className="text-sm font-medium"> <span className={labelClass}>
{t("certificateStatus")}: {t("certificateStatus")}:
</span> </span>
)} )}
<span className="text-sm text-red-500">{certError}</span> <span className={valueClass}>
<FileBadge
className="h-4 w-4 shrink-0 text-red-500"
aria-hidden
/>
{certError}
</span>
</div> </div>
); );
} }
@@ -106,11 +107,15 @@ export default function CertificateStatus({
return ( return (
<div className={`flex items-center gap-2 ${className}`}> <div className={`flex items-center gap-2 ${className}`}>
{showLabel && ( {showLabel && (
<span className="text-sm font-medium"> <span className={labelClass}>
{t("certificateStatus")}: {t("certificateStatus")}:
</span> </span>
)} )}
<span className="text-sm text-muted-foreground"> <span className={valueClass}>
<FileBadge
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden
/>
{t("none", { defaultValue: "None" })} {t("none", { defaultValue: "None" })}
</span> </span>
</div> </div>
@@ -123,50 +128,102 @@ export default function CertificateStatus({
return ( return (
<div className={`flex items-center gap-2 ${className}`}> <div className={`flex items-center gap-2 ${className}`}>
{showLabel && ( {showLabel && (
<span className="text-sm font-medium"> <span className={labelClass}>{t("certificateStatus")}:</span>
{t("certificateStatus")}:
</span>
)} )}
{isPending ? ( {isPending && !disableRestartButton ? (
<Button <Button
variant="ghost" variant="ghost"
className={`h-auto p-0 text-sm ${getStatusColor(cert.status)}`} className="h-auto min-h-0 shrink-0 p-0 text-sm font-normal leading-none inline-flex items-center self-center"
onClick={handleRefresh} onClick={handleRefresh}
disabled={refreshing || disableRestartButton} disabled={refreshing}
title={t("restartCertificate", { title={t("restartCertificate", {
defaultValue: "Restart Certificate" defaultValue: "Restart Certificate"
})} })}
> >
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-2 leading-none">
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)} <FileBadge
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
aria-hidden
/>
{cert.status.charAt(0).toUpperCase() +
cert.status.slice(1)}
<RotateCw <RotateCw
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
/> />
</span> </span>
</Button> </Button>
) : ( ) : (
<span className={`text-sm ${getStatusColor(cert.status)}`}> <span className={valueClass}>
<span className="inline-flex items-center gap-1"> <FileBadge
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)} className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
{shouldShowRefreshButton(cert.status, cert.updatedAt) && ( aria-hidden
<Button />
size="icon" {cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
variant="ghost" {shouldShowRefreshButton(cert.status, cert.updatedAt) &&
className="p-0 w-3 h-auto align-middle" !disableRestartButton ? (
onClick={handleRefresh} <Button
disabled={refreshing || disableRestartButton} size="icon"
title={t("restartCertificate", { variant="ghost"
defaultValue: "Restart Certificate" className="inline-flex h-auto min-h-0 w-3 shrink-0 items-center justify-center self-center p-0"
})} onClick={handleRefresh}
> disabled={refreshing}
<RotateCw title={t("restartCertificate", {
className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} defaultValue: "Restart Certificate"
/> })}
</Button> >
)} <RotateCw
</span> className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
) : null}
</span> </span>
)} )}
</div> </div>
); );
} }
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 (
<CertificateStatusContent
cert={hook.cert}
certLoading={hook.certLoading}
certError={hook.certError}
refreshing={hook.refreshing}
refreshCert={hook.refreshCert}
showLabel={showLabel}
className={className}
onRefresh={onRefresh}
/>
);
}

View File

@@ -51,6 +51,8 @@ import {
ResourceSitesStatusCell, ResourceSitesStatusCell,
type ResourceSiteRow type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell"; } from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
export type InternalResourceSiteRow = ResourceSiteRow; export type InternalResourceSiteRow = ResourceSiteRow;
@@ -440,13 +442,34 @@ export default function ClientResourcesTable({
); );
} }
if (resourceRow.mode === "http") { 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 =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
return ( return (
<CopyToClipboard <div className="flex items-center gap-2 min-w-0">
text={url} {did ? (
isLink={isSafeUrlForLink(url)} <ResourceAccessCertIndicator
displayText={url} orgId={resourceRow.orgId}
/> domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
); );
} }
return <span>-</span>; return <span>-</span>;

View File

@@ -485,6 +485,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
onSelectSite={(site) => { onSelectSite={(site) => {
setSelectedSite(site); setSelectedSite(site);
}} }}
filterTypes={["newt"]}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -55,6 +55,7 @@ import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus"; import CertificateStatus from "@app/components/CertificateStatus";
import { build } from "@server/build";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -156,7 +157,7 @@ export type InternalResourceData = {
const tagSchema = z.object({ id: z.string(), text: z.string() }); const tagSchema = z.object({ id: z.string(), text: z.string() });
function buildSelectedSitesForResource( function buildSelectedSitesForResource(
resource: InternalResourceData, resource: InternalResourceData
): Selectedsite[] { ): Selectedsite[] {
return resource.siteIds.map((siteId, idx) => ({ return resource.siteIds.map((siteId, idx) => ({
name: resource.siteNames[idx] ?? "", name: resource.siteNames[idx] ?? "",
@@ -609,9 +610,7 @@ export function InternalResourceForm({
users: [], users: [],
clients: [] clients: []
}); });
setSelectedSites( setSelectedSites(buildSelectedSitesForResource(resource));
buildSelectedSitesForResource(resource)
);
setTcpPortMode( setTcpPortMode(
getPortModeFromString(resource.tcpPortRangeString) getPortModeFromString(resource.tcpPortRangeString)
); );
@@ -800,7 +799,9 @@ export function InternalResourceForm({
); );
field.onChange( field.onChange(
sites.map( sites.map(
(s) => (
s
) =>
s.siteId s.siteId
) )
); );
@@ -822,15 +823,21 @@ export function InternalResourceForm({
[ [
{ {
value: "host", value: "host",
label: t(modeHostKey) label: t(
modeHostKey
)
}, },
{ {
value: "cidr", value: "cidr",
label: t(modeCidrKey) label: t(
modeCidrKey
)
}, },
{ {
value: "http", value: "http",
label: t(modeHttpKey) label: t(
modeHttpKey
)
} }
]; ];
return ( return (
@@ -839,7 +846,9 @@ export function InternalResourceForm({
{t(modeLabelKey)} {t(modeLabelKey)}
</FormLabel> </FormLabel>
<OptionSelect<InternalResourceMode> <OptionSelect<InternalResourceMode>
options={modeOptions} options={
modeOptions
}
value={field.value} value={field.value}
onChange={ onChange={
field.onChange field.onChange
@@ -899,7 +908,9 @@ export function InternalResourceForm({
field.value ?? field.value ??
"http" "http"
} }
disabled={httpSectionDisabled} disabled={
httpSectionDisabled
}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
@@ -940,7 +951,10 @@ export function InternalResourceForm({
<Input <Input
{...field} {...field}
className="w-full" className="w-full"
disabled={isHttpMode && httpSectionDisabled} disabled={
isHttpMode &&
httpSectionDisabled
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -996,7 +1010,9 @@ export function InternalResourceForm({
field.value ?? field.value ??
"" ""
} }
disabled={httpSectionDisabled} disabled={
httpSectionDisabled
}
onChange={(e) => { onChange={(e) => {
const raw = const raw =
e.target e.target
@@ -1031,7 +1047,9 @@ export function InternalResourceForm({
</div> </div>
{isHttpMode && ( {isHttpMode && (
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} /> <PaidFeaturesAlert
tiers={tierMatrix.httpPrivateResources}
/>
)} )}
{isHttpMode ? ( {isHttpMode ? (
@@ -1044,55 +1062,61 @@ export function InternalResourceForm({
{t(httpConfigurationDescriptionKey)} {t(httpConfigurationDescriptionKey)}
</div> </div>
</div> </div>
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}> <div
<DomainPicker className={
key={ httpSectionDisabled
variant === "edit" && siteResourceId ? "pointer-events-none opacity-50"
? `http-domain-${siteResourceId}` : undefined
: "http-domain-create"
} }
orgId={orgId} >
cols={2} <DomainPicker
hideFreeDomain key={
defaultSubdomain={ variant === "edit" && siteResourceId
httpConfigSubdomain ?? undefined ? `http-domain-${siteResourceId}`
} : "http-domain-create"
defaultDomainId={ }
httpConfigDomainId ?? undefined orgId={orgId}
} cols={2}
defaultFullDomain={ hideFreeDomain
httpConfigFullDomain ?? undefined defaultSubdomain={
} httpConfigSubdomain ?? undefined
onDomainChange={(res) => { }
if (res === null) { defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ?? undefined
}
onDomainChange={(res) => {
if (res === null) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
null
);
form.setValue(
"httpConfigFullDomain",
null
);
return;
}
form.setValue( form.setValue(
"httpConfigSubdomain", "httpConfigSubdomain",
null res.subdomain ?? null
); );
form.setValue( form.setValue(
"httpConfigDomainId", "httpConfigDomainId",
null res.domainId
); );
form.setValue( form.setValue(
"httpConfigFullDomain", "httpConfigFullDomain",
null res.fullDomain
); );
return; }}
} />
form.setValue(
"httpConfigSubdomain",
res.subdomain ?? null
);
form.setValue(
"httpConfigDomainId",
res.domainId
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
</div> </div>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<FormField <FormField
@@ -1103,7 +1127,9 @@ export function InternalResourceForm({
<FormControl> <FormControl>
<SwitchInput <SwitchInput
id="internal-resource-ssl" id="internal-resource-ssl"
label={t(enableSslLabelKey)} label={t(
enableSslLabelKey
)}
description={t( description={t(
enableSslDescriptionKey enableSslDescriptionKey
)} )}
@@ -1111,7 +1137,9 @@ export function InternalResourceForm({
onCheckedChange={ onCheckedChange={
field.onChange field.onChange
} }
disabled={httpSectionDisabled} disabled={
httpSectionDisabled
}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@@ -1120,15 +1148,22 @@ export function InternalResourceForm({
{variant === "edit" && {variant === "edit" &&
resource?.domainId && resource?.domainId &&
httpConfigFullDomain && httpConfigFullDomain &&
httpConfigDomainId ===
resource.domainId &&
httpConfigFullDomain ===
resource.fullDomain &&
build != "oss" &&
form.watch("ssl") && ( form.watch("ssl") && (
<div className="flex items-center gap-1 pt-1"> <div className="flex items-center gap-2 pt-1">
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium">
{t("certificateStatus")}: {t("certificateStatus")}:
</span> </span>
<CertificateStatus <CertificateStatus
orgId={resource.orgId} orgId={resource.orgId}
domainId={resource.domainId} domainId={resource.domainId}
fullDomain={httpConfigFullDomain} fullDomain={
httpConfigFullDomain
}
autoFetch={true} autoFetch={true}
showLabel={false} showLabel={false}
polling={true} polling={true}

View File

@@ -64,6 +64,8 @@ import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { ControlledDataTable } from "./ui/controlled-data-table"; import { ControlledDataTable } from "./ui/controlled-data-table";
import UptimeMiniBar from "./UptimeMiniBar"; import UptimeMiniBar from "./UptimeMiniBar";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
export type TargetHealth = { export type TargetHealth = {
targetId: number; targetId: number;
@@ -86,6 +88,8 @@ export type ResourceRow = {
proxyPort: number | null; proxyPort: number | null;
enabled: boolean; enabled: boolean;
domainId?: string; domainId?: string;
/** Hostname for certificate API (without scheme); distinct from `domain` URL shown in Access column */
fullDomain?: string | null;
ssl: boolean; ssl: boolean;
targetHost?: string; targetHost?: string;
targetPort?: number; targetPort?: number;
@@ -266,7 +270,7 @@ export default function ProxyResourcesTable({
<StatusIcon status={overallStatus} /> <StatusIcon status={overallStatus} />
<span className="text-sm"> <span className="text-sm">
{overallStatus === "healthy" && {overallStatus === "healthy" &&
t("resourcesTableHealthy")} t("resourcesTableHealthy")}
{overallStatus === "degraded" && {overallStatus === "degraded" &&
t("resourcesTableDegraded")} t("resourcesTableDegraded")}
{overallStatus === "unhealthy" && {overallStatus === "unhealthy" &&
@@ -488,7 +492,12 @@ export default function ProxyResourcesTable({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} healthStatus={resourceRow.health} />; return (
<TargetStatusCell
targets={resourceRow.targets}
healthStatus={resourceRow.health}
/>
);
}, },
sortingFn: (rowA, rowB) => { sortingFn: (rowA, rowB) => {
const statusA = rowA.original.health; const statusA = rowA.original.health;
@@ -520,24 +529,52 @@ export default function ProxyResourcesTable({
header: () => <span className="p-3">{t("access")}</span>, header: () => <span className="p-3">{t("access")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return (
<div className="flex items-center space-x-2"> if (!resourceRow.http) {
{!resourceRow.http ? ( return (
<div className="flex items-center gap-2 min-w-0">
<CopyToClipboard <CopyToClipboard
text={resourceRow.proxyPort?.toString() || ""} text={resourceRow.proxyPort?.toString() || ""}
isLink={false} isLink={false}
/> />
) : !resourceRow.domainId ? ( </div>
);
}
if (!resourceRow.domainId) {
return (
<div className="flex items-center gap-2 min-w-0">
<InfoPopup <InfoPopup
info={t("domainNotFoundDescription")} info={t("domainNotFoundDescription")}
text={t("domainNotFound")} text={t("domainNotFound")}
/> />
) : ( </div>
);
}
const domainId = resourceRow.domainId;
const certHostname = resourceRow.fullDomain;
const showHttpsCertIndicator =
build !== "oss" &&
resourceRow.ssl &&
certHostname != null &&
certHostname !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{showHttpsCertIndicator ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={certHostname}
/>
) : null}
<div className="">
<CopyToClipboard <CopyToClipboard
text={resourceRow.domain} text={resourceRow.domain}
isLink={true} isLink={true}
/> />
)} </div>
</div> </div>
); );
} }

View File

@@ -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<ReturnType<typeof setTimeout> | 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 = (
<div
className={cn(
"h-4 w-4 shrink-0 rounded-[2px] animate-pulse",
"bg-neutral-200 dark:bg-neutral-700"
)}
aria-busy="true"
aria-label={t("loading")}
/>
);
} else if (refreshing) {
triggerBody = (
<FileBadge
className={cn(
"h-4 w-4 shrink-0 animate-spin",
cert ? getStatusColor(cert.status) : "text-muted-foreground"
)}
aria-hidden
/>
);
} else if (certError) {
triggerBody = (
<FileBadge className="h-4 w-4 shrink-0 text-red-500" aria-hidden />
);
} else if (cert) {
triggerBody = (
<FileBadge
className={cn("h-4 w-4", getStatusColor(cert.status))}
aria-hidden
/>
);
} else {
triggerBody = (
<FileBadge
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden
/>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center shrink-0 rounded-[2px] outline-offset-2",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring",
certError && "text-red-500"
)}
onMouseEnter={handleEnterOpen}
onMouseLeave={scheduleClose}
onClick={(e) => {
e.preventDefault();
setOpen((v) => !v);
}}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={t("certificateStatus")}
>
{triggerBody}
</button>
</PopoverAnchor>
<PopoverContent
className="w-72 p-4"
align="start"
side="bottom"
sideOffset={6}
onMouseEnter={clearCloseTimer}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-3">
<CertificateStatusContent
cert={certificate.cert}
certLoading={certificate.certLoading}
certError={certificate.certError}
refreshing={certificate.refreshing}
refreshCert={certificate.refreshCert}
showLabel
/>
<p className="text-sm text-muted-foreground">
{t("certificateStatusAutoRefreshHint")}
</p>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,7 +1,15 @@
"use client"; "use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 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 { useResourceContext } from "@app/hooks/useResourceContext";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { import {
@@ -13,7 +21,7 @@ import {
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import CertificateStatus from "@app/components/CertificateStatus"; import CertificateStatus from "@app/components/CertificateStatus";
import { toUnicode } from "punycode"; import { toUnicode } from "punycode";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { build } from "@server/build";
type ResourceInfoBoxType = {}; type ResourceInfoBoxType = {};
@@ -28,7 +36,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert> <Alert>
<AlertDescription> <AlertDescription>
{/* 4 cols because of the certs */} {/* 4 cols because of the certs */}
<InfoSections cols={resource.http ? 6 : 5}> <InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle> <InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
@@ -61,12 +69,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist || authInfo.whitelist ||
authInfo.headerAuth ? ( authInfo.headerAuth ? (
<div className="flex items-start space-x-2"> <div className="flex items-start space-x-2">
<ShieldCheck className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-500" /> <ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("protected")}</span> <span>{t("protected")}</span>
</div> </div>
) : ( ) : (
<div className="flex items-center space-x-2 text-yellow-500"> <div className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 flex-shrink-0" /> <ShieldOff className="w-4 h-4 flex-shrink-0 text-yellow-500" />
<span>{t("notProtected")}</span> <span>{t("notProtected")}</span>
</div> </div>
)} )}
@@ -137,7 +145,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{/* Certificate Status Column */} {/* Certificate Status Column */}
{resource.http && {resource.http &&
resource.domainId && resource.domainId &&
resource.fullDomain && ( resource.fullDomain &&
build != "oss" && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("certificateStatus", { {t("certificateStatus", {
@@ -177,8 +186,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<span>{t("resourcesTableUnhealthy")}</span> <span>{t("resourcesTableUnhealthy")}</span>
</div> </div>
)} )}
{(!resource.health || resource.health === "unknown") && ( {(!resource.health ||
<div className="flex items-center space-x-2 text-muted-foreground"> resource.health === "unknown") && (
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4 flex-shrink-0" /> <Clock className="w-4 h-4 flex-shrink-0" />
<span>{t("resourcesTableUnknown")}</span> <span>{t("resourcesTableUnknown")}</span>
</div> </div>

View File

@@ -16,10 +16,10 @@ export type ResourceSiteRow = {
siteId: number; siteId: number;
siteName: string; siteName: string;
siteNiceId: string; siteNiceId: string;
online: boolean; online?: boolean | null;
}; };
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline"; type AggregateSitesStatus = "allOnline" | "partial" | "allOffline" | "unknown";
function aggregateSitesStatus( function aggregateSitesStatus(
resourceSites: ResourceSiteRow[] resourceSites: ResourceSiteRow[]
@@ -27,8 +27,17 @@ function aggregateSitesStatus(
if (resourceSites.length === 0) { if (resourceSites.length === 0) {
return "allOffline"; 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"; if (onlineCount > 0) return "partial";
return "allOffline"; return "allOffline";
} }
@@ -40,8 +49,10 @@ function aggregateStatusDotClass(status: AggregateSitesStatus): string {
case "partial": case "partial":
return "bg-yellow-500"; return "bg-yellow-500";
case "allOffline": case "allOffline":
default:
return "bg-neutral-500"; return "bg-neutral-500";
case "unknown":
default:
return "border border-muted-foreground/50 bg-transparent";
} }
} }
@@ -84,6 +95,7 @@ export function ResourceSitesStatusCell({
<DropdownMenuContent align="start" className="min-w-56"> <DropdownMenuContent align="start" className="min-w-56">
{resourceSites.map((site) => { {resourceSites.map((site) => {
const isOnline = site.online; const isOnline = site.online;
const hasKnownStatus = typeof isOnline === "boolean";
return ( return (
<DropdownMenuItem key={site.siteId} asChild> <DropdownMenuItem key={site.siteId} asChild>
<Link <Link
@@ -94,9 +106,11 @@ export function ResourceSitesStatusCell({
<div <div
className={cn( className={cn(
"h-2 w-2 shrink-0 rounded-full", "h-2 w-2 shrink-0 rounded-full",
isOnline !hasKnownStatus
? "bg-green-500" ? "border border-muted-foreground/50 bg-transparent"
: "bg-neutral-500" : isOnline
? "bg-green-500"
: "bg-neutral-500"
)} )}
/> />
<span className="truncate"> <span className="truncate">
@@ -106,12 +120,16 @@ export function ResourceSitesStatusCell({
<span <span
className={cn( className={cn(
"shrink-0 capitalize", "shrink-0 capitalize",
isOnline hasKnownStatus && isOnline
? "text-green-600" ? "text-green-600"
: "text-muted-foreground" : "text-muted-foreground"
)} )}
> >
{isOnline ? t("online") : t("offline")} {!hasKnownStatus
? t("resourcesTableUnknown")
: isOnline
? t("online")
: t("offline")}
</span> </span>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -60,7 +60,7 @@ export type SiteRow = {
type: "newt" | "wireguard" | "local"; type: "newt" | "wireguard" | "local";
newtVersion?: string; newtVersion?: string;
newtUpdateAvailable?: boolean; newtUpdateAvailable?: boolean;
online: boolean; online?: boolean | null;
address?: string; address?: string;
exitNodeName?: string; exitNodeName?: string;
exitNodeEndpoint?: string; exitNodeEndpoint?: string;

View File

@@ -111,11 +111,13 @@ export function MultiSitesSelector({
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate">
{site.name} {site.name}
</span> </span>
<SiteOnlineStatus {site.online != null && (
type={site.type} <SiteOnlineStatus
online={site.online} type={site.type}
t={t} online={site.online}
/> t={t}
/>
)}
</div> </div>
</CommandItem> </CommandItem>
))} ))}

View File

@@ -104,7 +104,7 @@ export function ResourceTargetAddressItem({
role="combobox" role="combobox"
className={cn( className={cn(
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent", "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" !proxyTarget.siteId && "text-muted-foreground"
)} )}
> >
@@ -142,7 +142,7 @@ export function ResourceTargetAddressItem({
}) })
} }
> >
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-xs"> <SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none">
{proxyTarget.method || "http"} {proxyTarget.method || "http"}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -124,11 +124,13 @@ export function SitesSelector({
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate">
{site.name} {site.name}
</span> </span>
<SiteOnlineStatus {site.online != null && (
type={site.type} <SiteOnlineStatus
online={site.online} type={site.type}
t={t} online={site.online}
/> t={t}
/>
)}
</div> </div>
</CommandItem> </CommandItem>
))} ))}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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