mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-08 09:19:53 +00:00
Compare commits
3 Commits
1.18.1-s.4
...
miloschwar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8685cf4208 | ||
|
|
26fe1259da | ||
|
|
70958185bd |
112
.github/workflows/cicd.yml
vendored
112
.github/workflows/cicd.yml
vendored
@@ -414,18 +414,28 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install cosign
|
||||
# cosign is used to sign container images using keyless (OIDC) signing
|
||||
# cosign is used to sign and verify container images (key and keyless)
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Sign (GHCR, keyless)
|
||||
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
|
||||
# Signatures are stored in the registry alongside the image.
|
||||
- name: Dual-sign and verify (GHCR & Docker Hub)
|
||||
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
||||
# then verify both the public key signature and the keyless OIDC signature.
|
||||
env:
|
||||
TAG: ${{ env.TAG }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
|
||||
COSIGN_YES: "true"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
issuer="https://token.actions.githubusercontent.com"
|
||||
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
|
||||
|
||||
# Track failures
|
||||
FAILED_TAGS=()
|
||||
SUCCESSFUL_TAGS=()
|
||||
|
||||
# Determine if this is an RC release
|
||||
IS_RC="false"
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
@@ -453,47 +463,95 @@ jobs:
|
||||
)
|
||||
fi
|
||||
|
||||
FAILED_TAGS=()
|
||||
SUCCESSFUL_TAGS=()
|
||||
# Sign each image variant for both registries
|
||||
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
|
||||
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
|
||||
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
TAG_FAILED=false
|
||||
|
||||
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
|
||||
echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}"
|
||||
TAG_FAILED=false
|
||||
# Wrap the entire tag processing in error handling
|
||||
(
|
||||
set -e
|
||||
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
|
||||
REF="${BASE_IMAGE}@${DIGEST}"
|
||||
echo "Resolved digest: ${REF}"
|
||||
|
||||
(
|
||||
set -e
|
||||
DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
|
||||
REF="${GHCR_IMAGE}@${DIGEST}"
|
||||
echo "Resolved digest: ${REF}"
|
||||
echo "==> cosign sign (keyless) --recursive ${REF}"
|
||||
cosign sign --recursive "${REF}"
|
||||
|
||||
echo "==> cosign sign (keyless) --recursive ${REF}"
|
||||
cosign sign --recursive "${REF}"
|
||||
) || TAG_FAILED=true
|
||||
echo "==> cosign sign (key) --recursive ${REF}"
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
||||
|
||||
if [ "$TAG_FAILED" = "true" ]; then
|
||||
echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}"
|
||||
FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
|
||||
else
|
||||
echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}"
|
||||
SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
|
||||
fi
|
||||
# Retry wrapper for verification to handle registry propagation delays
|
||||
retry_verify() {
|
||||
local cmd="$1"
|
||||
local attempts=6
|
||||
local delay=5
|
||||
local i=1
|
||||
until eval "$cmd"; do
|
||||
if [ $i -ge $attempts ]; then
|
||||
echo "Verification failed after $attempts attempts"
|
||||
return 1
|
||||
fi
|
||||
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
|
||||
sleep $delay
|
||||
i=$((i+1))
|
||||
delay=$((delay*2))
|
||||
# Cap the delay to avoid very long waits
|
||||
if [ $delay -gt 60 ]; then delay=60; fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "==> cosign verify (public key) ${REF}"
|
||||
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
|
||||
VERIFIED_INDEX=true
|
||||
else
|
||||
VERIFIED_INDEX=false
|
||||
fi
|
||||
|
||||
echo "==> cosign verify (keyless policy) ${REF}"
|
||||
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
|
||||
VERIFIED_INDEX_KEYLESS=true
|
||||
else
|
||||
VERIFIED_INDEX_KEYLESS=false
|
||||
fi
|
||||
|
||||
# Check if verification succeeded
|
||||
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
|
||||
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
echo "This may be due to registry propagation delays. Continuing anyway."
|
||||
fi
|
||||
) || TAG_FAILED=true
|
||||
|
||||
if [ "$TAG_FAILED" = "true" ]; then
|
||||
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
||||
else
|
||||
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Report summary
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Sign Summary"
|
||||
echo "Sign and Verify Summary"
|
||||
echo "=========================================="
|
||||
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
|
||||
echo "Failed: ${#FAILED_TAGS[@]}"
|
||||
echo ""
|
||||
|
||||
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
|
||||
echo "Failed tags:"
|
||||
for tag in "${FAILED_TAGS[@]}"; do
|
||||
echo " - $tag"
|
||||
done
|
||||
echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway"
|
||||
echo ""
|
||||
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
|
||||
else
|
||||
echo "✓ All images signed successfully!"
|
||||
echo "✓ All images signed and verified successfully!"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommandModule } from "yargs";
|
||||
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
|
||||
import { db, idpOidcConfig, licenseKey } from "@server/db";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -129,15 +129,9 @@ export const rotateServerSecret: CommandModule<
|
||||
console.log("\nReading encrypted data from database...");
|
||||
const idpConfigs = await db.select().from(idpOidcConfig);
|
||||
const licenseKeys = await db.select().from(licenseKey);
|
||||
const certs = await db.select().from(certificates);
|
||||
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
||||
const webhookActions = await db.select().from(alertWebhookActions);
|
||||
|
||||
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
||||
console.log(`Found ${licenseKeys.length} license key(s)`);
|
||||
console.log(`Found ${certs.length} certificate(s)`);
|
||||
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
||||
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
|
||||
|
||||
// Prepare all decrypted and re-encrypted values
|
||||
console.log("\nDecrypting and re-encrypting values...");
|
||||
@@ -155,27 +149,8 @@ export const rotateServerSecret: CommandModule<
|
||||
encryptedInstanceId: string;
|
||||
};
|
||||
|
||||
type CertUpdate = {
|
||||
certId: number;
|
||||
encryptedCertFile: string | null;
|
||||
encryptedKeyFile: string | null;
|
||||
};
|
||||
|
||||
type StreamingDestinationUpdate = {
|
||||
destinationId: number;
|
||||
encryptedConfig: string;
|
||||
};
|
||||
|
||||
type WebhookActionUpdate = {
|
||||
webhookActionId: number;
|
||||
encryptedConfig: string;
|
||||
};
|
||||
|
||||
const idpUpdates: IdpUpdate[] = [];
|
||||
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
||||
const certUpdates: CertUpdate[] = [];
|
||||
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
||||
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
||||
|
||||
// Process idpOidcConfig entries
|
||||
for (const idpConfig of idpConfigs) {
|
||||
@@ -242,70 +217,6 @@ export const rotateServerSecret: CommandModule<
|
||||
}
|
||||
}
|
||||
|
||||
// Process certificate entries
|
||||
for (const cert of certs) {
|
||||
try {
|
||||
const encryptedCertFile = cert.certFile
|
||||
? encrypt(decrypt(cert.certFile, oldSecret), newSecret)
|
||||
: null;
|
||||
const encryptedKeyFile = cert.keyFile
|
||||
? encrypt(decrypt(cert.keyFile, oldSecret), newSecret)
|
||||
: null;
|
||||
|
||||
certUpdates.push({
|
||||
certId: cert.certId,
|
||||
encryptedCertFile,
|
||||
encryptedKeyFile
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error processing certificate ${cert.certId} (${cert.domain}):`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process eventStreamingDestinations entries
|
||||
for (const dest of streamingDestinations) {
|
||||
try {
|
||||
const decryptedConfig = decrypt(dest.config, oldSecret);
|
||||
const encryptedConfig = encrypt(decryptedConfig, newSecret);
|
||||
|
||||
streamingDestinationUpdates.push({
|
||||
destinationId: dest.destinationId,
|
||||
encryptedConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error processing event streaming destination ${dest.destinationId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process alertWebhookActions entries
|
||||
for (const webhook of webhookActions) {
|
||||
try {
|
||||
if (webhook.config == null) continue;
|
||||
|
||||
const decryptedConfig = decrypt(webhook.config, oldSecret);
|
||||
const encryptedConfig = encrypt(decryptedConfig, newSecret);
|
||||
|
||||
webhookActionUpdates.push({
|
||||
webhookActionId: webhook.webhookActionId,
|
||||
encryptedConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error processing alert webhook action ${webhook.webhookActionId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform all database updates in a single transaction
|
||||
console.log("\nUpdating database in transaction...");
|
||||
await db.transaction(async (trx) => {
|
||||
@@ -339,50 +250,10 @@ export const rotateServerSecret: CommandModule<
|
||||
instanceId: update.encryptedInstanceId
|
||||
});
|
||||
}
|
||||
|
||||
// Update certificate entries
|
||||
for (const update of certUpdates) {
|
||||
await trx
|
||||
.update(certificates)
|
||||
.set({
|
||||
certFile: update.encryptedCertFile,
|
||||
keyFile: update.encryptedKeyFile
|
||||
})
|
||||
.where(eq(certificates.certId, update.certId));
|
||||
}
|
||||
|
||||
// Update event streaming destination entries
|
||||
for (const update of streamingDestinationUpdates) {
|
||||
await trx
|
||||
.update(eventStreamingDestinations)
|
||||
.set({ config: update.encryptedConfig })
|
||||
.where(
|
||||
eq(
|
||||
eventStreamingDestinations.destinationId,
|
||||
update.destinationId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Update alert webhook action entries
|
||||
for (const update of webhookActionUpdates) {
|
||||
await trx
|
||||
.update(alertWebhookActions)
|
||||
.set({ config: update.encryptedConfig })
|
||||
.where(
|
||||
eq(
|
||||
alertWebhookActions.webhookActionId,
|
||||
update.webhookActionId
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
||||
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
|
||||
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
||||
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
||||
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
||||
|
||||
// Update config file with new secret
|
||||
console.log("\nUpdating config file...");
|
||||
@@ -399,9 +270,6 @@ export const rotateServerSecret: CommandModule<
|
||||
console.log(`\nSummary:`);
|
||||
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
|
||||
console.log(` - License keys: ${licenseKeyUpdates.length}`);
|
||||
console.log(` - Certificates: ${certUpdates.length}`);
|
||||
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
||||
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
||||
console.log(
|
||||
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
||||
);
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Създаване на админ акаунт",
|
||||
"setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.",
|
||||
"certificateStatus": "Сертификат",
|
||||
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
|
||||
"loading": "Зареждане",
|
||||
"loadingAnalytics": "Зареждане на анализи",
|
||||
"restart": "Рестарт",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Vytvořit účet správce",
|
||||
"setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.",
|
||||
"certificateStatus": "Certifikát",
|
||||
"certificateStatusAutoRefreshHint": "Stav se automaticky obnovuje.",
|
||||
"loading": "Načítání",
|
||||
"loadingAnalytics": "Načítání analytiky",
|
||||
"restart": "Restartovat",
|
||||
@@ -3168,7 +3167,7 @@
|
||||
"publicIpEndpoint": "Koncový bod",
|
||||
"lastTriggeredAt": "Poslední spouštěč",
|
||||
"reject": "Odmítnout",
|
||||
"uptimeDaysAgo": "Před {count} dny",
|
||||
"uptimeDaysAgo": "{count} days ago",
|
||||
"uptimeToday": "Dnes",
|
||||
"uptimeNoDataAvailable": "Dostupná žádná data",
|
||||
"uptimeSuffix": "doba dostupnosti",
|
||||
|
||||
@@ -1432,7 +1432,7 @@
|
||||
"alertingTriggerHcToggle": "Gesundheits-Check-Status ändern",
|
||||
"alertingTriggerResourceHealthy": "Ressource gesund",
|
||||
"alertingTriggerResourceUnhealthy": "Ressource ungesund",
|
||||
"alertingTriggerResourceDegraded": "Ressource verschlechtert",
|
||||
"alertingTriggerResourceDegraded": "Resource degraded",
|
||||
"alertingSearchHealthChecks": "Gesundheits-Checks suchen…",
|
||||
"alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.",
|
||||
"alertingTriggerResourceToggle": "Ressourcenstatus ändern",
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Admin-Konto erstellen",
|
||||
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.",
|
||||
"certificateStatus": "Zertifikat",
|
||||
"certificateStatusAutoRefreshHint": "Der Status wird automatisch aktualisiert.",
|
||||
"loading": "Laden",
|
||||
"loadingAnalytics": "Analytik wird geladen",
|
||||
"restart": "Neustart",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Create Admin Account",
|
||||
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
|
||||
"certificateStatus": "Certificate",
|
||||
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
||||
"loading": "Loading",
|
||||
"loadingAnalytics": "Loading Analytics",
|
||||
"restart": "Restart",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Crear cuenta de administrador",
|
||||
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.",
|
||||
"certificateStatus": "Certificado",
|
||||
"certificateStatusAutoRefreshHint": "El estado se actualiza automáticamente.",
|
||||
"loading": "Cargando",
|
||||
"loadingAnalytics": "Cargando analíticas",
|
||||
"restart": "Reiniciar",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Créer un compte administrateur",
|
||||
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.",
|
||||
"certificateStatus": "Certificat",
|
||||
"certificateStatusAutoRefreshHint": "L'état se rafraîchit automatiquement.",
|
||||
"loading": "Chargement",
|
||||
"loadingAnalytics": "Chargement de l'analyse",
|
||||
"restart": "Redémarrer",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Crea Account Admin",
|
||||
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.",
|
||||
"certificateStatus": "Certificato",
|
||||
"certificateStatusAutoRefreshHint": "Lo stato si aggiorna automaticamente.",
|
||||
"loading": "Caricamento",
|
||||
"loadingAnalytics": "Caricamento Delle Analisi",
|
||||
"restart": "Riavvia",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "관리자 계정 생성",
|
||||
"setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.",
|
||||
"certificateStatus": "인증서",
|
||||
"certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.",
|
||||
"loading": "로딩 중",
|
||||
"loadingAnalytics": "분석 로딩 중",
|
||||
"restart": "재시작",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Opprett administratorkonto",
|
||||
"setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.",
|
||||
"certificateStatus": "Sertifikat",
|
||||
"certificateStatusAutoRefreshHint": "Status oppdateres automatisk.",
|
||||
"loading": "Laster inn",
|
||||
"loadingAnalytics": "Laster inn analyser",
|
||||
"restart": "Start på nytt",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Maak een beheeraccount aan",
|
||||
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.",
|
||||
"certificateStatus": "Certificaat",
|
||||
"certificateStatusAutoRefreshHint": "Status ververst automatisch.",
|
||||
"loading": "Bezig met laden",
|
||||
"loadingAnalytics": "Laden van Analytics",
|
||||
"restart": "Herstarten",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Utwórz konto administratora",
|
||||
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.",
|
||||
"certificateStatus": "Certyfikat",
|
||||
"certificateStatusAutoRefreshHint": "Status odświeża się automatycznie.",
|
||||
"loading": "Ładowanie",
|
||||
"loadingAnalytics": "Ładowanie Analityki",
|
||||
"restart": "Uruchom ponownie",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Criar Conta de Administrador",
|
||||
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.",
|
||||
"certificateStatus": "Certificado",
|
||||
"certificateStatusAutoRefreshHint": "Status atualiza automaticamente.",
|
||||
"loading": "Carregando",
|
||||
"loadingAnalytics": "Carregando Analytics",
|
||||
"restart": "Reiniciar",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Создать учётную запись администратора",
|
||||
"setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.",
|
||||
"certificateStatus": "Сертификат",
|
||||
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
|
||||
"loading": "Загрузка",
|
||||
"loadingAnalytics": "Загрузка аналитики",
|
||||
"restart": "Перезагрузка",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "Yönetici Hesabı Oluştur",
|
||||
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.",
|
||||
"certificateStatus": "Sertifika",
|
||||
"certificateStatusAutoRefreshHint": "Durum otomatik olarak yenilenir.",
|
||||
"loading": "Yükleniyor",
|
||||
"loadingAnalytics": "Analiz Yükleniyor",
|
||||
"restart": "Yeniden Başlat",
|
||||
|
||||
@@ -1597,7 +1597,6 @@
|
||||
"createAdminAccount": "创建管理员帐户",
|
||||
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
|
||||
"certificateStatus": "证书",
|
||||
"certificateStatusAutoRefreshHint": "状态自动刷新。",
|
||||
"loading": "加载中",
|
||||
"loadingAnalytics": "加载分析",
|
||||
"restart": "重启",
|
||||
|
||||
@@ -122,6 +122,8 @@ export enum ActionsEnum {
|
||||
createOrgDomain = "createOrgDomain",
|
||||
deleteOrgDomain = "deleteOrgDomain",
|
||||
restartOrgDomain = "restartOrgDomain",
|
||||
sendUsageNotification = "sendUsageNotification",
|
||||
sendTrialNotification = "sendTrialNotification",
|
||||
createRemoteExitNode = "createRemoteExitNode",
|
||||
updateRemoteExitNode = "updateRemoteExitNode",
|
||||
getRemoteExitNode = "getRemoteExitNode",
|
||||
|
||||
@@ -566,17 +566,6 @@ export const alertWebhookActions = pgTable("alertWebhookActions", {
|
||||
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
|
||||
});
|
||||
|
||||
export const trialNotifications = pgTable("trialNotifications", {
|
||||
notificationId: serial("notificationId").primaryKey(),
|
||||
subscriptionId: varchar("subscriptionId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => subscriptions.subscriptionId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
|
||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -615,12 +604,3 @@ export type EventStreamingCursor = InferSelectModel<
|
||||
typeof eventStreamingCursors
|
||||
>;
|
||||
export type AlertResources = InferSelectModel<typeof alertResources>;
|
||||
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
|
||||
export type AlertSites = InferSelectModel<typeof alertSites>;
|
||||
export type AlertRules = InferSelectModel<typeof alertRules>;
|
||||
export type AlertEmailActions = InferSelectModel<typeof alertEmailActions>;
|
||||
export type AlertEmailRecipients = InferSelectModel<
|
||||
typeof alertEmailRecipients
|
||||
>;
|
||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
|
||||
@@ -21,9 +21,6 @@ import {
|
||||
targetHealthCheck,
|
||||
users
|
||||
} from "./schema";
|
||||
import { serial, varchar } from "drizzle-orm/mysql-core";
|
||||
import { pgTable } from "drizzle-orm/pg-core";
|
||||
import { bigint } from "zod";
|
||||
|
||||
export const certificates = sqliteTable("certificates", {
|
||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||
@@ -572,19 +569,6 @@ export const alertWebhookActions = sqliteTable("alertWebhookActions", {
|
||||
lastSentAt: integer("lastSentAt")
|
||||
});
|
||||
|
||||
export const trialNotifications = sqliteTable("trialNotifications", {
|
||||
notificationId: integer("notificationId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
subscriptionId: text("subscriptionId")
|
||||
.notNull()
|
||||
.references(() => subscriptions.subscriptionId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
|
||||
sentAt: integer("sentAt").notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -617,10 +601,3 @@ export type EventStreamingCursor = InferSelectModel<
|
||||
typeof eventStreamingCursors
|
||||
>;
|
||||
export type AlertResources = InferSelectModel<typeof alertResources>;
|
||||
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
|
||||
export type AlertSites = InferSelectModel<typeof alertSites>;
|
||||
export type AlertRule = InferSelectModel<typeof alertRules>;
|
||||
export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
|
||||
@@ -64,7 +64,7 @@ export const NotifyTrialExpiring = ({
|
||||
|
||||
<EmailText>
|
||||
Some features and resources may now be
|
||||
restricted. To restore full
|
||||
restricted or disconnected. To restore full
|
||||
access and continue using all the features
|
||||
you had during your trial, please upgrade to
|
||||
a paid plan.
|
||||
@@ -85,7 +85,7 @@ export const NotifyTrialExpiring = ({
|
||||
<strong>{orgName}</strong> will end on{" "}
|
||||
<strong>{trialEndsAt}</strong>
|
||||
{isLastDay
|
||||
? " - that's tomorrow!"
|
||||
? " — that's tomorrow!"
|
||||
: `, in ${daysRemaining} days`}
|
||||
.
|
||||
</EmailText>
|
||||
@@ -93,7 +93,8 @@ export const NotifyTrialExpiring = ({
|
||||
<EmailText>
|
||||
After your trial ends, your account will be
|
||||
moved to the free plan and some
|
||||
functionality may be restricted.
|
||||
functionality may be restricted or your
|
||||
sites may disconnect.
|
||||
</EmailText>
|
||||
|
||||
<EmailText>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const tier1LimitSet: LimitSet = {
|
||||
|
||||
export const tier2LimitSet: LimitSet = {
|
||||
[FeatureId.USERS]: {
|
||||
value: 50,
|
||||
value: 100,
|
||||
description: "Team limit"
|
||||
},
|
||||
[FeatureId.SITES]: {
|
||||
@@ -48,7 +48,7 @@ export const tier2LimitSet: LimitSet = {
|
||||
|
||||
export const tier3LimitSet: LimitSet = {
|
||||
[FeatureId.USERS]: {
|
||||
value: 250,
|
||||
value: 500,
|
||||
description: "Business limit"
|
||||
},
|
||||
[FeatureId.SITES]: {
|
||||
|
||||
@@ -64,7 +64,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.SIEM]: ["enterprise"],
|
||||
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -131,22 +131,41 @@ export async function updateClientResources(
|
||||
: [];
|
||||
|
||||
const allSites: { siteId: number }[] = [];
|
||||
|
||||
if (resourceData.site) {
|
||||
// Look up site by niceId
|
||||
const [siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, resourceData.site),
|
||||
eq(sites.orgId, orgId)
|
||||
let siteSingle;
|
||||
const resourceSiteId = resourceData.site;
|
||||
|
||||
if (resourceSiteId) {
|
||||
// Look up site by niceId
|
||||
[siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, resourceSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (siteSingle) {
|
||||
allSites.push(siteSingle);
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
|
||||
if (!siteSingle) {
|
||||
throw new Error(
|
||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
allSites.push(siteSingle);
|
||||
}
|
||||
|
||||
if (resourceData.sites) {
|
||||
@@ -161,31 +180,15 @@ export async function updateClientResources(
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (site) {
|
||||
allSites.push(site);
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Site not found: ${siteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
allSites.push(site);
|
||||
}
|
||||
}
|
||||
|
||||
if (siteId && allSites.length === 0) {
|
||||
// only add if there are not provided sites
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
const [siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
if (siteSingle) {
|
||||
allSites.push(siteSingle);
|
||||
}
|
||||
}
|
||||
|
||||
if (allSites.length === 0) {
|
||||
throw new Error(
|
||||
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (existingResource) {
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
|
||||
@@ -250,31 +250,10 @@ function extractFirstCert(pemBundle: string): string | null {
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an ACME cert entry represents a wildcard cert by checking
|
||||
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
|
||||
* Traefik) store the bare apex in `main` and only put the wildcard form in
|
||||
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
|
||||
*/
|
||||
function detectWildcard(
|
||||
main: string,
|
||||
sans: string[] | undefined
|
||||
): { wildcard: boolean; wildcardSan: string | null } {
|
||||
if (main.startsWith("*.")) {
|
||||
return { wildcard: true, wildcardSan: null };
|
||||
}
|
||||
if (Array.isArray(sans)) {
|
||||
for (const san of sans) {
|
||||
if (typeof san !== "string") continue;
|
||||
if (san === `*.${main}` || san.startsWith("*.")) {
|
||||
return { wildcard: true, wildcardSan: san };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { wildcard: false, wildcardSan: null };
|
||||
}
|
||||
|
||||
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
async function syncAcmeCerts(
|
||||
acmeJsonPath: string,
|
||||
resolver: string
|
||||
): Promise<void> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||
@@ -291,41 +270,23 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvers = Object.keys(acmeJson || {});
|
||||
if (resolvers.length === 0) {
|
||||
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
|
||||
const resolverData = acmeJson[resolver];
|
||||
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect certificates from every resolver. If the same domain appears in
|
||||
// multiple resolvers, the last one wins (resolvers iterated in object order).
|
||||
const allCerts: AcmeCert[] = [];
|
||||
for (const resolver of resolvers) {
|
||||
const resolverData = acmeJson[resolver];
|
||||
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
logger.debug(
|
||||
`acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
|
||||
);
|
||||
for (const cert of resolverData.Certificates) {
|
||||
allCerts.push(cert);
|
||||
}
|
||||
}
|
||||
for (const cert of resolverData.Certificates) {
|
||||
const domain = cert.domain?.main;
|
||||
const wildcard = domain.startsWith("*.");
|
||||
|
||||
for (const cert of allCerts) {
|
||||
const domain = cert?.domain?.main;
|
||||
|
||||
if (!domain || typeof domain !== "string") {
|
||||
if (!domain) {
|
||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
|
||||
|
||||
if (!cert.certificate || !cert.key) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
||||
@@ -333,17 +294,10 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let certPem: string;
|
||||
let keyPem: string;
|
||||
try {
|
||||
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
|
||||
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const certPem = Buffer.from(cert.certificate, "base64").toString(
|
||||
"utf8"
|
||||
);
|
||||
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||
|
||||
if (!certPem.trim() || !keyPem.trim()) {
|
||||
logger.debug(
|
||||
@@ -352,39 +306,6 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate that the decoded data actually parses as a real X.509 cert
|
||||
// before we touch the database. This prevents importing partially-written
|
||||
// or corrupted entries from acme.json.
|
||||
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||
if (!firstCertPemForValidation) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let validatedX509: crypto.X509Certificate;
|
||||
try {
|
||||
validatedX509 = new crypto.X509Certificate(
|
||||
firstCertPemForValidation
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sanity-check the private key parses too
|
||||
try {
|
||||
crypto.createPrivateKey(keyPem);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if cert already exists in DB
|
||||
const existing = await db
|
||||
.select()
|
||||
@@ -405,11 +326,10 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
existing[0].certFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||
// logger.debug(
|
||||
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||
// );
|
||||
if (storedCertPem === certPem) {
|
||||
logger.debug(
|
||||
`acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Cert has changed; capture old values so we can send a correct
|
||||
@@ -435,16 +355,18 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse cert expiry from the validated X.509 certificate
|
||||
// Parse cert expiry from the first cert in the PEM bundle
|
||||
let expiresAt: number | null = null;
|
||||
try {
|
||||
expiresAt = Math.floor(
|
||||
new Date(validatedX509.validTo).getTime() / 1000
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
const firstCertPem = extractFirstCert(certPem);
|
||||
if (firstCertPem) {
|
||||
try {
|
||||
const x509 = new crypto.X509Certificate(firstCertPem);
|
||||
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const encryptedCert = encrypt(
|
||||
@@ -546,19 +468,20 @@ export function initAcmeCertSync(): void {
|
||||
const acmeJsonPath =
|
||||
privateConfigData.acme?.acme_json_path ??
|
||||
"config/letsencrypt/acme.json";
|
||||
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
|
||||
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
|
||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
||||
);
|
||||
|
||||
// Run immediately on init, then on the configured interval
|
||||
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||
});
|
||||
}, intervalMs);
|
||||
|
||||
@@ -19,13 +19,12 @@ import { eq, and, ne } from "drizzle-orm";
|
||||
|
||||
export async function getOrgTierData(
|
||||
orgId: string
|
||||
): Promise<{ tier: Tier | null; active: boolean; isTrial: boolean }> {
|
||||
): Promise<{ tier: Tier | null; active: boolean }> {
|
||||
let tier: Tier | null = null;
|
||||
let active = false;
|
||||
let isTrial = false;
|
||||
|
||||
if (build !== "saas") {
|
||||
return { tier, active, isTrial };
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -36,7 +35,7 @@ export async function getOrgTierData(
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return { tier, active, isTrial };
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
let orgIdToUse = org.orgId;
|
||||
@@ -45,7 +44,7 @@ export async function getOrgTierData(
|
||||
logger.warn(
|
||||
`Org ${orgId} is not a billing org and does not have a billingOrgId`
|
||||
);
|
||||
return { tier, active, isTrial };
|
||||
return { tier, active };
|
||||
}
|
||||
orgIdToUse = org.billingOrgId;
|
||||
}
|
||||
@@ -58,7 +57,7 @@ export async function getOrgTierData(
|
||||
.limit(1);
|
||||
|
||||
if (!customer) {
|
||||
return { tier, active, isTrial };
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
// Query for active subscriptions that are not license type
|
||||
@@ -85,13 +84,11 @@ export async function getOrgTierData(
|
||||
tier = subscription.type;
|
||||
active = true;
|
||||
}
|
||||
|
||||
isTrial = subscription.trial ?? false;
|
||||
}
|
||||
} catch (error) {
|
||||
// If org not found or error occurs, return null tier and inactive
|
||||
// This is acceptable behavior as per the function signature
|
||||
}
|
||||
|
||||
return { tier, active, isTrial };
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ export const privateConfigSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.default("config/letsencrypt/acme.json"),
|
||||
resolver: z.string().optional().default("letsencrypt"),
|
||||
sync_interval_ms: z.number().optional().default(5000)
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -277,37 +277,37 @@ export async function getTraefikConfig(
|
||||
});
|
||||
});
|
||||
|
||||
let siteResourcesWithFullDomain: {
|
||||
siteResourceId: number;
|
||||
fullDomain: string | null;
|
||||
mode: "http" | "host" | "cidr";
|
||||
}[] = [];
|
||||
if (build == "enterprise") {
|
||||
// we dont want to do this on the cloud
|
||||
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
||||
siteResourcesWithFullDomain = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
fullDomain: siteResources.fullDomain,
|
||||
mode: siteResources.mode
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteResources.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.enabled, true),
|
||||
isNotNull(siteResources.fullDomain),
|
||||
eq(siteResources.mode, "http"),
|
||||
eq(siteResources.ssl, true),
|
||||
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
||||
const siteResourcesWithFullDomain = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
fullDomain: siteResources.fullDomain,
|
||||
mode: siteResources.mode
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteResources.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.enabled, true),
|
||||
isNotNull(siteResources.fullDomain),
|
||||
eq(siteResources.mode, "http"),
|
||||
eq(siteResources.ssl, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
inArray(sites.type, siteTypes)
|
||||
)
|
||||
);
|
||||
}
|
||||
and(
|
||||
isNull(sites.exitNodeId),
|
||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||
eq(sites.type, "local"),
|
||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||
)
|
||||
),
|
||||
inArray(sites.type, siteTypes)
|
||||
)
|
||||
);
|
||||
|
||||
let validCerts: CertificateResult[] = [];
|
||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
|
||||
@@ -30,10 +30,8 @@ import {
|
||||
userOrgRoles,
|
||||
siteProvisioningKeyOrg,
|
||||
siteProvisioningKeys,
|
||||
alertRules,
|
||||
targetHealthCheck
|
||||
} from "@server/db";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Get the maximum allowed retention days for a given tier
|
||||
@@ -320,14 +318,6 @@ async function disableFeature(
|
||||
await disableSiteProvisioningKeys(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.AlertingRules:
|
||||
await disableAlertingRules(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.StandaloneHealthChecks:
|
||||
await disableStandaloneHealthChecks(orgId);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn(
|
||||
`Unknown feature ${feature} for org ${orgId}, skipping`
|
||||
@@ -370,7 +360,8 @@ async function disableFullRbac(orgId: string): Promise<void> {
|
||||
async function disableSiteProvisioningKeys(orgId: string): Promise<void> {
|
||||
const rows = await db
|
||||
.select({
|
||||
siteProvisioningKeyId: siteProvisioningKeyOrg.siteProvisioningKeyId
|
||||
siteProvisioningKeyId:
|
||||
siteProvisioningKeyOrg.siteProvisioningKeyId
|
||||
})
|
||||
.from(siteProvisioningKeyOrg)
|
||||
.where(eq(siteProvisioningKeyOrg.orgId, orgId));
|
||||
@@ -534,29 +525,6 @@ async function disablePasswordExpirationPolicies(orgId: string): Promise<void> {
|
||||
logger.info(`Disabled password expiration policies for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableAlertingRules(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(alertRules)
|
||||
.set({ enabled: false })
|
||||
.where(eq(alertRules.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled all alert rules for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableStandaloneHealthChecks(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(targetHealthCheck)
|
||||
.set({ hcEnabled: false })
|
||||
.where(
|
||||
and(
|
||||
eq(targetHealthCheck.orgId, orgId),
|
||||
isNull(targetHealthCheck.targetId)
|
||||
)
|
||||
);
|
||||
|
||||
logger.info(`Disabled standalone health checks for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableAutoProvisioning(orgId: string): Promise<void> {
|
||||
// Get all IDP IDs for this org through the idpOrg join table
|
||||
const orgIdps = await db
|
||||
|
||||
@@ -174,19 +174,6 @@ export async function handleSubscriptionCreated(
|
||||
// TODO: update user in Sendy
|
||||
}
|
||||
}
|
||||
|
||||
// delete the trial subscrition if we have one
|
||||
await db
|
||||
.delete(subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
subscriptions.customerId,
|
||||
subscription.customer as string
|
||||
),
|
||||
eq(subscriptions.trial, true)
|
||||
)
|
||||
);
|
||||
} else if (type === "license") {
|
||||
logger.debug(
|
||||
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.`
|
||||
|
||||
@@ -165,6 +165,7 @@ authenticated.get(
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/certificate/:domainId/:domain",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyCertificateAccess,
|
||||
verifyUserHasAction(ActionsEnum.getCertificate),
|
||||
|
||||
@@ -67,20 +67,24 @@ if (build == "saas") {
|
||||
verifyApiKeyIsRoot,
|
||||
certificates.syncCertToNewts
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/org/:orgId/send-usage-notification`,
|
||||
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
||||
org.sendUsageNotification
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/org/:orgId/send-trial-notification`,
|
||||
verifyApiKeyIsRoot,
|
||||
org.sendTrialNotification
|
||||
);
|
||||
}
|
||||
|
||||
authenticated.post(
|
||||
`/org/:orgId/send-usage-notification`,
|
||||
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
||||
verifyApiKeyHasAction(ActionsEnum.sendUsageNotification),
|
||||
logActionAudit(ActionsEnum.sendUsageNotification),
|
||||
org.sendUsageNotification
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/org/:orgId/send-trial-notification`,
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.sendTrialNotification),
|
||||
logActionAudit(ActionsEnum.sendTrialNotification),
|
||||
org.sendTrialNotification
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/idp/:idpId",
|
||||
verifyApiKeyIsRoot,
|
||||
|
||||
@@ -104,9 +104,8 @@ export async function deleteMyAccount(
|
||||
(r) => r.isBillingOrg && r.isOwner
|
||||
)?.orgId;
|
||||
if (primaryOrgId) {
|
||||
const { tier, active, isTrial } =
|
||||
await getOrgTierData(primaryOrgId);
|
||||
if (active && tier && !isTrial) {
|
||||
const { tier, active } = await getOrgTierData(primaryOrgId);
|
||||
if (active && tier) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
|
||||
@@ -152,7 +152,7 @@ export type ResourceWithTargets = {
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
online?: boolean; // undefined for local sites
|
||||
online: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -383,8 +383,12 @@ export async function listResources(
|
||||
.select({ resourceId: targets.resourceId })
|
||||
.from(targets)
|
||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
||||
.where(
|
||||
and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))
|
||||
);
|
||||
conditions.push(
|
||||
inArray(resources.resourceId, resourcesWithSite)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryResourcesBase().where(and(...conditions));
|
||||
@@ -422,8 +426,7 @@ export async function listResources(
|
||||
hcEnabled: targetHealthCheck.hcEnabled,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteOnline: sites.online,
|
||||
siteType: sites.type
|
||||
siteOnline: sites.online
|
||||
})
|
||||
.from(targets)
|
||||
.where(inArray(targets.resourceId, resourceIdList))
|
||||
@@ -478,19 +481,18 @@ export async function listResources(
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
online?: boolean;
|
||||
online: boolean;
|
||||
}
|
||||
>();
|
||||
for (const t of raw) {
|
||||
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
||||
continue;
|
||||
}
|
||||
const isLocal = t.siteType === "local";
|
||||
siteById.set(t.siteId, {
|
||||
siteId: t.siteId,
|
||||
siteName: t.siteName ?? "",
|
||||
siteNiceId: t.siteNiceId ?? "",
|
||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||
online: Boolean(t.siteOnline)
|
||||
});
|
||||
}
|
||||
entry.sites = Array.from(siteById.values());
|
||||
|
||||
@@ -31,9 +31,7 @@ let staleNewtVersion: string | null = null;
|
||||
|
||||
async function getLatestNewtVersion(): Promise<string | null> {
|
||||
try {
|
||||
const cachedVersion = await cache.get<string>(
|
||||
"cache:latestNewtVersion"
|
||||
);
|
||||
const cachedVersion = await cache.get<string>("cache:latestNewtVersion");
|
||||
if (cachedVersion) {
|
||||
return cachedVersion;
|
||||
}
|
||||
@@ -228,10 +226,7 @@ function querySitesBase() {
|
||||
);
|
||||
}
|
||||
|
||||
type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
|
||||
|
||||
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
||||
online?: SiteRowBase["online"]; // undefined for local sites
|
||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
|
||||
newtUpdateAvailable?: boolean;
|
||||
};
|
||||
|
||||
@@ -343,9 +338,7 @@ export async function listSites(
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(
|
||||
querySitesBase()
|
||||
.where(and(...conditions))
|
||||
.as("filtered_sites")
|
||||
querySitesBase().where(and(...conditions)).as("filtered_sites")
|
||||
);
|
||||
|
||||
const siteListQuery = baseQuery
|
||||
@@ -404,13 +397,9 @@ export async function listSites(
|
||||
);
|
||||
}
|
||||
|
||||
const sitesPayload = sitesWithUpdates.map((site) =>
|
||||
site.type === "local" ? { ...site, online: undefined } : site
|
||||
);
|
||||
|
||||
return response<ListSitesResponse>(res, {
|
||||
data: {
|
||||
sites: sitesPayload,
|
||||
sites: sitesWithUpdates,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
|
||||
@@ -46,7 +46,7 @@ const createSiteResourceSchema = z
|
||||
mode: z.enum(["host", "cidr", "http"]),
|
||||
ssl: z.boolean().optional(), // only used for http mode
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
siteIds: z.array(z.int()).optional(),
|
||||
siteIds: z.array(z.int()),
|
||||
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
destinationPort: z.int().positive().optional(),
|
||||
@@ -133,17 +133,6 @@ const createSiteResourceSchema = z
|
||||
message:
|
||||
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
return (
|
||||
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
||||
data.siteId !== undefined
|
||||
);
|
||||
},
|
||||
{
|
||||
message: "At least one of siteIds or siteId must be provided"
|
||||
}
|
||||
);
|
||||
|
||||
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
|
||||
@@ -199,7 +188,7 @@ export async function createSiteResource(
|
||||
const {
|
||||
name,
|
||||
niceId,
|
||||
siteIds: siteIdsInput = [],
|
||||
siteIds: siteIdsInput,
|
||||
siteId,
|
||||
mode,
|
||||
scheme,
|
||||
@@ -496,6 +485,11 @@ export async function createSiteResource(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
newSiteResource,
|
||||
trx
|
||||
); // we need to call this because we added to the admin role
|
||||
});
|
||||
|
||||
if (!newSiteResource) {
|
||||
@@ -521,22 +515,6 @@ export async function createSiteResource(
|
||||
await createCertificate(domainId, fullDomain, db);
|
||||
}
|
||||
|
||||
// Run in the background after the response is sent. Wrapped in its
|
||||
// own transaction so it always executes on the primary — avoiding any
|
||||
// replica-lag issues while still allowing the HTTP response to return
|
||||
// early.
|
||||
db.transaction(async (trx) => {
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
newSiteResource!,
|
||||
trx
|
||||
);
|
||||
}).catch((err) => {
|
||||
logger.error(
|
||||
`Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: newSiteResource,
|
||||
success: true,
|
||||
|
||||
@@ -63,26 +63,17 @@ export async function deleteSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the site resource
|
||||
const [removedSiteResource] = await db
|
||||
.delete(siteResources)
|
||||
.where(eq(siteResources.siteResourceId, siteResourceId))
|
||||
.returning();
|
||||
await db.transaction(async (trx) => {
|
||||
// Delete the site resource
|
||||
const [removedSiteResource] = await trx
|
||||
.delete(siteResources)
|
||||
.where(eq(siteResources.siteResourceId, siteResourceId))
|
||||
.returning();
|
||||
|
||||
// Run in the background after the response is sent. Wrapped in its
|
||||
// own transaction so it always executes on the primary — avoiding any
|
||||
// replica-lag issues while still allowing the HTTP response to return
|
||||
// early.
|
||||
db.transaction(async (trx) => {
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
removedSiteResource,
|
||||
trx
|
||||
);
|
||||
}).catch((err) => {
|
||||
logger.error(
|
||||
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
|
||||
logger.info(`Deleted site resource ${siteResourceId}`);
|
||||
|
||||
@@ -43,7 +43,7 @@ const updateSiteResourceParamsSchema = z.strictObject({
|
||||
const updateSiteResourceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
siteIds: z.array(z.int()).optional(),
|
||||
siteIds: z.array(z.int()),
|
||||
siteId: z.int().positive().optional(),
|
||||
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||
niceId: z
|
||||
@@ -143,17 +143,6 @@ const updateSiteResourceSchema = z
|
||||
message:
|
||||
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
return (
|
||||
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
||||
data.siteId !== undefined
|
||||
);
|
||||
},
|
||||
{
|
||||
message: "At least one of siteIds or siteId must be provided"
|
||||
}
|
||||
);
|
||||
|
||||
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
|
||||
@@ -208,7 +197,7 @@ export async function updateSiteResource(
|
||||
const { siteResourceId } = parsedParams.data;
|
||||
const {
|
||||
name,
|
||||
siteIds: siteIdsInput = [], // because it can change
|
||||
siteIds: siteIdsInput, // because it can change
|
||||
siteId,
|
||||
niceId,
|
||||
mode,
|
||||
@@ -431,6 +420,9 @@ export async function updateSiteResource(
|
||||
})
|
||||
.returning();
|
||||
|
||||
// wait some time to allow for messages to be handled
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
|
||||
const sshPamSet =
|
||||
isLicensedSshPam &&
|
||||
(authDaemonPort !== undefined ||
|
||||
@@ -553,6 +545,11 @@ export async function updateSiteResource(
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
updatedSiteResource,
|
||||
trx
|
||||
);
|
||||
} else {
|
||||
// Update the site resource
|
||||
const sshPamSet =
|
||||
@@ -682,24 +679,7 @@ export async function updateSiteResource(
|
||||
}
|
||||
|
||||
logger.info(`Updated site resource ${siteResourceId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Background: wait for removal messages to propagate, then rebuild
|
||||
// associations for the re-created resource. Own transaction ensures
|
||||
// execution on the primary against fully committed state.
|
||||
(async () => {
|
||||
await db.transaction(async (trx) => {
|
||||
if (!updatedSiteResource) {
|
||||
throw new Error("No updated resource found after update");
|
||||
}
|
||||
if (sitesChanged) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
updatedSiteResource,
|
||||
trx
|
||||
);
|
||||
}
|
||||
await handleMessagingForUpdatedSiteResource(
|
||||
existingSiteResource,
|
||||
updatedSiteResource,
|
||||
@@ -709,12 +689,7 @@ export async function updateSiteResource(
|
||||
})),
|
||||
trx
|
||||
);
|
||||
});
|
||||
})().catch((err) => {
|
||||
logger.error(
|
||||
`Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -16,9 +16,6 @@ export default async function migration() {
|
||||
thc."targetId",
|
||||
t."siteId",
|
||||
s."orgId",
|
||||
r."name" AS "resourceName",
|
||||
t."ip",
|
||||
t."port",
|
||||
thc."hcEnabled",
|
||||
thc."hcPath",
|
||||
thc."hcScheme",
|
||||
@@ -36,17 +33,13 @@ export default async function migration() {
|
||||
thc."hcTlsServerName"
|
||||
FROM "targetHealthCheck" thc
|
||||
JOIN "targets" t ON thc."targetId" = t."targetId"
|
||||
JOIN "sites" s ON t."siteId" = s."siteId"
|
||||
JOIN "resources" r ON t."resourceId" = r."resourceId"`
|
||||
JOIN "sites" s ON t."siteId" = s."siteId"`
|
||||
);
|
||||
const existingHealthChecks = healthChecksQuery.rows as {
|
||||
targetHealthCheckId: number;
|
||||
targetId: number;
|
||||
siteId: number;
|
||||
orgId: string;
|
||||
resourceName: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
hcEnabled: boolean;
|
||||
hcPath: string | null;
|
||||
hcScheme: string | null;
|
||||
@@ -392,7 +385,6 @@ export default async function migration() {
|
||||
"targetId",
|
||||
"orgId",
|
||||
"siteId",
|
||||
"name",
|
||||
"hcEnabled",
|
||||
"hcPath",
|
||||
"hcScheme",
|
||||
@@ -413,7 +405,6 @@ export default async function migration() {
|
||||
${hc.targetId},
|
||||
${hc.orgId},
|
||||
${hc.siteId},
|
||||
${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`},
|
||||
${hc.hcEnabled},
|
||||
${hc.hcPath},
|
||||
${hc.hcScheme},
|
||||
@@ -554,72 +545,6 @@ export default async function migration() {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Recompute resource health by aggregating across the resource's targets'
|
||||
// target health checks, then update the resources.health column to match.
|
||||
try {
|
||||
const resourceTargetHealthQuery = await db.execute(
|
||||
sql`SELECT
|
||||
r."resourceId" AS "resourceId",
|
||||
thc."hcHealth" AS "hcHealth"
|
||||
FROM "resources" r
|
||||
LEFT JOIN "targets" t ON t."resourceId" = r."resourceId"
|
||||
LEFT JOIN "targetHealthCheck" thc ON thc."targetId" = t."targetId"`
|
||||
);
|
||||
const resourceTargetHealthRows =
|
||||
resourceTargetHealthQuery.rows as {
|
||||
resourceId: number;
|
||||
hcHealth: string | null;
|
||||
}[];
|
||||
|
||||
const resourceHealthMap = new Map<
|
||||
number,
|
||||
{ hasHealthy: boolean; hasUnhealthy: boolean; hasUnknown: boolean }
|
||||
>();
|
||||
for (const row of resourceTargetHealthRows) {
|
||||
const entry = resourceHealthMap.get(row.resourceId) ?? {
|
||||
hasHealthy: false,
|
||||
hasUnhealthy: false,
|
||||
hasUnknown: false
|
||||
};
|
||||
const status = row.hcHealth ?? "unknown";
|
||||
if (status === "healthy") entry.hasHealthy = true;
|
||||
else if (status === "unhealthy") entry.hasUnhealthy = true;
|
||||
else entry.hasUnknown = true;
|
||||
resourceHealthMap.set(row.resourceId, entry);
|
||||
}
|
||||
|
||||
let updatedResourceCount = 0;
|
||||
for (const [resourceId, flags] of resourceHealthMap.entries()) {
|
||||
let aggregated: "healthy" | "unhealthy" | "degraded" | "unknown";
|
||||
if (flags.hasHealthy && flags.hasUnhealthy) {
|
||||
aggregated = "degraded";
|
||||
} else if (flags.hasHealthy) {
|
||||
aggregated = "healthy";
|
||||
} else if (flags.hasUnhealthy) {
|
||||
aggregated = "unhealthy";
|
||||
} else {
|
||||
aggregated = "unknown";
|
||||
}
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE "resources"
|
||||
SET "health" = ${aggregated}
|
||||
WHERE "resourceId" = ${resourceId}
|
||||
`);
|
||||
updatedResourceCount++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Recomputed health for ${updatedResourceCount} resource(s) based on target health checks`
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Error while recomputing resource health from target health checks:",
|
||||
e
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Seed statusHistory for all existing health checks
|
||||
try {
|
||||
const healthChecksQuery = await db.execute(
|
||||
|
||||
@@ -22,9 +22,6 @@ export default async function migration() {
|
||||
thc."targetId",
|
||||
t."siteId",
|
||||
s."orgId",
|
||||
r."name" AS "resourceName",
|
||||
t."ip",
|
||||
t."port",
|
||||
thc."hcEnabled",
|
||||
thc."hcPath",
|
||||
thc."hcScheme",
|
||||
@@ -42,17 +39,13 @@ export default async function migration() {
|
||||
thc."hcTlsServerName"
|
||||
FROM 'targetHealthCheck' thc
|
||||
JOIN 'targets' t ON thc."targetId" = t."targetId"
|
||||
JOIN 'sites' s ON t."siteId" = s."siteId"
|
||||
JOIN 'resources' r ON t."resourceId" = r."resourceId"`
|
||||
JOIN 'sites' s ON t."siteId" = s."siteId"`
|
||||
)
|
||||
.all() as {
|
||||
targetHealthCheckId: number;
|
||||
targetId: number;
|
||||
siteId: number;
|
||||
orgId: string;
|
||||
resourceName: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
hcEnabled: number;
|
||||
hcPath: string | null;
|
||||
hcScheme: string | null;
|
||||
@@ -262,7 +255,7 @@ export default async function migration() {
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", NULL, NULL, "niceId", "name", 0, "mode", NULL, "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", COALESCE("tcpPortRangeString", '*'), COALESCE("udpPortRangeString", '*'), COALESCE("disableIcmp", 0), "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", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", NULL, NULL, NULL FROM 'siteResources';
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
@@ -399,7 +392,6 @@ export default async function migration() {
|
||||
"targetId",
|
||||
"orgId",
|
||||
"siteId",
|
||||
"name",
|
||||
"hcEnabled",
|
||||
"hcPath",
|
||||
"hcScheme",
|
||||
@@ -415,7 +407,7 @@ export default async function migration() {
|
||||
"hcStatus",
|
||||
"hcHealth",
|
||||
"hcTlsServerName"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
@@ -425,7 +417,6 @@ export default async function migration() {
|
||||
hc.targetId,
|
||||
hc.orgId,
|
||||
hc.siteId,
|
||||
`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`,
|
||||
hc.hcEnabled,
|
||||
hc.hcPath,
|
||||
hc.hcScheme,
|
||||
@@ -518,70 +509,6 @@ export default async function migration() {
|
||||
`Seeded statusHistory for ${allResources.length} resource(s)`
|
||||
);
|
||||
|
||||
// Recompute resource health by aggregating across the resource's
|
||||
// targets' target health checks, then update resources.health.
|
||||
const resourceTargetHealthRows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
r."resourceId" AS "resourceId",
|
||||
thc."hcHealth" AS "hcHealth"
|
||||
FROM 'resources' r
|
||||
LEFT JOIN 'targets' t ON t."resourceId" = r."resourceId"
|
||||
LEFT JOIN 'targetHealthCheck' thc ON thc."targetId" = t."targetId"`
|
||||
)
|
||||
.all() as {
|
||||
resourceId: number;
|
||||
hcHealth: string | null;
|
||||
}[];
|
||||
|
||||
const resourceHealthMap = new Map<
|
||||
number,
|
||||
{
|
||||
hasHealthy: boolean;
|
||||
hasUnhealthy: boolean;
|
||||
hasUnknown: boolean;
|
||||
}
|
||||
>();
|
||||
for (const row of resourceTargetHealthRows) {
|
||||
const entry = resourceHealthMap.get(row.resourceId) ?? {
|
||||
hasHealthy: false,
|
||||
hasUnhealthy: false,
|
||||
hasUnknown: false
|
||||
};
|
||||
const status = row.hcHealth ?? "unknown";
|
||||
if (status === "healthy") entry.hasHealthy = true;
|
||||
else if (status === "unhealthy") entry.hasUnhealthy = true;
|
||||
else entry.hasUnknown = true;
|
||||
resourceHealthMap.set(row.resourceId, entry);
|
||||
}
|
||||
|
||||
const updateResourceHealth = db.prepare(
|
||||
`UPDATE 'resources' SET "health" = ? WHERE "resourceId" = ?`
|
||||
);
|
||||
const recomputeResourceHealth = db.transaction(() => {
|
||||
for (const [resourceId, flags] of resourceHealthMap.entries()) {
|
||||
let aggregated:
|
||||
| "healthy"
|
||||
| "unhealthy"
|
||||
| "degraded"
|
||||
| "unknown";
|
||||
if (flags.hasHealthy && flags.hasUnhealthy) {
|
||||
aggregated = "degraded";
|
||||
} else if (flags.hasHealthy) {
|
||||
aggregated = "healthy";
|
||||
} else if (flags.hasUnhealthy) {
|
||||
aggregated = "unhealthy";
|
||||
} else {
|
||||
aggregated = "unknown";
|
||||
}
|
||||
updateResourceHealth.run(aggregated, resourceId);
|
||||
}
|
||||
});
|
||||
recomputeResourceHealth();
|
||||
console.log(
|
||||
`Recomputed health for ${resourceHealthMap.size} resource(s) based on target health checks`
|
||||
);
|
||||
|
||||
// Seed statusHistory for all existing health checks
|
||||
const allHealthChecks = db
|
||||
.prepare(
|
||||
|
||||
@@ -120,7 +120,6 @@ export default async function ProxyResourcesPage(
|
||||
: "not_protected",
|
||||
enabled: resource.enabled,
|
||||
domainId: resource.domainId || undefined,
|
||||
fullDomain: resource.fullDomain ?? null,
|
||||
ssl: resource.ssl,
|
||||
targets: resource.targets?.map((target) => ({
|
||||
targetId: target.targetId,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
||||
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
|
||||
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
|
||||
import localFont from "next/font/local";
|
||||
import { Inter, Mona_Sans } from "next/font/google";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||
@@ -32,30 +32,12 @@ export const metadata: Metadata = {
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const monaSans = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../fonts/mona-sans/MonaSans-Regular.woff2",
|
||||
weight: "400",
|
||||
style: "normal"
|
||||
},
|
||||
{
|
||||
path: "../fonts/mona-sans/MonaSans-Medium.woff2",
|
||||
weight: "500",
|
||||
style: "normal"
|
||||
},
|
||||
{
|
||||
path: "../fonts/mona-sans/MonaSans-SemiBold.woff2",
|
||||
weight: "600",
|
||||
style: "normal"
|
||||
},
|
||||
{
|
||||
path: "../fonts/mona-sans/MonaSans-Bold.woff2",
|
||||
weight: "700",
|
||||
style: "normal"
|
||||
}
|
||||
],
|
||||
display: "swap"
|
||||
const inter = Inter({
|
||||
subsets: ["latin"]
|
||||
});
|
||||
|
||||
const monaSans = Mona_Sans({
|
||||
subsets: ["latin"]
|
||||
});
|
||||
|
||||
const fontClassName = monaSans.className;
|
||||
|
||||
@@ -399,10 +399,11 @@ function AuthPageSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{build !== "oss" && (build === "enterprise" ||
|
||||
!isPaidUser(
|
||||
tierMatrix.loginPageDomain
|
||||
)) &&
|
||||
{env.flags.usePangolinDns &&
|
||||
(build === "enterprise" ||
|
||||
!isPaidUser(
|
||||
tierMatrix.loginPageDomain
|
||||
)) &&
|
||||
loginPage?.domainId &&
|
||||
loginPage?.fullDomain &&
|
||||
!hasUnsavedChanges && (
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileBadge, RotateCw } from "lucide-react";
|
||||
import { Loader2, RotateCw } from "lucide-react";
|
||||
import { useCertificate } from "@app/hooks/useCertificate";
|
||||
import type { GetCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type CertificateStatusContentProps = {
|
||||
cert: GetCertificateResponse | null;
|
||||
certLoading: boolean;
|
||||
certError: string | null;
|
||||
refreshing: boolean;
|
||||
refreshCert: () => Promise<void>;
|
||||
type CertificateStatusProps = {
|
||||
orgId: string;
|
||||
domainId: string;
|
||||
fullDomain: string;
|
||||
autoFetch?: boolean;
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
onRefresh?: () => void;
|
||||
polling?: boolean;
|
||||
pollingInterval?: number;
|
||||
};
|
||||
|
||||
/** Presentation-only certificate row (shared hook state possible via props). */
|
||||
export function CertificateStatusContent({
|
||||
cert,
|
||||
certLoading,
|
||||
certError,
|
||||
refreshing,
|
||||
refreshCert,
|
||||
export default function CertificateStatus({
|
||||
orgId,
|
||||
domainId,
|
||||
fullDomain,
|
||||
autoFetch = true,
|
||||
showLabel = true,
|
||||
className = "",
|
||||
onRefresh
|
||||
}: CertificateStatusContentProps) {
|
||||
onRefresh,
|
||||
polling = false,
|
||||
pollingInterval = 5000
|
||||
}: CertificateStatusProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const labelClass =
|
||||
"inline-flex shrink-0 items-center self-center text-sm font-medium leading-none";
|
||||
const valueClass = "inline-flex items-center gap-2 text-sm leading-none";
|
||||
const { cert, certLoading, certError, refreshing, refreshCert } =
|
||||
useCertificate({
|
||||
orgId,
|
||||
domainId,
|
||||
fullDomain,
|
||||
autoFetch,
|
||||
polling,
|
||||
pollingInterval
|
||||
});
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await refreshCert();
|
||||
@@ -69,13 +74,13 @@ export function CertificateStatusContent({
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{showLabel && (
|
||||
<span className={labelClass}>
|
||||
<span className="text-sm font-medium">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
)}
|
||||
<span className={valueClass}>
|
||||
<FileBadge
|
||||
className="h-4 w-4 shrink-0 animate-pulse text-muted-foreground"
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 shrink-0 animate-spin"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("loading")}
|
||||
@@ -88,17 +93,11 @@ export function CertificateStatusContent({
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{showLabel && (
|
||||
<span className={labelClass}>
|
||||
<span className="text-sm font-medium">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
)}
|
||||
<span className={valueClass}>
|
||||
<FileBadge
|
||||
className="h-4 w-4 shrink-0 text-red-500"
|
||||
aria-hidden
|
||||
/>
|
||||
{certError}
|
||||
</span>
|
||||
<span className="text-sm text-red-500">{certError}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,15 +106,11 @@ export function CertificateStatusContent({
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{showLabel && (
|
||||
<span className={labelClass}>
|
||||
<span className="text-sm font-medium">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
)}
|
||||
<span className={valueClass}>
|
||||
<FileBadge
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("none", { defaultValue: "None" })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -128,102 +123,50 @@ export function CertificateStatusContent({
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{showLabel && (
|
||||
<span className={labelClass}>{t("certificateStatus")}:</span>
|
||||
<span className="text-sm font-medium">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
)}
|
||||
{isPending && !disableRestartButton ? (
|
||||
{isPending ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto min-h-0 shrink-0 p-0 text-sm font-normal leading-none inline-flex items-center self-center"
|
||||
className={`h-auto p-0 text-sm ${getStatusColor(cert.status)}`}
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
disabled={refreshing || disableRestartButton}
|
||||
title={t("restartCertificate", {
|
||||
defaultValue: "Restart Certificate"
|
||||
})}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 leading-none">
|
||||
<FileBadge
|
||||
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{cert.status.charAt(0).toUpperCase() +
|
||||
cert.status.slice(1)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
|
||||
<RotateCw
|
||||
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
|
||||
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<span className={valueClass}>
|
||||
<FileBadge
|
||||
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
|
||||
{shouldShowRefreshButton(cert.status, cert.updatedAt) &&
|
||||
!disableRestartButton ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="inline-flex h-auto min-h-0 w-3 shrink-0 items-center justify-center self-center p-0"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title={t("restartCertificate", {
|
||||
defaultValue: "Restart Certificate"
|
||||
})}
|
||||
>
|
||||
<RotateCw
|
||||
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
<span className={`text-sm ${getStatusColor(cert.status)}`}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
|
||||
{shouldShowRefreshButton(cert.status, cert.updatedAt) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="p-0 w-3 h-auto align-middle"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || disableRestartButton}
|
||||
title={t("restartCertificate", {
|
||||
defaultValue: "Restart Certificate"
|
||||
})}
|
||||
>
|
||||
<RotateCw
|
||||
className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</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,8 +51,6 @@ import {
|
||||
ResourceSitesStatusCell,
|
||||
type ResourceSiteRow
|
||||
} from "@app/components/ResourceSitesStatusCell";
|
||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||
|
||||
@@ -442,34 +440,13 @@ export default function ClientResourcesTable({
|
||||
);
|
||||
}
|
||||
if (resourceRow.mode === "http") {
|
||||
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 !== "";
|
||||
|
||||
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`;
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{did ? (
|
||||
<ResourceAccessCertIndicator
|
||||
orgId={resourceRow.orgId}
|
||||
domainId={domainId}
|
||||
fullDomain={fullDomain}
|
||||
/>
|
||||
) : null}
|
||||
<div className="">
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
isLink={isSafeUrlForLink(url)}
|
||||
displayText={url}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
isLink={isSafeUrlForLink(url)}
|
||||
displayText={url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>-</span>;
|
||||
|
||||
@@ -485,7 +485,6 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
|
||||
onSelectSite={(site) => {
|
||||
setSelectedSite(site);
|
||||
}}
|
||||
filterTypes={["newt"]}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -55,7 +55,6 @@ import { MachinesSelector } from "./machines-selector";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// --- Helpers (shared) ---
|
||||
|
||||
@@ -157,7 +156,7 @@ export type InternalResourceData = {
|
||||
const tagSchema = z.object({ id: z.string(), text: z.string() });
|
||||
|
||||
function buildSelectedSitesForResource(
|
||||
resource: InternalResourceData
|
||||
resource: InternalResourceData,
|
||||
): Selectedsite[] {
|
||||
return resource.siteIds.map((siteId, idx) => ({
|
||||
name: resource.siteNames[idx] ?? "",
|
||||
@@ -610,7 +609,9 @@ export function InternalResourceForm({
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
setSelectedSites(buildSelectedSitesForResource(resource));
|
||||
setSelectedSites(
|
||||
buildSelectedSitesForResource(resource)
|
||||
);
|
||||
setTcpPortMode(
|
||||
getPortModeFromString(resource.tcpPortRangeString)
|
||||
);
|
||||
@@ -799,9 +800,7 @@ export function InternalResourceForm({
|
||||
);
|
||||
field.onChange(
|
||||
sites.map(
|
||||
(
|
||||
s
|
||||
) =>
|
||||
(s) =>
|
||||
s.siteId
|
||||
)
|
||||
);
|
||||
@@ -823,21 +822,15 @@ export function InternalResourceForm({
|
||||
[
|
||||
{
|
||||
value: "host",
|
||||
label: t(
|
||||
modeHostKey
|
||||
)
|
||||
label: t(modeHostKey)
|
||||
},
|
||||
{
|
||||
value: "cidr",
|
||||
label: t(
|
||||
modeCidrKey
|
||||
)
|
||||
label: t(modeCidrKey)
|
||||
},
|
||||
{
|
||||
value: "http",
|
||||
label: t(
|
||||
modeHttpKey
|
||||
)
|
||||
label: t(modeHttpKey)
|
||||
}
|
||||
];
|
||||
return (
|
||||
@@ -846,9 +839,7 @@ export function InternalResourceForm({
|
||||
{t(modeLabelKey)}
|
||||
</FormLabel>
|
||||
<OptionSelect<InternalResourceMode>
|
||||
options={
|
||||
modeOptions
|
||||
}
|
||||
options={modeOptions}
|
||||
value={field.value}
|
||||
onChange={
|
||||
field.onChange
|
||||
@@ -908,9 +899,7 @@ export function InternalResourceForm({
|
||||
field.value ??
|
||||
"http"
|
||||
}
|
||||
disabled={
|
||||
httpSectionDisabled
|
||||
}
|
||||
disabled={httpSectionDisabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
@@ -951,10 +940,7 @@ export function InternalResourceForm({
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
disabled={
|
||||
isHttpMode &&
|
||||
httpSectionDisabled
|
||||
}
|
||||
disabled={isHttpMode && httpSectionDisabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -1010,9 +996,7 @@ export function InternalResourceForm({
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
disabled={
|
||||
httpSectionDisabled
|
||||
}
|
||||
disabled={httpSectionDisabled}
|
||||
onChange={(e) => {
|
||||
const raw =
|
||||
e.target
|
||||
@@ -1047,9 +1031,7 @@ export function InternalResourceForm({
|
||||
</div>
|
||||
|
||||
{isHttpMode && (
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.httpPrivateResources}
|
||||
/>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} />
|
||||
)}
|
||||
|
||||
{isHttpMode ? (
|
||||
@@ -1062,61 +1044,55 @@ export function InternalResourceForm({
|
||||
{t(httpConfigurationDescriptionKey)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
httpSectionDisabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}>
|
||||
<DomainPicker
|
||||
key={
|
||||
variant === "edit" && siteResourceId
|
||||
? `http-domain-${siteResourceId}`
|
||||
: "http-domain-create"
|
||||
}
|
||||
>
|
||||
<DomainPicker
|
||||
key={
|
||||
variant === "edit" && siteResourceId
|
||||
? `http-domain-${siteResourceId}`
|
||||
: "http-domain-create"
|
||||
}
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ?? undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (res === null) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ?? undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (res === null) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<FormField
|
||||
@@ -1127,9 +1103,7 @@ export function InternalResourceForm({
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="internal-resource-ssl"
|
||||
label={t(
|
||||
enableSslLabelKey
|
||||
)}
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(
|
||||
enableSslDescriptionKey
|
||||
)}
|
||||
@@ -1137,9 +1111,7 @@ export function InternalResourceForm({
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
disabled={
|
||||
httpSectionDisabled
|
||||
}
|
||||
disabled={httpSectionDisabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -1148,22 +1120,15 @@ export function InternalResourceForm({
|
||||
{variant === "edit" &&
|
||||
resource?.domainId &&
|
||||
httpConfigFullDomain &&
|
||||
httpConfigDomainId ===
|
||||
resource.domainId &&
|
||||
httpConfigFullDomain ===
|
||||
resource.fullDomain &&
|
||||
build != "oss" &&
|
||||
form.watch("ssl") && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="text-sm font-medium">
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
<CertificateStatus
|
||||
orgId={resource.orgId}
|
||||
domainId={resource.domainId}
|
||||
fullDomain={
|
||||
httpConfigFullDomain
|
||||
}
|
||||
fullDomain={httpConfigFullDomain}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
|
||||
@@ -64,8 +64,6 @@ import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import UptimeMiniBar from "./UptimeMiniBar";
|
||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export type TargetHealth = {
|
||||
targetId: number;
|
||||
@@ -88,8 +86,6 @@ export type ResourceRow = {
|
||||
proxyPort: number | null;
|
||||
enabled: boolean;
|
||||
domainId?: string;
|
||||
/** Hostname for certificate API (without scheme); distinct from `domain` URL shown in Access column */
|
||||
fullDomain?: string | null;
|
||||
ssl: boolean;
|
||||
targetHost?: string;
|
||||
targetPort?: number;
|
||||
@@ -270,7 +266,7 @@ export default function ProxyResourcesTable({
|
||||
<StatusIcon status={overallStatus} />
|
||||
<span className="text-sm">
|
||||
{overallStatus === "healthy" &&
|
||||
t("resourcesTableHealthy")}
|
||||
t("resourcesTableHealthy")}
|
||||
{overallStatus === "degraded" &&
|
||||
t("resourcesTableDegraded")}
|
||||
{overallStatus === "unhealthy" &&
|
||||
@@ -492,12 +488,7 @@ export default function ProxyResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<TargetStatusCell
|
||||
targets={resourceRow.targets}
|
||||
healthStatus={resourceRow.health}
|
||||
/>
|
||||
);
|
||||
return <TargetStatusCell targets={resourceRow.targets} healthStatus={resourceRow.health} />;
|
||||
},
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const statusA = rowA.original.health;
|
||||
@@ -529,52 +520,24 @@ export default function ProxyResourcesTable({
|
||||
header: () => <span className="p-3">{t("access")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
|
||||
if (!resourceRow.http) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{!resourceRow.http ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.proxyPort?.toString() || ""}
|
||||
isLink={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!resourceRow.domainId) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
) : !resourceRow.domainId ? (
|
||||
<InfoPopup
|
||||
info={t("domainNotFoundDescription")}
|
||||
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
|
||||
text={resourceRow.domain}
|
||||
isLink={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
"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,15 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock
|
||||
} from "lucide-react";
|
||||
import { ShieldCheck, ShieldOff, Eye, EyeOff, CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
@@ -21,7 +13,7 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import { toUnicode } from "punycode";
|
||||
import { build } from "@server/build";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
@@ -36,7 +28,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{/* 4 cols because of the certs */}
|
||||
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
|
||||
<InfoSections cols={resource.http ? 6 : 5}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
@@ -69,12 +61,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
authInfo.whitelist ||
|
||||
authInfo.headerAuth ? (
|
||||
<div className="flex items-start space-x-2">
|
||||
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-500" />
|
||||
<span>{t("protected")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
||||
<div className="flex items-center space-x-2 text-yellow-500">
|
||||
<ShieldOff className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t("notProtected")}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -145,8 +137,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
{/* Certificate Status Column */}
|
||||
{resource.http &&
|
||||
resource.domainId &&
|
||||
resource.fullDomain &&
|
||||
build != "oss" && (
|
||||
resource.fullDomain && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("certificateStatus", {
|
||||
@@ -186,9 +177,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<span>{t("resourcesTableUnhealthy")}</span>
|
||||
</div>
|
||||
)}
|
||||
{(!resource.health ||
|
||||
resource.health === "unknown") && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{(!resource.health || resource.health === "unknown") && (
|
||||
<div className="flex items-center space-x-2 text-muted-foreground">
|
||||
<Clock className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t("resourcesTableUnknown")}</span>
|
||||
</div>
|
||||
|
||||
@@ -16,10 +16,10 @@ export type ResourceSiteRow = {
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
online?: boolean | null;
|
||||
online: boolean;
|
||||
};
|
||||
|
||||
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline" | "unknown";
|
||||
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
|
||||
|
||||
function aggregateSitesStatus(
|
||||
resourceSites: ResourceSiteRow[]
|
||||
@@ -27,17 +27,8 @@ function aggregateSitesStatus(
|
||||
if (resourceSites.length === 0) {
|
||||
return "allOffline";
|
||||
}
|
||||
|
||||
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";
|
||||
const onlineCount = resourceSites.filter((rs) => rs.online).length;
|
||||
if (onlineCount === resourceSites.length) return "allOnline";
|
||||
if (onlineCount > 0) return "partial";
|
||||
return "allOffline";
|
||||
}
|
||||
@@ -49,10 +40,8 @@ function aggregateStatusDotClass(status: AggregateSitesStatus): string {
|
||||
case "partial":
|
||||
return "bg-yellow-500";
|
||||
case "allOffline":
|
||||
return "bg-neutral-500";
|
||||
case "unknown":
|
||||
default:
|
||||
return "border border-muted-foreground/50 bg-transparent";
|
||||
return "bg-neutral-500";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +84,6 @@ export function ResourceSitesStatusCell({
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
{resourceSites.map((site) => {
|
||||
const isOnline = site.online;
|
||||
const hasKnownStatus = typeof isOnline === "boolean";
|
||||
return (
|
||||
<DropdownMenuItem key={site.siteId} asChild>
|
||||
<Link
|
||||
@@ -106,11 +94,9 @@ export function ResourceSitesStatusCell({
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
!hasKnownStatus
|
||||
? "border border-muted-foreground/50 bg-transparent"
|
||||
: isOnline
|
||||
? "bg-green-500"
|
||||
: "bg-neutral-500"
|
||||
isOnline
|
||||
? "bg-green-500"
|
||||
: "bg-neutral-500"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
@@ -120,16 +106,12 @@ export function ResourceSitesStatusCell({
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 capitalize",
|
||||
hasKnownStatus && isOnline
|
||||
isOnline
|
||||
? "text-green-600"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{!hasKnownStatus
|
||||
? t("resourcesTableUnknown")
|
||||
: isOnline
|
||||
? t("online")
|
||||
: t("offline")}
|
||||
{isOnline ? t("online") : t("offline")}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -60,7 +60,7 @@ export type SiteRow = {
|
||||
type: "newt" | "wireguard" | "local";
|
||||
newtVersion?: string;
|
||||
newtUpdateAvailable?: boolean;
|
||||
online?: boolean | null;
|
||||
online: boolean;
|
||||
address?: string;
|
||||
exitNodeName?: string;
|
||||
exitNodeEndpoint?: string;
|
||||
|
||||
@@ -111,13 +111,11 @@ export function MultiSitesSelector({
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{site.name}
|
||||
</span>
|
||||
{site.online != null && (
|
||||
<SiteOnlineStatus
|
||||
type={site.type}
|
||||
online={site.online}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<SiteOnlineStatus
|
||||
type={site.type}
|
||||
online={site.online}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function ResourceTargetAddressItem({
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
|
||||
"",
|
||||
"rounded-l-md rounded-r-xs",
|
||||
!proxyTarget.siteId && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
@@ -142,7 +142,7 @@ export function ResourceTargetAddressItem({
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none">
|
||||
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-xs">
|
||||
{proxyTarget.method || "http"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -124,13 +124,11 @@ export function SitesSelector({
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{site.name}
|
||||
</span>
|
||||
{site.online != null && (
|
||||
<SiteOnlineStatus
|
||||
type={site.type}
|
||||
online={site.online}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<SiteOnlineStatus
|
||||
type={site.type}
|
||||
online={site.online}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -20,7 +20,7 @@ type UseCertificateReturn = {
|
||||
certLoading: boolean;
|
||||
certError: string | null;
|
||||
refreshing: boolean;
|
||||
fetchCert: (showLoading?: boolean) => Promise<void>;
|
||||
fetchCert: () => Promise<void>;
|
||||
refreshCert: () => Promise<void>;
|
||||
clearCert: () => void;
|
||||
};
|
||||
@@ -102,33 +102,15 @@ export function useCertificate({
|
||||
}
|
||||
}, [autoFetch, orgId, domainId, fullDomain, fetchCert]);
|
||||
|
||||
// Polling effect
|
||||
useEffect(() => {
|
||||
if (!polling || !orgId || !domainId || !fullDomain) return;
|
||||
|
||||
const POLL_JITTER_MS = 1000;
|
||||
let cancelled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const interval = setInterval(() => {
|
||||
fetchCert(false); // Don't show loading for polling
|
||||
}, pollingInterval);
|
||||
|
||||
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);
|
||||
};
|
||||
return () => clearInterval(interval);
|
||||
}, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user