mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-16 18:46:35 +02:00
@@ -1597,6 +1597,7 @@
|
|||||||
"createAdminAccount": "Създаване на админ акаунт",
|
"createAdminAccount": "Създаване на админ акаунт",
|
||||||
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
|
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
|
||||||
"certificateStatus": "Сертификат",
|
"certificateStatus": "Сертификат",
|
||||||
|
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
|
||||||
"loading": "Зареждане",
|
"loading": "Зареждане",
|
||||||
"loadingAnalytics": "Зареждане на анализи",
|
"loadingAnalytics": "Зареждане на анализи",
|
||||||
"restart": "Рестарт",
|
"restart": "Рестарт",
|
||||||
|
|||||||
+2
-1
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1597,6 +1597,7 @@
|
|||||||
"createAdminAccount": "관리자 계정 생성",
|
"createAdminAccount": "관리자 계정 생성",
|
||||||
"setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.",
|
"setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.",
|
||||||
"certificateStatus": "인증서",
|
"certificateStatus": "인증서",
|
||||||
|
"certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.",
|
||||||
"loading": "로딩 중",
|
"loading": "로딩 중",
|
||||||
"loadingAnalytics": "분석 로딩 중",
|
"loadingAnalytics": "분석 로딩 중",
|
||||||
"restart": "재시작",
|
"restart": "재시작",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1597,6 +1597,7 @@
|
|||||||
"createAdminAccount": "Создать учётную запись администратора",
|
"createAdminAccount": "Создать учётную запись администратора",
|
||||||
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
|
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
|
||||||
"certificateStatus": "Сертификат",
|
"certificateStatus": "Сертификат",
|
||||||
|
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
|
||||||
"loading": "Загрузка",
|
"loading": "Загрузка",
|
||||||
"loadingAnalytics": "Загрузка аналитики",
|
"loadingAnalytics": "Загрузка аналитики",
|
||||||
"restart": "Перезагрузка",
|
"restart": "Перезагрузка",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1597,6 +1597,7 @@
|
|||||||
"createAdminAccount": "创建管理员帐户",
|
"createAdminAccount": "创建管理员帐户",
|
||||||
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
|
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
|
||||||
"certificateStatus": "证书",
|
"certificateStatus": "证书",
|
||||||
|
"certificateStatusAutoRefreshHint": "状态自动刷新。",
|
||||||
"loading": "加载中",
|
"loading": "加载中",
|
||||||
"loadingAnalytics": "加载分析",
|
"loadingAnalytics": "加载分析",
|
||||||
"restart": "重启",
|
"restart": "重启",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -485,6 +485,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
|
|||||||
onSelectSite={(site) => {
|
onSelectSite={(site) => {
|
||||||
setSelectedSite(site);
|
setSelectedSite(site);
|
||||||
}}
|
}}
|
||||||
|
filterTypes={["newt"]}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user