Merge pull request #2933 from fosrl/dev

1.18.1
This commit is contained in:
Owen Schwartz
2026-04-29 15:00:29 -07:00
committed by GitHub
52 changed files with 918 additions and 272 deletions
+1
View File
@@ -1597,6 +1597,7 @@
"createAdminAccount": "Създаване на админ акаунт", "createAdminAccount": "Създаване на админ акаунт",
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.", "setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
"certificateStatus": "Сертификат", "certificateStatus": "Сертификат",
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
"loading": "Зареждане", "loading": "Зареждане",
"loadingAnalytics": "Зареждане на анализи", "loadingAnalytics": "Зареждане на анализи",
"restart": "Рестарт", "restart": "Рестарт",
+2 -1
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",
+2 -1
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",
+1
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",
+1
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",
+1
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",
+1
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",
+1
View File
@@ -1597,6 +1597,7 @@
"createAdminAccount": "관리자 계정 생성", "createAdminAccount": "관리자 계정 생성",
"setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.", "setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.",
"certificateStatus": "인증서", "certificateStatus": "인증서",
"certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.",
"loading": "로딩 중", "loading": "로딩 중",
"loadingAnalytics": "분석 로딩 중", "loadingAnalytics": "분석 로딩 중",
"restart": "재시작", "restart": "재시작",
+1
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",
+1
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",
+1
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",
+1
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",
+1
View File
@@ -1597,6 +1597,7 @@
"createAdminAccount": "Создать учётную запись администратора", "createAdminAccount": "Создать учётную запись администратора",
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
"certificateStatus": "Сертификат", "certificateStatus": "Сертификат",
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
"loading": "Загрузка", "loading": "Загрузка",
"loadingAnalytics": "Загрузка аналитики", "loadingAnalytics": "Загрузка аналитики",
"restart": "Перезагрузка", "restart": "Перезагрузка",
+1
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",
+1
View File
@@ -1597,6 +1597,7 @@
"createAdminAccount": "创建管理员帐户", "createAdminAccount": "创建管理员帐户",
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
"certificateStatus": "证书", "certificateStatus": "证书",
"certificateStatusAutoRefreshHint": "状态自动刷新。",
"loading": "加载中", "loading": "加载中",
"loadingAnalytics": "加载分析", "loadingAnalytics": "加载分析",
"restart": "重启", "restart": "重启",
+111 -35
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()
@@ -327,9 +406,9 @@ async function syncAcmeCerts(
config.getRawConfig().server.secret! config.getRawConfig().server.secret!
); );
if (storedCertPem === certPem) { if (storedCertPem === certPem) {
logger.debug( // logger.debug(
`acmeCertSync: cert for ${domain} is unchanged, skipping` // `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 +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; 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 +545,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);
-1
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(),
+30 -30
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) {
-1
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),
+8 -10
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());
+15 -4
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,
@@ -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,
@@ -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,
+66
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(
+65 -1
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(
@@ -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,
+25 -7
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;
+4 -5
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 && (
+120 -63
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}
/>
);
}
+27 -6
View File
@@ -51,6 +51,7 @@ import {
ResourceSitesStatusCell, ResourceSitesStatusCell,
type ResourceSiteRow type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell"; } from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
export type InternalResourceSiteRow = ResourceSiteRow; export type InternalResourceSiteRow = ResourceSiteRow;
@@ -440,13 +441,33 @@ 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 =
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>;
+1
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>
+92 -57
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}
+43 -8
View File
@@ -64,6 +64,7 @@ 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";
export type TargetHealth = { export type TargetHealth = {
targetId: number; targetId: number;
@@ -86,6 +87,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 +269,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 +491,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 +528,51 @@ 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 =
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>
); );
} }
@@ -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>
);
}
+19 -9
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>
+28 -10
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>
+1 -1
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;
+7 -5
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>
))} ))}
@@ -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>
+7 -5
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.
+24 -6
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 {