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({ -
+
); 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")} + + +