mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-08 09:19:53 +00:00
Compare commits
42 Commits
miloschwar
...
1.18.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79541ec7b8 | ||
|
|
81197f8a86 | ||
|
|
dcfc7822f4 | ||
|
|
269bd9aa0f | ||
|
|
0a0817b860 | ||
|
|
b7a903ab32 | ||
|
|
ab60438aa7 | ||
|
|
b9f3f90de6 | ||
|
|
b53cc397be | ||
|
|
994fb456c2 | ||
|
|
b36927c7a0 | ||
|
|
1c57473b6d | ||
|
|
c02c3eaa4a | ||
|
|
3c265ee577 | ||
|
|
98dfd05f06 | ||
|
|
faa2e97530 | ||
|
|
175f10a51d | ||
|
|
6284930fce | ||
|
|
6c93aca444 | ||
|
|
d83318cbfc | ||
|
|
143f362a48 | ||
|
|
698cd868a8 | ||
|
|
a55842ffff | ||
|
|
2ffe254879 | ||
|
|
e173f59d89 | ||
|
|
d3870f4920 | ||
|
|
227501d8f8 | ||
|
|
a16f805709 | ||
|
|
a029b107ae | ||
|
|
f03389a9a0 | ||
|
|
78fff6bfde | ||
|
|
bc585c24fc | ||
|
|
0f6c66dc67 | ||
|
|
6be150bafe | ||
|
|
1eac7741a5 | ||
|
|
b8ca0499af | ||
|
|
b39a2bcfb1 | ||
|
|
d45b727dca | ||
|
|
5c31d35e28 | ||
|
|
8c645315f3 | ||
|
|
ab6377e086 | ||
|
|
8ed9adbfae |
@@ -1597,6 +1597,7 @@
|
|||||||
"createAdminAccount": "Създаване на админ акаунт",
|
"createAdminAccount": "Създаване на админ акаунт",
|
||||||
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
|
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
|
||||||
"certificateStatus": "Сертификат",
|
"certificateStatus": "Сертификат",
|
||||||
|
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
|
||||||
"loading": "Зареждане",
|
"loading": "Зареждане",
|
||||||
"loadingAnalytics": "Зареждане на анализи",
|
"loadingAnalytics": "Зареждане на анализи",
|
||||||
"restart": "Рестарт",
|
"restart": "Рестарт",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "重启",
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.SIEM]: ["enterprise"],
|
[TierFeature.SIEM]: ["enterprise"],
|
||||||
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
||||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||||
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -250,10 +250,31 @@ function extractFirstCert(pemBundle: string): string | null {
|
|||||||
return match ? match[0] : null;
|
return match ? match[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncAcmeCerts(
|
/**
|
||||||
acmeJsonPath: string,
|
* Determine whether an ACME cert entry represents a wildcard cert by checking
|
||||||
resolver: string
|
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
|
||||||
): Promise<void> {
|
* Traefik) store the bare apex in `main` and only put the wildcard form in
|
||||||
|
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
|
||||||
|
*/
|
||||||
|
function detectWildcard(
|
||||||
|
main: string,
|
||||||
|
sans: string[] | undefined
|
||||||
|
): { wildcard: boolean; wildcardSan: string | null } {
|
||||||
|
if (main.startsWith("*.")) {
|
||||||
|
return { wildcard: true, wildcardSan: null };
|
||||||
|
}
|
||||||
|
if (Array.isArray(sans)) {
|
||||||
|
for (const san of sans) {
|
||||||
|
if (typeof san !== "string") continue;
|
||||||
|
if (san === `*.${main}` || san.startsWith("*.")) {
|
||||||
|
return { wildcard: true, wildcardSan: san };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { wildcard: false, wildcardSan: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||||
@@ -270,23 +291,41 @@ async function syncAcmeCerts(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolverData = acmeJson[resolver];
|
const resolvers = Object.keys(acmeJson || {});
|
||||||
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
if (resolvers.length === 0) {
|
||||||
logger.debug(
|
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
|
||||||
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const cert of resolverData.Certificates) {
|
// Collect certificates from every resolver. If the same domain appears in
|
||||||
const domain = cert.domain?.main;
|
// multiple resolvers, the last one wins (resolvers iterated in object order).
|
||||||
const wildcard = domain.startsWith("*.");
|
const allCerts: AcmeCert[] = [];
|
||||||
|
for (const resolver of resolvers) {
|
||||||
|
const resolverData = acmeJson[resolver];
|
||||||
|
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
|
||||||
|
);
|
||||||
|
for (const cert of resolverData.Certificates) {
|
||||||
|
allCerts.push(cert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!domain) {
|
for (const cert of allCerts) {
|
||||||
|
const domain = cert?.domain?.main;
|
||||||
|
|
||||||
|
if (!domain || typeof domain !== "string") {
|
||||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
|
||||||
|
|
||||||
if (!cert.certificate || !cert.key) {
|
if (!cert.certificate || !cert.key) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
||||||
@@ -294,10 +333,17 @@ async function syncAcmeCerts(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const certPem = Buffer.from(cert.certificate, "base64").toString(
|
let certPem: string;
|
||||||
"utf8"
|
let keyPem: string;
|
||||||
);
|
try {
|
||||||
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
|
||||||
|
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!certPem.trim() || !keyPem.trim()) {
|
if (!certPem.trim() || !keyPem.trim()) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -306,6 +352,39 @@ async function syncAcmeCerts(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that the decoded data actually parses as a real X.509 cert
|
||||||
|
// before we touch the database. This prevents importing partially-written
|
||||||
|
// or corrupted entries from acme.json.
|
||||||
|
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||||
|
if (!firstCertPemForValidation) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let validatedX509: crypto.X509Certificate;
|
||||||
|
try {
|
||||||
|
validatedX509 = new crypto.X509Certificate(
|
||||||
|
firstCertPemForValidation
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity-check the private key parses too
|
||||||
|
try {
|
||||||
|
crypto.createPrivateKey(keyPem);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if cert already exists in DB
|
// Check if cert already exists in DB
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -326,10 +405,11 @@ async function syncAcmeCerts(
|
|||||||
existing[0].certFile,
|
existing[0].certFile,
|
||||||
config.getRawConfig().server.secret!
|
config.getRawConfig().server.secret!
|
||||||
);
|
);
|
||||||
if (storedCertPem === certPem) {
|
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||||
logger.debug(
|
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||||
`acmeCertSync: cert for ${domain} is unchanged, skipping`
|
// logger.debug(
|
||||||
);
|
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||||
|
// );
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Cert has changed; capture old values so we can send a correct
|
// Cert has changed; capture old values so we can send a correct
|
||||||
@@ -355,18 +435,16 @@ async function syncAcmeCerts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse cert expiry from the first cert in the PEM bundle
|
// Parse cert expiry from the validated X.509 certificate
|
||||||
let expiresAt: number | null = null;
|
let expiresAt: number | null = null;
|
||||||
const firstCertPem = extractFirstCert(certPem);
|
try {
|
||||||
if (firstCertPem) {
|
expiresAt = Math.floor(
|
||||||
try {
|
new Date(validatedX509.validTo).getTime() / 1000
|
||||||
const x509 = new crypto.X509Certificate(firstCertPem);
|
);
|
||||||
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
|
} catch (err) {
|
||||||
} catch (err) {
|
logger.debug(
|
||||||
logger.debug(
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedCert = encrypt(
|
const encryptedCert = encrypt(
|
||||||
@@ -468,20 +546,19 @@ export function initAcmeCertSync(): void {
|
|||||||
const acmeJsonPath =
|
const acmeJsonPath =
|
||||||
privateConfigData.acme?.acme_json_path ??
|
privateConfigData.acme?.acme_json_path ??
|
||||||
"config/letsencrypt/acme.json";
|
"config/letsencrypt/acme.json";
|
||||||
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
|
|
||||||
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run immediately on init, then on the configured interval
|
// Run immediately on init, then on the configured interval
|
||||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
||||||
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
||||||
logger.error(`acmeCertSync: error during sync: ${err}`);
|
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||||
});
|
});
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,8 @@ import {
|
|||||||
ResourceSitesStatusCell,
|
ResourceSitesStatusCell,
|
||||||
type ResourceSiteRow
|
type ResourceSiteRow
|
||||||
} from "@app/components/ResourceSitesStatusCell";
|
} from "@app/components/ResourceSitesStatusCell";
|
||||||
|
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||||
|
|
||||||
@@ -440,13 +442,34 @@ export default function ClientResourcesTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (resourceRow.mode === "http") {
|
if (resourceRow.mode === "http") {
|
||||||
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`;
|
const domainId = resourceRow.domainId;
|
||||||
|
const fullDomain = resourceRow.fullDomain;
|
||||||
|
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
|
||||||
|
const did =
|
||||||
|
build !== "oss" &&
|
||||||
|
resourceRow.ssl &&
|
||||||
|
domainId != null &&
|
||||||
|
domainId !== "" &&
|
||||||
|
fullDomain != null &&
|
||||||
|
fullDomain !== "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CopyToClipboard
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
text={url}
|
{did ? (
|
||||||
isLink={isSafeUrlForLink(url)}
|
<ResourceAccessCertIndicator
|
||||||
displayText={url}
|
orgId={resourceRow.orgId}
|
||||||
/>
|
domainId={domainId}
|
||||||
|
fullDomain={fullDomain}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="">
|
||||||
|
<CopyToClipboard
|
||||||
|
text={url}
|
||||||
|
isLink={isSafeUrlForLink(url)}
|
||||||
|
displayText={url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span>-</span>;
|
return <span>-</span>;
|
||||||
|
|||||||
@@ -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,8 @@ import z from "zod";
|
|||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
import UptimeMiniBar from "./UptimeMiniBar";
|
import UptimeMiniBar from "./UptimeMiniBar";
|
||||||
|
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export type TargetHealth = {
|
export type TargetHealth = {
|
||||||
targetId: number;
|
targetId: number;
|
||||||
@@ -86,6 +88,8 @@ export type ResourceRow = {
|
|||||||
proxyPort: number | null;
|
proxyPort: number | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
domainId?: string;
|
domainId?: string;
|
||||||
|
/** Hostname for certificate API (without scheme); distinct from `domain` URL shown in Access column */
|
||||||
|
fullDomain?: string | null;
|
||||||
ssl: boolean;
|
ssl: boolean;
|
||||||
targetHost?: string;
|
targetHost?: string;
|
||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
@@ -266,7 +270,7 @@ export default function ProxyResourcesTable({
|
|||||||
<StatusIcon status={overallStatus} />
|
<StatusIcon status={overallStatus} />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{overallStatus === "healthy" &&
|
{overallStatus === "healthy" &&
|
||||||
t("resourcesTableHealthy")}
|
t("resourcesTableHealthy")}
|
||||||
{overallStatus === "degraded" &&
|
{overallStatus === "degraded" &&
|
||||||
t("resourcesTableDegraded")}
|
t("resourcesTableDegraded")}
|
||||||
{overallStatus === "unhealthy" &&
|
{overallStatus === "unhealthy" &&
|
||||||
@@ -488,7 +492,12 @@ export default function ProxyResourcesTable({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return <TargetStatusCell targets={resourceRow.targets} healthStatus={resourceRow.health} />;
|
return (
|
||||||
|
<TargetStatusCell
|
||||||
|
targets={resourceRow.targets}
|
||||||
|
healthStatus={resourceRow.health}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
sortingFn: (rowA, rowB) => {
|
sortingFn: (rowA, rowB) => {
|
||||||
const statusA = rowA.original.health;
|
const statusA = rowA.original.health;
|
||||||
@@ -520,24 +529,52 @@ export default function ProxyResourcesTable({
|
|||||||
header: () => <span className="p-3">{t("access")}</span>,
|
header: () => <span className="p-3">{t("access")}</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
if (!resourceRow.http) {
|
||||||
{!resourceRow.http ? (
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={resourceRow.proxyPort?.toString() || ""}
|
text={resourceRow.proxyPort?.toString() || ""}
|
||||||
isLink={false}
|
isLink={false}
|
||||||
/>
|
/>
|
||||||
) : !resourceRow.domainId ? (
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resourceRow.domainId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<InfoPopup
|
<InfoPopup
|
||||||
info={t("domainNotFoundDescription")}
|
info={t("domainNotFoundDescription")}
|
||||||
text={t("domainNotFound")}
|
text={t("domainNotFound")}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainId = resourceRow.domainId;
|
||||||
|
const certHostname = resourceRow.fullDomain;
|
||||||
|
const showHttpsCertIndicator =
|
||||||
|
build !== "oss" &&
|
||||||
|
resourceRow.ssl &&
|
||||||
|
certHostname != null &&
|
||||||
|
certHostname !== "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{showHttpsCertIndicator ? (
|
||||||
|
<ResourceAccessCertIndicator
|
||||||
|
orgId={resourceRow.orgId}
|
||||||
|
domainId={domainId}
|
||||||
|
fullDomain={certHostname}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="">
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={resourceRow.domain}
|
text={resourceRow.domain}
|
||||||
isLink={true}
|
isLink={true}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
179
src/components/ResourceAccessCertIndicator.tsx
Normal file
179
src/components/ResourceAccessCertIndicator.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CertificateStatusContent } from "@app/components/CertificateStatus";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { useCertificate } from "@app/hooks/useCertificate";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { FileBadge } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
type ResourceAccessCertIndicatorProps = {
|
||||||
|
orgId: string;
|
||||||
|
domainId: string;
|
||||||
|
fullDomain: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case "valid":
|
||||||
|
return "text-green-500";
|
||||||
|
case "pending":
|
||||||
|
case "requested":
|
||||||
|
return "text-yellow-500";
|
||||||
|
case "expired":
|
||||||
|
case "failed":
|
||||||
|
return "text-red-500";
|
||||||
|
default:
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact cert icon + hover popover with full certificate status (shared by proxy and client resource tables). */
|
||||||
|
export function ResourceAccessCertIndicator({
|
||||||
|
orgId,
|
||||||
|
domainId,
|
||||||
|
fullDomain
|
||||||
|
}: ResourceAccessCertIndicatorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const certificate = useCertificate({
|
||||||
|
orgId,
|
||||||
|
domainId,
|
||||||
|
fullDomain,
|
||||||
|
autoFetch: true,
|
||||||
|
polling: open,
|
||||||
|
pollingInterval: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const { cert, certLoading, certError, refreshing, fetchCert } = certificate;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void fetchCert(false);
|
||||||
|
}, [open, fetchCert]);
|
||||||
|
|
||||||
|
const clearCloseTimer = useCallback(() => {
|
||||||
|
if (closeTimerRef.current != null) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleClose = useCallback(() => {
|
||||||
|
clearCloseTimer();
|
||||||
|
closeTimerRef.current = setTimeout(() => setOpen(false), 280);
|
||||||
|
}, [clearCloseTimer]);
|
||||||
|
|
||||||
|
const handleEnterOpen = useCallback(() => {
|
||||||
|
clearCloseTimer();
|
||||||
|
setOpen(true);
|
||||||
|
}, [clearCloseTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearCloseTimer();
|
||||||
|
}, [clearCloseTimer]);
|
||||||
|
|
||||||
|
let triggerBody: ReactNode;
|
||||||
|
if (certLoading) {
|
||||||
|
triggerBody = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 rounded-[2px] animate-pulse",
|
||||||
|
"bg-neutral-200 dark:bg-neutral-700"
|
||||||
|
)}
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label={t("loading")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (refreshing) {
|
||||||
|
triggerBody = (
|
||||||
|
<FileBadge
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 animate-spin",
|
||||||
|
cert ? getStatusColor(cert.status) : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (certError) {
|
||||||
|
triggerBody = (
|
||||||
|
<FileBadge className="h-4 w-4 shrink-0 text-red-500" aria-hidden />
|
||||||
|
);
|
||||||
|
} else if (cert) {
|
||||||
|
triggerBody = (
|
||||||
|
<FileBadge
|
||||||
|
className={cn("h-4 w-4", getStatusColor(cert.status))}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
triggerBody = (
|
||||||
|
<FileBadge
|
||||||
|
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverAnchor asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center shrink-0 rounded-[2px] outline-offset-2",
|
||||||
|
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring",
|
||||||
|
certError && "text-red-500"
|
||||||
|
)}
|
||||||
|
onMouseEnter={handleEnterOpen}
|
||||||
|
onMouseLeave={scheduleClose}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={t("certificateStatus")}
|
||||||
|
>
|
||||||
|
{triggerBody}
|
||||||
|
</button>
|
||||||
|
</PopoverAnchor>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-72 p-4"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={6}
|
||||||
|
onMouseEnter={clearCloseTimer}
|
||||||
|
onMouseLeave={scheduleClose}
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<CertificateStatusContent
|
||||||
|
cert={certificate.cert}
|
||||||
|
certLoading={certificate.certLoading}
|
||||||
|
certError={certificate.certError}
|
||||||
|
refreshing={certificate.refreshing}
|
||||||
|
refreshCert={certificate.refreshCert}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("certificateStatusAutoRefreshHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
BIN
src/fonts/mona-sans/MonaSans-Bold.ttf
Normal file
BIN
src/fonts/mona-sans/MonaSans-Bold.ttf
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSans-Bold.woff2
Normal file
BIN
src/fonts/mona-sans/MonaSans-Bold.woff2
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSans-Italic-VariableFont_wdth,wght.ttf
Normal file
BIN
src/fonts/mona-sans/MonaSans-Italic-VariableFont_wdth,wght.ttf
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSans-Medium.ttf
Normal file
BIN
src/fonts/mona-sans/MonaSans-Medium.ttf
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSans-Medium.woff2
Normal file
BIN
src/fonts/mona-sans/MonaSans-Medium.woff2
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSans-Regular.ttf
Normal file
BIN
src/fonts/mona-sans/MonaSans-Regular.ttf
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSans-Regular.woff2
Normal file
BIN
src/fonts/mona-sans/MonaSans-Regular.woff2
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSans-SemiBold.ttf
Normal file
BIN
src/fonts/mona-sans/MonaSans-SemiBold.ttf
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSans-SemiBold.woff2
Normal file
BIN
src/fonts/mona-sans/MonaSans-SemiBold.woff2
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSans-VariableFont_wdth,wght.ttf
Normal file
BIN
src/fonts/mona-sans/MonaSans-VariableFont_wdth,wght.ttf
Normal file
Binary file not shown.
BIN
src/fonts/mona-sans/MonaSansVF[wdth,wght,opsz,ital].woff2
Normal file
BIN
src/fonts/mona-sans/MonaSansVF[wdth,wght,opsz,ital].woff2
Normal file
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