Merge pull request #2971 from fosrl/dev

1.18.2
This commit is contained in:
Owen Schwartz
2026-05-02 20:59:51 -07:00
committed by GitHub
55 changed files with 1346 additions and 1013 deletions
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието", "pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието",
"newtUpdateAvailable": "Ново обновление", "newtUpdateAvailable": "Ново обновление",
"newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.", "newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.",
"pangolinNodeUpdateAvailableInfo": "Налична е нова версия на Pangolin Node. Моля, актуализирайте до последната версия за най-добро изживяване.",
"domainPickerEnterDomain": "Домейн", "domainPickerEnterDomain": "Домейн",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.", "domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите", "orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите",
"orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.", "orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.",
"orgAuthSignInWithPangolin": "Впишете се с Pangolin", "orgAuthSignInWithPangolin": "Впишете се с Pangolin",
"orgAuthSignInToOrg": "Влезте в организация", "orgAuthSignInToOrg": "Идентификационен доставчик на организация (SSO)",
"orgAuthSelectOrgTitle": "Вход в организация.", "orgAuthSelectOrgTitle": "Вход в организация.",
"orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.", "orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.",
"orgAuthOrgIdPlaceholder": "вашата-организация", "orgAuthOrgIdPlaceholder": "вашата-организация",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Zobrazit poznámky k vydání", "pangolinUpdateAvailableReleaseNotes": "Zobrazit poznámky k vydání",
"newtUpdateAvailable": "Dostupná aktualizace", "newtUpdateAvailable": "Dostupná aktualizace",
"newtUpdateAvailableInfo": "Je k dispozici nová verze Newt. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.", "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", "domainPickerEnterDomain": "Doména",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Zadejte úplnou doménu zdroje pro zobrazení dostupných možností.", "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", "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.", "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", "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", "orgAuthSelectOrgTitle": "Přihlášení do organizace",
"orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování", "orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování",
"orgAuthOrgIdPlaceholder": "vaše-organizace", "orgAuthOrgIdPlaceholder": "vaše-organizace",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Versionshinweise anzeigen", "pangolinUpdateAvailableReleaseNotes": "Versionshinweise anzeigen",
"newtUpdateAvailable": "Update verfügbar", "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.", "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", "domainPickerEnterDomain": "Domäne",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Geben Sie die vollständige Domain der Ressource ein, um verfügbare Optionen zu sehen.", "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", "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.", "orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.",
"orgAuthSignInWithPangolin": "Mit Pangolin anmelden", "orgAuthSignInWithPangolin": "Mit Pangolin anmelden",
"orgAuthSignInToOrg": "Bei einer Organisation anmelden", "orgAuthSignInToOrg": "Organisations-Identitätsanbieter (SSO)",
"orgAuthSelectOrgTitle": "Organisations-Anmeldung", "orgAuthSelectOrgTitle": "Organisations-Anmeldung",
"orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren", "orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren",
"orgAuthOrgIdPlaceholder": "Ihre Organisation", "orgAuthOrgIdPlaceholder": "Ihre Organisation",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "View Release Notes", "pangolinUpdateAvailableReleaseNotes": "View Release Notes",
"newtUpdateAvailable": "Update Available", "newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "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", "domainPickerEnterDomain": "Domain",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescription": "Enter the full domain of the resource to see available options.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Choose your identity provider to continue", "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.", "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
"orgAuthSignInWithPangolin": "Sign in with Pangolin", "orgAuthSignInWithPangolin": "Sign in with Pangolin",
"orgAuthSignInToOrg": "Sign in to an organization", "orgAuthSignInToOrg": "Organization Identity Provider (SSO)",
"orgAuthSelectOrgTitle": "Organization Sign In", "orgAuthSelectOrgTitle": "Organization Sign In",
"orgAuthSelectOrgDescription": "Enter your organization ID to continue", "orgAuthSelectOrgDescription": "Enter your organization ID to continue",
"orgAuthOrgIdPlaceholder": "your-organization", "orgAuthOrgIdPlaceholder": "your-organization",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Ver notas de lanzamiento", "pangolinUpdateAvailableReleaseNotes": "Ver notas de lanzamiento",
"newtUpdateAvailable": "Nueva actualización disponible", "newtUpdateAvailable": "Nueva actualización disponible",
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.", "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", "domainPickerEnterDomain": "Dominio",
"domainPickerPlaceholder": "miapp.ejemplo.com", "domainPickerPlaceholder": "miapp.ejemplo.com",
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.", "domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar", "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.", "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", "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", "orgAuthSelectOrgTitle": "Inicio de sesión de organización",
"orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar", "orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar",
"orgAuthOrgIdPlaceholder": "tu-organización", "orgAuthOrgIdPlaceholder": "tu-organización",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Voir les notes de publication", "pangolinUpdateAvailableReleaseNotes": "Voir les notes de publication",
"newtUpdateAvailable": "Mise à jour disponible", "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.", "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", "domainPickerEnterDomain": "Domaine",
"domainPickerPlaceholder": "monapp.exemple.com", "domainPickerPlaceholder": "monapp.exemple.com",
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.", "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", "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.", "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", "orgAuthSignInWithPangolin": "Se connecter avec Pangolin",
"orgAuthSignInToOrg": "Se connecter à une organisation", "orgAuthSignInToOrg": "Fournisseur d'identité d'organisation (SSO)",
"orgAuthSelectOrgTitle": "Connexion à l'organisation", "orgAuthSelectOrgTitle": "Connexion à l'organisation",
"orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer", "orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer",
"orgAuthOrgIdPlaceholder": "votre-organisation", "orgAuthOrgIdPlaceholder": "votre-organisation",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Visualizza Note Di Rilascio", "pangolinUpdateAvailableReleaseNotes": "Visualizza Note Di Rilascio",
"newtUpdateAvailable": "Aggiornamento Disponibile", "newtUpdateAvailable": "Aggiornamento Disponibile",
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "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", "domainPickerEnterDomain": "Dominio",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.", "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", "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.", "orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.",
"orgAuthSignInWithPangolin": "Accedi con Pangolino", "orgAuthSignInWithPangolin": "Accedi con Pangolino",
"orgAuthSignInToOrg": "Accedi a un'organizzazione", "orgAuthSignInToOrg": "Provider di identità dell'organizzazione (SSO)",
"orgAuthSelectOrgTitle": "Accesso Organizzazione", "orgAuthSelectOrgTitle": "Accesso Organizzazione",
"orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare", "orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare",
"orgAuthOrgIdPlaceholder": "la-tua-organizzazione", "orgAuthOrgIdPlaceholder": "la-tua-organizzazione",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기", "pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기",
"newtUpdateAvailable": "업데이트 가능", "newtUpdateAvailable": "업데이트 가능",
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"pangolinNodeUpdateAvailableInfo": "Pangolin Node의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"domainPickerEnterDomain": "도메인", "domainPickerEnterDomain": "도메인",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.", "orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.",
"orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.", "orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.",
"orgAuthSignInWithPangolin": "Pangolin으로 로그인", "orgAuthSignInWithPangolin": "Pangolin으로 로그인",
"orgAuthSignInToOrg": "조직에 로그인", "orgAuthSignInToOrg": "조직 아이덴티티 제공자 (SSO)",
"orgAuthSelectOrgTitle": "조직 로그인", "orgAuthSelectOrgTitle": "조직 로그인",
"orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.", "orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.",
"orgAuthOrgIdPlaceholder": "your-organization", "orgAuthOrgIdPlaceholder": "your-organization",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater", "pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater",
"newtUpdateAvailable": "Oppdatering tilgjengelig", "newtUpdateAvailable": "Oppdatering tilgjengelig",
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", "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", "domainPickerEnterDomain": "Domene",
"domainPickerPlaceholder": "minapp.eksempel.no", "domainPickerPlaceholder": "minapp.eksempel.no",
"domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.", "domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette", "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.", "orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.",
"orgAuthSignInWithPangolin": "Logg inn med Pangolin", "orgAuthSignInWithPangolin": "Logg inn med Pangolin",
"orgAuthSignInToOrg": "Logg inn på en organisasjon", "orgAuthSignInToOrg": "Organisasjonens identitetsleverandør (SSO)",
"orgAuthSelectOrgTitle": "Organisasjonsinnlogging", "orgAuthSelectOrgTitle": "Organisasjonsinnlogging",
"orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette", "orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette",
"orgAuthOrgIdPlaceholder": "din-organisasjon", "orgAuthOrgIdPlaceholder": "din-organisasjon",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Uitgaveopmerkingen bekijken", "pangolinUpdateAvailableReleaseNotes": "Uitgaveopmerkingen bekijken",
"newtUpdateAvailable": "Update beschikbaar", "newtUpdateAvailable": "Update beschikbaar",
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", "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", "domainPickerEnterDomain": "Domein",
"domainPickerPlaceholder": "mijnapp.voorbeeld.nl", "domainPickerPlaceholder": "mijnapp.voorbeeld.nl",
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.", "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", "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.", "orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.",
"orgAuthSignInWithPangolin": "Log in met Pangolin", "orgAuthSignInWithPangolin": "Log in met Pangolin",
"orgAuthSignInToOrg": "Log in bij een organisatie", "orgAuthSignInToOrg": "Organisatie Identiteitsprovider (SSO)",
"orgAuthSelectOrgTitle": "Organisatie Inloggen", "orgAuthSelectOrgTitle": "Organisatie Inloggen",
"orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan", "orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan",
"orgAuthOrgIdPlaceholder": "jouw-organisatie", "orgAuthOrgIdPlaceholder": "jouw-organisatie",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Zobacz informacje o wydaniu", "pangolinUpdateAvailableReleaseNotes": "Zobacz informacje o wydaniu",
"newtUpdateAvailable": "Dostępna aktualizacja", "newtUpdateAvailable": "Dostępna aktualizacja",
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", "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", "domainPickerEnterDomain": "Domena",
"domainPickerPlaceholder": "mojapp.example.com", "domainPickerPlaceholder": "mojapp.example.com",
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.", "domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować", "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.", "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", "orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin",
"orgAuthSignInToOrg": "Zaloguj się do organizacji", "orgAuthSignInToOrg": "Dostawca tożsamości organizacji (SSO)",
"orgAuthSelectOrgTitle": "Logowanie do organizacji", "orgAuthSelectOrgTitle": "Logowanie do organizacji",
"orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować", "orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować",
"orgAuthOrgIdPlaceholder": "twoja-organizacja", "orgAuthOrgIdPlaceholder": "twoja-organizacja",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Ver notas de versão", "pangolinUpdateAvailableReleaseNotes": "Ver notas de versão",
"newtUpdateAvailable": "Nova Atualização Disponível", "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.", "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", "domainPickerEnterDomain": "Domínio",
"domainPickerPlaceholder": "myapp.exemplo.com", "domainPickerPlaceholder": "myapp.exemplo.com",
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.", "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", "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.", "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", "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", "orgAuthSelectOrgTitle": "Entrada da Organização",
"orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar", "orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar",
"orgAuthOrgIdPlaceholder": "sua-organização", "orgAuthOrgIdPlaceholder": "sua-organização",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску", "pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску",
"newtUpdateAvailable": "Доступно обновление", "newtUpdateAvailable": "Доступно обновление",
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.", "newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"pangolinNodeUpdateAvailableInfo": "Доступна новая версия Pangolin Node. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"domainPickerEnterDomain": "Домен", "domainPickerEnterDomain": "Домен",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.", "domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения", "orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения",
"orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.", "orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.",
"orgAuthSignInWithPangolin": "Войти через Pangolin", "orgAuthSignInWithPangolin": "Войти через Pangolin",
"orgAuthSignInToOrg": "Войти в организацию", "orgAuthSignInToOrg": "Поставщик удостоверений организации (SSO)",
"orgAuthSelectOrgTitle": "Вход в организацию", "orgAuthSelectOrgTitle": "Вход в организацию",
"orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить", "orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить",
"orgAuthOrgIdPlaceholder": "ваша-организация", "orgAuthOrgIdPlaceholder": "ваша-организация",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "Yayın Notlarını Görüntüle", "pangolinUpdateAvailableReleaseNotes": "Yayın Notlarını Görüntüle",
"newtUpdateAvailable": "Güncelleme Mevcut", "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.", "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ı", "domainPickerEnterDomain": "Alan Adı",
"domainPickerPlaceholder": "myapp.example.com", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.", "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", "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.", "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", "orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap",
"orgAuthSignInToOrg": "Bir kuruluşa giriş yapın", "orgAuthSignInToOrg": "Kuruluş Kimlik Sağlayıcısı (SSO)",
"orgAuthSelectOrgTitle": "Kuruluş Giriş", "orgAuthSelectOrgTitle": "Kuruluş Giriş",
"orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin", "orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin",
"orgAuthOrgIdPlaceholder": "kuruluşunuz", "orgAuthOrgIdPlaceholder": "kuruluşunuz",
+2 -1
View File
@@ -1667,6 +1667,7 @@
"pangolinUpdateAvailableReleaseNotes": "查看发布说明", "pangolinUpdateAvailableReleaseNotes": "查看发布说明",
"newtUpdateAvailable": "更新可用", "newtUpdateAvailable": "更新可用",
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。", "newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
"pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。请更新到最新版本以获得最佳体验。",
"domainPickerEnterDomain": "域名", "domainPickerEnterDomain": "域名",
"domainPickerPlaceholder": "example.com", "domainPickerPlaceholder": "example.com",
"domainPickerDescription": "输入资源的完整域名以查看可用选项。", "domainPickerDescription": "输入资源的完整域名以查看可用选项。",
@@ -2354,7 +2355,7 @@
"orgAuthChooseIdpDescription": "选择您的身份提供商以继续", "orgAuthChooseIdpDescription": "选择您的身份提供商以继续",
"orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。", "orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。",
"orgAuthSignInWithPangolin": "使用 Pangolin 登录", "orgAuthSignInWithPangolin": "使用 Pangolin 登录",
"orgAuthSignInToOrg": "登录到组织", "orgAuthSignInToOrg": "组织身份提供商 (SSO)",
"orgAuthSelectOrgTitle": "组织登录", "orgAuthSelectOrgTitle": "组织登录",
"orgAuthSelectOrgDescription": "输入您的组织ID以继续", "orgAuthSelectOrgDescription": "输入您的组织ID以继续",
"orgAuthOrgIdPlaceholder": "您的组织", "orgAuthOrgIdPlaceholder": "您的组织",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

