diff --git a/messages/bg-BG.json b/messages/bg-BG.json
index 9efa9e394..c3dd75de2 100644
--- a/messages/bg-BG.json
+++ b/messages/bg-BG.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието",
"newtUpdateAvailable": "Ново обновление",
"newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.",
+ "pangolinNodeUpdateAvailableInfo": "Налична е нова версия на Pangolin Node. Моля, актуализирайте до последната версия за най-добро изживяване.",
"domainPickerEnterDomain": "Домейн",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите",
"orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.",
"orgAuthSignInWithPangolin": "Впишете се с Pangolin",
- "orgAuthSignInToOrg": "Влезте в организация",
+ "orgAuthSignInToOrg": "Идентификационен доставчик на организация (SSO)",
"orgAuthSelectOrgTitle": "Вход в организация.",
"orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.",
"orgAuthOrgIdPlaceholder": "вашата-организация",
diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json
index 3e1965f89..00ee73906 100644
--- a/messages/cs-CZ.json
+++ b/messages/cs-CZ.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Zobrazit poznámky k vydání",
"newtUpdateAvailable": "Dostupná aktualizace",
"newtUpdateAvailableInfo": "Je k dispozici nová verze Newt. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.",
+ "pangolinNodeUpdateAvailableInfo": "Je k dispozici nová verze uzlu Pangolin. Pro nejlepší zážitek aktualizujte na nejnovější verzi.",
"domainPickerEnterDomain": "Doména",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Zadejte úplnou doménu zdroje pro zobrazení dostupných možností.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Chcete-li pokračovat, vyberte svého poskytovatele identity",
"orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.",
"orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu",
- "orgAuthSignInToOrg": "Přihlásit se do organizace",
+ "orgAuthSignInToOrg": "Poskytovatel identity organizace (SSO)",
"orgAuthSelectOrgTitle": "Přihlášení do organizace",
"orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování",
"orgAuthOrgIdPlaceholder": "vaše-organizace",
diff --git a/messages/de-DE.json b/messages/de-DE.json
index 0109a011b..b4411fac4 100644
--- a/messages/de-DE.json
+++ b/messages/de-DE.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Versionshinweise anzeigen",
"newtUpdateAvailable": "Update verfügbar",
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
+ "pangolinNodeUpdateAvailableInfo": "Eine neue Version von Pangolin Node ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
"domainPickerEnterDomain": "Domäne",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Geben Sie die vollständige Domain der Ressource ein, um verfügbare Optionen zu sehen.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Wähle deinen Identitätsanbieter um fortzufahren",
"orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.",
"orgAuthSignInWithPangolin": "Mit Pangolin anmelden",
- "orgAuthSignInToOrg": "Bei einer Organisation anmelden",
+ "orgAuthSignInToOrg": "Organisations-Identitätsanbieter (SSO)",
"orgAuthSelectOrgTitle": "Organisations-Anmeldung",
"orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren",
"orgAuthOrgIdPlaceholder": "Ihre Organisation",
diff --git a/messages/en-US.json b/messages/en-US.json
index 6e1947b3e..a7b045480 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "View Release Notes",
"newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
+ "pangolinNodeUpdateAvailableInfo": "A new version of Pangolin Node is available. Please update to the latest version for the best experience.",
"domainPickerEnterDomain": "Domain",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Choose your identity provider to continue",
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
- "orgAuthSignInToOrg": "Sign in to an organization",
+ "orgAuthSignInToOrg": "Organization Identity Provider (SSO)",
"orgAuthSelectOrgTitle": "Organization Sign In",
"orgAuthSelectOrgDescription": "Enter your organization ID to continue",
"orgAuthOrgIdPlaceholder": "your-organization",
diff --git a/messages/es-ES.json b/messages/es-ES.json
index f1db6ad90..63219984f 100644
--- a/messages/es-ES.json
+++ b/messages/es-ES.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Ver notas de lanzamiento",
"newtUpdateAvailable": "Nueva actualización disponible",
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
+ "pangolinNodeUpdateAvailableInfo": "Hay una nueva versión de Pangolin Node disponible. Actualice a la última versión para la mejor experiencia.",
"domainPickerEnterDomain": "Dominio",
"domainPickerPlaceholder": "miapp.ejemplo.com",
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar",
"orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.",
"orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin",
- "orgAuthSignInToOrg": "Iniciar sesión en una organización",
+ "orgAuthSignInToOrg": "Proveedor de identidad de la organización (SSO)",
"orgAuthSelectOrgTitle": "Inicio de sesión de organización",
"orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar",
"orgAuthOrgIdPlaceholder": "tu-organización",
diff --git a/messages/fr-FR.json b/messages/fr-FR.json
index e443246fc..edbd6839b 100644
--- a/messages/fr-FR.json
+++ b/messages/fr-FR.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Voir les notes de publication",
"newtUpdateAvailable": "Mise à jour disponible",
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
+ "pangolinNodeUpdateAvailableInfo": "Une nouvelle version de Pangolin Node est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
"domainPickerEnterDomain": "Domaine",
"domainPickerPlaceholder": "monapp.exemple.com",
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Choisissez votre fournisseur d'identité pour continuer",
"orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.",
"orgAuthSignInWithPangolin": "Se connecter avec Pangolin",
- "orgAuthSignInToOrg": "Se connecter à une organisation",
+ "orgAuthSignInToOrg": "Fournisseur d'identité d'organisation (SSO)",
"orgAuthSelectOrgTitle": "Connexion à l'organisation",
"orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer",
"orgAuthOrgIdPlaceholder": "votre-organisation",
diff --git a/messages/it-IT.json b/messages/it-IT.json
index 4b9dc2717..f51bd5845 100644
--- a/messages/it-IT.json
+++ b/messages/it-IT.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Visualizza Note Di Rilascio",
"newtUpdateAvailable": "Aggiornamento Disponibile",
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
+ "pangolinNodeUpdateAvailableInfo": "È disponibile una nuova versione di Pangolin Node. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
"domainPickerEnterDomain": "Dominio",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Scegli il tuo provider di identità per continuare",
"orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.",
"orgAuthSignInWithPangolin": "Accedi con Pangolino",
- "orgAuthSignInToOrg": "Accedi a un'organizzazione",
+ "orgAuthSignInToOrg": "Provider di identità dell'organizzazione (SSO)",
"orgAuthSelectOrgTitle": "Accesso Organizzazione",
"orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare",
"orgAuthOrgIdPlaceholder": "la-tua-organizzazione",
diff --git a/messages/ko-KR.json b/messages/ko-KR.json
index 2dd09f853..57316ea1e 100644
--- a/messages/ko-KR.json
+++ b/messages/ko-KR.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기",
"newtUpdateAvailable": "업데이트 가능",
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
+ "pangolinNodeUpdateAvailableInfo": "Pangolin Node의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"domainPickerEnterDomain": "도메인",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.",
"orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.",
"orgAuthSignInWithPangolin": "Pangolin으로 로그인",
- "orgAuthSignInToOrg": "조직에 로그인",
+ "orgAuthSignInToOrg": "조직 아이덴티티 제공자 (SSO)",
"orgAuthSelectOrgTitle": "조직 로그인",
"orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.",
"orgAuthOrgIdPlaceholder": "your-organization",
diff --git a/messages/nb-NO.json b/messages/nb-NO.json
index 318c31bca..fb02be1a8 100644
--- a/messages/nb-NO.json
+++ b/messages/nb-NO.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater",
"newtUpdateAvailable": "Oppdatering tilgjengelig",
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
+ "pangolinNodeUpdateAvailableInfo": "En ny versjon av Pangolin Node er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
"domainPickerEnterDomain": "Domene",
"domainPickerPlaceholder": "minapp.eksempel.no",
"domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette",
"orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.",
"orgAuthSignInWithPangolin": "Logg inn med Pangolin",
- "orgAuthSignInToOrg": "Logg inn på en organisasjon",
+ "orgAuthSignInToOrg": "Organisasjonens identitetsleverandør (SSO)",
"orgAuthSelectOrgTitle": "Organisasjonsinnlogging",
"orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette",
"orgAuthOrgIdPlaceholder": "din-organisasjon",
diff --git a/messages/nl-NL.json b/messages/nl-NL.json
index bd01045bb..0060daa90 100644
--- a/messages/nl-NL.json
+++ b/messages/nl-NL.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Uitgaveopmerkingen bekijken",
"newtUpdateAvailable": "Update beschikbaar",
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
+ "pangolinNodeUpdateAvailableInfo": "Er is een nieuwe versie van Pangolin Node beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
"domainPickerEnterDomain": "Domein",
"domainPickerPlaceholder": "mijnapp.voorbeeld.nl",
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Kies uw identiteitsprovider om door te gaan",
"orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.",
"orgAuthSignInWithPangolin": "Log in met Pangolin",
- "orgAuthSignInToOrg": "Log in bij een organisatie",
+ "orgAuthSignInToOrg": "Organisatie Identiteitsprovider (SSO)",
"orgAuthSelectOrgTitle": "Organisatie Inloggen",
"orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan",
"orgAuthOrgIdPlaceholder": "jouw-organisatie",
diff --git a/messages/pl-PL.json b/messages/pl-PL.json
index b8182232e..2fd09d2e4 100644
--- a/messages/pl-PL.json
+++ b/messages/pl-PL.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Zobacz informacje o wydaniu",
"newtUpdateAvailable": "Dostępna aktualizacja",
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
+ "pangolinNodeUpdateAvailableInfo": "Nowa wersja Pangolin Node jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
"domainPickerEnterDomain": "Domena",
"domainPickerPlaceholder": "mojapp.example.com",
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować",
"orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.",
"orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin",
- "orgAuthSignInToOrg": "Zaloguj się do organizacji",
+ "orgAuthSignInToOrg": "Dostawca tożsamości organizacji (SSO)",
"orgAuthSelectOrgTitle": "Logowanie do organizacji",
"orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować",
"orgAuthOrgIdPlaceholder": "twoja-organizacja",
diff --git a/messages/pt-PT.json b/messages/pt-PT.json
index d7824263e..7444606c7 100644
--- a/messages/pt-PT.json
+++ b/messages/pt-PT.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Ver notas de versão",
"newtUpdateAvailable": "Nova Atualização Disponível",
"newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.",
+ "pangolinNodeUpdateAvailableInfo": "Uma nova versão do Pangolin Node está disponível. Atualize para a versão mais recente para uma melhor experiência.",
"domainPickerEnterDomain": "Domínio",
"domainPickerPlaceholder": "myapp.exemplo.com",
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Escolha o seu provedor de identidade para continuar",
"orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.",
"orgAuthSignInWithPangolin": "Entrar com o Pangolin",
- "orgAuthSignInToOrg": "Fazer login em uma organização",
+ "orgAuthSignInToOrg": "Provedor de Identidade da Organização (SSO)",
"orgAuthSelectOrgTitle": "Entrada da Organização",
"orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar",
"orgAuthOrgIdPlaceholder": "sua-organização",
diff --git a/messages/ru-RU.json b/messages/ru-RU.json
index 2d9811c65..d41e8555d 100644
--- a/messages/ru-RU.json
+++ b/messages/ru-RU.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску",
"newtUpdateAvailable": "Доступно обновление",
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
+ "pangolinNodeUpdateAvailableInfo": "Доступна новая версия Pangolin Node. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"domainPickerEnterDomain": "Домен",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения",
"orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.",
"orgAuthSignInWithPangolin": "Войти через Pangolin",
- "orgAuthSignInToOrg": "Войти в организацию",
+ "orgAuthSignInToOrg": "Поставщик удостоверений организации (SSO)",
"orgAuthSelectOrgTitle": "Вход в организацию",
"orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить",
"orgAuthOrgIdPlaceholder": "ваша-организация",
diff --git a/messages/tr-TR.json b/messages/tr-TR.json
index f2c56eeb5..0364d2953 100644
--- a/messages/tr-TR.json
+++ b/messages/tr-TR.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Yayın Notlarını Görüntüle",
"newtUpdateAvailable": "Güncelleme Mevcut",
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
+ "pangolinNodeUpdateAvailableInfo": "Pangolin Node'un yeni bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
"domainPickerEnterDomain": "Alan Adı",
"domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Devam etmek için kimlik sağlayıcınızı seçin",
"orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.",
"orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap",
- "orgAuthSignInToOrg": "Bir kuruluşa giriş yapın",
+ "orgAuthSignInToOrg": "Kuruluş Kimlik Sağlayıcısı (SSO)",
"orgAuthSelectOrgTitle": "Kuruluş Giriş",
"orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin",
"orgAuthOrgIdPlaceholder": "kuruluşunuz",
diff --git a/messages/zh-CN.json b/messages/zh-CN.json
index fb7874750..6b7531b18 100644
--- a/messages/zh-CN.json
+++ b/messages/zh-CN.json
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "查看发布说明",
"newtUpdateAvailable": "更新可用",
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
+ "pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。请更新到最新版本以获得最佳体验。",
"domainPickerEnterDomain": "域名",
"domainPickerPlaceholder": "example.com",
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "选择您的身份提供商以继续",
"orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。",
"orgAuthSignInWithPangolin": "使用 Pangolin 登录",
- "orgAuthSignInToOrg": "登录到组织",
+ "orgAuthSignInToOrg": "组织身份提供商 (SSO)",
"orgAuthSelectOrgTitle": "组织登录",
"orgAuthSelectOrgDescription": "输入您的组织ID以继续",
"orgAuthOrgIdPlaceholder": "您的组织",
diff --git a/public/screenshots/private-resources.png b/public/screenshots/private-resources.png
index b393e31f4..55bf97d3b 100644
Binary files a/public/screenshots/private-resources.png and b/public/screenshots/private-resources.png differ
diff --git a/server/db/mac_models.json b/server/db/mac_models.json
index db473f3ae..6d9b837d5 100644
--- a/server/db/mac_models.json
+++ b/server/db/mac_models.json
@@ -1,94 +1,53 @@
{
- "PowerMac4,4": "eMac",
- "PowerMac6,4": "eMac",
- "PowerBook2,1": "iBook",
- "PowerBook2,2": "iBook",
- "PowerBook4,1": "iBook",
- "PowerBook4,2": "iBook",
- "PowerBook4,3": "iBook",
- "PowerBook6,3": "iBook",
- "PowerBook6,5": "iBook",
- "PowerBook6,7": "iBook",
- "iMac,1": "iMac",
- "PowerMac2,1": "iMac",
- "PowerMac2,2": "iMac",
- "PowerMac4,1": "iMac",
- "PowerMac4,2": "iMac",
- "PowerMac4,5": "iMac",
- "PowerMac6,1": "iMac",
- "PowerMac6,3*": "iMac",
- "PowerMac6,3": "iMac",
- "PowerMac8,1": "iMac",
- "PowerMac8,2": "iMac",
- "PowerMac12,1": "iMac",
- "iMac4,1": "iMac",
- "iMac4,2": "iMac",
- "iMac5,2": "iMac",
- "iMac5,1": "iMac",
- "iMac6,1": "iMac",
- "iMac7,1": "iMac",
- "iMac8,1": "iMac",
- "iMac9,1": "iMac",
- "iMac10,1": "iMac",
- "iMac11,1": "iMac",
- "iMac11,2": "iMac",
- "iMac11,3": "iMac",
- "iMac12,1": "iMac",
- "iMac12,2": "iMac",
- "iMac13,1": "iMac",
- "iMac13,2": "iMac",
- "iMac14,1": "iMac",
- "iMac14,3": "iMac",
- "iMac14,2": "iMac",
- "iMac14,4": "iMac",
- "iMac15,1": "iMac",
- "iMac16,1": "iMac",
- "iMac16,2": "iMac",
- "iMac17,1": "iMac",
- "iMac18,1": "iMac",
- "iMac18,2": "iMac",
- "iMac18,3": "iMac",
- "iMac19,2": "iMac",
- "iMac19,1": "iMac",
- "iMac20,1": "iMac",
- "iMac20,2": "iMac",
- "iMac21,2": "iMac",
- "iMac21,1": "iMac",
- "iMacPro1,1": "iMac Pro",
- "PowerMac10,1": "Mac mini",
- "PowerMac10,2": "Mac mini",
- "Macmini1,1": "Mac mini",
- "Macmini2,1": "Mac mini",
- "Macmini3,1": "Mac mini",
- "Macmini4,1": "Mac mini",
- "Macmini5,1": "Mac mini",
- "Macmini5,2": "Mac mini",
- "Macmini5,3": "Mac mini",
- "Macmini6,1": "Mac mini",
- "Macmini6,2": "Mac mini",
- "Macmini7,1": "Mac mini",
- "Macmini8,1": "Mac mini",
"ADP3,2": "Mac mini",
- "Macmini9,1": "Mac mini",
- "Mac14,3": "Mac mini",
- "Mac14,12": "Mac mini",
- "MacPro1,1*": "Mac Pro",
- "MacPro2,1": "Mac Pro",
- "MacPro3,1": "Mac Pro",
- "MacPro4,1": "Mac Pro",
- "MacPro5,1": "Mac Pro",
- "MacPro6,1": "Mac Pro",
- "MacPro7,1": "Mac Pro",
- "N/A*": "Power Macintosh",
- "PowerMac1,1": "Power Macintosh",
- "PowerMac3,1": "Power Macintosh",
- "PowerMac3,3": "Power Macintosh",
- "PowerMac3,4": "Power Macintosh",
- "PowerMac3,5": "Power Macintosh",
- "PowerMac3,6": "Power Macintosh",
"Mac13,1": "Mac Studio",
"Mac13,2": "Mac Studio",
+ "Mac14,10": "MacBook Pro",
+ "Mac14,12": "Mac mini",
+ "Mac14,13": "Mac Studio",
+ "Mac14,14": "Mac Studio",
+ "Mac14,15": "MacBook Air",
+ "Mac14,2": "MacBook Air",
+ "Mac14,3": "Mac mini",
+ "Mac14,5": "MacBook Pro",
+ "Mac14,6": "MacBook Pro",
+ "Mac14,7": "MacBook Pro",
+ "Mac14,8": "Mac Pro",
+ "Mac14,9": "MacBook Pro",
+ "Mac15,10": "MacBook Pro",
+ "Mac15,11": "MacBook Pro",
+ "Mac15,12": "MacBook Air",
+ "Mac15,13": "MacBook Air",
+ "Mac15,14": "Mac Studio",
+ "Mac15,3": "MacBook Pro",
+ "Mac15,4": "iMac",
+ "Mac15,5": "iMac",
+ "Mac15,6": "MacBook Pro",
+ "Mac15,7": "MacBook Pro",
+ "Mac15,8": "MacBook Pro",
+ "Mac15,9": "MacBook Pro",
+ "Mac16,1": "MacBook Pro",
+ "Mac16,10": "Mac mini",
+ "Mac16,11": "Mac mini",
+ "Mac16,12": "MacBook Air",
+ "Mac16,13": "MacBook Air",
+ "Mac16,2": "iMac",
+ "Mac16,3": "iMac",
+ "Mac16,5": "MacBook Pro",
+ "Mac16,6": "MacBook Pro",
+ "Mac16,7": "MacBook Pro",
+ "Mac16,8": "MacBook Pro",
+ "Mac16,9": "Mac Studio",
+ "Mac17,2": "MacBook Pro",
+ "Mac17,3": "MacBook Air",
+ "Mac17,4": "MacBook Air",
+ "Mac17,5": "MacBook Neo",
+ "Mac17,6": "MacBook Pro",
+ "Mac17,7": "MacBook Pro",
+ "Mac17,8": "MacBook Pro",
+ "Mac17,9": "MacBook Pro",
"MacBook1,1": "MacBook",
+ "MacBook10,1": "MacBook",
"MacBook2,1": "MacBook",
"MacBook3,1": "MacBook",
"MacBook4,1": "MacBook",
@@ -98,8 +57,8 @@
"MacBook7,1": "MacBook",
"MacBook8,1": "MacBook",
"MacBook9,1": "MacBook",
- "MacBook10,1": "MacBook",
"MacBookAir1,1": "MacBook Air",
+ "MacBookAir10,1": "MacBook Air",
"MacBookAir2,1": "MacBook Air",
"MacBookAir3,1": "MacBook Air",
"MacBookAir3,2": "MacBook Air",
@@ -114,88 +73,163 @@
"MacBookAir8,1": "MacBook Air",
"MacBookAir8,2": "MacBook Air",
"MacBookAir9,1": "MacBook Air",
- "MacBookAir10,1": "MacBook Air",
- "Mac14,2": "MacBook Air",
"MacBookPro1,1": "MacBook Pro",
"MacBookPro1,2": "MacBook Pro",
- "MacBookPro2,2": "MacBook Pro",
- "MacBookPro2,1": "MacBook Pro",
- "MacBookPro3,1": "MacBook Pro",
- "MacBookPro4,1": "MacBook Pro",
- "MacBookPro5,1": "MacBook Pro",
- "MacBookPro5,2": "MacBook Pro",
- "MacBookPro5,5": "MacBook Pro",
- "MacBookPro5,4": "MacBook Pro",
- "MacBookPro5,3": "MacBook Pro",
- "MacBookPro7,1": "MacBook Pro",
- "MacBookPro6,2": "MacBook Pro",
- "MacBookPro6,1": "MacBook Pro",
- "MacBookPro8,1": "MacBook Pro",
- "MacBookPro8,2": "MacBook Pro",
- "MacBookPro8,3": "MacBook Pro",
- "MacBookPro9,2": "MacBook Pro",
- "MacBookPro9,1": "MacBook Pro",
"MacBookPro10,1": "MacBook Pro",
"MacBookPro10,2": "MacBook Pro",
"MacBookPro11,1": "MacBook Pro",
"MacBookPro11,2": "MacBook Pro",
"MacBookPro11,3": "MacBook Pro",
- "MacBookPro12,1": "MacBook Pro",
"MacBookPro11,4": "MacBook Pro",
"MacBookPro11,5": "MacBook Pro",
+ "MacBookPro12,1": "MacBook Pro",
"MacBookPro13,1": "MacBook Pro",
"MacBookPro13,2": "MacBook Pro",
"MacBookPro13,3": "MacBook Pro",
"MacBookPro14,1": "MacBook Pro",
"MacBookPro14,2": "MacBook Pro",
"MacBookPro14,3": "MacBook Pro",
- "MacBookPro15,2": "MacBook Pro",
"MacBookPro15,1": "MacBook Pro",
+ "MacBookPro15,2": "MacBook Pro",
"MacBookPro15,3": "MacBook Pro",
"MacBookPro15,4": "MacBook Pro",
"MacBookPro16,1": "MacBook Pro",
- "MacBookPro16,3": "MacBook Pro",
"MacBookPro16,2": "MacBook Pro",
+ "MacBookPro16,3": "MacBook Pro",
"MacBookPro16,4": "MacBook Pro",
"MacBookPro17,1": "MacBook Pro",
- "MacBookPro18,3": "MacBook Pro",
- "MacBookPro18,4": "MacBook Pro",
"MacBookPro18,1": "MacBook Pro",
"MacBookPro18,2": "MacBook Pro",
- "Mac14,7": "MacBook Pro",
- "Mac14,9": "MacBook Pro",
- "Mac14,5": "MacBook Pro",
- "Mac14,10": "MacBook Pro",
- "Mac14,6": "MacBook Pro",
- "PowerMac1,2": "Power Macintosh",
- "PowerMac5,1": "Power Macintosh",
- "PowerMac7,2": "Power Macintosh",
- "PowerMac7,3": "Power Macintosh",
- "PowerMac9,1": "Power Macintosh",
- "PowerMac11,2": "Power Macintosh",
+ "MacBookPro18,3": "MacBook Pro",
+ "MacBookPro18,4": "MacBook Pro",
+ "MacBookPro2,1": "MacBook Pro",
+ "MacBookPro2,2": "MacBook Pro",
+ "MacBookPro3,1": "MacBook Pro",
+ "MacBookPro4,1": "MacBook Pro",
+ "MacBookPro5,1": "MacBook Pro",
+ "MacBookPro5,2": "MacBook Pro",
+ "MacBookPro5,3": "MacBook Pro",
+ "MacBookPro5,4": "MacBook Pro",
+ "MacBookPro5,5": "MacBook Pro",
+ "MacBookPro6,1": "MacBook Pro",
+ "MacBookPro6,2": "MacBook Pro",
+ "MacBookPro7,1": "MacBook Pro",
+ "MacBookPro8,1": "MacBook Pro",
+ "MacBookPro8,2": "MacBook Pro",
+ "MacBookPro8,3": "MacBook Pro",
+ "MacBookPro9,1": "MacBook Pro",
+ "MacBookPro9,2": "MacBook Pro",
+ "MacPro1,1": "Mac Pro",
+ "MacPro2,1": "Mac Pro",
+ "MacPro3,1": "Mac Pro",
+ "MacPro4,1": "Mac Pro",
+ "MacPro5,1": "Mac Pro",
+ "MacPro6,1": "Mac Pro",
+ "MacPro7,1": "Mac Pro",
+ "Macmini1,1": "Mac mini",
+ "Macmini2,1": "Mac mini",
+ "Macmini3,1": "Mac mini",
+ "Macmini4,1": "Mac mini",
+ "Macmini5,1": "Mac mini",
+ "Macmini5,2": "Mac mini",
+ "Macmini5,3": "Mac mini",
+ "Macmini6,1": "Mac mini",
+ "Macmini6,2": "Mac mini",
+ "Macmini7,1": "Mac mini",
+ "Macmini8,1": "Mac mini",
+ "Macmini9,1": "Mac mini",
"PowerBook1,1": "PowerBook",
+ "PowerBook2,1": "iBook",
+ "PowerBook2,2": "iBook",
"PowerBook3,1": "PowerBook",
"PowerBook3,2": "PowerBook",
"PowerBook3,3": "PowerBook",
"PowerBook3,4": "PowerBook",
"PowerBook3,5": "PowerBook",
- "PowerBook6,1": "PowerBook",
+ "PowerBook4,1": "iBook",
+ "PowerBook4,2": "iBook",
+ "PowerBook4,3": "iBook",
"PowerBook5,1": "PowerBook",
- "PowerBook6,2": "PowerBook",
"PowerBook5,2": "PowerBook",
"PowerBook5,3": "PowerBook",
- "PowerBook6,4": "PowerBook",
"PowerBook5,4": "PowerBook",
"PowerBook5,5": "PowerBook",
- "PowerBook6,8": "PowerBook",
"PowerBook5,6": "PowerBook",
"PowerBook5,7": "PowerBook",
"PowerBook5,8": "PowerBook",
"PowerBook5,9": "PowerBook",
+ "PowerBook6,1": "PowerBook",
+ "PowerBook6,2": "PowerBook",
+ "PowerBook6,3": "iBook",
+ "PowerBook6,4": "PowerBook",
+ "PowerBook6,5": "iBook",
+ "PowerBook6,7": "iBook",
+ "PowerBook6,8": "PowerBook",
+ "PowerMac1,1": "Power Macintosh",
+ "PowerMac1,2": "Power Macintosh",
+ "PowerMac10,1": "Mac mini",
+ "PowerMac10,2": "Mac mini",
+ "PowerMac11,2": "Power Macintosh",
+ "PowerMac12,1": "iMac",
+ "PowerMac2,1": "iMac",
+ "PowerMac2,2": "iMac",
+ "PowerMac3,1": "Mac Server",
+ "PowerMac3,3": "Power Macintosh",
+ "PowerMac3,4": "Power Macintosh",
+ "PowerMac3,5": "Power Macintosh",
+ "PowerMac3,6": "Power Macintosh",
+ "PowerMac4,1": "iMac",
+ "PowerMac4,2": "iMac",
+ "PowerMac4,4": "eMac",
+ "PowerMac4,5": "iMac",
+ "PowerMac5,1": "Power Macintosh",
+ "PowerMac6,1": "iMac",
+ "PowerMac6,3": "iMac",
+ "PowerMac6,4": "eMac",
+ "PowerMac7,2": "Power Macintosh",
+ "PowerMac7,3": "Power Macintosh",
+ "PowerMac8,1": "iMac",
+ "PowerMac8,2": "iMac",
+ "PowerMac9,1": "Power Macintosh",
"RackMac1,1": "Xserve",
"RackMac1,2": "Xserve",
"RackMac3,1": "Xserve",
"Xserve1,1": "Xserve",
"Xserve2,1": "Xserve",
- "Xserve3,1": "Xserve"
-}
\ No newline at end of file
+ "Xserve3,1": "Xserve",
+ "iMac,1": "iMac",
+ "iMac10,1": "iMac",
+ "iMac11,1": "iMac",
+ "iMac11,2": "iMac",
+ "iMac11,3": "iMac",
+ "iMac12,1": "iMac",
+ "iMac12,2": "iMac",
+ "iMac13,1": "iMac",
+ "iMac13,2": "iMac",
+ "iMac14,1": "iMac",
+ "iMac14,2": "iMac",
+ "iMac14,3": "iMac",
+ "iMac14,4": "iMac",
+ "iMac15,1": "iMac",
+ "iMac16,1": "iMac",
+ "iMac16,2": "iMac",
+ "iMac17,1": "iMac",
+ "iMac18,1": "iMac",
+ "iMac18,2": "iMac",
+ "iMac18,3": "iMac",
+ "iMac19,1": "iMac",
+ "iMac19,2": "iMac",
+ "iMac20,1": "iMac",
+ "iMac20,2": "iMac",
+ "iMac21,1": "iMac",
+ "iMac21,2": "iMac",
+ "iMac4,1": "iMac",
+ "iMac4,2": "iMac",
+ "iMac5,1": "iMac",
+ "iMac5,2": "iMac",
+ "iMac6,1": "iMac",
+ "iMac7,1": "iMac",
+ "iMac8,1": "iMac",
+ "iMac9,1": "iMac",
+ "iMacPro1,1": "iMac Pro"
+}
diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts
index 00afa22f0..1b9ff40ae 100644
--- a/server/lib/alerts/events/healthCheckEvents.ts
+++ b/server/lib/alerts/events/healthCheckEvents.ts
@@ -1,27 +1,153 @@
-// stub
+import logger from "@server/logger";
+import { processAlerts } from "#dynamic/lib/alerts";
+import {
+ db,
+ statusHistory,
+ targetHealthCheck,
+ targets,
+ resources,
+ Transaction,
+ logsDb
+} from "@server/db";
+import { eq } from "drizzle-orm";
+import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
+import {
+ fireResourceDegradedAlert,
+ fireResourceHealthyAlert,
+ fireResourceUnhealthyAlert,
+ fireResourceUnknownAlert
+} from "./resourceEvents";
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Fire a `health_check_healthy` alert for the given health check.
+ *
+ * Call this after a previously-failing health check has recovered so that any
+ * matching `alertRules` can dispatch their email and webhook actions.
+ *
+ * @param orgId - Organisation that owns the health check.
+ * @param healthCheckId - Numeric primary key of the health check.
+ * @param healthCheckName - Human-readable name shown in notifications (optional).
+ * @param extra - Any additional key/value pairs to include in the payload.
+ */
export async function fireHealthCheckHealthyAlert(
orgId: string,
healthCheckId: number,
- healthCheckName?: string,
+ healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record,
send: boolean = true,
- trx?: unknown
+ trx: Transaction | typeof db = db
): Promise {
- return;
+ try {
+ await logsDb.insert(statusHistory).values({
+ entityType: "health_check",
+ entityId: healthCheckId,
+ orgId: orgId,
+ status: "healthy",
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ await invalidateStatusHistoryCache("health_check", healthCheckId);
+
+ await handleResource(orgId, healthCheckTargetId, send, trx);
+
+ if (!send) {
+ return;
+ }
+
+ await processAlerts({
+ eventType: "health_check_healthy",
+ orgId,
+ healthCheckId,
+ data: {
+ ...(healthCheckName != null ? { healthCheckName } : {}),
+ ...extra
+ }
+ });
+ await processAlerts({
+ eventType: "health_check_toggle",
+ orgId,
+ healthCheckId,
+ data: {
+ healthCheckId,
+ status: "healthy",
+ ...(healthCheckName != null ? { healthCheckName } : {}),
+ ...extra
+ }
+ });
+ } catch (err) {
+ logger.error(
+ `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
+ err
+ );
+ }
}
+/**
+ * Fire a `health_check_unhealthy` alert for the given health check.
+ *
+ * Call this after a health check has been detected as failing so that any
+ * matching `alertRules` can dispatch their email and webhook actions.
+ *
+ * @param orgId - Organisation that owns the health check.
+ * @param healthCheckId - Numeric primary key of the health check.
+ * @param healthCheckName - Human-readable name shown in notifications (optional).
+ * @param extra - Any additional key/value pairs to include in the payload.
+ */
export async function fireHealthCheckUnhealthyAlert(
orgId: string,
healthCheckId: number,
- healthCheckName?: string,
+ healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record,
send: boolean = true,
- trx?: unknown
+ trx: Transaction | typeof db = db
): Promise {
- return;
+ try {
+ await logsDb.insert(statusHistory).values({
+ entityType: "health_check",
+ entityId: healthCheckId,
+ orgId: orgId,
+ status: "unhealthy",
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ await invalidateStatusHistoryCache("health_check", healthCheckId);
+
+ await handleResource(orgId, healthCheckTargetId, send, trx);
+
+ if (!send) {
+ return;
+ }
+
+ await processAlerts({
+ eventType: "health_check_unhealthy",
+ orgId,
+ healthCheckId,
+ data: {
+ ...(healthCheckName != null ? { healthCheckName } : {}),
+ ...extra
+ }
+ });
+ await processAlerts({
+ eventType: "health_check_toggle",
+ orgId,
+ healthCheckId,
+ data: {
+ healthCheckId,
+ status: "unhealthy",
+ ...(healthCheckName != null ? { healthCheckName } : {}),
+ ...extra
+ }
+ });
+ } catch (err) {
+ logger.error(
+ `fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
+ err
+ );
+ }
}
export async function fireHealthCheckUnknownAlert(
@@ -31,7 +157,137 @@ export async function fireHealthCheckUnknownAlert(
healthCheckTargetId?: number | null,
extra?: Record,
send: boolean = true,
- trx?: unknown
+ trx: Transaction | typeof db = db
): Promise {
- return;
+ try {
+ await logsDb.insert(statusHistory).values({
+ entityType: "health_check",
+ entityId: healthCheckId,
+ orgId: orgId,
+ status: "unknown",
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ await invalidateStatusHistoryCache("health_check", healthCheckId);
+
+ await handleResource(orgId, healthCheckTargetId, send, trx);
+
+ if (!send) {
+ return;
+ }
+ } catch (err) {
+ logger.error(
+ `fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
+ err
+ );
+ }
+}
+
+async function handleResource(
+ orgId: string,
+ healthCheckTargetId?: number | null,
+ send: boolean = true,
+ trx: Transaction | typeof db = db
+) {
+ if (!healthCheckTargetId) {
+ return;
+ }
+ // we have targets lets get them
+ const [target] = await trx
+ .select()
+ .from(targets)
+ .where(eq(targets.targetId, healthCheckTargetId))
+ .limit(1);
+
+ if (!target) {
+ return;
+ }
+
+ const [resource] = await trx
+ .select()
+ .from(resources)
+ .where(eq(resources.resourceId, target.resourceId))
+ .limit(1);
+
+ if (!resource) {
+ return;
+ }
+
+ const otherTargets = await trx
+ .select({ hcHealth: targetHealthCheck.hcHealth })
+ .from(targets)
+ .innerJoin(
+ targetHealthCheck,
+ eq(targetHealthCheck.targetId, targets.targetId)
+ )
+ .where(eq(targets.resourceId, resource.resourceId));
+
+ let health = "healthy";
+ const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
+ const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
+ const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
+
+ if (allUnknown) {
+ logger.debug(
+ `Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
+ );
+ health = "unknown";
+ } else if (allHealthy) {
+ health = "healthy";
+ } else if (allUnhealthy) {
+ logger.debug(
+ `Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy`
+ );
+ health = "unhealthy";
+ } else {
+ logger.debug(
+ `Marking resource ${resource.resourceId} as degraded because some targets are unhealthy`
+ );
+ health = "degraded";
+ }
+
+ if (health != resource.health) {
+ // it changed
+ await trx
+ .update(resources)
+ .set({ health })
+ .where(eq(resources.resourceId, resource.resourceId));
+
+ if (health === "unknown") {
+ await fireResourceUnknownAlert(
+ orgId,
+ resource.resourceId,
+ resource.name,
+ undefined,
+ send,
+ trx
+ );
+ } else if (health === "unhealthy") {
+ await fireResourceUnhealthyAlert(
+ orgId,
+ resource.resourceId,
+ resource.name,
+ undefined,
+ send,
+ trx
+ );
+ } else if (health === "healthy") {
+ await fireResourceHealthyAlert(
+ orgId,
+ resource.resourceId,
+ resource.name,
+ undefined,
+ send,
+ trx
+ );
+ } else if (health === "degraded") {
+ await fireResourceDegradedAlert(
+ orgId,
+ resource.resourceId,
+ resource.name,
+ undefined,
+ send,
+ trx
+ );
+ }
+ }
}
diff --git a/server/lib/alerts/events/resourceEvents.ts b/server/lib/alerts/events/resourceEvents.ts
index e7a374b44..0d84328d2 100644
--- a/server/lib/alerts/events/resourceEvents.ts
+++ b/server/lib/alerts/events/resourceEvents.ts
@@ -1,26 +1,243 @@
+import logger from "@server/logger";
+import { processAlerts } from "#dynamic/lib/alerts";
+import { db, logsDb, statusHistory, Transaction } from "@server/db";
+import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Fire a `resource_healthy` alert for the given resource.
+ *
+ * Call this after a previously-unhealthy resource has recovered so that any
+ * matching `alertRules` can dispatch their email and webhook actions.
+ *
+ * @param orgId - Organisation that owns the resource.
+ * @param resourceId - Numeric primary key of the resource.
+ * @param resourceName - Human-readable name shown in notifications (optional).
+ * @param extra - Any additional key/value pairs to include in the payload.
+ */
export async function fireResourceHealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record,
send: boolean = true,
- trx?: unknown
-): Promise {}
+ trx: Transaction | typeof db = db
+): Promise {
+ try {
+ await logsDb.insert(statusHistory).values({
+ entityType: "resource",
+ entityId: resourceId,
+ orgId: orgId,
+ status: "healthy",
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ await invalidateStatusHistoryCache("resource", resourceId);
+ if (!send) {
+ return;
+ }
+
+ await processAlerts({
+ eventType: "resource_healthy",
+ orgId,
+ resourceId,
+ data: {
+ ...(resourceName != null ? { resourceName } : {}),
+ ...extra
+ }
+ });
+ await processAlerts({
+ eventType: "resource_toggle",
+ orgId,
+ resourceId,
+ data: {
+ resourceId,
+ status: "healthy",
+ ...(resourceName != null ? { resourceName } : {}),
+ ...extra
+ }
+ });
+ } catch (err) {
+ logger.error(
+ `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
+ err
+ );
+ }
+}
+
+/**
+ * Fire a `resource_unhealthy` alert for the given resource.
+ *
+ * Call this after a resource has been detected as unhealthy so that any
+ * matching `alertRules` can dispatch their email and webhook actions.
+ *
+ * @param orgId - Organisation that owns the resource.
+ * @param resourceId - Numeric primary key of the resource.
+ * @param resourceName - Human-readable name shown in notifications (optional).
+ * @param extra - Any additional key/value pairs to include in the payload.
+ */
export async function fireResourceUnhealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record,
send: boolean = true,
- trx?: unknown
-): Promise {}
+ trx: Transaction | typeof db = db
+): Promise {
+ try {
+ await logsDb.insert(statusHistory).values({
+ entityType: "resource",
+ entityId: resourceId,
+ orgId: orgId,
+ status: "unhealthy",
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ await invalidateStatusHistoryCache("resource", resourceId);
-export async function fireResourceToggleAlert(
+ if (!send) {
+ return;
+ }
+
+ await processAlerts({
+ eventType: "resource_unhealthy",
+ orgId,
+ resourceId,
+ data: {
+ ...(resourceName != null ? { resourceName } : {}),
+ ...extra
+ }
+ });
+ await processAlerts({
+ eventType: "resource_toggle",
+ orgId,
+ resourceId,
+ data: {
+ resourceId,
+ status: "unhealthy",
+ ...(resourceName != null ? { resourceName } : {}),
+ ...extra
+ }
+ });
+ } catch (err) {
+ logger.error(
+ `fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
+ err
+ );
+ }
+}
+
+/**
+ * Fire a `resource_degraded` alert for the given resource.
+ *
+ * Call this after a resource has been detected as degraded so that any
+ * matching `alertRules` can dispatch their email and webhook actions.
+ *
+ * @param orgId - Organisation that owns the resource.
+ * @param resourceId - Numeric primary key of the resource.
+ * @param resourceName - Human-readable name shown in notifications (optional).
+ * @param extra - Any additional key/value pairs to include in the payload.
+ */
+export async function fireResourceDegradedAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record,
send: boolean = true,
- trx?: unknown
-): Promise {}
+ trx: Transaction | typeof db = db
+): Promise {
+ try {
+ await logsDb.insert(statusHistory).values({
+ entityType: "resource",
+ entityId: resourceId,
+ orgId: orgId,
+ status: "degraded",
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ await invalidateStatusHistoryCache("resource", resourceId);
+
+ if (!send) {
+ return;
+ }
+
+ await processAlerts({
+ eventType: "resource_degraded",
+ orgId,
+ resourceId,
+ data: {
+ ...(resourceName != null ? { resourceName } : {}),
+ ...extra
+ }
+ });
+ await processAlerts({
+ eventType: "resource_toggle",
+ orgId,
+ resourceId,
+ data: {
+ resourceId,
+ status: "degraded",
+ ...(resourceName != null ? { resourceName } : {}),
+ ...extra
+ }
+ });
+ } catch (err) {
+ logger.error(
+ `fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
+ err
+ );
+ }
+}
+
+/**
+ * Fire a `resource_unknown` alert for the given resource.
+ *
+ * Call this when all health checks on a resource are disabled so that the
+ * resource status transitions to unknown.
+ *
+ * @param orgId - Organisation that owns the resource.
+ * @param resourceId - Numeric primary key of the resource.
+ * @param resourceName - Human-readable name shown in notifications (optional).
+ * @param extra - Any additional key/value pairs to include in the payload.
+ */
+export async function fireResourceUnknownAlert(
+ orgId: string,
+ resourceId: number,
+ resourceName?: string | null,
+ extra?: Record,
+ send: boolean = true,
+ trx: Transaction | typeof db = db
+): Promise {
+ try {
+ await logsDb.insert(statusHistory).values({
+ entityType: "resource",
+ entityId: resourceId,
+ orgId: orgId,
+ status: "unknown",
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ await invalidateStatusHistoryCache("resource", resourceId);
+
+ if (!send) {
+ return;
+ }
+
+ await processAlerts({
+ eventType: "resource_toggle",
+ orgId,
+ resourceId,
+ data: {
+ resourceId,
+ status: "unknown",
+ ...(resourceName != null ? { resourceName } : {}),
+ ...extra
+ }
+ });
+ } catch (err) {
+ logger.error(
+ `fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
+ err
+ );
+ }
+}
diff --git a/server/lib/alerts/events/siteEvents.ts b/server/lib/alerts/events/siteEvents.ts
index 1e96951cc..e64ac72f7 100644
--- a/server/lib/alerts/events/siteEvents.ts
+++ b/server/lib/alerts/events/siteEvents.ts
@@ -1,21 +1,156 @@
-// stub
+import logger from "@server/logger";
+import { processAlerts } from "#dynamic/lib/alerts";
+import {
+ db,
+ logsDb,
+ statusHistory,
+ targetHealthCheck,
+ Transaction
+} from "@server/db";
+import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
+import { and, eq, inArray } from "drizzle-orm";
+import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Fire a `site_online` alert for the given site.
+ *
+ * Call this after the site has been confirmed reachable / connected so that
+ * any matching `alertRules` can dispatch their email and webhook actions.
+ *
+ * @param orgId - Organisation that owns the site.
+ * @param siteId - Numeric primary key of the site.
+ * @param siteName - Human-readable name shown in notifications (optional).
+ * @param extra - Any additional key/value pairs to include in the payload.
+ */
export async function fireSiteOnlineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record,
- trx?: unknown
+ trx: Transaction | typeof db = db
): Promise {
- return;
+ try {
+ await logsDb.insert(statusHistory).values({
+ entityType: "site",
+ entityId: siteId,
+ orgId: orgId,
+ status: "online",
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ await invalidateStatusHistoryCache("site", siteId);
+
+ await processAlerts({
+ eventType: "site_online",
+ orgId,
+ siteId,
+ data: {
+ ...(siteName != null ? { siteName } : {}),
+ ...extra
+ }
+ });
+ await processAlerts({
+ eventType: "site_toggle",
+ orgId,
+ siteId,
+ data: {
+ siteId,
+ status: "online",
+ ...(siteName != null ? { siteName } : {}),
+ ...extra
+ }
+ });
+ } catch (err) {
+ logger.error(
+ `fireSiteOnlineAlert: unexpected error for siteId ${siteId}`,
+ err
+ );
+ }
}
+/**
+ * Fire a `site_offline` alert for the given site.
+ *
+ * Call this after the site has been detected as unreachable / disconnected so
+ * that any matching `alertRules` can dispatch their email and webhook actions.
+ *
+ * @param orgId - Organisation that owns the site.
+ * @param siteId - Numeric primary key of the site.
+ * @param siteName - Human-readable name shown in notifications (optional).
+ * @param extra - Any additional key/value pairs to include in the payload.
+ */
export async function fireSiteOfflineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record,
- trx?: unknown
+ trx: Transaction | typeof db = db
): Promise {
- return;
-}
\ No newline at end of file
+ try {
+ await logsDb.insert(statusHistory).values({
+ entityType: "site",
+ entityId: siteId,
+ orgId: orgId,
+ status: "offline",
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ await invalidateStatusHistoryCache("site", siteId);
+
+ const unhealthyHealthChecks = await trx
+ .update(targetHealthCheck)
+ .set({ hcHealth: "unhealthy" })
+ .where(
+ and(
+ eq(targetHealthCheck.orgId, orgId),
+ eq(targetHealthCheck.siteId, siteId),
+ eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled
+ )
+ )
+ .returning();
+
+ for (const healthCheck of unhealthyHealthChecks) {
+ logger.info(
+ `Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
+ );
+
+ await fireHealthCheckUnhealthyAlert(
+ healthCheck.orgId,
+ healthCheck.targetHealthCheckId,
+ healthCheck.name,
+ healthCheck.targetId, // for the resource if we have one
+ undefined,
+ true,
+ trx
+ );
+ }
+
+ await processAlerts({
+ eventType: "site_offline",
+ orgId,
+ siteId,
+ data: {
+ ...(siteName != null ? { siteName } : {}),
+ ...extra
+ }
+ });
+ await processAlerts({
+ eventType: "site_toggle",
+ orgId,
+ siteId,
+ data: {
+ siteId,
+ status: "offline",
+ ...(siteName != null ? { siteName } : {}),
+ ...extra
+ }
+ });
+ } catch (err) {
+ logger.error(
+ `fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
+ err
+ );
+ }
+}
diff --git a/server/lib/alerts/index.ts b/server/lib/alerts/index.ts
index 1a64d1cdd..324e4cf6a 100644
--- a/server/lib/alerts/index.ts
+++ b/server/lib/alerts/index.ts
@@ -1,3 +1,4 @@
export * from "./events/siteEvents";
export * from "./events/healthCheckEvents";
export * from "./events/resourceEvents";
+export * from "./processAlerts";
diff --git a/server/lib/alerts/processAlerts.ts b/server/lib/alerts/processAlerts.ts
new file mode 100644
index 000000000..2d0fb7bfd
--- /dev/null
+++ b/server/lib/alerts/processAlerts.ts
@@ -0,0 +1,5 @@
+import { AlertContext } from "@server/routers/alertRule/types";
+
+export async function processAlerts(context: AlertContext): Promise {
+ return;
+}
diff --git a/server/lib/billing/getOrgTierData.ts b/server/lib/billing/getOrgTierData.ts
index 75f125594..afe45d961 100644
--- a/server/lib/billing/getOrgTierData.ts
+++ b/server/lib/billing/getOrgTierData.ts
@@ -1,8 +1,9 @@
export async function getOrgTierData(
orgId: string
-): Promise<{ tier: string | null; active: boolean }> {
+): Promise<{ tier: string | null; active: boolean; isTrial: boolean }> {
const tier = null;
const active = false;
+ const isTrial = false;
- return { tier, active };
+ return { tier, active, isTrial };
}
diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts
index ba93bc46a..34b352a42 100644
--- a/server/lib/blueprints/proxyResources.ts
+++ b/server/lib/blueprints/proxyResources.ts
@@ -34,7 +34,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
-import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
+import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix";
export type ProxyResourcesResults = {
@@ -165,7 +165,8 @@ export async function updateProxyResources(
hcStatus: healthcheckData?.status,
hcHealth: "unknown",
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
- hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
+ hcUnhealthyThreshold:
+ healthcheckData?.["unhealthy-threshold"]
})
.returning();
@@ -544,8 +545,10 @@ export async function updateProxyResources(
healthcheckData?.["follow-redirects"],
hcMethod: healthcheckData?.method,
hcStatus: healthcheckData?.status,
- hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
- hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
+ hcHealthyThreshold:
+ healthcheckData?.["healthy-threshold"],
+ hcUnhealthyThreshold:
+ healthcheckData?.["unhealthy-threshold"]
})
.where(
eq(
@@ -1120,8 +1123,10 @@ function checkIfHealthcheckChanged(
JSON.stringify(incoming.hcHeaders)
)
return true;
- if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold) return true;
- if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold) return true;
+ if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold)
+ return true;
+ if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold)
+ return true;
return false;
}
@@ -1184,7 +1189,11 @@ async function getDomainId(
orgId: string,
fullDomain: string,
trx: Transaction
-): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> {
+): Promise<{
+ subdomain: string | null;
+ domainId: string;
+ wildcard: boolean;
+} | null> {
const isWildcardFullDomain = fullDomain.startsWith("*.");
const possibleDomains = await trx
diff --git a/server/lib/consts.ts b/server/lib/consts.ts
index d2218e874..3d290d93b 100644
--- a/server/lib/consts.ts
+++ b/server/lib/consts.ts
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
-export const APP_VERSION = "1.18.0";
+export const APP_VERSION = "1.18.2";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);
diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts
deleted file mode 100644
index ae9f1f05b..000000000
--- a/server/private/lib/alerts/events/healthCheckEvents.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-/*
- * This file is part of a proprietary work.
- *
- * Copyright (c) 2025-2026 Fossorial, Inc.
- * All rights reserved.
- *
- * This file is licensed under the Fossorial Commercial License.
- * You may not use this file except in compliance with the License.
- * Unauthorized use, copying, modification, or distribution is strictly prohibited.
- *
- * This file is not licensed under the AGPLv3.
- */
-
-import logger from "@server/logger";
-import { processAlerts } from "../processAlerts";
-import {
- db,
- statusHistory,
- targetHealthCheck,
- targets,
- resources,
- Transaction,
- logsDb
-} from "@server/db";
-import { eq } from "drizzle-orm";
-import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
-import {
- fireResourceDegradedAlert,
- fireResourceHealthyAlert,
- fireResourceUnhealthyAlert,
- fireResourceUnknownAlert
-} from "./resourceEvents";
-
-// ---------------------------------------------------------------------------
-// Public API
-// ---------------------------------------------------------------------------
-
-/**
- * Fire a `health_check_healthy` alert for the given health check.
- *
- * Call this after a previously-failing health check has recovered so that any
- * matching `alertRules` can dispatch their email and webhook actions.
- *
- * @param orgId - Organisation that owns the health check.
- * @param healthCheckId - Numeric primary key of the health check.
- * @param healthCheckName - Human-readable name shown in notifications (optional).
- * @param extra - Any additional key/value pairs to include in the payload.
- */
-export async function fireHealthCheckHealthyAlert(
- orgId: string,
- healthCheckId: number,
- healthCheckName?: string | null,
- healthCheckTargetId?: number | null,
- extra?: Record,
- send: boolean = true,
- trx: Transaction | typeof db = db
-): Promise {
- try {
- await logsDb.insert(statusHistory).values({
- entityType: "health_check",
- entityId: healthCheckId,
- orgId: orgId,
- status: "healthy",
- timestamp: Math.floor(Date.now() / 1000)
- });
- await invalidateStatusHistoryCache("health_check", healthCheckId);
-
- await handleResource(orgId, healthCheckTargetId, send, trx);
-
- if (!send) {
- return;
- }
-
- await processAlerts({
- eventType: "health_check_healthy",
- orgId,
- healthCheckId,
- data: {
- ...(healthCheckName != null ? { healthCheckName } : {}),
- ...extra
- }
- });
- await processAlerts({
- eventType: "health_check_toggle",
- orgId,
- healthCheckId,
- data: {
- healthCheckId,
- status: "healthy",
- ...(healthCheckName != null ? { healthCheckName } : {}),
- ...extra
- }
- });
- } catch (err) {
- logger.error(
- `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
- err
- );
- }
-}
-
-/**
- * Fire a `health_check_unhealthy` alert for the given health check.
- *
- * Call this after a health check has been detected as failing so that any
- * matching `alertRules` can dispatch their email and webhook actions.
- *
- * @param orgId - Organisation that owns the health check.
- * @param healthCheckId - Numeric primary key of the health check.
- * @param healthCheckName - Human-readable name shown in notifications (optional).
- * @param extra - Any additional key/value pairs to include in the payload.
- */
-export async function fireHealthCheckUnhealthyAlert(
- orgId: string,
- healthCheckId: number,
- healthCheckName?: string | null,
- healthCheckTargetId?: number | null,
- extra?: Record,
- send: boolean = true,
- trx: Transaction | typeof db = db
-): Promise {
- try {
- await logsDb.insert(statusHistory).values({
- entityType: "health_check",
- entityId: healthCheckId,
- orgId: orgId,
- status: "unhealthy",
- timestamp: Math.floor(Date.now() / 1000)
- });
- await invalidateStatusHistoryCache("health_check", healthCheckId);
-
- await handleResource(orgId, healthCheckTargetId, send, trx);
-
- if (!send) {
- return;
- }
-
- await processAlerts({
- eventType: "health_check_unhealthy",
- orgId,
- healthCheckId,
- data: {
- ...(healthCheckName != null ? { healthCheckName } : {}),
- ...extra
- }
- });
- await processAlerts({
- eventType: "health_check_toggle",
- orgId,
- healthCheckId,
- data: {
- healthCheckId,
- status: "unhealthy",
- ...(healthCheckName != null ? { healthCheckName } : {}),
- ...extra
- }
- });
- } catch (err) {
- logger.error(
- `fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
- err
- );
- }
-}
-
-export async function fireHealthCheckUnknownAlert(
- orgId: string,
- healthCheckId: number,
- healthCheckName?: string | null,
- healthCheckTargetId?: number | null,
- extra?: Record,
- send: boolean = true,
- trx: Transaction | typeof db = db
-): Promise {
- try {
- await logsDb.insert(statusHistory).values({
- entityType: "health_check",
- entityId: healthCheckId,
- orgId: orgId,
- status: "unknown",
- timestamp: Math.floor(Date.now() / 1000)
- });
- await invalidateStatusHistoryCache("health_check", healthCheckId);
-
- await handleResource(orgId, healthCheckTargetId, send, trx);
-
- if (!send) {
- return;
- }
- } catch (err) {
- logger.error(
- `fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
- err
- );
- }
-}
-
-async function handleResource(
- orgId: string,
- healthCheckTargetId?: number | null,
- send: boolean = true,
- trx: Transaction | typeof db = db
-) {
- if (!healthCheckTargetId) {
- return;
- }
- // we have targets lets get them
- const [target] = await trx
- .select()
- .from(targets)
- .where(eq(targets.targetId, healthCheckTargetId))
- .limit(1);
-
- if (!target) {
- return;
- }
-
- const [resource] = await trx
- .select()
- .from(resources)
- .where(eq(resources.resourceId, target.resourceId))
- .limit(1);
-
- if (!resource) {
- return;
- }
-
- const otherTargets = await trx
- .select({ hcHealth: targetHealthCheck.hcHealth })
- .from(targets)
- .innerJoin(
- targetHealthCheck,
- eq(targetHealthCheck.targetId, targets.targetId)
- )
- .where(eq(targets.resourceId, resource.resourceId));
-
- let health = "healthy";
- const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
- const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
- const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
-
- if (allUnknown) {
- logger.debug(
- `Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
- );
- health = "unknown";
- } else if (allHealthy) {
- health = "healthy";
- } else if (allUnhealthy) {
- logger.debug(
- `Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy`
- );
- health = "unhealthy";
- } else {
- logger.debug(
- `Marking resource ${resource.resourceId} as degraded because some targets are unhealthy`
- );
- health = "degraded";
- }
-
- if (health != resource.health) {
- // it changed
- await trx
- .update(resources)
- .set({ health })
- .where(eq(resources.resourceId, resource.resourceId));
-
- if (health === "unknown") {
- await fireResourceUnknownAlert(
- orgId,
- resource.resourceId,
- resource.name,
- undefined,
- send,
- trx
- );
- } else if (health === "unhealthy") {
- await fireResourceUnhealthyAlert(
- orgId,
- resource.resourceId,
- resource.name,
- undefined,
- send,
- trx
- );
- } else if (health === "healthy") {
- await fireResourceHealthyAlert(
- orgId,
- resource.resourceId,
- resource.name,
- undefined,
- send,
- trx
- );
- } else if (health === "degraded") {
- await fireResourceDegradedAlert(
- orgId,
- resource.resourceId,
- resource.name,
- undefined,
- send,
- trx
- );
- }
- }
-}
diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts
deleted file mode 100644
index 54b40b80d..000000000
--- a/server/private/lib/alerts/events/resourceEvents.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-/*
- * This file is part of a proprietary work.
- *
- * Copyright (c) 2025-2026 Fossorial, Inc.
- * All rights reserved.
- *
- * This file is licensed under the Fossorial Commercial License.
- * You may not use this file except in compliance with the License.
- * Unauthorized use, copying, modification, or distribution is strictly prohibited.
- *
- * This file is not licensed under the AGPLv3.
- */
-
-import logger from "@server/logger";
-import { processAlerts } from "../processAlerts";
-import { db, logsDb, statusHistory, Transaction } from "@server/db";
-import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
-
-// ---------------------------------------------------------------------------
-// Public API
-// ---------------------------------------------------------------------------
-
-/**
- * Fire a `resource_healthy` alert for the given resource.
- *
- * Call this after a previously-unhealthy resource has recovered so that any
- * matching `alertRules` can dispatch their email and webhook actions.
- *
- * @param orgId - Organisation that owns the resource.
- * @param resourceId - Numeric primary key of the resource.
- * @param resourceName - Human-readable name shown in notifications (optional).
- * @param extra - Any additional key/value pairs to include in the payload.
- */
-export async function fireResourceHealthyAlert(
- orgId: string,
- resourceId: number,
- resourceName?: string | null,
- extra?: Record,
- send: boolean = true,
- trx: Transaction | typeof db = db
-): Promise {
- try {
- await logsDb.insert(statusHistory).values({
- entityType: "resource",
- entityId: resourceId,
- orgId: orgId,
- status: "healthy",
- timestamp: Math.floor(Date.now() / 1000)
- });
- await invalidateStatusHistoryCache("resource", resourceId);
-
- if (!send) {
- return;
- }
-
- await processAlerts({
- eventType: "resource_healthy",
- orgId,
- resourceId,
- data: {
- ...(resourceName != null ? { resourceName } : {}),
- ...extra
- }
- });
- await processAlerts({
- eventType: "resource_toggle",
- orgId,
- resourceId,
- data: {
- resourceId,
- status: "healthy",
- ...(resourceName != null ? { resourceName } : {}),
- ...extra
- }
- });
- } catch (err) {
- logger.error(
- `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
- err
- );
- }
-}
-
-/**
- * Fire a `resource_unhealthy` alert for the given resource.
- *
- * Call this after a resource has been detected as unhealthy so that any
- * matching `alertRules` can dispatch their email and webhook actions.
- *
- * @param orgId - Organisation that owns the resource.
- * @param resourceId - Numeric primary key of the resource.
- * @param resourceName - Human-readable name shown in notifications (optional).
- * @param extra - Any additional key/value pairs to include in the payload.
- */
-export async function fireResourceUnhealthyAlert(
- orgId: string,
- resourceId: number,
- resourceName?: string | null,
- extra?: Record,
- send: boolean = true,
- trx: Transaction | typeof db = db
-): Promise {
- try {
- await logsDb.insert(statusHistory).values({
- entityType: "resource",
- entityId: resourceId,
- orgId: orgId,
- status: "unhealthy",
- timestamp: Math.floor(Date.now() / 1000)
- });
- await invalidateStatusHistoryCache("resource", resourceId);
-
- if (!send) {
- return;
- }
-
- await processAlerts({
- eventType: "resource_unhealthy",
- orgId,
- resourceId,
- data: {
- ...(resourceName != null ? { resourceName } : {}),
- ...extra
- }
- });
- await processAlerts({
- eventType: "resource_toggle",
- orgId,
- resourceId,
- data: {
- resourceId,
- status: "unhealthy",
- ...(resourceName != null ? { resourceName } : {}),
- ...extra
- }
- });
- } catch (err) {
- logger.error(
- `fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
- err
- );
- }
-}
-
-/**
- * Fire a `resource_degraded` alert for the given resource.
- *
- * Call this after a resource has been detected as degraded so that any
- * matching `alertRules` can dispatch their email and webhook actions.
- *
- * @param orgId - Organisation that owns the resource.
- * @param resourceId - Numeric primary key of the resource.
- * @param resourceName - Human-readable name shown in notifications (optional).
- * @param extra - Any additional key/value pairs to include in the payload.
- */
-export async function fireResourceDegradedAlert(
- orgId: string,
- resourceId: number,
- resourceName?: string | null,
- extra?: Record,
- send: boolean = true,
- trx: Transaction | typeof db = db
-): Promise {
- try {
- await logsDb.insert(statusHistory).values({
- entityType: "resource",
- entityId: resourceId,
- orgId: orgId,
- status: "degraded",
- timestamp: Math.floor(Date.now() / 1000)
- });
- await invalidateStatusHistoryCache("resource", resourceId);
-
- if (!send) {
- return;
- }
-
- await processAlerts({
- eventType: "resource_degraded",
- orgId,
- resourceId,
- data: {
- ...(resourceName != null ? { resourceName } : {}),
- ...extra
- }
- });
- await processAlerts({
- eventType: "resource_toggle",
- orgId,
- resourceId,
- data: {
- resourceId,
- status: "degraded",
- ...(resourceName != null ? { resourceName } : {}),
- ...extra
- }
- });
- } catch (err) {
- logger.error(
- `fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
- err
- );
- }
-}
-
-/**
- * Fire a `resource_unknown` alert for the given resource.
- *
- * Call this when all health checks on a resource are disabled so that the
- * resource status transitions to unknown.
- *
- * @param orgId - Organisation that owns the resource.
- * @param resourceId - Numeric primary key of the resource.
- * @param resourceName - Human-readable name shown in notifications (optional).
- * @param extra - Any additional key/value pairs to include in the payload.
- */
-export async function fireResourceUnknownAlert(
- orgId: string,
- resourceId: number,
- resourceName?: string | null,
- extra?: Record,
- send: boolean = true,
- trx: Transaction | typeof db = db
-): Promise {
- try {
- await logsDb.insert(statusHistory).values({
- entityType: "resource",
- entityId: resourceId,
- orgId: orgId,
- status: "unknown",
- timestamp: Math.floor(Date.now() / 1000)
- });
- await invalidateStatusHistoryCache("resource", resourceId);
-
- if (!send) {
- return;
- }
-
- await processAlerts({
- eventType: "resource_toggle",
- orgId,
- resourceId,
- data: {
- resourceId,
- status: "unknown",
- ...(resourceName != null ? { resourceName } : {}),
- ...extra
- }
- });
- } catch (err) {
- logger.error(
- `fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
- err
- );
- }
-}
diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts
deleted file mode 100644
index e1871dc85..000000000
--- a/server/private/lib/alerts/events/siteEvents.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- * This file is part of a proprietary work.
- *
- * Copyright (c) 2025-2026 Fossorial, Inc.
- * All rights reserved.
- *
- * This file is licensed under the Fossorial Commercial License.
- * You may not use this file except in compliance with the License.
- * Unauthorized use, copying, modification, or distribution is strictly prohibited.
- *
- * This file is not licensed under the AGPLv3.
- */
-
-import logger from "@server/logger";
-import { processAlerts } from "../processAlerts";
-import {
- db,
- logsDb,
- statusHistory,
- targetHealthCheck,
- Transaction
-} from "@server/db";
-import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
-import { and, eq, inArray } from "drizzle-orm";
-import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
-
-// ---------------------------------------------------------------------------
-// Public API
-// ---------------------------------------------------------------------------
-
-/**
- * Fire a `site_online` alert for the given site.
- *
- * Call this after the site has been confirmed reachable / connected so that
- * any matching `alertRules` can dispatch their email and webhook actions.
- *
- * @param orgId - Organisation that owns the site.
- * @param siteId - Numeric primary key of the site.
- * @param siteName - Human-readable name shown in notifications (optional).
- * @param extra - Any additional key/value pairs to include in the payload.
- */
-export async function fireSiteOnlineAlert(
- orgId: string,
- siteId: number,
- siteName?: string,
- extra?: Record,
- trx: Transaction | typeof db = db
-): Promise {
- try {
- await logsDb.insert(statusHistory).values({
- entityType: "site",
- entityId: siteId,
- orgId: orgId,
- status: "online",
- timestamp: Math.floor(Date.now() / 1000)
- });
- await invalidateStatusHistoryCache("site", siteId);
-
- await processAlerts({
- eventType: "site_online",
- orgId,
- siteId,
- data: {
- ...(siteName != null ? { siteName } : {}),
- ...extra
- }
- });
- await processAlerts({
- eventType: "site_toggle",
- orgId,
- siteId,
- data: {
- siteId,
- status: "online",
- ...(siteName != null ? { siteName } : {}),
- ...extra
- }
- });
- } catch (err) {
- logger.error(
- `fireSiteOnlineAlert: unexpected error for siteId ${siteId}`,
- err
- );
- }
-}
-
-/**
- * Fire a `site_offline` alert for the given site.
- *
- * Call this after the site has been detected as unreachable / disconnected so
- * that any matching `alertRules` can dispatch their email and webhook actions.
- *
- * @param orgId - Organisation that owns the site.
- * @param siteId - Numeric primary key of the site.
- * @param siteName - Human-readable name shown in notifications (optional).
- * @param extra - Any additional key/value pairs to include in the payload.
- */
-export async function fireSiteOfflineAlert(
- orgId: string,
- siteId: number,
- siteName?: string,
- extra?: Record,
- trx: Transaction | typeof db = db
-): Promise {
- try {
- await logsDb.insert(statusHistory).values({
- entityType: "site",
- entityId: siteId,
- orgId: orgId,
- status: "offline",
- timestamp: Math.floor(Date.now() / 1000)
- });
- await invalidateStatusHistoryCache("site", siteId);
-
- const unhealthyHealthChecks = await trx
- .update(targetHealthCheck)
- .set({ hcHealth: "unhealthy" })
- .where(
- and(
- eq(targetHealthCheck.orgId, orgId),
- eq(targetHealthCheck.siteId, siteId),
- eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled
- )
- )
- .returning();
-
- for (const healthCheck of unhealthyHealthChecks) {
- logger.info(
- `Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
- );
-
- await fireHealthCheckUnhealthyAlert(
- healthCheck.orgId,
- healthCheck.targetHealthCheckId,
- healthCheck.name,
- healthCheck.targetId, // for the resource if we have one
- undefined,
- true,
- trx
- );
- }
-
- await processAlerts({
- eventType: "site_offline",
- orgId,
- siteId,
- data: {
- ...(siteName != null ? { siteName } : {}),
- ...extra
- }
- });
- await processAlerts({
- eventType: "site_toggle",
- orgId,
- siteId,
- data: {
- siteId,
- status: "offline",
- ...(siteName != null ? { siteName } : {}),
- ...extra
- }
- });
- } catch (err) {
- logger.error(
- `fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
- err
- );
- }
-}
diff --git a/server/private/lib/alerts/index.ts b/server/private/lib/alerts/index.ts
index 7f34aea34..04b4763d0 100644
--- a/server/private/lib/alerts/index.ts
+++ b/server/private/lib/alerts/index.ts
@@ -14,6 +14,3 @@
export * from "./processAlerts";
export * from "./sendAlertWebhook";
export * from "./sendAlertEmail";
-export * from "./events/siteEvents";
-export * from "./events/healthCheckEvents";
-export * from "./events/resourceEvents";
diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts
index 3975eb09f..dd5088a6c 100644
--- a/server/private/lib/alerts/sendAlertWebhook.ts
+++ b/server/private/lib/alerts/sendAlertWebhook.ts
@@ -42,17 +42,23 @@ export async function sendAlertWebhook(
webhookConfig: WebhookAlertConfig,
context: AlertContext
): Promise {
- const payload = {
- event: context.eventType,
- timestamp: new Date().toISOString(),
- status: deriveStatus(context.eventType, context.data),
- data: {
- orgId: context.orgId,
- ...context.data
- }
- };
+ const eventType = context.eventType;
+ const timestamp = new Date().toISOString();
+ const status = deriveStatus(eventType, context.data);
+ const data = { orgId: context.orgId, ...context.data };
+
+ let body: string;
+ if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
+ body = renderTemplate(webhookConfig.bodyTemplate, {
+ event: eventType,
+ timestamp,
+ status,
+ data
+ });
+ } else {
+ body = JSON.stringify({ event: eventType, timestamp, status, data });
+ }
- const body = JSON.stringify(payload);
const headers = buildHeaders(webhookConfig);
let lastError: Error | undefined;
@@ -217,3 +223,52 @@ function buildHeaders(
return headers;
}
+
+// ---------------------------------------------------------------------------
+// Body template rendering
+// ---------------------------------------------------------------------------
+
+interface TemplateContext {
+ event: string;
+ timestamp: string;
+ status: string;
+ data: Record;
+}
+
+/**
+ * Render a body template with {{event}}, {{timestamp}}, {{status}}, and
+ * {{data}} placeholders, mirroring the logic in HttpLogDestination.
+ *
+ * {{data}} is replaced first (as raw JSON) so that any literal "{{…}}"
+ * strings inside data values are not re-expanded.
+ */
+function renderTemplate(template: string, ctx: TemplateContext): string {
+ const rendered = template
+ .replace(/\{\{data\}\}/g, JSON.stringify(ctx.data))
+ .replace(/\{\{event\}\}/g, escapeJsonString(ctx.event))
+ .replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp))
+ .replace(/\{\{status\}\}/g, escapeJsonString(ctx.status));
+
+ // Validate the rendered result is valid JSON; if not, log a warning and
+ // fall back to the default payload so the webhook still fires.
+ try {
+ JSON.parse(rendered);
+ return rendered;
+ } catch {
+ logger.warn(
+ `sendAlertWebhook: body template produced invalid JSON for event ` +
+ `"${ctx.event}" destined for a webhook. Falling back to default ` +
+ `payload. Check that {{data}} is NOT wrapped in quotes in your template.`
+ );
+ return JSON.stringify({
+ event: ctx.event,
+ timestamp: ctx.timestamp,
+ status: ctx.status,
+ data: ctx.data
+ });
+ }
+}
+
+function escapeJsonString(value: string): string {
+ return JSON.stringify(value).slice(1, -1);
+}
diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts
index e79db2ef5..36a71026d 100644
--- a/server/private/lib/alerts/types.ts
+++ b/server/private/lib/alerts/types.ts
@@ -45,6 +45,10 @@ export interface WebhookAlertConfig {
headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */
method?: string;
+ /** Whether to use a custom body template */
+ useBodyTemplate?: boolean;
+ /** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
+ bodyTemplate?: string;
}
// ---------------------------------------------------------------------------
@@ -60,4 +64,4 @@ export interface AlertContext {
healthCheckId?: number;
/** Human-readable context data included in emails and webhook payloads */
data: Record;
-}
\ No newline at end of file
+}
diff --git a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts
index 530557463..18761b568 100644
--- a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts
+++ b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts
@@ -24,7 +24,7 @@ import { eq, and } from "drizzle-orm";
import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert
-} from "#private/lib/alerts/events/healthCheckEvents";
+} from "@server/lib/alerts";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
@@ -73,10 +73,7 @@ export async function triggerHealthCheckAlert(
.from(targetHealthCheck)
.where(
and(
- eq(
- targetHealthCheck.targetHealthCheckId,
- healthCheckId
- ),
+ eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
eq(targetHealthCheck.orgId, orgId)
)
)
diff --git a/server/private/routers/alertEvents/triggerResourceAlert.ts b/server/private/routers/alertEvents/triggerResourceAlert.ts
index afda63e9a..3c2f8fb96 100644
--- a/server/private/routers/alertEvents/triggerResourceAlert.ts
+++ b/server/private/routers/alertEvents/triggerResourceAlert.ts
@@ -25,7 +25,7 @@ import {
fireResourceHealthyAlert,
fireResourceUnhealthyAlert,
fireResourceDegradedAlert
-} from "#private/lib/alerts/events/resourceEvents";
+} from "@server/lib/alerts";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
diff --git a/server/private/routers/alertEvents/triggerSiteAlert.ts b/server/private/routers/alertEvents/triggerSiteAlert.ts
index 25b14acb9..b9f182887 100644
--- a/server/private/routers/alertEvents/triggerSiteAlert.ts
+++ b/server/private/routers/alertEvents/triggerSiteAlert.ts
@@ -21,10 +21,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
-import {
- fireSiteOnlineAlert,
- fireSiteOfflineAlert
-} from "#private/lib/alerts/events/siteEvents";
+import { fireSiteOnlineAlert, fireSiteOfflineAlert } from "@server/lib/alerts";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts
index ead58e996..0fa5a77e9 100644
--- a/server/private/routers/healthChecks/createHealthCheck.ts
+++ b/server/private/routers/healthChecks/createHealthCheck.ts
@@ -22,7 +22,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
-import { fireHealthCheckUnhealthyAlert } from "#private/lib/alerts";
+import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts
index 8afeca6a4..4df92a5a7 100644
--- a/server/private/routers/healthChecks/updateHealthCheck.ts
+++ b/server/private/routers/healthChecks/updateHealthCheck.ts
@@ -22,7 +22,11 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, isNull } from "drizzle-orm";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
-import { fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckHealthyAlert } from "#private/lib/alerts";
+import {
+ fireHealthCheckUnhealthyAlert,
+ fireHealthCheckUnknownAlert,
+ fireHealthCheckHealthyAlert
+} from "@server/lib/alerts";
const paramsSchema = z
.object({
@@ -234,7 +238,10 @@ export async function updateHealthCheck(
)
.returning();
- if (updated.hcHealth === "unhealthy" && existingHealthCheck.hcHealth !== "unhealthy") {
+ if (
+ updated.hcHealth === "unhealthy" &&
+ existingHealthCheck.hcHealth !== "unhealthy"
+ ) {
await fireHealthCheckUnhealthyAlert(
updated.orgId,
updated.targetHealthCheckId,
@@ -243,7 +250,10 @@ export async function updateHealthCheck(
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
- } else if (updated.hcHealth === "unknown" && existingHealthCheck.hcHealth !== "unknown") {
+ } else if (
+ updated.hcHealth === "unknown" &&
+ existingHealthCheck.hcHealth !== "unknown"
+ ) {
// if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert(
updated.orgId,
@@ -253,7 +263,10 @@ export async function updateHealthCheck(
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
- } else if (updated.hcHealth === "healthy" && existingHealthCheck.hcHealth !== "healthy") {
+ } else if (
+ updated.hcHealth === "healthy" &&
+ existingHealthCheck.hcHealth !== "healthy"
+ ) {
await fireHealthCheckHealthyAlert(
updated.orgId,
updated.targetHealthCheckId,
@@ -264,7 +277,6 @@ export async function updateHealthCheck(
);
}
-
// Push updated health check to newt if the site is a newt site
const [newt] = await db
.select()
diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts
index 54001432f..061be1792 100644
--- a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts
+++ b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts
@@ -22,6 +22,91 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
+import cache from "#private/lib/cache";
+import semver from "semver";
+
+let stalePangolinNodeVersion: string | null = null;
+
+async function getLatestPangolinNodeVersion(): Promise {
+ try {
+ const cachedVersion = await cache.get(
+ "cache:latestPangolinNodeVersion"
+ );
+ if (cachedVersion) {
+ return cachedVersion;
+ }
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 1500);
+
+ const res = await fetch(
+ "https://api.github.com/repos/fosrl/pangolin-node/tags",
+ { signal: controller.signal }
+ );
+
+ clearTimeout(timeoutId);
+
+ if (!res.ok) {
+ logger.warn(
+ `Failed to fetch latest pangolin-node version from GitHub: ${res.status} ${res.statusText}`
+ );
+ return stalePangolinNodeVersion;
+ }
+
+ let tags = await res.json();
+ if (!Array.isArray(tags) || tags.length === 0) {
+ logger.warn("No tags found for pangolin-node repository");
+ return stalePangolinNodeVersion;
+ }
+
+ tags = tags.filter((tag: any) => !tag.name.includes("rc"));
+ tags.sort((a: any, b: any) => {
+ const va = semver.coerce(a.name);
+ const vb = semver.coerce(b.name);
+ if (!va && !vb) return 0;
+ if (!va) return 1;
+ if (!vb) return -1;
+ return semver.rcompare(va, vb);
+ });
+
+ const seen = new Set();
+ tags = tags.filter((tag: any) => {
+ const normalised = semver.coerce(tag.name)?.version;
+ if (!normalised || seen.has(normalised)) return false;
+ seen.add(normalised);
+ return true;
+ });
+
+ if (tags.length === 0) {
+ logger.warn(
+ "No valid semver tags found for pangolin-node repository"
+ );
+ return stalePangolinNodeVersion;
+ }
+
+ const latestVersion = tags[0].name;
+ stalePangolinNodeVersion = latestVersion;
+ await cache.set("cache:latestPangolinNodeVersion", latestVersion, 3600);
+
+ return latestVersion;
+ } catch (error: any) {
+ if (error.name === "AbortError") {
+ logger.warn(
+ "Request to fetch latest pangolin-node version timed out (1.5s)"
+ );
+ } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
+ logger.warn(
+ "Connection timeout while fetching latest pangolin-node version"
+ );
+ } else {
+ logger.warn(
+ "Error fetching latest pangolin-node version:",
+ error.message || error
+ );
+ }
+ return stalePangolinNodeVersion;
+ }
+}
const listRemoteExitNodesParamsSchema = z.strictObject({
orgId: z.string()
@@ -118,9 +203,41 @@ export async function listRemoteExitNodes(
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
+ const latestPangolinNodeVersionPromise = getLatestPangolinNodeVersion();
+
+ const nodesWithUpdates = remoteExitNodesList.map((node) => ({
+ ...node,
+ updateAvailable: false
+ }));
+
+ try {
+ const latestPangolinNodeVersion =
+ await latestPangolinNodeVersionPromise;
+
+ if (latestPangolinNodeVersion) {
+ nodesWithUpdates.forEach((node) => {
+ if (node.version) {
+ try {
+ node.updateAvailable = semver.lt(
+ node.version,
+ latestPangolinNodeVersion
+ );
+ } catch {
+ node.updateAvailable = false;
+ }
+ }
+ });
+ }
+ } catch (error) {
+ logger.warn(
+ "Failed to check for pangolin-node updates, continuing without update info:",
+ error
+ );
+ }
+
return response(res, {
data: {
- remoteExitNodes: remoteExitNodesList,
+ remoteExitNodes: nodesWithUpdates,
pagination: {
total: totalCount,
limit,
diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts
index e3f92591d..ebffd3c5b 100644
--- a/server/routers/alertRule/types.ts
+++ b/server/routers/alertRule/types.ts
@@ -80,6 +80,10 @@ export interface WebhookAlertConfig {
headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */
method?: string;
+ /** Whether to use a custom body template */
+ useBodyTemplate?: boolean;
+ /** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
+ bodyTemplate?: string;
}
// ---------------------------------------------------------------------------
diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts
index 7c9e53cf2..d26a8fbe3 100644
--- a/server/routers/idp/validateOidcCallback.ts
+++ b/server/routers/idp/validateOidcCallback.ts
@@ -336,31 +336,22 @@ export async function validateOidcCallback(
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
allOrgs = idpOrgs.map((o) => o.orgs);
- // TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1
- if (allOrgs.length > 1) {
- // for some reason there is more than one org
- logger.error(
- "More than one organization linked to this IdP. This should not happen with auto-provisioning enabled."
+ for (const org of allOrgs) {
+ const subscribed = await isSubscribed(
+ org.orgId,
+ tierMatrix.autoProvisioning
);
- return next(
- createHttpError(
- HttpCode.INTERNAL_SERVER_ERROR,
- "Multiple organizations linked to this IdP. Please contact support."
- )
- );
- }
+ if (!subscribed) {
+ // filter out the org
+ allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
- const subscribed = await isSubscribed(
- allOrgs[0].orgId,
- tierMatrix.autoProvisioning
- );
- if (!subscribed) {
- return next(
- createHttpError(
- HttpCode.FORBIDDEN,
- "This organization's current plan does not support this feature."
- )
- );
+ // return next(
+ // createHttpError(
+ // HttpCode.FORBIDDEN,
+ // "This organization's current plan does not support this feature."
+ // )
+ // );
+ }
}
} else {
allOrgs = await db.select().from(orgs);
diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts
index a05d410c8..a2b963fc9 100644
--- a/server/routers/newt/handleNewtDisconnectingMessage.ts
+++ b/server/routers/newt/handleNewtDisconnectingMessage.ts
@@ -1,12 +1,8 @@
import { MessageHandler } from "@server/routers/ws";
-import {
- db,
- Newt,
- sites
-} from "@server/db";
+import { db, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
-import { fireSiteOfflineAlert } from "#dynamic/lib/alerts";
+import { fireSiteOfflineAlert } from "@server/lib/alerts";
/**
* Handles disconnecting messages from sites to show disconnected in the ui
@@ -38,7 +34,13 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
.where(eq(sites.siteId, newt.siteId!))
.returning();
- await fireSiteOfflineAlert(site.orgId, site.siteId, site.name, undefined, trx);
+ await fireSiteOfflineAlert(
+ site.orgId,
+ site.siteId,
+ site.name,
+ undefined,
+ trx
+ );
});
} catch (error) {
logger.error("Error handling disconnecting message", { error });
diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts
index 6ff43688a..0d9148509 100644
--- a/server/routers/newt/offlineChecker.ts
+++ b/server/routers/newt/offlineChecker.ts
@@ -1,12 +1,8 @@
-import {
- db,
- newts,
- sites
-} from "@server/db";
+import { db, newts, sites } from "@server/db";
import { hasActiveConnections } from "#dynamic/routers/ws";
import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm";
import logger from "@server/logger";
-import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "#dynamic/lib/alerts";
+import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "@server/lib/alerts";
// Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null;
diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts
index 307565723..5351c6723 100644
--- a/server/routers/newt/pingAccumulator.ts
+++ b/server/routers/newt/pingAccumulator.ts
@@ -2,7 +2,7 @@ import { db } from "@server/db";
import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
-import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
+import { fireSiteOnlineAlert } from "@server/lib/alerts";
/**
* Ping Accumulator
@@ -127,7 +127,11 @@ async function flushSitePingsToDb(): Promise {
eq(sites.online, false)
)
)
- .returning({ siteId: sites.siteId, orgId: sites.orgId, name: sites.name });
+ .returning({
+ siteId: sites.siteId,
+ orgId: sites.orgId,
+ name: sites.name
+ });
// Update lastPing for sites that were already online.
// After the update above, the newly-online sites now have
@@ -148,7 +152,13 @@ async function flushSitePingsToDb(): Promise {
for (const site of newlyOnlineSites) {
await db.transaction(async (trx) => {
- await fireSiteOnlineAlert(site.orgId, site.siteId, site.name, undefined, trx);
+ await fireSiteOnlineAlert(
+ site.orgId,
+ site.siteId,
+ site.name,
+ undefined,
+ trx
+ );
});
}
} catch (error) {
diff --git a/server/routers/remoteExitNode/types.ts b/server/routers/remoteExitNode/types.ts
index 25a7d6c53..9984b1b4f 100644
--- a/server/routers/remoteExitNode/types.ts
+++ b/server/routers/remoteExitNode/types.ts
@@ -21,6 +21,7 @@ export type ListRemoteExitNodesResponse = {
remoteExitNodeId: string;
dateCreated: string;
version: string | null;
+ updateAvailable?: boolean;
exitNodeId: number | null;
name: string;
address: string;
diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts
index d582d06da..c629e378e 100644
--- a/server/routers/target/createTarget.ts
+++ b/server/routers/target/createTarget.ts
@@ -23,7 +23,7 @@ import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert,
fireHealthCheckUnknownAlert
-} from "#dynamic/lib/alerts";
+} from "@server/lib/alerts";
const createTargetParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())
diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts
index e5f286524..61a927d3e 100644
--- a/server/routers/target/handleHealthcheckStatusMessage.ts
+++ b/server/routers/target/handleHealthcheckStatusMessage.ts
@@ -6,7 +6,7 @@ import logger from "@server/logger";
import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert
-} from "#dynamic/lib/alerts";
+} from "@server/lib/alerts";
interface TargetHealthStatus {
status: string;
diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts
index 92c434a19..4533dc2e5 100644
--- a/server/routers/target/updateTarget.ts
+++ b/server/routers/target/updateTarget.ts
@@ -10,7 +10,11 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
-import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts";
+import {
+ fireHealthCheckHealthyAlert,
+ fireHealthCheckUnknownAlert,
+ fireHealthCheckUnhealthyAlert
+} from "@server/lib/alerts";
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
@@ -169,7 +173,7 @@ export async function updateTarget(
let updatedTarget: any;
let updatedHc: any;
await db.transaction(async (trx) => {
- [updatedTarget] = await trx
+ [updatedTarget] = await trx
.update(targets)
.set({
siteId: parsedBody.data.siteId,
@@ -181,8 +185,12 @@ export async function updateTarget(
path: parsedBody.data.path,
pathMatchType: parsedBody.data.pathMatchType,
priority: parsedBody.data.priority,
- rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath,
- rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType
+ rewritePath: pathMatchTypeRemoved
+ ? null
+ : parsedBody.data.rewritePath,
+ rewritePathType: pathMatchTypeRemoved
+ ? null
+ : parsedBody.data.rewritePathType
})
.where(eq(targets.targetId, targetId))
.returning();
@@ -213,7 +221,8 @@ export async function updateTarget(
// If hcEnabled is being turned on (was false, now true), set to "unhealthy"
// so the target must pass a health check before being considered healthy.
const hcEnabledTurnedOn =
- parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false;
+ parsedBody.data.hcEnabled === true &&
+ existingHc.hcEnabled === false;
let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined;
if (
@@ -253,7 +262,10 @@ export async function updateTarget(
.where(eq(targetHealthCheck.targetId, targetId))
.returning();
- if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") {
+ if (
+ updatedHc.hcHealth === "unhealthy" &&
+ existingHc.hcHealth !== "unhealthy"
+ ) {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert`
);
@@ -266,7 +278,10 @@ export async function updateTarget(
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
- } else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") {
+ } else if (
+ updatedHc.hcHealth === "unknown" &&
+ existingHc.hcHealth !== "unknown"
+ ) {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert`
);
@@ -280,7 +295,10 @@ export async function updateTarget(
false, // dont send the alert because we just want to create the alert, not notify users yet
trx
);
- } else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") {
+ } else if (
+ updatedHc.hcHealth === "healthy" &&
+ existingHc.hcHealth !== "healthy"
+ ) {
logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert`
);
diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx
index 2c34d92ec..890a14564 100644
--- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx
+++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx
@@ -45,6 +45,7 @@ export default async function RemoteExitNodesPage(
type: node.type,
dateCreated: node.dateCreated,
version: node.version || undefined,
+ updateAvailable: node.updateAvailable,
orgId: params.orgId
};
}
diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx
index c2aaefaa6..6373e334a 100644
--- a/src/app/auth/login/page.tsx
+++ b/src/app/auth/login/page.tsx
@@ -160,6 +160,18 @@ export default async function Page(props: {
redirect={redirectUrl}
forceLogin={forceLogin}
defaultUser={defaultUser}
+ orgSignIn={
+ !isInvite &&
+ (build === "saas" ||
+ env.app.identityProviderMode === "org")
+ ? {
+ href: `/auth/org${buildQueryString(searchParams)}`,
+ linkText: t("orgAuthSignInToOrg"),
+ descriptionText:
+ t("needToSignInToOrg")
+ }
+ : undefined
+ }
/>
@@ -195,7 +207,8 @@ export default async function Page(props: {
)}
- {!isInvite &&
+ {!useSmartLogin &&
+ !isInvite &&
(build === "saas" || env.app.identityProviderMode === "org") ? (
{
};
const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
- // return (
- //
- // {children}
- //
- // );
-
return (
- {children}
+
{children}
+
);
};
@@ -172,7 +170,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
return (
- {originalRow.version && originalRow.version ? (
+ {originalRow.version ? (
{"v" + originalRow.version}
) : (
"-"
)}
+ {originalRow.updateAvailable && (
+
+ )}
);
}
diff --git a/src/components/OrgSignInLink.tsx b/src/components/OrgSignInLink.tsx
index 819a1dc7f..d900b98d3 100644
--- a/src/components/OrgSignInLink.tsx
+++ b/src/components/OrgSignInLink.tsx
@@ -5,11 +5,15 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
+import { cn } from "@app/lib/cn";
+import { Building2 } from "lucide-react";
type OrgSignInLinkProps = {
href: string;
linkText: string;
descriptionText: string;
+ primaryActionVariant?: "link" | "button";
+ className?: string;
};
const STORAGE_KEY_CLICKED = "orgSignInLinkClicked";
@@ -18,7 +22,9 @@ const STORAGE_KEY_ACKNOWLEDGED = "orgSignInTipAcknowledged";
export default function OrgSignInLink({
href,
linkText,
- descriptionText
+ descriptionText,
+ primaryActionVariant = "link",
+ className
}: OrgSignInLinkProps) {
const router = useRouter();
const t = useTranslations();
@@ -93,14 +99,32 @@ export default function OrgSignInLink({
)}
-
-
{descriptionText}
-
+
+ {primaryActionVariant === "button" ? (
+
+ ) : (
+
+ )}
>
);
diff --git a/src/components/SmartLoginForm.tsx b/src/components/SmartLoginForm.tsx
index 24f2acb72..164311b7b 100644
--- a/src/components/SmartLoginForm.tsx
+++ b/src/components/SmartLoginForm.tsx
@@ -15,15 +15,18 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Alert, AlertDescription } from "@app/components/ui/alert";
+import Link from "next/link";
import { useRouter } from "next/navigation";
import { useUserLookup } from "@app/hooks/useUserLookup";
+import { useEnvContext } from "@app/hooks/useEnvContext";
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
import { useTranslations } from "next-intl";
import LoginPasswordForm from "@app/components/LoginPasswordForm";
import LoginOrgSelector from "@app/components/LoginOrgSelector";
import UserProfileCard from "@app/components/UserProfileCard";
-import { ArrowLeft } from "lucide-react";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
+import { Separator } from "@app/components/ui/separator";
+import OrgSignInLink from "@app/components/OrgSignInLink";
const identifierSchema = z.object({
identifier: z.string().min(1, "Username or email is required")
@@ -39,10 +42,17 @@ const isValidEmail = (str: string): boolean => {
}
};
+type OrgSignInConfig = {
+ href: string;
+ linkText: string;
+ descriptionText: string;
+};
+
type SmartLoginFormProps = {
redirect?: string;
forceLogin?: boolean;
defaultUser?: string;
+ orgSignIn?: OrgSignInConfig;
};
type ViewState =
@@ -58,12 +68,31 @@ type ViewState =
lookupResult: LookupUserResponse;
};
+function buildResetPasswordHref(
+ dashboardUrl: string,
+ identifier: string,
+ redirectParam?: string
+) {
+ const trimmed = identifier.trim();
+ const params = new URLSearchParams();
+ if (isValidEmail(trimmed)) {
+ params.set("email", trimmed);
+ }
+ if (redirectParam) {
+ params.set("redirect", redirectParam);
+ }
+ const qs = params.toString();
+ return `${dashboardUrl}/auth/reset-password${qs ? `?${qs}` : ""}`;
+}
+
export default function SmartLoginForm({
redirect,
forceLogin,
- defaultUser
+ defaultUser,
+ orgSignIn
}: SmartLoginFormProps) {
const router = useRouter();
+ const { env } = useEnvContext();
const { lookup, loading, error } = useUserLookup();
const t = useTranslations();
const [viewState, setViewState] = useState
({ type: "initial" });
@@ -78,6 +107,13 @@ export default function SmartLoginForm({
}
});
+ const watchedIdentifier = form.watch("identifier");
+ const resetPasswordHref = buildResetPasswordHref(
+ env.app.dashboardUrl,
+ watchedIdentifier,
+ redirect
+ );
+
const hasAutoLookedUp = useRef(false);
useEffect(() => {
if (defaultUser?.trim() && !hasAutoLookedUp.current) {
@@ -209,6 +245,15 @@ export default function SmartLoginForm({
)}
/>
+
+
+ {t("passwordForgot")}
+
+
+
{(error || securityKeyError) && (
@@ -219,7 +264,7 @@ export default function SmartLoginForm({
-
+
+
+ {orgSignIn && (
+ <>
+
+
+
+
+
+
+ {t("idpContinue")}
+
+
+
+
+ >
+ )}
);
diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx
index 887fbaa5a..b374df5f8 100644
--- a/src/components/alert-rule-editor/AlertRuleFields.tsx
+++ b/src/components/alert-rule-editor/AlertRuleFields.tsx
@@ -12,12 +12,15 @@ import {
} from "@app/components/ui/command";
import {
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
+import { Switch } from "@app/components/ui/switch";
+import { Textarea } from "@app/components/ui/textarea";
import {
Popover,
PopoverContent,
@@ -962,6 +965,69 @@ function WebhookActionFields({
/>
+ {/* Body Template */}
+
+
+
+
+ {t("httpDestBodyTemplateDescription")}
+
+
+
(
+
+
+
+
+
+
+
+
+ )}
+ />
+ {useWatch({
+ control,
+ name: `actions.${index}.useBodyTemplate`
+ }) && (
+ (
+
+
+ {t("httpDestBodyTemplateLabel")}
+
+
+
+
+
+ {t("httpDestBodyTemplateHint")}
+
+
+
+ )}
+ />
+ )}
+
);
}
diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
index e1e71507b..7cac1960f 100644
--- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
+++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx
@@ -368,7 +368,9 @@ export default function AlertRuleGraphEditor({
customHeaderName:
"",
customHeaderValue:
- ""
+ "",
+ useBodyTemplate: false,
+ bodyTemplate: ""
});
}
}}
diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts
index 039c367b6..4fea27af7 100644
--- a/src/lib/alertRuleForm.ts
+++ b/src/lib/alertRuleForm.ts
@@ -45,6 +45,8 @@ export type AlertRuleFormAction =
basicCredentials: string;
customHeaderName: string;
customHeaderValue: string;
+ useBodyTemplate: boolean;
+ bodyTemplate: string;
};
export type AlertRuleFormValues = {
@@ -130,6 +132,8 @@ export type AlertRuleApiResponse = {
customHeaderValue?: string;
headers?: { key: string; value: string }[];
method?: string;
+ useBodyTemplate?: boolean;
+ bodyTemplate?: string;
} | null;
}[];
};
@@ -187,7 +191,9 @@ export function buildFormSchema(t: (k: string) => string) {
bearerToken: z.string(),
basicCredentials: z.string(),
customHeaderName: z.string(),
- customHeaderValue: z.string()
+ customHeaderValue: z.string(),
+ useBodyTemplate: z.boolean().default(false),
+ bodyTemplate: z.string().default("")
})
])
)
@@ -415,7 +421,9 @@ export function apiResponseToFormValues(
bearerToken: cfg?.bearerToken ?? "",
basicCredentials: cfg?.basicCredentials ?? "",
customHeaderName: cfg?.customHeaderName ?? "",
- customHeaderValue: cfg?.customHeaderValue ?? ""
+ customHeaderValue: cfg?.customHeaderValue ?? "",
+ useBodyTemplate: cfg?.useBodyTemplate ?? false,
+ bodyTemplate: cfg?.bodyTemplate ?? ""
});
}
@@ -479,7 +487,11 @@ export function formValuesToApiPayload(
customHeaderName: action.customHeaderName || undefined,
customHeaderValue: action.customHeaderValue || undefined,
headers: action.headers.filter((h) => h.key.trim()),
- method: action.method
+ method: action.method,
+ useBodyTemplate: action.useBodyTemplate || undefined,
+ bodyTemplate: action.useBodyTemplate
+ ? action.bodyTemplate || undefined
+ : undefined
})
});
}