After

Width:  |  Height:  |  Size: 532 KiB

+162 -128
View File
@@ -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", "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,1": "Mac Studio",
"Mac13,2": "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", "MacBook1,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBook2,1": "MacBook", "MacBook2,1": "MacBook",
"MacBook3,1": "MacBook", "MacBook3,1": "MacBook",
"MacBook4,1": "MacBook", "MacBook4,1": "MacBook",
@@ -98,8 +57,8 @@
"MacBook7,1": "MacBook", "MacBook7,1": "MacBook",
"MacBook8,1": "MacBook", "MacBook8,1": "MacBook",
"MacBook9,1": "MacBook", "MacBook9,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBookAir1,1": "MacBook Air", "MacBookAir1,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"MacBookAir2,1": "MacBook Air", "MacBookAir2,1": "MacBook Air",
"MacBookAir3,1": "MacBook Air", "MacBookAir3,1": "MacBook Air",
"MacBookAir3,2": "MacBook Air", "MacBookAir3,2": "MacBook Air",
@@ -114,88 +73,163 @@
"MacBookAir8,1": "MacBook Air", "MacBookAir8,1": "MacBook Air",
"MacBookAir8,2": "MacBook Air", "MacBookAir8,2": "MacBook Air",
"MacBookAir9,1": "MacBook Air", "MacBookAir9,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"Mac14,2": "MacBook Air",
"MacBookPro1,1": "MacBook Pro", "MacBookPro1,1": "MacBook Pro",
"MacBookPro1,2": "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,1": "MacBook Pro",
"MacBookPro10,2": "MacBook Pro", "MacBookPro10,2": "MacBook Pro",
"MacBookPro11,1": "MacBook Pro", "MacBookPro11,1": "MacBook Pro",
"MacBookPro11,2": "MacBook Pro", "MacBookPro11,2": "MacBook Pro",
"MacBookPro11,3": "MacBook Pro", "MacBookPro11,3": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro11,4": "MacBook Pro", "MacBookPro11,4": "MacBook Pro",
"MacBookPro11,5": "MacBook Pro", "MacBookPro11,5": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro13,1": "MacBook Pro", "MacBookPro13,1": "MacBook Pro",
"MacBookPro13,2": "MacBook Pro", "MacBookPro13,2": "MacBook Pro",
"MacBookPro13,3": "MacBook Pro", "MacBookPro13,3": "MacBook Pro",
"MacBookPro14,1": "MacBook Pro", "MacBookPro14,1": "MacBook Pro",
"MacBookPro14,2": "MacBook Pro", "MacBookPro14,2": "MacBook Pro",
"MacBookPro14,3": "MacBook Pro", "MacBookPro14,3": "MacBook Pro",
"MacBookPro15,2": "MacBook Pro",
"MacBookPro15,1": "MacBook Pro", "MacBookPro15,1": "MacBook Pro",
"MacBookPro15,2": "MacBook Pro",
"MacBookPro15,3": "MacBook Pro", "MacBookPro15,3": "MacBook Pro",
"MacBookPro15,4": "MacBook Pro", "MacBookPro15,4": "MacBook Pro",
"MacBookPro16,1": "MacBook Pro", "MacBookPro16,1": "MacBook Pro",
"MacBookPro16,3": "MacBook Pro",
"MacBookPro16,2": "MacBook Pro", "MacBookPro16,2": "MacBook Pro",
"MacBookPro16,3": "MacBook Pro",
"MacBookPro16,4": "MacBook Pro", "MacBookPro16,4": "MacBook Pro",
"MacBookPro17,1": "MacBook Pro", "MacBookPro17,1": "MacBook Pro",
"MacBookPro18,3": "MacBook Pro",
"MacBookPro18,4": "MacBook Pro",
"MacBookPro18,1": "MacBook Pro", "MacBookPro18,1": "MacBook Pro",
"MacBookPro18,2": "MacBook Pro", "MacBookPro18,2": "MacBook Pro",
"Mac14,7": "MacBook Pro", "MacBookPro18,3": "MacBook Pro",
"Mac14,9": "MacBook Pro", "MacBookPro18,4": "MacBook Pro",
"Mac14,5": "MacBook Pro", "MacBookPro2,1": "MacBook Pro",
"Mac14,10": "MacBook Pro", "MacBookPro2,2": "MacBook Pro",
"Mac14,6": "MacBook Pro", "MacBookPro3,1": "MacBook Pro",
"PowerMac1,2": "Power Macintosh", "MacBookPro4,1": "MacBook Pro",
"PowerMac5,1": "Power Macintosh", "MacBookPro5,1": "MacBook Pro",
"PowerMac7,2": "Power Macintosh", "MacBookPro5,2": "MacBook Pro",
"PowerMac7,3": "Power Macintosh", "MacBookPro5,3": "MacBook Pro",
"PowerMac9,1": "Power Macintosh", "MacBookPro5,4": "MacBook Pro",
"PowerMac11,2": "Power Macintosh", "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", "PowerBook1,1": "PowerBook",
"PowerBook2,1": "iBook",
"PowerBook2,2": "iBook",
"PowerBook3,1": "PowerBook", "PowerBook3,1": "PowerBook",
"PowerBook3,2": "PowerBook", "PowerBook3,2": "PowerBook",
"PowerBook3,3": "PowerBook", "PowerBook3,3": "PowerBook",
"PowerBook3,4": "PowerBook", "PowerBook3,4": "PowerBook",
"PowerBook3,5": "PowerBook", "PowerBook3,5": "PowerBook",
"PowerBook6,1": "PowerBook", "PowerBook4,1": "iBook",
"PowerBook4,2": "iBook",
"PowerBook4,3": "iBook",
"PowerBook5,1": "PowerBook", "PowerBook5,1": "PowerBook",
"PowerBook6,2": "PowerBook",
"PowerBook5,2": "PowerBook", "PowerBook5,2": "PowerBook",
"PowerBook5,3": "PowerBook", "PowerBook5,3": "PowerBook",
"PowerBook6,4": "PowerBook",
"PowerBook5,4": "PowerBook", "PowerBook5,4": "PowerBook",
"PowerBook5,5": "PowerBook", "PowerBook5,5": "PowerBook",
"PowerBook6,8": "PowerBook",
"PowerBook5,6": "PowerBook", "PowerBook5,6": "PowerBook",
"PowerBook5,7": "PowerBook", "PowerBook5,7": "PowerBook",
"PowerBook5,8": "PowerBook", "PowerBook5,8": "PowerBook",
"PowerBook5,9": "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,1": "Xserve",
"RackMac1,2": "Xserve", "RackMac1,2": "Xserve",
"RackMac3,1": "Xserve", "RackMac3,1": "Xserve",
"Xserve1,1": "Xserve", "Xserve1,1": "Xserve",
"Xserve2,1": "Xserve", "Xserve2,1": "Xserve",
"Xserve3,1": "Xserve" "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"
}
+265 -9
View File
@@ -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( export async function fireHealthCheckHealthyAlert(
orgId: string, orgId: string,
healthCheckId: number, healthCheckId: number,
healthCheckName?: string, healthCheckName?: string | null,
healthCheckTargetId?: number | null, healthCheckTargetId?: number | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
send: boolean = true, send: boolean = true,
trx?: unknown trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
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( export async function fireHealthCheckUnhealthyAlert(
orgId: string, orgId: string,
healthCheckId: number, healthCheckId: number,
healthCheckName?: string, healthCheckName?: string | null,
healthCheckTargetId?: number | null, healthCheckTargetId?: number | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
send: boolean = true, send: boolean = true,
trx?: unknown trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
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( export async function fireHealthCheckUnknownAlert(
@@ -31,7 +157,137 @@ export async function fireHealthCheckUnknownAlert(
healthCheckTargetId?: number | null, healthCheckTargetId?: number | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
send: boolean = true, send: boolean = true,
trx?: unknown trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
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
);
}
}
} }
+224 -7
View File
@@ -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( export async function fireResourceHealthyAlert(
orgId: string, orgId: string,
resourceId: number, resourceId: number,
resourceName?: string | null, resourceName?: string | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
send: boolean = true, send: boolean = true,
trx?: unknown trx: Transaction | typeof db = db
): Promise<void> {} ): Promise<void> {
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( export async function fireResourceUnhealthyAlert(
orgId: string, orgId: string,
resourceId: number, resourceId: number,
resourceName?: string | null, resourceName?: string | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
send: boolean = true, send: boolean = true,
trx?: unknown trx: Transaction | typeof db = db
): Promise<void> {} ): Promise<void> {
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, orgId: string,
resourceId: number, resourceId: number,
resourceName?: string | null, resourceName?: string | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
send: boolean = true, send: boolean = true,
trx?: unknown trx: Transaction | typeof db = db
): Promise<void> {} ): Promise<void> {
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<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
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
);
}
}
+141 -6
View File
@@ -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( export async function fireSiteOnlineAlert(
orgId: string, orgId: string,
siteId: number, siteId: number,
siteName?: string, siteName?: string,
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
trx?: unknown trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
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( export async function fireSiteOfflineAlert(
orgId: string, orgId: string,
siteId: number, siteId: number,
siteName?: string, siteName?: string,
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
trx?: unknown trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
return; 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
);
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./events/siteEvents"; export * from "./events/siteEvents";
export * from "./events/healthCheckEvents"; export * from "./events/healthCheckEvents";
export * from "./events/resourceEvents"; export * from "./events/resourceEvents";
export * from "./processAlerts";
+5
View File
@@ -0,0 +1,5 @@
import { AlertContext } from "@server/routers/alertRule/types";
export async function processAlerts(context: AlertContext): Promise<void> {
return;
}
+3 -2
View File
@@ -1,8 +1,9 @@
export async function getOrgTierData( export async function getOrgTierData(
orgId: string orgId: string
): Promise<{ tier: string | null; active: boolean }> { ): Promise<{ tier: string | null; active: boolean; isTrial: boolean }> {
const tier = null; const tier = null;
const active = false; const active = false;
const isTrial = false;
return { tier, active }; return { tier, active, isTrial };
} }
+16 -7
View File
@@ -34,7 +34,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
export type ProxyResourcesResults = { export type ProxyResourcesResults = {
@@ -165,7 +165,8 @@ export async function updateProxyResources(
hcStatus: healthcheckData?.status, hcStatus: healthcheckData?.status,
hcHealth: "unknown", hcHealth: "unknown",
hcHealthyThreshold: healthcheckData?.["healthy-threshold"], hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"] hcUnhealthyThreshold:
healthcheckData?.["unhealthy-threshold"]
}) })
.returning(); .returning();
@@ -544,8 +545,10 @@ export async function updateProxyResources(
healthcheckData?.["follow-redirects"], healthcheckData?.["follow-redirects"],
hcMethod: healthcheckData?.method, hcMethod: healthcheckData?.method,
hcStatus: healthcheckData?.status, hcStatus: healthcheckData?.status,
hcHealthyThreshold: healthcheckData?.["healthy-threshold"], hcHealthyThreshold:
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"] healthcheckData?.["healthy-threshold"],
hcUnhealthyThreshold:
healthcheckData?.["unhealthy-threshold"]
}) })
.where( .where(
eq( eq(
@@ -1120,8 +1123,10 @@ function checkIfHealthcheckChanged(
JSON.stringify(incoming.hcHeaders) JSON.stringify(incoming.hcHeaders)
) )
return true; return true;
if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold) return true; if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold)
if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold) return true; return true;
if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold)
return true;
return false; return false;
} }
@@ -1184,7 +1189,11 @@ async function getDomainId(
orgId: string, orgId: string,
fullDomain: string, fullDomain: string,
trx: Transaction 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 isWildcardFullDomain = fullDomain.startsWith("*.");
const possibleDomains = await trx const possibleDomains = await trx
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // 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 __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);
@@ -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<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
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<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
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<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
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
);
}
}
}
@@ -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<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
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<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
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<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
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<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
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
);
}
}
@@ -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<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
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<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
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
);
}
}
-3
View File
@@ -14,6 +14,3 @@
export * from "./processAlerts"; export * from "./processAlerts";
export * from "./sendAlertWebhook"; export * from "./sendAlertWebhook";
export * from "./sendAlertEmail"; export * from "./sendAlertEmail";
export * from "./events/siteEvents";
export * from "./events/healthCheckEvents";
export * from "./events/resourceEvents";
+65 -10
View File
@@ -42,17 +42,23 @@ export async function sendAlertWebhook(
webhookConfig: WebhookAlertConfig, webhookConfig: WebhookAlertConfig,
context: AlertContext context: AlertContext
): Promise<void> { ): Promise<void> {
const payload = { const eventType = context.eventType;
event: context.eventType, const timestamp = new Date().toISOString();
timestamp: new Date().toISOString(), const status = deriveStatus(eventType, context.data);
status: deriveStatus(context.eventType, context.data), const data = { orgId: context.orgId, ...context.data };
data: {
orgId: context.orgId, let body: string;
...context.data 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); const headers = buildHeaders(webhookConfig);
let lastError: Error | undefined; let lastError: Error | undefined;
@@ -217,3 +223,52 @@ function buildHeaders(
return headers; return headers;
} }
// ---------------------------------------------------------------------------
// Body template rendering
// ---------------------------------------------------------------------------
interface TemplateContext {
event: string;
timestamp: string;
status: string;
data: Record<string, unknown>;
}
/**
* 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);
}
+5 -1
View File
@@ -45,6 +45,10 @@ export interface WebhookAlertConfig {
headers?: Array<{ key: string; value: string }>; headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */ /** HTTP method (default POST) */
method?: string; 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; healthCheckId?: number;
/** Human-readable context data included in emails and webhook payloads */ /** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>; data: Record<string, unknown>;
} }
@@ -24,7 +24,7 @@ import { eq, and } from "drizzle-orm";
import { import {
fireHealthCheckHealthyAlert, fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert fireHealthCheckUnhealthyAlert
} from "#private/lib/alerts/events/healthCheckEvents"; } from "@server/lib/alerts";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty(), orgId: z.string().nonempty(),
@@ -73,10 +73,7 @@ export async function triggerHealthCheckAlert(
.from(targetHealthCheck) .from(targetHealthCheck)
.where( .where(
and( and(
eq( eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
targetHealthCheck.targetHealthCheckId,
healthCheckId
),
eq(targetHealthCheck.orgId, orgId) eq(targetHealthCheck.orgId, orgId)
) )
) )
@@ -25,7 +25,7 @@ import {
fireResourceHealthyAlert, fireResourceHealthyAlert,
fireResourceUnhealthyAlert, fireResourceUnhealthyAlert,
fireResourceDegradedAlert fireResourceDegradedAlert
} from "#private/lib/alerts/events/resourceEvents"; } from "@server/lib/alerts";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty(), orgId: z.string().nonempty(),
@@ -21,10 +21,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { import { fireSiteOnlineAlert, fireSiteOfflineAlert } from "@server/lib/alerts";
fireSiteOnlineAlert,
fireSiteOfflineAlert
} from "#private/lib/alerts/events/siteEvents";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty(), orgId: z.string().nonempty(),
@@ -22,7 +22,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
import { fireHealthCheckUnhealthyAlert } from "#private/lib/alerts"; import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -22,7 +22,11 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; 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 const paramsSchema = z
.object({ .object({
@@ -234,7 +238,10 @@ export async function updateHealthCheck(
) )
.returning(); .returning();
if (updated.hcHealth === "unhealthy" && existingHealthCheck.hcHealth !== "unhealthy") { if (
updated.hcHealth === "unhealthy" &&
existingHealthCheck.hcHealth !== "unhealthy"
) {
await fireHealthCheckUnhealthyAlert( await fireHealthCheckUnhealthyAlert(
updated.orgId, updated.orgId,
updated.targetHealthCheckId, updated.targetHealthCheckId,
@@ -243,7 +250,10 @@ export async function updateHealthCheck(
undefined, undefined,
false // dont send the alert because we just want to create the alert, not notify users yet 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 // if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert( await fireHealthCheckUnknownAlert(
updated.orgId, updated.orgId,
@@ -253,7 +263,10 @@ export async function updateHealthCheck(
undefined, undefined,
false // dont send the alert because we just want to create the alert, not notify users yet 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( await fireHealthCheckHealthyAlert(
updated.orgId, updated.orgId,
updated.targetHealthCheckId, updated.targetHealthCheckId,
@@ -264,7 +277,6 @@ export async function updateHealthCheck(
); );
} }
// Push updated health check to newt if the site is a newt site // Push updated health check to newt if the site is a newt site
const [newt] = await db const [newt] = await db
.select() .select()
@@ -22,6 +22,91 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; 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<string | null> {
try {
const cachedVersion = await cache.get<string>(
"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<string>();
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({ const listRemoteExitNodesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -118,9 +203,41 @@ export async function listRemoteExitNodes(
const totalCountResult = await countQuery; const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count; 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<ListRemoteExitNodesResponse>(res, { return response<ListRemoteExitNodesResponse>(res, {
data: { data: {
remoteExitNodes: remoteExitNodesList, remoteExitNodes: nodesWithUpdates,
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, limit,
+4
View File
@@ -80,6 +80,10 @@ export interface WebhookAlertConfig {
headers?: Array<{ key: string; value: string }>; headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */ /** HTTP method (default POST) */
method?: string; method?: string;
/** Whether to use a custom body template */
useBodyTemplate?: boolean;
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
bodyTemplate?: string;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+14 -23
View File
@@ -336,31 +336,22 @@ export async function validateOidcCallback(
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)); .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
allOrgs = idpOrgs.map((o) => o.orgs); allOrgs = idpOrgs.map((o) => o.orgs);
// TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1 for (const org of allOrgs) {
if (allOrgs.length > 1) { const subscribed = await isSubscribed(
// for some reason there is more than one org org.orgId,
logger.error( tierMatrix.autoProvisioning
"More than one organization linked to this IdP. This should not happen with auto-provisioning enabled."
); );
return next( if (!subscribed) {
createHttpError( // filter out the org
HttpCode.INTERNAL_SERVER_ERROR, allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
"Multiple organizations linked to this IdP. Please contact support."
)
);
}
const subscribed = await isSubscribed( // return next(
allOrgs[0].orgId, // createHttpError(
tierMatrix.autoProvisioning // HttpCode.FORBIDDEN,
); // "This organization's current plan does not support this feature."
if (!subscribed) { // )
return next( // );
createHttpError( }
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
} }
} else { } else {
allOrgs = await db.select().from(orgs); allOrgs = await db.select().from(orgs);
@@ -1,12 +1,8 @@
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import { db, Newt, sites } from "@server/db";
db,
Newt,
sites
} from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; 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 * 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!)) .where(eq(sites.siteId, newt.siteId!))
.returning(); .returning();
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name, undefined, trx); await fireSiteOfflineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
}); });
} catch (error) { } catch (error) {
logger.error("Error handling disconnecting message", { error }); logger.error("Error handling disconnecting message", { error });
+2 -6
View File
@@ -1,12 +1,8 @@
import { import { db, newts, sites } from "@server/db";
db,
newts,
sites
} from "@server/db";
import { hasActiveConnections } from "#dynamic/routers/ws"; import { hasActiveConnections } from "#dynamic/routers/ws";
import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm"; import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm";
import logger from "@server/logger"; 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 // Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null; let offlineCheckerInterval: NodeJS.Timeout | null = null;
+13 -3
View File
@@ -2,7 +2,7 @@ import { db } from "@server/db";
import { sites, clients, olms } from "@server/db"; import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts"; import { fireSiteOnlineAlert } from "@server/lib/alerts";
/** /**
* Ping Accumulator * Ping Accumulator
@@ -127,7 +127,11 @@ async function flushSitePingsToDb(): Promise<void> {
eq(sites.online, false) 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. // Update lastPing for sites that were already online.
// After the update above, the newly-online sites now have // After the update above, the newly-online sites now have
@@ -148,7 +152,13 @@ async function flushSitePingsToDb(): Promise<void> {
for (const site of newlyOnlineSites) { for (const site of newlyOnlineSites) {
await db.transaction(async (trx) => { 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) { } catch (error) {
+1
View File
@@ -21,6 +21,7 @@ export type ListRemoteExitNodesResponse = {
remoteExitNodeId: string; remoteExitNodeId: string;
dateCreated: string; dateCreated: string;
version: string | null; version: string | null;
updateAvailable?: boolean;
exitNodeId: number | null; exitNodeId: number | null;
name: string; name: string;
address: string; address: string;
+1 -1
View File
@@ -23,7 +23,7 @@ import {
fireHealthCheckHealthyAlert, fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert, fireHealthCheckUnhealthyAlert,
fireHealthCheckUnknownAlert fireHealthCheckUnknownAlert
} from "#dynamic/lib/alerts"; } from "@server/lib/alerts";
const createTargetParamsSchema = z.strictObject({ const createTargetParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive()) resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -6,7 +6,7 @@ import logger from "@server/logger";
import { import {
fireHealthCheckHealthyAlert, fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert fireHealthCheckUnhealthyAlert
} from "#dynamic/lib/alerts"; } from "@server/lib/alerts";
interface TargetHealthStatus { interface TargetHealthStatus {
status: string; status: string;
+26 -8
View File
@@ -10,7 +10,11 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets"; 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 { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -169,7 +173,7 @@ export async function updateTarget(
let updatedTarget: any; let updatedTarget: any;
let updatedHc: any; let updatedHc: any;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
[updatedTarget] = await trx [updatedTarget] = await trx
.update(targets) .update(targets)
.set({ .set({
siteId: parsedBody.data.siteId, siteId: parsedBody.data.siteId,
@@ -181,8 +185,12 @@ export async function updateTarget(
path: parsedBody.data.path, path: parsedBody.data.path,
pathMatchType: parsedBody.data.pathMatchType, pathMatchType: parsedBody.data.pathMatchType,
priority: parsedBody.data.priority, priority: parsedBody.data.priority,
rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, rewritePath: pathMatchTypeRemoved
rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType ? null
: parsedBody.data.rewritePath,
rewritePathType: pathMatchTypeRemoved
? null
: parsedBody.data.rewritePathType
}) })
.where(eq(targets.targetId, targetId)) .where(eq(targets.targetId, targetId))
.returning(); .returning();
@@ -213,7 +221,8 @@ export async function updateTarget(
// If hcEnabled is being turned on (was false, now true), set to "unhealthy" // 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. // so the target must pass a health check before being considered healthy.
const hcEnabledTurnedOn = const hcEnabledTurnedOn =
parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; parsedBody.data.hcEnabled === true &&
existingHc.hcEnabled === false;
let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined;
if ( if (
@@ -253,7 +262,10 @@ export async function updateTarget(
.where(eq(targetHealthCheck.targetId, targetId)) .where(eq(targetHealthCheck.targetId, targetId))
.returning(); .returning();
if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { if (
updatedHc.hcHealth === "unhealthy" &&
existingHc.hcHealth !== "unhealthy"
) {
logger.debug( logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert` `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 false, // dont send the alert because we just want to create the alert, not notify users yet
trx trx
); );
} else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { } else if (
updatedHc.hcHealth === "unknown" &&
existingHc.hcHealth !== "unknown"
) {
logger.debug( logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert` `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 false, // dont send the alert because we just want to create the alert, not notify users yet
trx trx
); );
} else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { } else if (
updatedHc.hcHealth === "healthy" &&
existingHc.hcHealth !== "healthy"
) {
logger.debug( logger.debug(
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert` `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert`
); );
@@ -45,6 +45,7 @@ export default async function RemoteExitNodesPage(
type: node.type, type: node.type,
dateCreated: node.dateCreated, dateCreated: node.dateCreated,
version: node.version || undefined, version: node.version || undefined,
updateAvailable: node.updateAvailable,
orgId: params.orgId orgId: params.orgId
}; };
} }
+14 -1
View File
@@ -160,6 +160,18 @@ export default async function Page(props: {
redirect={redirectUrl} redirect={redirectUrl}
forceLogin={forceLogin} forceLogin={forceLogin}
defaultUser={defaultUser} defaultUser={defaultUser}
orgSignIn={
!isInvite &&
(build === "saas" ||
env.app.identityProviderMode === "org")
? {
href: `/auth/org${buildQueryString(searchParams)}`,
linkText: t("orgAuthSignInToOrg"),
descriptionText:
t("needToSignInToOrg")
}
: undefined
}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -195,7 +207,8 @@ export default async function Page(props: {
</p> </p>
)} )}
{!isInvite && {!useSmartLogin &&
!isInvite &&
(build === "saas" || env.app.identityProviderMode === "org") ? ( (build === "saas" || env.app.identityProviderMode === "org") ? (
<OrgSignInLink <OrgSignInLink
href={`/auth/org${buildQueryString(searchParams)}`} href={`/auth/org${buildQueryString(searchParams)}`}
+7 -9
View File
@@ -145,21 +145,19 @@ const CredenzaTitle = ({ className, children, ...props }: CredenzaProps) => {
}; };
const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => { const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
// return (
// <div className={cn("px-4 md:px-0 mb-4", className)} {...props}>
// {children}
// </div>
// );
return ( return (
<div <div
className={cn( className={cn(
"min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto overflow-x-hidden px-0", "relative min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-0",
className className
)} )}
{...props} {...props}
> >
{children} <div className="space-y-4">{children}</div>
<div
className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent"
aria-hidden
/>
</div> </div>
); );
}; };
@@ -172,7 +170,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
return ( return (
<CredenzaFooter <CredenzaFooter
className={cn( className={cn(
"mt-8 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card md:mt-0 md:-mb-4 md:gap-0", "-mt-4 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card md:-mb-4 md:gap-0",
className className
)} )}
{...props} {...props}
+8 -1
View File
@@ -21,6 +21,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { InfoPopup } from "@app/components/ui/info-popup";
export type RemoteExitNodeRow = { export type RemoteExitNodeRow = {
id: string; id: string;
@@ -33,6 +34,7 @@ export type RemoteExitNodeRow = {
online: boolean; online: boolean;
dateCreated: string; dateCreated: string;
version?: string; version?: string;
updateAvailable?: boolean;
}; };
type ExitNodesTableProps = { type ExitNodesTableProps = {
@@ -233,13 +235,18 @@ export default function ExitNodesTable({
const originalRow = row.original; const originalRow = row.original;
return ( return (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{originalRow.version && originalRow.version ? ( {originalRow.version ? (
<Badge variant="secondary"> <Badge variant="secondary">
{"v" + originalRow.version} {"v" + originalRow.version}
</Badge> </Badge>
) : ( ) : (
"-" "-"
)} )}
{originalRow.updateAvailable && (
<InfoPopup
info={t("pangolinNodeUpdateAvailableInfo")}
/>
)}
</div> </div>
); );
} }
+33 -9
View File
@@ -5,11 +5,15 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { cn } from "@app/lib/cn";
import { Building2 } from "lucide-react";
type OrgSignInLinkProps = { type OrgSignInLinkProps = {
href: string; href: string;
linkText: string; linkText: string;
descriptionText: string; descriptionText: string;
primaryActionVariant?: "link" | "button";
className?: string;
}; };
const STORAGE_KEY_CLICKED = "orgSignInLinkClicked"; const STORAGE_KEY_CLICKED = "orgSignInLinkClicked";
@@ -18,7 +22,9 @@ const STORAGE_KEY_ACKNOWLEDGED = "orgSignInTipAcknowledged";
export default function OrgSignInLink({ export default function OrgSignInLink({
href, href,
linkText, linkText,
descriptionText descriptionText,
primaryActionVariant = "link",
className
}: OrgSignInLinkProps) { }: OrgSignInLinkProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
@@ -93,14 +99,32 @@ export default function OrgSignInLink({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<div className="text-sm text-center text-muted-foreground mt-8 flex flex-col items-center"> <div
<span>{descriptionText}</span> className={cn(
<button "",
onClick={handleClick} primaryActionVariant === "button" && "gap-3",
className="underline text-inherit bg-transparent border-none p-0 cursor-pointer" className
> )}
{linkText} >
</button> {primaryActionVariant === "button" ? (
<Button
type="button"
variant="outline"
className="w-full inline-flex items-center gap-2"
onClick={handleClick}
>
<Building2 className="size-4 shrink-0" aria-hidden />
<span>{linkText}</span>
</Button>
) : (
<button
type="button"
onClick={handleClick}
className="underline text-inherit bg-transparent border-none p-0 cursor-pointer"
>
{linkText}
</button>
)}
</div> </div>
</> </>
); );
+70 -3
View File
@@ -15,15 +15,18 @@ import {
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useUserLookup } from "@app/hooks/useUserLookup"; import { useUserLookup } from "@app/hooks/useUserLookup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { LookupUserResponse } from "@server/routers/auth/lookupUser"; import { LookupUserResponse } from "@server/routers/auth/lookupUser";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import LoginPasswordForm from "@app/components/LoginPasswordForm"; import LoginPasswordForm from "@app/components/LoginPasswordForm";
import LoginOrgSelector from "@app/components/LoginOrgSelector"; import LoginOrgSelector from "@app/components/LoginOrgSelector";
import UserProfileCard from "@app/components/UserProfileCard"; import UserProfileCard from "@app/components/UserProfileCard";
import { ArrowLeft } from "lucide-react";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { Separator } from "@app/components/ui/separator";
import OrgSignInLink from "@app/components/OrgSignInLink";
const identifierSchema = z.object({ const identifierSchema = z.object({
identifier: z.string().min(1, "Username or email is required") 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 = { type SmartLoginFormProps = {
redirect?: string; redirect?: string;
forceLogin?: boolean; forceLogin?: boolean;
defaultUser?: string; defaultUser?: string;
orgSignIn?: OrgSignInConfig;
}; };
type ViewState = type ViewState =
@@ -58,12 +68,31 @@ type ViewState =
lookupResult: LookupUserResponse; 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({ export default function SmartLoginForm({
redirect, redirect,
forceLogin, forceLogin,
defaultUser defaultUser,
orgSignIn
}: SmartLoginFormProps) { }: SmartLoginFormProps) {
const router = useRouter(); const router = useRouter();
const { env } = useEnvContext();
const { lookup, loading, error } = useUserLookup(); const { lookup, loading, error } = useUserLookup();
const t = useTranslations(); const t = useTranslations();
const [viewState, setViewState] = useState<ViewState>({ type: "initial" }); const [viewState, setViewState] = useState<ViewState>({ 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); const hasAutoLookedUp = useRef(false);
useEffect(() => { useEffect(() => {
if (defaultUser?.trim() && !hasAutoLookedUp.current) { if (defaultUser?.trim() && !hasAutoLookedUp.current) {
@@ -209,6 +245,15 @@ export default function SmartLoginForm({
)} )}
/> />
<div className="text-center">
<Link
href={resetPasswordHref}
className="text-sm text-muted-foreground"
>
{t("passwordForgot")}
</Link>
</div>
{(error || securityKeyError) && ( {(error || securityKeyError) && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription> <AlertDescription>
@@ -219,7 +264,7 @@ export default function SmartLoginForm({
</form> </form>
</Form> </Form>
<div className="space-y-2"> <div className="space-y-4">
<Button <Button
type="submit" type="submit"
form="form" form="form"
@@ -236,6 +281,28 @@ export default function SmartLoginForm({
onError={setSecurityKeyError} onError={setSecurityKeyError}
disabled={loading} disabled={loading}
/> />
{orgSignIn && (
<>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground">
{t("idpContinue")}
</span>
</div>
</div>
<OrgSignInLink
href={orgSignIn.href}
linkText={orgSignIn.linkText}
descriptionText={orgSignIn.descriptionText}
primaryActionVariant="button"
className="mt-0"
/>
</>
)}
</div> </div>
</div> </div>
); );
@@ -12,12 +12,15 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { import {
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Switch } from "@app/components/ui/switch";
import { Textarea } from "@app/components/ui/textarea";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -962,6 +965,69 @@ function WebhookActionFields({
/> />
</div> </div>
<WebhookHeadersField index={index} control={control} form={form} /> <WebhookHeadersField index={index} control={control} form={form} />
{/* Body Template */}
<div className="space-y-3">
<div>
<label className="font-medium text-sm block">
{t("httpDestBodyTemplateTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestBodyTemplateDescription")}
</p>
</div>
<FormField
control={control}
name={`actions.${index}.useBodyTemplate`}
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-3">
<FormControl>
<Switch
id={`body-template-${index}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<Label
htmlFor={`body-template-${index}`}
className="cursor-pointer"
>
{t("httpDestEnableBodyTemplate")}
</Label>
</div>
</FormItem>
)}
/>
{useWatch({
control,
name: `actions.${index}.useBodyTemplate`
}) && (
<FormField
control={control}
name={`actions.${index}.bodyTemplate`}
render={({ field }) => (
<FormItem>
<FormLabel>
{t("httpDestBodyTemplateLabel")}
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={
'{\n "event": "{{event}}",\n "timestamp": "{{timestamp}}",\n "status": "{{status}}",\n "data": {{data}}\n}'
}
className="font-mono text-xs min-h-45 resize-y"
/>
</FormControl>
<FormDescription>
{t("httpDestBodyTemplateHint")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div> </div>
); );
} }
@@ -368,7 +368,9 @@ export default function AlertRuleGraphEditor({
customHeaderName: customHeaderName:
"", "",
customHeaderValue: customHeaderValue:
"" "",
useBodyTemplate: false,
bodyTemplate: ""
}); });
} }
}} }}
+15 -3
View File
@@ -45,6 +45,8 @@ export type AlertRuleFormAction =
basicCredentials: string; basicCredentials: string;
customHeaderName: string; customHeaderName: string;
customHeaderValue: string; customHeaderValue: string;
useBodyTemplate: boolean;
bodyTemplate: string;
}; };
export type AlertRuleFormValues = { export type AlertRuleFormValues = {
@@ -130,6 +132,8 @@ export type AlertRuleApiResponse = {
customHeaderValue?: string; customHeaderValue?: string;
headers?: { key: string; value: string }[]; headers?: { key: string; value: string }[];
method?: string; method?: string;
useBodyTemplate?: boolean;
bodyTemplate?: string;
} | null; } | null;
}[]; }[];
}; };
@@ -187,7 +191,9 @@ export function buildFormSchema(t: (k: string) => string) {
bearerToken: z.string(), bearerToken: z.string(),
basicCredentials: z.string(), basicCredentials: z.string(),
customHeaderName: z.string(), customHeaderName: z.string(),
customHeaderValue: z.string() customHeaderValue: z.string(),
useBodyTemplate: z.boolean().default(false),
bodyTemplate: z.string().default("")
}) })
]) ])
) )
@@ -415,7 +421,9 @@ export function apiResponseToFormValues(
bearerToken: cfg?.bearerToken ?? "", bearerToken: cfg?.bearerToken ?? "",
basicCredentials: cfg?.basicCredentials ?? "", basicCredentials: cfg?.basicCredentials ?? "",
customHeaderName: cfg?.customHeaderName ?? "", customHeaderName: cfg?.customHeaderName ?? "",
customHeaderValue: cfg?.customHeaderValue ?? "" customHeaderValue: cfg?.customHeaderValue ?? "",
useBodyTemplate: cfg?.useBodyTemplate ?? false,
bodyTemplate: cfg?.bodyTemplate ?? ""
}); });
} }
@@ -479,7 +487,11 @@ export function formValuesToApiPayload(
customHeaderName: action.customHeaderName || undefined, customHeaderName: action.customHeaderName || undefined,
customHeaderValue: action.customHeaderValue || undefined, customHeaderValue: action.customHeaderValue || undefined,
headers: action.headers.filter((h) => h.key.trim()), headers: action.headers.filter((h) => h.key.trim()),
method: action.method method: action.method,
useBodyTemplate: action.useBodyTemplate || undefined,
bodyTemplate: action.useBodyTemplate
? action.bodyTemplate || undefined
: undefined
}) })
}); });
} }