Compare commits
158 Commits
1.17.0-rc.
...
1.17.1-s.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18ea66def | ||
|
|
93998f9fd5 | ||
|
|
c554e69514 | ||
|
|
a6e10e55cc | ||
|
|
41f541a531 | ||
|
|
9cb1043545 | ||
|
|
96e33d33b0 | ||
|
|
ccc7003ac1 | ||
|
|
93cbd47b5d | ||
|
|
8b808e44b6 | ||
|
|
0644e26297 | ||
|
|
682653b977 | ||
|
|
0053cfc8fc | ||
|
|
5cb62a30cc | ||
|
|
e596a63058 | ||
|
|
3ec32afb37 | ||
|
|
0189a86757 | ||
|
|
ee32307654 | ||
|
|
2f08e6b838 | ||
|
|
c8a3fc350d | ||
|
|
dc63ef1284 | ||
|
|
92332fb02f | ||
|
|
acc6a26654 | ||
|
|
2bd4d2faaf | ||
|
|
1e77ead488 | ||
|
|
c008ef7c1b | ||
|
|
02dfeed3ce | ||
|
|
34cc2e0ed1 | ||
|
|
f5d0694574 | ||
|
|
f91da2ec46 | ||
|
|
89471a0174 | ||
|
|
0cb04d0290 | ||
|
|
e118e5b047 | ||
|
|
7e4e8ea266 | ||
|
|
2f386f8e47 | ||
|
|
f4ea572f6b | ||
|
|
825df7da63 | ||
|
|
cd34f0a7b0 | ||
|
|
b1b22c439a | ||
|
|
eac747849b | ||
|
|
1aedf9da0a | ||
|
|
840684aeba | ||
|
|
f57012eb90 | ||
|
|
34387d9859 | ||
|
|
80f5914fdd | ||
|
|
eaa70da4dd | ||
|
|
466f137590 | ||
|
|
028df8bf27 | ||
|
|
28ef5238c9 | ||
|
|
7d3d5b2b22 | ||
|
|
81eba50c9a | ||
|
|
3436105bec | ||
|
|
d948d2ec33 | ||
|
|
4b3375ab8e | ||
|
|
6b8a3c8d77 | ||
|
|
ba9794c067 | ||
|
|
6ce165bfd5 | ||
|
|
eb4b2daaab | ||
|
|
8cbc8dec89 | ||
|
|
e89e60d50b | ||
|
|
c45308f234 | ||
|
|
40205c40c5 | ||
|
|
f3fe2dd33b | ||
|
|
8edcc45033 | ||
|
|
91471a4aca | ||
|
|
ae2c37a2f6 | ||
|
|
c8208f0a88 | ||
|
|
e11dfbd29c | ||
|
|
b375d20598 | ||
|
|
c4b82c69f8 | ||
|
|
c9a00420a0 | ||
|
|
36ef9cd442 | ||
|
|
5e08779ab0 | ||
|
|
16a0e1ce7b | ||
|
|
8b03484ade | ||
|
|
9da9974adf | ||
|
|
6f80cf3db2 | ||
|
|
76d8f44779 | ||
|
|
700c92efcb | ||
|
|
d17e0c9f50 | ||
|
|
f00b9794f5 | ||
|
|
daff59c93f | ||
|
|
aa8954366c | ||
|
|
87464d53bd | ||
|
|
e04f17c9aa | ||
|
|
b25e3499d8 | ||
|
|
2e6f74a6f8 | ||
|
|
8eee0ca5a5 | ||
|
|
c2ebc0a0ff | ||
|
|
03c905a7af | ||
|
|
035644eaf7 | ||
|
|
8ce45a1acd | ||
|
|
16e7233a3e | ||
|
|
a331dd3fb4 | ||
|
|
e3e2938b28 | ||
|
|
73e96b1b28 | ||
|
|
b8194295ec | ||
|
|
382a46dfff | ||
|
|
1f74e1b320 | ||
|
|
fee780cb81 | ||
|
|
5056cba85d | ||
|
|
dab38ff82c | ||
|
|
d83fa63af5 | ||
|
|
d5837ab718 | ||
|
|
f85cfc4c68 | ||
|
|
0b2aceafe0 | ||
|
|
059db34a53 | ||
|
|
bc1ea86b4e | ||
|
|
9f2ced1933 | ||
|
|
013cff9b6e | ||
|
|
aa19437031 | ||
|
|
e848ef848b | ||
|
|
bb6605337f | ||
|
|
8df8383468 | ||
|
|
a7e9de3ac4 | ||
|
|
8df41f514e | ||
|
|
c2bf50b121 | ||
|
|
4e7dcbd7b5 | ||
|
|
b7ccb92236 | ||
|
|
23a151dd45 | ||
|
|
122079ddb2 | ||
|
|
1d0b0ae6ec | ||
|
|
a55dd769cf | ||
|
|
f1a0bc97e3 | ||
|
|
a57dfd1d12 | ||
|
|
c0a8304b91 | ||
|
|
ab7b968e28 | ||
|
|
f10b40c3b0 | ||
|
|
7878ac9c76 | ||
|
|
0752951842 | ||
|
|
06bb6636a1 | ||
|
|
2fdd332a31 | ||
|
|
98b1e9546a | ||
|
|
184aa65c6d | ||
|
|
70b3a432a4 | ||
|
|
fb4fc75bd8 | ||
|
|
0479ed9e7f | ||
|
|
1dc3409135 | ||
|
|
1bb89fce26 | ||
|
|
8f3fbb94d2 | ||
|
|
e8c35bec1c | ||
|
|
728e7252eb | ||
|
|
1218507f7d | ||
|
|
a2dff0a35d | ||
|
|
f411180908 | ||
|
|
231a19b679 | ||
|
|
58a87a986a | ||
|
|
61a78ef352 | ||
|
|
e28e5ebb4e | ||
|
|
19cef8c453 | ||
|
|
1290d6cd5c | ||
|
|
ad301074db | ||
|
|
30a756d254 | ||
|
|
363c13c387 | ||
|
|
08e4afaef0 | ||
|
|
69aa6e2d1d | ||
|
|
547865e0da | ||
|
|
3a9e79e6d5 |
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @oschwartz10612 @miloschwartz
|
||||
58
README.md
@@ -35,43 +35,53 @@
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.pangolin.net/careers/join-us">
|
||||
<img src="https://img.shields.io/badge/🚀_We're_Hiring!-Join_Our_Team-brightgreen?style=for-the-badge" alt="We're Hiring!" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>
|
||||
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources, all with zero-trust security and granular access control.
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
||||
|
||||
## Installation
|
||||
|
||||
- Check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to install and set up Pangolin.
|
||||
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer.
|
||||
- Get started for free with [Pangolin Cloud](https://app.pangolin.net/).
|
||||
- Or, check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to self-host Pangolin.
|
||||
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer.
|
||||
|
||||
<img src="public/screenshots/hero.png" />
|
||||
<img src="public/screenshots/hero.png" alt="Pangolin" width="100%" />
|
||||
|
||||
## Deployment Options
|
||||
|
||||
| <img width=500 /> | Description |
|
||||
|-----------------|--------------|
|
||||
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. |
|
||||
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
|
||||
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
|
||||
- **Pangolin Cloud** — Fully managed service - no infrastructure required.
|
||||
- **Self-Host: Community Edition** — Free, open source, and licensed under AGPL-3.
|
||||
- **Self-Host: Enterprise Edition** — Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
||||
|
||||
## Key Features
|
||||
|
||||
| <img width=500 /> | <img width=500 /> |
|
||||
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
|
||||
| **Connect remote networks with sites**<br /><br />Pangolin's lightweight site connectors create secure tunnels from remote networks without requiring public IP addresses or open ports. Sites make any network anywhere available for authorized access. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
|
||||
| **Browser-based reverse proxy access**<br /><br />Expose web applications through identity and context-aware tunneled reverse proxies. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Users access applications through any web browser with authentication and granular access control. | <img src="public/clip.gif" width=500 /><tr></tr> |
|
||||
| **Client-based private resource access**<br /><br />Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. | <img src="public/screenshots/private-resources.png" width=500 /><tr></tr> |
|
||||
| **Zero-trust granular access**<br /><br />Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications and services you explicitly define, reducing security risk and attack surface. | <img src="public/screenshots/user-devices.png" width=500 /><tr></tr> |
|
||||
### Connect remote networks with sites and NAT traversal
|
||||
|
||||
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
||||
|
||||
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
||||
|
||||
### Browser-based reverse proxy access
|
||||
|
||||
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
||||
|
||||
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
||||
|
||||
### Client-based private resource access
|
||||
|
||||
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
||||
|
||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||
|
||||
### Give users and roles access to resources
|
||||
|
||||
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||
|
||||
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
||||
|
||||
## Download Clients
|
||||
|
||||
@@ -87,7 +97,7 @@ Download the Pangolin client for your platform:
|
||||
|
||||
### Sign up now
|
||||
|
||||
Create an account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud. A generous free tier is available.
|
||||
Create a free account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud.
|
||||
|
||||
### Check out the docs
|
||||
|
||||
@@ -102,7 +112,3 @@ Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License
|
||||
## Contributions
|
||||
|
||||
Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices.
|
||||
|
||||
---
|
||||
|
||||
WireGuard® is a registered trademark of Jason A. Donenfeld.
|
||||
|
||||
@@ -86,6 +86,8 @@ entryPoints:
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
middlewares:
|
||||
- crowdsec@file
|
||||
encodedCharacters:
|
||||
allowEncodedSlash: true
|
||||
allowEncodedQuestionMark: true
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Ключът за осигуряване е актуализиран",
|
||||
"provisioningKeysUpdatedDescription": "Вашите промени бяха запазени.",
|
||||
"provisioningKeysBannerTitle": "Ключове за осигуряване на сайта",
|
||||
"provisioningKeysBannerDescription": "Генерирайте ключ за осигуряване и го използвайте с Newt конектора за автоматично създаване на сайтове при първото стартиране — няма нужда от създаване на отделни идентификационни данни за всеки сайт.",
|
||||
"provisioningKeysBannerDescription": "Генерирайте ключ за осигуряване и го използвайте със съединителя Newt за автоматично създаване на сайтове при първоначално стартиране - не е необходимо да се създават отделни идентификационни данни за всеки сайт.",
|
||||
"provisioningKeysBannerButtonText": "Научете повече",
|
||||
"pendingSitesBannerTitle": "Чакащи сайтове",
|
||||
"pendingSitesBannerDescription": "Сайтовете, които се свързват чрез ключ за осигуряване, се появяват тук за преглед. Одобрете всеки сайт, преди да стане активен и да получи достъп до вашите ресурси.",
|
||||
"pendingSitesBannerDescription": "Сайтовете, които се свързват с ключ за осигуряване, ще се появят тук за преглед.",
|
||||
"pendingSitesBannerButtonText": "Научете повече",
|
||||
"apiKeysSettings": "Настройки на {apiKeyName}",
|
||||
"userTitle": "Управление на всички потребители",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Неуспешно активиране на лицензионния ключ",
|
||||
"licenseErrorKeyActivateDescription": "Възникна грешка при активирането на лицензионния ключ.",
|
||||
"licenseAbout": "Относно лицензите",
|
||||
"licenseBannerTitle": "Активирайте своята корпоративна лицензия",
|
||||
"licenseBannerDescription": "Отключете корпоративните функции за вашият хостинг на Pangolin. Закупете лицензионен ключ, за да активирате премиум възможности, след това го добавете по-долу.",
|
||||
"licenseBannerGetLicense": "Вземете лиценз",
|
||||
"licenseBannerViewDocs": "Преглед на документацията",
|
||||
"communityEdition": "Комюнити издание",
|
||||
"licenseAboutDescription": "Това е за бизнес и корпоративни потребители, които използват Pangolin в търговска среда. Ако използвате Pangolin за лична употреба, можете да игнорирате този раздел.",
|
||||
"licenseKeyActivated": "Лицензионният ключ е активиран",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт",
|
||||
"targetErrorNoSite": "Няма избран сайт",
|
||||
"targetErrorNoSiteDescription": "Моля, изберете сайт за целта",
|
||||
"targetTargetsCleared": "Мишените са премахнати",
|
||||
"targetTargetsClearedDescription": "Всички цели са били премахнати от този ресурс",
|
||||
"targetCreated": "Целта е създадена",
|
||||
"targetCreatedDescription": "Целта беше успешно създадена",
|
||||
"targetErrorCreate": "Неуспешно създаване на целта",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Изберете домейн за страницата за удостоверяване на организацията",
|
||||
"domainPickerProvidedDomain": "Предоставен домейн",
|
||||
"domainPickerFreeProvidedDomain": "Безплатен предоставен домейн",
|
||||
"domainPickerFreeDomainsPaidFeature": "Предоставените домейни са платена функция. Абонирайте се, за да получите домейн, включен във вашия план - няма нужда да използвате вашия собствен.",
|
||||
"domainPickerVerified": "Проверено",
|
||||
"domainPickerUnverified": "Непроверено",
|
||||
"domainPickerManual": "Ръчно",
|
||||
"domainPickerInvalidSubdomainStructure": "Този поддомен съдържа невалидни знаци или структура. Ще бъде автоматично пречистен при запазване.",
|
||||
"domainPickerError": "Грешка",
|
||||
"domainPickerErrorLoadDomains": "Неуспешно зареждане на домейни на организацията",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Само за лична употреба (безплатен лиценз — без плащане)",
|
||||
"personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Продължете към плащане"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Машинни клиенти",
|
||||
"install": "Инсталирай",
|
||||
"run": "Изпълни",
|
||||
"envFile": "Файл за среда",
|
||||
"serviceFile": "Файл за услуга",
|
||||
"enableAndStart": "Активиране и стартиране",
|
||||
"clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.",
|
||||
"clientAddress": "Клиентски адрес (Разширено)",
|
||||
"setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Без удостоверяване",
|
||||
"httpDestAuthNoneDescription": "Изпращане на заявки без заглавие за удостоверяване.",
|
||||
"httpDestAuthBearerTitle": "Bearer Токен",
|
||||
"httpDestAuthBearerDescription": "Добавя заглавие за удостоверяване Bearer <token> към всяка заявка.",
|
||||
"httpDestAuthBearerDescription": "Добавя заглавие Authorization: Bearer '<token>' към всяка заявка.",
|
||||
"httpDestAuthBearerPlaceholder": "Вашият API ключ или токен",
|
||||
"httpDestAuthBasicTitle": "Основно удостоверяване",
|
||||
"httpDestAuthBasicDescription": "Добавя заглавие за удостоверяване Basic <credentials> към всяка заявка. Осигурете идентификационни данни като потребителско име:парола.",
|
||||
"httpDestAuthBasicDescription": "Добавя заглавие Authorization: Basic '<credentials>'. Осигурете идентификационни данни като потребителско име:парола.",
|
||||
"httpDestAuthBasicPlaceholder": "потребителско име:парола",
|
||||
"httpDestAuthCustomTitle": "Персонализирано заглавие",
|
||||
"httpDestAuthCustomDescription": "Посочете персонализирано име и стойност на заглавието за удостоверяване (например X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Zajišťovací klíč byl aktualizován",
|
||||
"provisioningKeysUpdatedDescription": "Vaše změny byly uloženy.",
|
||||
"provisioningKeysBannerTitle": "Klíče pro poskytování webu",
|
||||
"provisioningKeysBannerDescription": "Vygenerujte konfigurační klíč a používejte jej pomocí nového konektoru k automatickému vytváření stránek při prvním startu – není třeba nastavovat samostatné přihlašovací údaje pro každý web.",
|
||||
"provisioningKeysBannerDescription": "Vygenerujte klíč pro zřízení a použijte ho s Newt konektorem k automatickému vytvoření stránek při prvním spuštění – není potřeba nastavit samostatné přihlašovací údaje pro každou stránku.",
|
||||
"provisioningKeysBannerButtonText": "Zjistit více",
|
||||
"pendingSitesBannerTitle": "Nevyřízené weby",
|
||||
"pendingSitesBannerDescription": "Zde se zobrazují stránky, které se připojují pomocí doplňovacího klíče. Schválte každý web předtím, než bude aktivní, a získejte přístup k vašim zdrojům.",
|
||||
"pendingSitesBannerDescription": "Stránky, které se připojují pomocí klíče pro zřízení, se zde objeví ke kontrole.",
|
||||
"pendingSitesBannerButtonText": "Zjistit více",
|
||||
"apiKeysSettings": "Nastavení {apiKeyName}",
|
||||
"userTitle": "Spravovat všechny uživatele",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Nepodařilo se aktivovat licenční klíč",
|
||||
"licenseErrorKeyActivateDescription": "Došlo k chybě při aktivaci licenčního klíče.",
|
||||
"licenseAbout": "O licencích",
|
||||
"licenseBannerTitle": "Aktivovat vaši firemní licenci",
|
||||
"licenseBannerDescription": "Odemkněte firemní funkce pro vaši samohostovanou instanci Pangolin. Zakupte si licenční klíč pro aktivaci prémiových možností a poté jej přidejte níže.",
|
||||
"licenseBannerGetLicense": "Zakoupit licenci",
|
||||
"licenseBannerViewDocs": "Zobrazit dokumentaci",
|
||||
"communityEdition": "Komunitní edice",
|
||||
"licenseAboutDescription": "To je pro obchodní a podnikové uživatele, kteří používají Pangolin v komerčním prostředí. Pokud používáte Pangolin pro osobní použití, můžete tuto sekci ignorovat.",
|
||||
"licenseKeyActivated": "Licenční klíč aktivován",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Zadejte platné číslo portu",
|
||||
"targetErrorNoSite": "Není vybrán žádný web",
|
||||
"targetErrorNoSiteDescription": "Vyberte prosím web pro cíl",
|
||||
"targetTargetsCleared": "Cíle vymazány",
|
||||
"targetTargetsClearedDescription": "Všechny cíle byly odstraněny z tohoto zdroje",
|
||||
"targetCreated": "Cíl byl vytvořen",
|
||||
"targetCreatedDescription": "Cíl byl úspěšně vytvořen",
|
||||
"targetErrorCreate": "Nepodařilo se vytvořit cíl",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Vyberte doménu pro ověřovací stránku organizace",
|
||||
"domainPickerProvidedDomain": "Poskytnutá doména",
|
||||
"domainPickerFreeProvidedDomain": "Zdarma poskytnutá doména",
|
||||
"domainPickerFreeDomainsPaidFeature": "Poskytnuté domény jsou placenou funkcí. Předplaťte si plán, abyste získali doménu zahrnutou v plánu – nemusíte si přinést vlastní.",
|
||||
"domainPickerVerified": "Ověřeno",
|
||||
"domainPickerUnverified": "Neověřeno",
|
||||
"domainPickerManual": "Ruční nastavení",
|
||||
"domainPickerInvalidSubdomainStructure": "Tato subdoména obsahuje neplatné znaky nebo strukturu. Bude automaticky sanitována při uložení.",
|
||||
"domainPickerError": "Chyba",
|
||||
"domainPickerErrorLoadDomains": "Nepodařilo se načíst domény organizace",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Pouze osobní použití (bezplatná licence – bez platby)",
|
||||
"personalUseOnly": "Pouze pro osobní použití (zdarma licence - bez ověření)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Pokračovat do pokladny"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Strojoví klienti",
|
||||
"install": "Instalovat",
|
||||
"run": "Spustit",
|
||||
"envFile": "Konfigurační soubor prostředí",
|
||||
"serviceFile": "Služební soubor",
|
||||
"enableAndStart": "Povolit a spustit",
|
||||
"clientNameDescription": "Zobrazované jméno klienta, které lze později změnit.",
|
||||
"clientAddress": "Adresa klienta (Rozšířeno)",
|
||||
"setupFailedToFetchSubnet": "Nepodařilo se načíst výchozí podsíť",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Žádné ověření",
|
||||
"httpDestAuthNoneDescription": "Odešle žádosti bez záhlaví autorizace.",
|
||||
"httpDestAuthBearerTitle": "Token na doručitele",
|
||||
"httpDestAuthBearerDescription": "Přidá autorizaci: Hlavička Bearer <token> ke každému požadavku.",
|
||||
"httpDestAuthBearerDescription": "Přidává hlavičku Authorization: Bearer '<token>' k každému požadavku.",
|
||||
"httpDestAuthBearerPlaceholder": "Váš API klíč nebo token",
|
||||
"httpDestAuthBasicTitle": "Základní ověření",
|
||||
"httpDestAuthBasicDescription": "Přidá autorizaci: Základní <credentials> hlavička. Poskytněte přihlašovací údaje jako uživatelské jméno:password.",
|
||||
"httpDestAuthBasicDescription": "Přidává hlavičku Authorization: Basic '<credentials>'. Poskytněte přihlašovací údaje ve formátu uživatelské jméno:heslo.",
|
||||
"httpDestAuthBasicPlaceholder": "uživatelské jméno:heslo",
|
||||
"httpDestAuthCustomTitle": "Vlastní záhlaví",
|
||||
"httpDestAuthCustomDescription": "Zadejte název a hodnotu vlastního HTTP hlavičky pro ověření (např. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert",
|
||||
"provisioningKeysUpdatedDescription": "Ihre Änderungen wurden gespeichert.",
|
||||
"provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel",
|
||||
"provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Konnektor, um beim ersten Start automatisch Sites zu erstellen – keine Notwendigkeit, separate Anmeldeinformationen für jede Seite einzurichten.",
|
||||
"provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Connector, um Standorte beim ersten Start automatisch zu erstellen - keine Notwendigkeit, separate Anmeldedaten für jede Seite einzurichten.",
|
||||
"provisioningKeysBannerButtonText": "Mehr erfahren",
|
||||
"pendingSitesBannerTitle": "Ausstehende Seiten",
|
||||
"pendingSitesBannerDescription": "Sites, die sich mit einem Bereitstellungsschlüssel verbinden, erscheinen hier zur Überprüfung. Bestätigen Sie jede Site, bevor sie aktiv wird und erhalten Zugriff auf Ihre Ressourcen.",
|
||||
"pendingSitesBannerDescription": "Websites, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.",
|
||||
"pendingSitesBannerButtonText": "Mehr erfahren",
|
||||
"apiKeysSettings": "{apiKeyName} Einstellungen",
|
||||
"userTitle": "Alle Benutzer verwalten",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Fehler beim Aktivieren des Lizenzschlüssels",
|
||||
"licenseErrorKeyActivateDescription": "Beim Aktivieren des Lizenzschlüssels ist ein Fehler aufgetreten.",
|
||||
"licenseAbout": "Über Lizenzierung",
|
||||
"licenseBannerTitle": "Aktivieren Sie Ihre Enterprise-Lizenz",
|
||||
"licenseBannerDescription": "Schalten Sie Unternehmensfunktionen für Ihre selbstgehostete Pangolin-Instanz frei. Kaufen Sie einen Lizenzschlüssel, um Premium-Funktionen zu aktivieren, und fügen Sie ihn dann unten hinzu.",
|
||||
"licenseBannerGetLicense": "Lizenz erhalten",
|
||||
"licenseBannerViewDocs": "Dokumentation anzeigen",
|
||||
"communityEdition": "Community-Edition",
|
||||
"licenseAboutDescription": "Dies ist für Geschäfts- und Unternehmensanwender, die Pangolin in einem kommerziellen Umfeld einsetzen. Wenn Sie Pangolin für den persönlichen Gebrauch verwenden, können Sie diesen Abschnitt ignorieren.",
|
||||
"licenseKeyActivated": "Lizenzschlüssel aktiviert",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Bitte geben Sie eine gültige Portnummer ein",
|
||||
"targetErrorNoSite": "Kein Standort ausgewählt",
|
||||
"targetErrorNoSiteDescription": "Bitte wähle einen Standort für das Ziel aus",
|
||||
"targetTargetsCleared": "Ziele gelöscht",
|
||||
"targetTargetsClearedDescription": "Alle Ziele wurden aus dieser Ressource entfernt",
|
||||
"targetCreated": "Ziel erstellt",
|
||||
"targetCreatedDescription": "Ziel wurde erfolgreich erstellt",
|
||||
"targetErrorCreate": "Fehler beim Erstellen des Ziels",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Wählen Sie eine Domain für die Authentifizierungsseite der Organisation",
|
||||
"domainPickerProvidedDomain": "Angegebene Domain",
|
||||
"domainPickerFreeProvidedDomain": "Kostenlose Domain",
|
||||
"domainPickerFreeDomainsPaidFeature": "Bereitgestellte Domains sind ein kostenpflichtiges Feature. Abonnieren Sie, um eine Domain in Ihrem Tarif zu erhalten – keine Notwendigkeit, Ihre eigene mitzubringen.",
|
||||
"domainPickerVerified": "Verifiziert",
|
||||
"domainPickerUnverified": "Nicht verifiziert",
|
||||
"domainPickerManual": "Manuell",
|
||||
"domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.",
|
||||
"domainPickerError": "Fehler",
|
||||
"domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domains",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz — keine Kasse)",
|
||||
"personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz - kein Checkout)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Weiter zur Kasse"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Maschinen-Clients",
|
||||
"install": "Installieren",
|
||||
"run": "Ausführen",
|
||||
"envFile": "Umgebungsdatei",
|
||||
"serviceFile": "Servicedatei",
|
||||
"enableAndStart": "Aktivieren und Starten",
|
||||
"clientNameDescription": "Der Anzeigename des Clients, der später geändert werden kann.",
|
||||
"clientAddress": "Clientadresse (Erweitert)",
|
||||
"setupFailedToFetchSubnet": "Fehler beim Abrufen des Standard-Subnetzes",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Keine Authentifizierung",
|
||||
"httpDestAuthNoneDescription": "Sendet Anfragen ohne Autorisierungs-Header.",
|
||||
"httpDestAuthBearerTitle": "Bären-Token",
|
||||
"httpDestAuthBearerDescription": "Fügt eine Berechtigung hinzu: Bearer <token> Header zu jeder Anfrage.",
|
||||
"httpDestAuthBearerDescription": "Fügt jedem Anfrage-Header eine \"Authorization: Bearer '<token>'\" hinzu.",
|
||||
"httpDestAuthBearerPlaceholder": "Ihr API-Schlüssel oder Token",
|
||||
"httpDestAuthBasicTitle": "Einfacher Auth",
|
||||
"httpDestAuthBasicDescription": "Fügt eine Autorisierung hinzu: Basic <credentials> Kopfzeile hinzu. Geben Sie Anmeldedaten als Benutzername:password an.",
|
||||
"httpDestAuthBasicDescription": "Fügt einen \"Authorization: Basic '<credentials>'\"-Header hinzu. Geben Sie die Anmeldedaten als Benutzername:Passwort an.",
|
||||
"httpDestAuthBasicPlaceholder": "benutzername:password",
|
||||
"httpDestAuthCustomTitle": "Eigene Kopfzeile",
|
||||
"httpDestAuthCustomDescription": "Geben Sie einen eigenen HTTP-Header-Namen und einen Wert für die Authentifizierung an (z.B. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Provisioning key updated",
|
||||
"provisioningKeysUpdatedDescription": "Your changes have been saved.",
|
||||
"provisioningKeysBannerTitle": "Site Provisioning Keys",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup — no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerButtonText": "Learn More",
|
||||
"pendingSitesBannerTitle": "Pending Sites",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review. Approve each site before it becomes active and gains access to your resources.",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerButtonText": "Learn More",
|
||||
"apiKeysSettings": "{apiKeyName} Settings",
|
||||
"userTitle": "Manage All Users",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Failed to activate license key",
|
||||
"licenseErrorKeyActivateDescription": "An error occurred while activating the license key.",
|
||||
"licenseAbout": "About Licensing",
|
||||
"licenseBannerTitle": "Enable Your Enterprise License",
|
||||
"licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.",
|
||||
"licenseBannerGetLicense": "Get a License",
|
||||
"licenseBannerViewDocs": "View Documentation",
|
||||
"communityEdition": "Community Edition",
|
||||
"licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.",
|
||||
"licenseKeyActivated": "License key activated",
|
||||
@@ -2113,9 +2117,11 @@
|
||||
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.",
|
||||
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
|
||||
"domainPickerProvidedDomain": "Provided Domain",
|
||||
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||
"domainPickerFreeProvidedDomain": "Provided Domain",
|
||||
"domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.",
|
||||
"domainPickerVerified": "Verified",
|
||||
"domainPickerUnverified": "Unverified",
|
||||
"domainPickerManual": "Manual",
|
||||
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
||||
@@ -2348,7 +2354,7 @@
|
||||
"description": "Enterprise features, 50 users, 50 sites, and priority support."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license — no checkout)",
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continue to Checkout"
|
||||
},
|
||||
@@ -2609,6 +2615,9 @@
|
||||
"machineClients": "Machine Clients",
|
||||
"install": "Install",
|
||||
"run": "Run",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"clientNameDescription": "The display name of the client that can be changed later.",
|
||||
"clientAddress": "Client Address (Advanced)",
|
||||
"setupFailedToFetchSubnet": "Failed to fetch default subnet",
|
||||
@@ -2847,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "No Authentication",
|
||||
"httpDestAuthNoneDescription": "Sends requests without an Authorization header.",
|
||||
"httpDestAuthBearerTitle": "Bearer Token",
|
||||
"httpDestAuthBearerDescription": "Adds an Authorization: Bearer <token> header to each request.",
|
||||
"httpDestAuthBearerDescription": "Adds an Authorization: Bearer '<token>' header to each request.",
|
||||
"httpDestAuthBearerPlaceholder": "Your API key or token",
|
||||
"httpDestAuthBasicTitle": "Basic Auth",
|
||||
"httpDestAuthBasicDescription": "Adds an Authorization: Basic <credentials> header. Provide credentials as username:password.",
|
||||
"httpDestAuthBasicDescription": "Adds an Authorization: Basic '<credentials>' header. Provide credentials as username:password.",
|
||||
"httpDestAuthBasicPlaceholder": "username:password",
|
||||
"httpDestAuthCustomTitle": "Custom Header",
|
||||
"httpDestAuthCustomDescription": "Specify a custom HTTP header name and value for authentication (e.g. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Clave de aprovisionamiento actualizada",
|
||||
"provisioningKeysUpdatedDescription": "Sus cambios han sido guardados.",
|
||||
"provisioningKeysBannerTitle": "Claves de aprovisionamiento del sitio",
|
||||
"provisioningKeysBannerDescription": "Generar una clave de aprovisionamiento y usarla con el conector Newt para crear automáticamente sitios en el primer inicio — no es necesario configurar credenciales separadas para cada sitio.",
|
||||
"provisioningKeysBannerDescription": "Genere una clave de aprovisionamiento y utilícela con el conector Newt para crear automáticamente sitios en el primer inicio: no es necesario configurar credenciales separadas para cada sitio.",
|
||||
"provisioningKeysBannerButtonText": "Saber más",
|
||||
"pendingSitesBannerTitle": "Sitios pendientes",
|
||||
"pendingSitesBannerDescription": "Los sitios que se conectan usando una clave de aprovisionamiento aparecen aquí para su revisión. Aprobar cada sitio antes de que se active y obtenga acceso a sus recursos.",
|
||||
"pendingSitesBannerDescription": "Los sitios que se conectan utilizando una clave de aprovisionamiento aparecerán aquí para su revisión.",
|
||||
"pendingSitesBannerButtonText": "Saber más",
|
||||
"apiKeysSettings": "Ajustes {apiKeyName}",
|
||||
"userTitle": "Administrar todos los usuarios",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Error al activar la clave de licencia",
|
||||
"licenseErrorKeyActivateDescription": "Se ha producido un error al activar la clave de licencia.",
|
||||
"licenseAbout": "Acerca de la licencia",
|
||||
"licenseBannerTitle": "Habilitar su Licencia Enterprise",
|
||||
"licenseBannerDescription": "Desbloquea funciones empresariales para tu instancia autohospedada de Pangolin. Compra una clave de licencia para activar capacidades premium, luego agréguela a continuación.",
|
||||
"licenseBannerGetLicense": "Obtener una Licencia",
|
||||
"licenseBannerViewDocs": "Ver Documentación",
|
||||
"communityEdition": "Edición comunitaria",
|
||||
"licenseAboutDescription": "Esto es para usuarios empresariales y empresariales que utilizan Pangolin en un entorno comercial. Si estás usando Pangolin para uso personal, puedes ignorar esta sección.",
|
||||
"licenseKeyActivated": "Clave de licencia activada",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Por favor, introduzca un número de puerto válido",
|
||||
"targetErrorNoSite": "Ningún sitio seleccionado",
|
||||
"targetErrorNoSiteDescription": "Por favor, seleccione un sitio para el objetivo",
|
||||
"targetTargetsCleared": "Objetivos eliminados",
|
||||
"targetTargetsClearedDescription": "Todos los objetivos han sido eliminados de este recurso",
|
||||
"targetCreated": "Objetivo creado",
|
||||
"targetCreatedDescription": "El objetivo se ha creado correctamente",
|
||||
"targetErrorCreate": "Error al crear el objetivo",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización",
|
||||
"domainPickerProvidedDomain": "Dominio proporcionado",
|
||||
"domainPickerFreeProvidedDomain": "Dominio proporcionado gratis",
|
||||
"domainPickerFreeDomainsPaidFeature": "Los dominios proporcionados son una función de pago. Suscríbete para obtener un dominio incluido con tu plan — no necesitas traer el tuyo propio.",
|
||||
"domainPickerVerified": "Verificado",
|
||||
"domainPickerUnverified": "Sin verificar",
|
||||
"domainPickerManual": "Manual",
|
||||
"domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Solo uso personal (licencia gratuita, sin pago)",
|
||||
"personalUseOnly": "Solo uso personal (licencia gratuita - sin salida)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continuar con el pago"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Clientes de la máquina",
|
||||
"install": "Instalar",
|
||||
"run": "Ejecutar",
|
||||
"envFile": "Archivo de Entorno",
|
||||
"serviceFile": "Archivo de Servicio",
|
||||
"enableAndStart": "Habilitar y empezar",
|
||||
"clientNameDescription": "El nombre mostrado del cliente que se puede cambiar más adelante.",
|
||||
"clientAddress": "Dirección del cliente (Avanzado)",
|
||||
"setupFailedToFetchSubnet": "No se pudo obtener la subred por defecto",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Sin autenticación",
|
||||
"httpDestAuthNoneDescription": "Envía solicitudes sin un encabezado de autorización.",
|
||||
"httpDestAuthBearerTitle": "Tóken de portador",
|
||||
"httpDestAuthBearerDescription": "Añade una autorización: portador <token> encabezado a cada solicitud.",
|
||||
"httpDestAuthBearerDescription": "Añade un encabezado Authorization: Bearer '<token>' a cada solicitud.",
|
||||
"httpDestAuthBearerPlaceholder": "Tu clave o token API",
|
||||
"httpDestAuthBasicTitle": "Auth Básica",
|
||||
"httpDestAuthBasicDescription": "Añade una Autorización: encabezado básico <credentials> . Proporcione credenciales como nombre de usuario: contraseña.",
|
||||
"httpDestAuthBasicDescription": "Añade un encabezado Authorization: Basic '<credenciales>'. Proporcione las credenciales como nombredeusuario:contraseña.",
|
||||
"httpDestAuthBasicPlaceholder": "usuario:contraseña",
|
||||
"httpDestAuthCustomTitle": "Cabecera personalizada",
|
||||
"httpDestAuthCustomDescription": "Especifique un nombre de cabecera HTTP personalizado y un valor para la autenticación (por ejemplo, X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Clé de provisioning mise à jour",
|
||||
"provisioningKeysUpdatedDescription": "Vos modifications ont été enregistrées.",
|
||||
"provisioningKeysBannerTitle": "Clés de provisioning du site",
|
||||
"provisioningKeysBannerDescription": "Générez une clé de provisioning et utilisez-la avec le connecteur Newt pour créer automatiquement des sites au premier démarrage — pas besoin de configurer des identifiants distincts pour chaque site.",
|
||||
"provisioningKeysBannerDescription": "Générez une clé de provisionnement et utilisez-la avec le connecteur Newt pour créer automatiquement des sites lors du premier démarrage - sans besoin de configurer des identifiants séparés pour chaque site.",
|
||||
"provisioningKeysBannerButtonText": "En savoir plus",
|
||||
"pendingSitesBannerTitle": "Sites en attente",
|
||||
"pendingSitesBannerDescription": "Les sites qui se connectent à l'aide d'une clé de provisioning apparaissent ici pour être revus. Approuver chaque site avant qu'il ne devienne actif et qu'il accède à vos ressources.",
|
||||
"pendingSitesBannerDescription": "Les sites qui se connectent en utilisant une clé de provisionnement apparaissent ici pour révision.",
|
||||
"pendingSitesBannerButtonText": "En savoir plus",
|
||||
"apiKeysSettings": "Paramètres de {apiKeyName}",
|
||||
"userTitle": "Gérer tous les utilisateurs",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Échec de l'activation de la clé de licence",
|
||||
"licenseErrorKeyActivateDescription": "Une erreur s'est produite lors de l'activation de la clé de licence.",
|
||||
"licenseAbout": "À propos de la licence",
|
||||
"licenseBannerTitle": "Activer Votre Licence Entreprise",
|
||||
"licenseBannerDescription": "Débloquez les fonctionnalités d'entreprise pour votre instance autohébergée de Pangolin. Achetez une clé de licence pour activer les capacités premium, puis ajoutez-la ci-dessous.",
|
||||
"licenseBannerGetLicense": "Obtenez une Licence",
|
||||
"licenseBannerViewDocs": "Afficher la Documentation",
|
||||
"communityEdition": "Edition Communautaire",
|
||||
"licenseAboutDescription": "Ceci est destiné aux entreprises qui utilisent Pangolin dans un environnement commercial. Si vous utilisez Pangolin pour un usage personnel, vous pouvez ignorer cette section.",
|
||||
"licenseKeyActivated": "Clé de licence activée",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Veuillez entrer un numéro de port valide",
|
||||
"targetErrorNoSite": "Aucun site sélectionné",
|
||||
"targetErrorNoSiteDescription": "Veuillez sélectionner un site pour la cible",
|
||||
"targetTargetsCleared": "Cibles effacées",
|
||||
"targetTargetsClearedDescription": "Toutes les cibles ont été retirées de cette ressource",
|
||||
"targetCreated": "Cible créée",
|
||||
"targetCreatedDescription": "La cible a été créée avec succès",
|
||||
"targetErrorCreate": "Impossible de créer la cible",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Sélectionnez un domaine pour la page d'authentification de l'organisation",
|
||||
"domainPickerProvidedDomain": "Domaine fourni",
|
||||
"domainPickerFreeProvidedDomain": "Domaine fourni gratuitement",
|
||||
"domainPickerFreeDomainsPaidFeature": "Les domaines fournis sont une fonctionnalité payante. Abonnez-vous pour obtenir un domaine inclus avec votre plan — plus besoin de fournir le vôtre.",
|
||||
"domainPickerVerified": "Vérifié",
|
||||
"domainPickerUnverified": "Non vérifié",
|
||||
"domainPickerManual": "Manuel",
|
||||
"domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.",
|
||||
"domainPickerError": "Erreur",
|
||||
"domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Fonctionnalités d'entreprise, 50 utilisateurs, 50 sites et une prise en charge prioritaire."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Utilisation personnelle uniquement (licence gratuite — sans checkout)",
|
||||
"personalUseOnly": "Usage personnel uniquement (licence gratuite - pas de validation)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continuer vers le paiement"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Clients Machines",
|
||||
"install": "Installer",
|
||||
"run": "Exécuter",
|
||||
"envFile": "Fichier Environnement",
|
||||
"serviceFile": "Fichier de Service",
|
||||
"enableAndStart": "Activer et Démarrer",
|
||||
"clientNameDescription": "Le nom d'affichage du client qui peut être modifié plus tard.",
|
||||
"clientAddress": "Adresse du client (Avancé)",
|
||||
"setupFailedToFetchSubnet": "Impossible de récupérer le sous-réseau par défaut",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Aucune authentification",
|
||||
"httpDestAuthNoneDescription": "Envoie des requêtes sans en-tête d'autorisation.",
|
||||
"httpDestAuthBearerTitle": "Jeton de Porteur",
|
||||
"httpDestAuthBearerDescription": "Ajoute un en-tête Authorization: Bearer <token> à chaque requête.",
|
||||
"httpDestAuthBearerDescription": "Ajoute un en-tête Authorization: Bearer '<token>' à chaque requête.",
|
||||
"httpDestAuthBearerPlaceholder": "Votre clé API ou votre jeton",
|
||||
"httpDestAuthBasicTitle": "Authentification basique",
|
||||
"httpDestAuthBasicDescription": "Ajoute une autorisation : en-tête de base <credentials> . Fournissez des informations d'identification comme nom d'utilisateur:mot de passe.",
|
||||
"httpDestAuthBasicDescription": "Ajoute un en-tête Authorization: Basic '<credentials>'. Fournissez les identifiants sous la forme nom d'utilisateur:mot de passe.",
|
||||
"httpDestAuthBasicPlaceholder": "nom d'utilisateur:mot de passe",
|
||||
"httpDestAuthCustomTitle": "En-tête personnalisé",
|
||||
"httpDestAuthCustomDescription": "Spécifiez un nom d'en-tête HTTP personnalisé et une valeur pour l'authentification (par exemple X-API-Key).",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"setupCreate": "Creare l'organizzazione, il sito e le risorse",
|
||||
"headerAuthCompatibilityInfo": "Abilita questo per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.",
|
||||
"headerAuthCompatibilityInfo": "Abilita questa funzionalità per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.",
|
||||
"headerAuthCompatibility": "Compatibilità estesa",
|
||||
"setupNewOrg": "Nuova Organizzazione",
|
||||
"setupCreateOrg": "Crea Organizzazione",
|
||||
"setupCreateResources": "Crea Risorse",
|
||||
"setupOrgName": "Nome Dell'Organizzazione",
|
||||
"setupOrgName": "Nome dell'Organizzazione",
|
||||
"orgDisplayName": "Questo è il nome visualizzato dell'organizzazione.",
|
||||
"orgId": "Id Organizzazione",
|
||||
"setupIdentifierMessage": "Questo è l'identificatore univoco per l'organizzazione.",
|
||||
"setupErrorIdentifier": "L'ID dell'organizzazione è già utilizzato. Si prega di sceglierne uno diverso.",
|
||||
"componentsErrorNoMemberCreate": "Al momento non sei un membro di nessuna organizzazione. Crea un'organizzazione per iniziare.",
|
||||
"componentsErrorNoMember": "Attualmente non sei membro di nessuna organizzazione.",
|
||||
"welcome": "Benvenuti a Pangolin",
|
||||
"welcomeTo": "Benvenuto a",
|
||||
"welcome": "Benvenuto su Pangolin!",
|
||||
"welcomeTo": "Benvenuto su Pangolin!",
|
||||
"componentsCreateOrg": "Crea un'organizzazione",
|
||||
"componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.",
|
||||
"componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.",
|
||||
@@ -27,7 +27,7 @@
|
||||
"inviteLoginUser": "Assicurati di aver effettuato l'accesso come utente corretto.",
|
||||
"inviteErrorNoUser": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia per un utente che esiste.",
|
||||
"inviteCreateUser": "Si prega di creare un account prima.",
|
||||
"goHome": "Vai A Home",
|
||||
"goHome": "Vai alla Home",
|
||||
"inviteLogInOtherUser": "Accedi come utente diverso",
|
||||
"createAnAccount": "Crea un account",
|
||||
"inviteNotAccepted": "Invito Non Accettato",
|
||||
@@ -51,7 +51,7 @@
|
||||
"edit": "Modifica",
|
||||
"siteConfirmDelete": "Conferma Eliminazione Sito",
|
||||
"siteDelete": "Elimina Sito",
|
||||
"siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli obiettivi associati al sito verranno rimossi.",
|
||||
"siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.",
|
||||
"siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?",
|
||||
"siteManageSites": "Gestisci Siti",
|
||||
"siteDescription": "Creare e gestire siti per abilitare la connettività a reti private",
|
||||
@@ -75,9 +75,9 @@
|
||||
"siteLoadWGConfig": "Caricamento configurazione WireGuard...",
|
||||
"siteDocker": "Espandi per i dettagli di distribuzione Docker",
|
||||
"toggle": "Attiva/disattiva",
|
||||
"dockerCompose": "Composizione Docker",
|
||||
"dockerCompose": "Docker Compose",
|
||||
"dockerRun": "Corsa Docker",
|
||||
"siteLearnLocal": "I siti locali non tunnel, saperne di più",
|
||||
"siteLearnLocal": "I siti locali non effettuano il tunnel, per saperne di più",
|
||||
"siteConfirmCopy": "Ho copiato la configurazione",
|
||||
"searchSitesProgress": "Cerca siti...",
|
||||
"siteAdd": "Aggiungi Sito",
|
||||
@@ -88,29 +88,29 @@
|
||||
"operatingSystem": "Sistema Operativo",
|
||||
"commands": "Comandi",
|
||||
"recommended": "Consigliato",
|
||||
"siteNewtDescription": "Per la migliore esperienza utente, utilizzare Newt. Utilizza WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.",
|
||||
"siteNewtDescription": "Per la migliore esperienza utente utilizzare Newt, che usa WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.",
|
||||
"siteRunsInDocker": "Esegue nel Docker",
|
||||
"siteRunsInShell": "Esegue in shell su macOS, Linux e Windows",
|
||||
"siteErrorDelete": "Errore nell'eliminare il sito",
|
||||
"siteErrorDelete": "Errore nella eliminazione del sito",
|
||||
"siteErrorUpdate": "Impossibile aggiornare il sito",
|
||||
"siteErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento del sito.",
|
||||
"siteUpdated": "Sito aggiornato",
|
||||
"siteUpdatedDescription": "Il sito è stato aggiornato.",
|
||||
"siteGeneralDescription": "Configura le impostazioni generali per questo sito",
|
||||
"siteSettingDescription": "Configura le impostazioni del sito",
|
||||
"siteSetting": "Impostazioni {siteName}",
|
||||
"siteSetting": "Impostazioni del sito {siteName}",
|
||||
"siteNewtTunnel": "Nuovo Sito (Consigliato)",
|
||||
"siteNewtTunnelDescription": "Modo più semplice per creare un entrypoint in qualsiasi rete. Nessuna configurazione aggiuntiva.",
|
||||
"siteWg": "WireGuard Base",
|
||||
"siteWgDescription": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
|
||||
"siteWgDescriptionSaas": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta. FUNZIONA SOLO SU NODI AUTO-OSPITATI",
|
||||
"siteWgDescription": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
|
||||
"siteWgDescriptionSaas": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
|
||||
"siteLocalDescription": "Solo risorse locali. Nessun tunneling.",
|
||||
"siteLocalDescriptionSaas": "Solo risorse locali. Nessun tunneling. Disponibile solo su nodi remoti.",
|
||||
"siteSeeAll": "Vedi Tutti I Siti",
|
||||
"siteTunnelDescription": "Determinare come si desidera connettersi al sito",
|
||||
"siteTunnelDescription": "Selezionare la modalità con la quale si desidera connettersi al sito",
|
||||
"siteNewtCredentials": "Credenziali",
|
||||
"siteNewtCredentialsDescription": "Questo è come il sito si autenticerà con il server",
|
||||
"remoteNodeCredentialsDescription": "Questo è come il nodo remoto si autenticherà con il server",
|
||||
"siteNewtCredentialsDescription": "Questo è come il sito si autenticherà con il server",
|
||||
"remoteNodeCredentialsDescription": "Questo è il modo in cui il nodo remoto si autenticherà con il server",
|
||||
"siteCredentialsSave": "Salva le credenziali",
|
||||
"siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.",
|
||||
"siteInfo": "Informazioni Sito",
|
||||
@@ -140,8 +140,8 @@
|
||||
"shareCreateDescription": "Chiunque con questo link può accedere alla risorsa",
|
||||
"shareTitleOptional": "Titolo (facoltativo)",
|
||||
"expireIn": "Scadenza In",
|
||||
"neverExpire": "Mai scadere",
|
||||
"shareExpireDescription": "Il tempo di scadenza è per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.",
|
||||
"neverExpire": "Nessuna scadenza",
|
||||
"shareExpireDescription": "Il tempo di scadenza indica per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.",
|
||||
"shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.",
|
||||
"shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.",
|
||||
"shareTokenUsage": "Vedi Utilizzo Token Di Accesso",
|
||||
@@ -161,9 +161,9 @@
|
||||
"never": "Mai",
|
||||
"shareErrorSelectResource": "Seleziona una risorsa",
|
||||
"proxyResourceTitle": "Gestisci Risorse Pubbliche",
|
||||
"proxyResourceDescription": "Creare e gestire risorse accessibili al pubblico tramite un browser web",
|
||||
"proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web",
|
||||
"proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
|
||||
"proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili a chiunque su Internet tramite un browser web. A differenza delle risorse private, non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
|
||||
"proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
|
||||
"clientResourceTitle": "Gestisci Risorse Private",
|
||||
"clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso",
|
||||
"privateResourcesBannerTitle": "Accesso Privato Zero-Trust",
|
||||
@@ -174,12 +174,12 @@
|
||||
"authentication": "Autenticazione",
|
||||
"protected": "Protetto",
|
||||
"notProtected": "Non Protetto",
|
||||
"resourceMessageRemove": "Una volta rimossa, la risorsa non sarà più accessibile. Tutti gli obiettivi associati alla risorsa saranno rimossi.",
|
||||
"resourceMessageRemove": "Una volta rimossa la risorsa non sarà più accessibile. Tutti gli oggetti target associati alla risorsa saranno rimossi.",
|
||||
"resourceQuestionRemove": "Sei sicuro di voler rimuovere la risorsa dall'organizzazione?",
|
||||
"resourceHTTP": "Risorsa HTTPS",
|
||||
"resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.",
|
||||
"resourceRaw": "Risorsa Raw TCP/UDP",
|
||||
"resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.",
|
||||
"resourceRawDescription": "Richieste proxy su TCP/UDP raw utilizzando un numero di porta.",
|
||||
"resourceRawDescriptionCloud": "Richiesta proxy su TCP/UDP grezzo utilizzando un numero di porta. Richiede siti per connettersi a un nodo remoto.",
|
||||
"resourceCreate": "Crea Risorsa",
|
||||
"resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa",
|
||||
@@ -192,7 +192,7 @@
|
||||
"selectCountry": "Seleziona paese",
|
||||
"searchCountries": "Cerca paesi...",
|
||||
"noCountryFound": "Nessun paese trovato.",
|
||||
"siteSelectionDescription": "Questo sito fornirà connettività all'obiettivo.",
|
||||
"siteSelectionDescription": "Questo sito fornirà connettività all'oggetto target.",
|
||||
"resourceType": "Tipo Di Risorsa",
|
||||
"resourceTypeDescription": "Determinare come accedere alla risorsa",
|
||||
"resourceHTTPSSettings": "Impostazioni HTTPS",
|
||||
@@ -206,13 +206,13 @@
|
||||
"protocol": "Protocollo",
|
||||
"protocolSelect": "Seleziona un protocollo",
|
||||
"resourcePortNumber": "Numero Porta",
|
||||
"resourcePortNumberDescription": "Il numero di porta esterna per le richieste di proxy.",
|
||||
"resourcePortNumberDescription": "Il numero di porta esterna per le richieste proxy.",
|
||||
"back": "Indietro",
|
||||
"cancel": "Annulla",
|
||||
"resourceConfig": "Snippet Di Configurazione",
|
||||
"resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP",
|
||||
"resourceAddEntrypoints": "Traefik: Aggiungi Ingresso",
|
||||
"resourceExposePorts": "Gerbil: espone le porte in Docker componi",
|
||||
"resourceAddEntrypoints": "Traefik: Aggiungi Entrypoint",
|
||||
"resourceExposePorts": "Gerbil: espone le porte in Docker Compose",
|
||||
"resourceLearnRaw": "Scopri come configurare le risorse TCP/UDP",
|
||||
"resourceBack": "Torna alle risorse",
|
||||
"resourceGoTo": "Vai alla Risorsa",
|
||||
@@ -228,7 +228,7 @@
|
||||
"rules": "Regole",
|
||||
"resourceSettingDescription": "Configura le impostazioni sulla risorsa",
|
||||
"resourceSetting": "Impostazioni {resourceName}",
|
||||
"alwaysAllow": "Autenticazione Bypass",
|
||||
"alwaysAllow": "Bypass Autenticazione",
|
||||
"alwaysDeny": "Blocca Accesso",
|
||||
"passToAuth": "Passa all'autenticazione",
|
||||
"orgSettingsDescription": "Configura le impostazioni dell'organizzazione",
|
||||
@@ -237,11 +237,11 @@
|
||||
"saveGeneralSettings": "Salva Impostazioni Generali",
|
||||
"saveSettings": "Salva Impostazioni",
|
||||
"orgDangerZone": "Zona Pericolosa",
|
||||
"orgDangerZoneDescription": "Una volta che si elimina questo org, non c'è ritorno. Si prega di essere certi.",
|
||||
"orgDangerZoneDescription": "Una volta che si elimina questa org non sarà possibile tornare indietro, assicurarsi quindi di essere certi della decisione.",
|
||||
"orgDelete": "Elimina Organizzazione",
|
||||
"orgDeleteConfirm": "Conferma Elimina Organizzazione",
|
||||
"orgMessageRemove": "Questa azione è irreversibile e cancellerà tutti i dati associati.",
|
||||
"orgMessageConfirm": "Per confermare, digita il nome dell'organizzazione qui sotto.",
|
||||
"orgMessageConfirm": "Per confermare digita il nome dell'organizzazione qui sotto.",
|
||||
"orgQuestionRemove": "Sei sicuro di voler rimuovere l'organizzazione?",
|
||||
"orgUpdated": "Organizzazione aggiornata",
|
||||
"orgUpdatedDescription": "L'organizzazione è stata aggiornata.",
|
||||
@@ -254,10 +254,10 @@
|
||||
"orgDeleted": "Organizzazione eliminata",
|
||||
"orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.",
|
||||
"deleteAccount": "Elimina Account",
|
||||
"deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.",
|
||||
"deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.",
|
||||
"deleteAccountButton": "Elimina Account",
|
||||
"deleteAccountConfirmTitle": "Elimina Account",
|
||||
"deleteAccountConfirmMessage": "Questo cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.",
|
||||
"deleteAccountConfirmMessage": "Questa operazione cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.",
|
||||
"deleteAccountConfirmString": "elimina account",
|
||||
"deleteAccountSuccess": "Account Eliminato",
|
||||
"deleteAccountSuccessMessage": "Il tuo account è stato eliminato.",
|
||||
@@ -272,7 +272,7 @@
|
||||
"accessUserCreate": "Crea Utente",
|
||||
"accessUserRemove": "Rimuovi Utente",
|
||||
"username": "Nome utente",
|
||||
"identityProvider": "Provider Di Identità",
|
||||
"identityProvider": "Provider Identità",
|
||||
"role": "Ruolo",
|
||||
"nameRequired": "Il nome è obbligatorio",
|
||||
"accessRolesManage": "Gestisci Ruoli",
|
||||
@@ -328,8 +328,8 @@
|
||||
"apiKeysDelete": "Elimina Chiave API",
|
||||
"apiKeysManage": "Gestisci Chiavi API",
|
||||
"apiKeysDescription": "Le chiavi API sono utilizzate per autenticarsi con l'API di integrazione",
|
||||
"provisioningKeysTitle": "Chiave Di Provvedimento",
|
||||
"provisioningKeysManage": "Gestisci Chiavi Di Provvedimento",
|
||||
"provisioningKeysTitle": "Chiave di provisioning",
|
||||
"provisioningKeysManage": "Gestisci Chiavi di provisioning",
|
||||
"provisioningKeysDescription": "Le chiavi di provisioning vengono utilizzate per autenticare il provisioning automatico del sito per la tua organizzazione.",
|
||||
"provisioningManage": "Accantonamento",
|
||||
"provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.",
|
||||
@@ -337,25 +337,25 @@
|
||||
"siteApproveSuccess": "Sito approvato con successo",
|
||||
"siteApproveError": "Errore nell'approvazione del sito",
|
||||
"provisioningKeys": "Chiavi Di Provvedimento",
|
||||
"searchProvisioningKeys": "Cerca i tasti di provisioning ...",
|
||||
"provisioningKeysAdd": "Genera Chiave Di Provvedimento",
|
||||
"provisioningKeysErrorDelete": "Errore nell'eliminare la chiave di provisioning",
|
||||
"provisioningKeysErrorDeleteMessage": "Errore nell'eliminare la chiave di provisioning",
|
||||
"searchProvisioningKeys": "Cerca le chiavi di provisioning...",
|
||||
"provisioningKeysAdd": "Genera Chiave di provisioning",
|
||||
"provisioningKeysErrorDelete": "Errore nell'eliminazione della chiave di provisioning",
|
||||
"provisioningKeysErrorDeleteMessage": "Errore nell'eliminazione della chiave di provisioning",
|
||||
"provisioningKeysQuestionRemove": "Sei sicuro di voler rimuovere questa chiave di provisioning dall'organizzazione?",
|
||||
"provisioningKeysMessageRemove": "Una volta rimossa, la chiave non può più essere utilizzata per il provisioning.",
|
||||
"provisioningKeysDeleteConfirm": "Conferma Elimina Chiave Provvisoria",
|
||||
"provisioningKeysDeleteConfirm": "Conferma Eliminazione della chiave di provisioning",
|
||||
"provisioningKeysDelete": "Elimina chiave di provisioning",
|
||||
"provisioningKeysCreate": "Genera Chiave Di Provvedimento",
|
||||
"provisioningKeysCreate": "Genera Chiave di provisioning",
|
||||
"provisioningKeysCreateDescription": "Genera una nuova chiave di provisioning per l'organizzazione",
|
||||
"provisioningKeysSeeAll": "Vedi tutte le chiavi di provisioning",
|
||||
"provisioningKeysSave": "Salva la chiave di provisioning",
|
||||
"provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.",
|
||||
"provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning",
|
||||
"provisioningKeysList": "Nuova chiave di provisioning",
|
||||
"provisioningKeysMaxBatchSize": "Dimensione massima lotto",
|
||||
"provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del lotto (nessun limite)",
|
||||
"provisioningKeysMaxBatchSize": "Dimensione massima batch",
|
||||
"provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del batch (nessun limite)",
|
||||
"provisioningKeysMaxBatchUnlimited": "Illimitato",
|
||||
"provisioningKeysMaxBatchSizeInvalid": "Inserisci un lotto massimo valido (1–1.000.000).",
|
||||
"provisioningKeysMaxBatchSizeInvalid": "Inserisci una dimensione massima valida del batch (1–1.000.000).",
|
||||
"provisioningKeysValidUntil": "Valido fino al",
|
||||
"provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.",
|
||||
"provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.",
|
||||
@@ -363,18 +363,18 @@
|
||||
"provisioningKeysLastUsed": "Ultimo utilizzo",
|
||||
"provisioningKeysNoExpiry": "Nessuna scadenza",
|
||||
"provisioningKeysNeverUsed": "Mai",
|
||||
"provisioningKeysEdit": "Modifica Chiave Di Provvedimento",
|
||||
"provisioningKeysEditDescription": "Aggiorna la dimensione massima del lotto e il tempo di scadenza per questa chiave.",
|
||||
"provisioningKeysEdit": "Modifica Chiave di provisioning",
|
||||
"provisioningKeysEditDescription": "Aggiorna la dimensione massima del batch e il tempo di scadenza per questa chiave.",
|
||||
"provisioningKeysApproveNewSites": "Approva nuovi siti",
|
||||
"provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.",
|
||||
"provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning",
|
||||
"provisioningKeysUpdated": "Chiave di accantonamento aggiornata",
|
||||
"provisioningKeysUpdated": "Chiave di provisioning aggiornata",
|
||||
"provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.",
|
||||
"provisioningKeysBannerTitle": "Chiavi Di Provvedimento Sito",
|
||||
"provisioningKeysBannerDescription": "Generare una chiave di provisioning e usarla con il connettore Newt per creare automaticamente siti al primo avvio — non è necessario impostare credenziali separate per ogni sito.",
|
||||
"provisioningKeysBannerTitle": "Chiavi di provisioning del Sito",
|
||||
"provisioningKeysBannerDescription": "Genera una chiave di provisioning e usala con il connettore Newt per creare automaticamente i siti al primo avvio - non è necessario configurare credenziali separate per ogni sito.",
|
||||
"provisioningKeysBannerButtonText": "Scopri di più",
|
||||
"pendingSitesBannerTitle": "Siti In Attesa",
|
||||
"pendingSitesBannerDescription": "I siti che si connettono utilizzando una chiave di provisioning appaiono qui per la revisione. Approva ogni sito prima che diventi attivo e ottenga l'accesso alle tue risorse.",
|
||||
"pendingSitesBannerDescription": "I siti che si connettono utilizzando una chiave di provisioning vengono visualizzati qui per la revisione.",
|
||||
"pendingSitesBannerButtonText": "Scopri di più",
|
||||
"apiKeysSettings": "Impostazioni {apiKeyName}",
|
||||
"userTitle": "Gestisci Tutti Gli Utenti",
|
||||
@@ -386,7 +386,7 @@
|
||||
"userErrorDelete": "Errore nell'eliminare l'utente",
|
||||
"userDeleteConfirm": "Conferma Eliminazione Utente",
|
||||
"userDeleteServer": "Elimina utente dal server",
|
||||
"userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni ed essere completamente rimosso dal server.",
|
||||
"userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni e verrà completamente rimosso dal server.",
|
||||
"userQuestionRemove": "Sei sicuro di voler eliminare definitivamente l'utente dal server?",
|
||||
"licenseKey": "Chiave Di Licenza",
|
||||
"valid": "Valido",
|
||||
@@ -404,9 +404,13 @@
|
||||
"licenseKeyDeletedDescription": "La chiave di licenza è stata eliminata.",
|
||||
"licenseErrorKeyActivate": "Attivazione della chiave di licenza non riuscita",
|
||||
"licenseErrorKeyActivateDescription": "Si è verificato un errore nell'attivazione della chiave di licenza.",
|
||||
"licenseAbout": "Informazioni Su Licenze",
|
||||
"licenseAbout": "Informazioni sul Licensing",
|
||||
"licenseBannerTitle": "Attiva la tua Licenza Enterprise",
|
||||
"licenseBannerDescription": "Sblocca le funzionalità enterprise per la tua istanza Pangolin auto-ospitata. Acquista una chiave di licenza per attivare le capacità premium e poi aggiungila qui sotto.",
|
||||
"licenseBannerGetLicense": "Ottieni una Licenza",
|
||||
"licenseBannerViewDocs": "Visualizza Documentazione",
|
||||
"communityEdition": "Edizione Community",
|
||||
"licenseAboutDescription": "Questo è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.",
|
||||
"licenseAboutDescription": "Questa sezione è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.",
|
||||
"licenseKeyActivated": "Chiave di licenza attivata",
|
||||
"licenseKeyActivatedDescription": "La chiave di licenza è stata attivata correttamente.",
|
||||
"licenseErrorKeyRecheck": "Impossibile ricontrollare le chiavi di licenza",
|
||||
@@ -429,7 +433,7 @@
|
||||
"licenseHostDescription": "Gestisci la chiave di licenza principale per l'host.",
|
||||
"licensedNot": "Non Licenziato",
|
||||
"hostId": "ID Host",
|
||||
"licenseReckeckAll": "Ricontrolla Tutte Le Tasti",
|
||||
"licenseReckeckAll": "Ricontrolla Tutte le chiavi",
|
||||
"licenseSiteUsage": "Utilizzo Siti",
|
||||
"licenseSiteUsageDecsription": "Visualizza il numero di siti che utilizzano questa licenza.",
|
||||
"licenseNoSiteLimit": "Non c'è alcun limite al numero di siti che utilizzano un host senza licenza.",
|
||||
@@ -480,7 +484,7 @@
|
||||
"userOrgRemoved": "Utente rimosso",
|
||||
"userOrgRemovedDescription": "L'utente {email} è stato rimosso dall'organizzazione.",
|
||||
"userQuestionOrgRemove": "Sei sicuro di voler rimuovere questo utente dall'organizzazione?",
|
||||
"userMessageOrgRemove": "Una volta rimosso, questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.",
|
||||
"userMessageOrgRemove": "Una volta rimosso questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.",
|
||||
"userRemoveOrgConfirm": "Conferma Rimozione Utente",
|
||||
"userRemoveOrg": "Rimuovi Utente dall'Organizzazione",
|
||||
"users": "Utenti",
|
||||
@@ -532,13 +536,13 @@
|
||||
"approve": "Approva",
|
||||
"approved": "Approvato",
|
||||
"denied": "Negato",
|
||||
"deniedApproval": "Omologazione Negata",
|
||||
"deniedApproval": "Approvazione Negata",
|
||||
"all": "Tutti",
|
||||
"deny": "Nega",
|
||||
"viewDetails": "Visualizza Dettagli",
|
||||
"requestingNewDeviceApproval": "ha richiesto un nuovo dispositivo",
|
||||
"resetFilters": "Ripristina Filtri",
|
||||
"totalBlocked": "Richieste Bloccate Da Pangolino",
|
||||
"totalBlocked": "Richieste Bloccate Da Pangolin",
|
||||
"totalRequests": "Totale Richieste",
|
||||
"requestsByCountry": "Richieste Per Paese",
|
||||
"requestsByDay": "Richieste Per Giorno",
|
||||
@@ -546,7 +550,7 @@
|
||||
"allowed": "Consentito",
|
||||
"topCountries": "Paesi Principali",
|
||||
"accessRoleSelect": "Seleziona ruolo",
|
||||
"inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. Devono accedere al link per accettare l'invito.",
|
||||
"inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. L'utente deve accedere al link per accettare l'invito.",
|
||||
"inviteSentDescription": "L'utente è stato invitato. Deve accedere al link qui sotto per accettare l'invito.",
|
||||
"inviteExpiresIn": "L'invito scadrà tra {days, plural, one {# giorno} other {# giorni}}.",
|
||||
"idpTitle": "Informazioni Generali",
|
||||
@@ -562,7 +566,7 @@
|
||||
"userSaved": "Utente salvato",
|
||||
"userSavedDescription": "L'utente è stato aggiornato.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionSettings": "Impostazioni Automatiche Di Fornitura",
|
||||
"autoProvisionSettings": "Impostazioni Automatiche di provisioning",
|
||||
"autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità",
|
||||
"accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione",
|
||||
"accessControlsSubmit": "Salva Controlli di Accesso",
|
||||
@@ -576,9 +580,9 @@
|
||||
"proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.",
|
||||
"proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.",
|
||||
"proxyEnableSSL": "Abilita SSL",
|
||||
"proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure agli obiettivi.",
|
||||
"proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alle risorse interne target.",
|
||||
"target": "Target",
|
||||
"configureTarget": "Configura Obiettivi",
|
||||
"configureTarget": "Configura Risorse Interne",
|
||||
"targetErrorFetch": "Impossibile recuperare i target",
|
||||
"targetErrorFetchDescription": "Si è verificato un errore durante il recupero dei target",
|
||||
"siteErrorFetch": "Impossibile recuperare la risorsa",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Inserisci un numero di porta valido",
|
||||
"targetErrorNoSite": "Nessun sito selezionato",
|
||||
"targetErrorNoSiteDescription": "Si prega di selezionare un sito per l'obiettivo",
|
||||
"targetTargetsCleared": "Obiettivi cancellati",
|
||||
"targetTargetsClearedDescription": "Tutti gli obiettivi sono stati rimossi da questa risorsa",
|
||||
"targetCreated": "Destinazione creata",
|
||||
"targetCreatedDescription": "L'obiettivo è stato creato con successo",
|
||||
"targetErrorCreate": "Impossibile creare l'obiettivo",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Seleziona un dominio per la pagina di autenticazione dell'organizzazione",
|
||||
"domainPickerProvidedDomain": "Dominio Fornito",
|
||||
"domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito",
|
||||
"domainPickerFreeDomainsPaidFeature": "I domini forniti sono una funzionalità a pagamento. Abbonati per ricevere un dominio incluso con il tuo piano — non è necessario portare il proprio.",
|
||||
"domainPickerVerified": "Verificato",
|
||||
"domainPickerUnverified": "Non Verificato",
|
||||
"domainPickerManual": "Manuale",
|
||||
"domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.",
|
||||
"domainPickerError": "Errore",
|
||||
"domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Solo uso personale (licenza gratuita — nessun checkout)",
|
||||
"personalUseOnly": "Uso personale esclusivo (licenza gratuita - nessun pagamento)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continua al Checkout"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Machine Clients",
|
||||
"install": "Installa",
|
||||
"run": "Esegui",
|
||||
"envFile": "File di ambiente",
|
||||
"serviceFile": "File di servizio",
|
||||
"enableAndStart": "Abilita e avvia",
|
||||
"clientNameDescription": "Il nome visualizzato del client che può essere modificato in seguito.",
|
||||
"clientAddress": "Indirizzo Client (Avanzato)",
|
||||
"setupFailedToFetchSubnet": "Recupero della sottorete predefinita non riuscito",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Nessuna Autenticazione",
|
||||
"httpDestAuthNoneDescription": "Invia richieste senza intestazione autorizzazione.",
|
||||
"httpDestAuthBearerTitle": "Token Del Portatore",
|
||||
"httpDestAuthBearerDescription": "Aggiunge un'intestazione Autorizzazione: Bearer <token> ad ogni richiesta.",
|
||||
"httpDestAuthBearerDescription": "Aggiunge un'intestazione Authorization: Bearer '<token>' a ogni richiesta.",
|
||||
"httpDestAuthBearerPlaceholder": "La tua chiave API o token",
|
||||
"httpDestAuthBasicTitle": "Autenticazione Base",
|
||||
"httpDestAuthBasicDescription": "Aggiunge un'autorizzazione: intestazione di base <credentials> . Fornisce le credenziali come username:password.",
|
||||
"httpDestAuthBasicDescription": "Aggiunge un'intestazione Authorization: Basic '<credentials>'. Fornire le credenziali come username:password.",
|
||||
"httpDestAuthBasicPlaceholder": "username:password",
|
||||
"httpDestAuthCustomTitle": "Intestazione Personalizzata",
|
||||
"httpDestAuthCustomDescription": "Specifica un nome e un valore di intestazione HTTP personalizzati per l'autenticazione (ad esempio X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다",
|
||||
"provisioningKeysUpdatedDescription": "변경 사항이 저장되었습니다.",
|
||||
"provisioningKeysBannerTitle": "사이트 프로비저닝 키",
|
||||
"provisioningKeysBannerDescription": "프로비저닝 키를 생성하여 Newt 커넥터와 함께 사용해 첫 실행 시 자동으로 사이트를 생성하세요 — 각 사이트마다 별도의 인증을 설정할 필요가 없습니다.",
|
||||
"provisioningKeysBannerDescription": "프로비저닝 키를 생성하고 Newt 커넥터와 함께 사용하여 첫 시작 시 사이트를 자동 생성 - 각 사이트에 대한 별도 자격 증명이 필요 없습니다.",
|
||||
"provisioningKeysBannerButtonText": "자세히 알아보기",
|
||||
"pendingSitesBannerTitle": "대기중인 사이트",
|
||||
"pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결하는 사이트는 검토 대기 중입니다. 사이트가 활성화되어 리소스에 액세스하기 전에 각 사이트를 승인하세요.",
|
||||
"pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결된 사이트가 검토를 위해 여기에 표시됩니다.",
|
||||
"pendingSitesBannerButtonText": "자세히 알아보기",
|
||||
"apiKeysSettings": "{apiKeyName} 설정",
|
||||
"userTitle": "모든 사용자 관리",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "라이센스 키 활성화에 실패했습니다.",
|
||||
"licenseErrorKeyActivateDescription": "라이센스 키를 활성화하는 동안 오류가 발생했습니다",
|
||||
"licenseAbout": "라이센스에 대한 정보",
|
||||
"licenseBannerTitle": "기업 라이선스 활성화",
|
||||
"licenseBannerDescription": "자체 호스팅된 Pangolin 인스턴스에서 기업 기능을 잠금 해제하십시오. 라이선스 키를 구입하여 프리미엄 기능을 활성화하고 아래에 추가하십시오.",
|
||||
"licenseBannerGetLicense": "라이선스 획득",
|
||||
"licenseBannerViewDocs": "문서 보기",
|
||||
"communityEdition": "커뮤니티 에디션",
|
||||
"licenseAboutDescription": "이것은 상업적 환경에서 Pangolin을 사용하는 비즈니스 및 기업 사용자용입니다. 개인 용도로 Pangolin을 사용하는 경우 이 섹션을 무시할 수 있습니다.",
|
||||
"licenseKeyActivated": "라이센스 키가 활성화되었습니다",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.",
|
||||
"targetErrorNoSite": "선택된 사이트 없음",
|
||||
"targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.",
|
||||
"targetTargetsCleared": "대상이 제거됨",
|
||||
"targetTargetsClearedDescription": "이 리소스에서 모든 대상이 제거되었습니다",
|
||||
"targetCreated": "대상 생성",
|
||||
"targetCreatedDescription": "대상이 성공적으로 생성되었습니다.",
|
||||
"targetErrorCreate": "대상 생성 실패",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "조직 인증 페이지에 대한 도메인을 선택하세요.",
|
||||
"domainPickerProvidedDomain": "제공된 도메인",
|
||||
"domainPickerFreeProvidedDomain": "무료 제공된 도메인",
|
||||
"domainPickerFreeDomainsPaidFeature": "제공된 도메인은 유료 기능입니다. 요금제에 도메인이 포함되도록 구독하세요. — 별도로 도메인을 준비할 필요 없습니다.",
|
||||
"domainPickerVerified": "검증됨",
|
||||
"domainPickerUnverified": "검증되지 않음",
|
||||
"domainPickerManual": "수동",
|
||||
"domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.",
|
||||
"domainPickerError": "오류",
|
||||
"domainPickerErrorLoadDomains": "조직 도메인 로드 실패",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "개인 사용 전용 (무료 라이센스 — 체크아웃 없음)",
|
||||
"personalUseOnly": "개인용으로만 사용 (무료 라이선스 - 결제 없음)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "결제로 진행"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "기계 클라이언트",
|
||||
"install": "설치",
|
||||
"run": "실행",
|
||||
"envFile": "환경 파일",
|
||||
"serviceFile": "서비스 파일",
|
||||
"enableAndStart": "활성화 및 시작",
|
||||
"clientNameDescription": "나중에 변경할 수 있는 클라이언트의 표시 이름입니다.",
|
||||
"clientAddress": "클라이언트 주소(고급)",
|
||||
"setupFailedToFetchSubnet": "기본값 로드 실패",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "인증 없음",
|
||||
"httpDestAuthNoneDescription": "Authorization 헤더 없이 요청을 보냅니다.",
|
||||
"httpDestAuthBearerTitle": "Bearer 토큰",
|
||||
"httpDestAuthBearerDescription": "모든 요청에 Authorization: Bearer <token> 헤더를 추가합니다.",
|
||||
"httpDestAuthBearerDescription": "각 요청에 Authorization: Bearer '<token>' 헤더를 추가합니다.",
|
||||
"httpDestAuthBearerPlaceholder": "API 키 또는 토큰",
|
||||
"httpDestAuthBasicTitle": "기본 인증",
|
||||
"httpDestAuthBasicDescription": "Authorization: Basic <credentials> 헤더를 추가합니다. 자격 증명은 username:password 형식으로 제공하세요.",
|
||||
"httpDestAuthBasicDescription": "Authorization: Basic '<credentials>' 헤더를 추가합니다. 자격 증명은 사용자 이름:비밀번호로 제공합니다.",
|
||||
"httpDestAuthBasicPlaceholder": "사용자 이름:비밀번호",
|
||||
"httpDestAuthCustomTitle": "사용자 정의 헤더",
|
||||
"httpDestAuthCustomDescription": "인증을 위한 사용자 정의 HTTP 헤더 이름 및 값을 지정하세요 (예: X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Foreslå nøkkel oppdatert",
|
||||
"provisioningKeysUpdatedDescription": "Dine endringer er lagret.",
|
||||
"provisioningKeysBannerTitle": "Sidens bestemmende nøkler",
|
||||
"provisioningKeysBannerDescription": "Generer en foreløpig nøkkel og bruk den med Nyhetskontakten for å automatisk opprette sider ved første oppstart — trenger ikke å sette opp separat innloggingsinformasjon for hver side.",
|
||||
"provisioningKeysBannerDescription": "Generer en provisjonsnøkkel og bruk den med Newt-kontakten for automatisk opprettelse av nettsteder ved første oppstart - ingen behov for å sette opp separate legitimasjoner for hvert nettsted.",
|
||||
"provisioningKeysBannerButtonText": "Lær mer",
|
||||
"pendingSitesBannerTitle": "Ventende nettsteder",
|
||||
"pendingSitesBannerDescription": "Nettsteder som kobler deg til ved hjelp av en bestemmelsestekst, vises her for gjennomgang. Godkjenn hvert nettsted før det blir aktivt og får tilgang til ressursene dine.",
|
||||
"pendingSitesBannerDescription": "Nettsteder som kobler seg til ved bruk av en provisjonsnøkkel vises her for vurdering.",
|
||||
"pendingSitesBannerButtonText": "Lær mer",
|
||||
"apiKeysSettings": "{apiKeyName} Innstillinger",
|
||||
"userTitle": "Administrer alle brukere",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Aktivering av lisensnøkkel feilet",
|
||||
"licenseErrorKeyActivateDescription": "Det oppstod en feil under aktivering av lisensnøkkelen.",
|
||||
"licenseAbout": "Om Lisensiering",
|
||||
"licenseBannerTitle": "Aktiver din bedriftslisens",
|
||||
"licenseBannerDescription": "Lås opp bedriftsfunksjoner for din egenvertede Pangolin-instans. Kjøp en lisensnøkkel for å aktivere premium-funksjoner og legg den inn nedenfor.",
|
||||
"licenseBannerGetLicense": "Få en lisens",
|
||||
"licenseBannerViewDocs": "Vis dokumentasjon",
|
||||
"communityEdition": "Fellesskapsutgave",
|
||||
"licenseAboutDescription": "Dette er for bedrifts- og foretaksbrukere som bruker Pangolin i et kommersielt miljø. Hvis du bruker Pangolin til personlig bruk, kan du ignorere denne seksjonen.",
|
||||
"licenseKeyActivated": "Lisensnøkkel aktivert",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Vennligst skriv inn et gyldig portnummer",
|
||||
"targetErrorNoSite": "Ingen nettsted valgt",
|
||||
"targetErrorNoSiteDescription": "Velg et nettsted for målet",
|
||||
"targetTargetsCleared": "Mål ryddet",
|
||||
"targetTargetsClearedDescription": "Alle mål har blitt fjernet fra denne ressursen",
|
||||
"targetCreated": "Mål opprettet",
|
||||
"targetCreatedDescription": "Målet har blitt opprettet",
|
||||
"targetErrorCreate": "Kunne ikke opprette målet",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Velg et domene for organisasjonens autentiseringsside",
|
||||
"domainPickerProvidedDomain": "Gitt domene",
|
||||
"domainPickerFreeProvidedDomain": "Gratis oppgitt domene",
|
||||
"domainPickerFreeDomainsPaidFeature": "Angitte domener er en betalingsfunksjon. Abonner for å få et domene inkludert i din plan – ingen behov for å ta med ditt eget.",
|
||||
"domainPickerVerified": "Bekreftet",
|
||||
"domainPickerUnverified": "Uverifisert",
|
||||
"domainPickerManual": "Manuell",
|
||||
"domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.",
|
||||
"domainPickerError": "Feil",
|
||||
"domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Kun personlig bruk (gratis lisens - ingen utsjekking)",
|
||||
"personalUseOnly": "Kun personlig bruk (gratis lisens - ingen kasse)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Fortsett til kassen"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Maskinklienter",
|
||||
"install": "Installer",
|
||||
"run": "Kjør",
|
||||
"envFile": "Miljøfil",
|
||||
"serviceFile": "Tjenestefil",
|
||||
"enableAndStart": "Aktiver og start",
|
||||
"clientNameDescription": "Visningsnavnet til klienten som kan endres senere.",
|
||||
"clientAddress": "Klientadresse (avansert)",
|
||||
"setupFailedToFetchSubnet": "Kunne ikke hente standard undernett",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Ingen godkjenning",
|
||||
"httpDestAuthNoneDescription": "Sender forespørsler uten autorisasjonsoverskrift.",
|
||||
"httpDestAuthBearerTitle": "Bærer Symbol",
|
||||
"httpDestAuthBearerDescription": "Legger til en autorisasjon: Bearer <token> header til hver forespørsel.",
|
||||
"httpDestAuthBearerDescription": "Legger til en Autorisasjon: Bearer '<token>' header til hver forespørsel.",
|
||||
"httpDestAuthBearerPlaceholder": "Din API-nøkkel eller token",
|
||||
"httpDestAuthBasicTitle": "Standard Auth",
|
||||
"httpDestAuthBasicDescription": "Legger til en godkjenning: Grunnleggende <credentials> overskrift. Angi legitimasjon som brukernavn:passord.",
|
||||
"httpDestAuthBasicDescription": "Legger til en Autorisasjon: Basic '<credentials>' header. Gi legitimasjon som brukernavn:passord.",
|
||||
"httpDestAuthBasicPlaceholder": "brukernavn:passord",
|
||||
"httpDestAuthCustomTitle": "Egendefinert topptekst",
|
||||
"httpDestAuthCustomDescription": "Angi et egendefinert HTTP headers navn og verdi for autentisering (f.eks X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Provisie sleutel bijgewerkt",
|
||||
"provisioningKeysUpdatedDescription": "Uw wijzigingen zijn opgeslagen.",
|
||||
"provisioningKeysBannerTitle": "Bewerkingssleutels voor websites",
|
||||
"provisioningKeysBannerDescription": "Genereer een provisioning-sleutel en gebruik deze met de Newt-connector om automatisch sites aan te maken bij het opstarten van de eerste opstart- het is niet nodig om afzonderlijke inloggegevens in te stellen voor elke site.",
|
||||
"provisioningKeysBannerDescription": "Genereer een inrichtingssleutel en gebruik deze met de Newt-connector om automatisch sites te maken bij de eerste opstart - er is geen behoefte om aparte inloggegevens voor elke site in te stellen.",
|
||||
"provisioningKeysBannerButtonText": "Meer informatie",
|
||||
"pendingSitesBannerTitle": "Openstaande sites",
|
||||
"pendingSitesBannerDescription": "Sites die met elkaar verbinden met behulp van een provisioning-sleutel verschijnen hier voor beoordeling. Accepteer elke site voordat deze actief wordt en krijgt toegang tot uw bronnen.",
|
||||
"pendingSitesBannerDescription": "Sites die verbinding maken met een inrichtingssleutel verschijnen hier voor beoordeling.",
|
||||
"pendingSitesBannerButtonText": "Meer informatie",
|
||||
"apiKeysSettings": "{apiKeyName} instellingen",
|
||||
"userTitle": "Alle gebruikers beheren",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Licentiesleutel activeren mislukt",
|
||||
"licenseErrorKeyActivateDescription": "Er is een fout opgetreden tijdens het activeren van de licentiesleutel.",
|
||||
"licenseAbout": "Over licenties",
|
||||
"licenseBannerTitle": "Activeer Uw Enterprise Licentie",
|
||||
"licenseBannerDescription": "Ontgrendel enterprise-functies voor uw zelf-gehoste Pangolin-instantie. Koop een licentiesleutel om premium mogelijkheden te activeren, voeg deze vervolgens hieronder toe.",
|
||||
"licenseBannerGetLicense": "Koop een Licentie",
|
||||
"licenseBannerViewDocs": "Bekijk Documentatie",
|
||||
"communityEdition": "Community editie",
|
||||
"licenseAboutDescription": "Dit geldt voor gebruikers van bedrijven en ondernemingen die Pangolin in gebruiken in een commerciële omgeving. Als u Pangolin gebruikt voor persoonlijk gebruik, kunt u dit gedeelte negeren.",
|
||||
"licenseKeyActivated": "Licentiesleutel geactiveerd",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Voer een geldig poortnummer in",
|
||||
"targetErrorNoSite": "Geen site geselecteerd",
|
||||
"targetErrorNoSiteDescription": "Selecteer een site voor het doel",
|
||||
"targetTargetsCleared": "Doelen gewist",
|
||||
"targetTargetsClearedDescription": "Alle doelen zijn verwijderd van deze bron",
|
||||
"targetCreated": "Doel aangemaakt",
|
||||
"targetCreatedDescription": "Doel is succesvol aangemaakt",
|
||||
"targetErrorCreate": "Kan doel niet aanmaken",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Selecteer een domein voor de authenticatiepagina van de organisatie",
|
||||
"domainPickerProvidedDomain": "Opgegeven domein",
|
||||
"domainPickerFreeProvidedDomain": "Gratis verstrekt domein",
|
||||
"domainPickerFreeDomainsPaidFeature": "Geleverde domeinen zijn een betaalde functie. Abonneer je om een domein bij je plan te krijgen — je hoeft er zelf geen mee te brengen.",
|
||||
"domainPickerVerified": "Geverifieerd",
|
||||
"domainPickerUnverified": "Ongeverifieerd",
|
||||
"domainPickerManual": "Handleiding",
|
||||
"domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.",
|
||||
"domainPickerError": "Foutmelding",
|
||||
"domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Alleen persoonlijk gebruik (gratis licentie - geen afrekenen)",
|
||||
"personalUseOnly": "Alleen voor persoonlijk gebruik (gratis licentie - geen afrekening)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Doorgaan naar afrekenen"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Machine Clienten",
|
||||
"install": "Installeren",
|
||||
"run": "Uitvoeren",
|
||||
"envFile": "Omgevingsbestand",
|
||||
"serviceFile": "Servicebestand",
|
||||
"enableAndStart": "Inschakelen en Starten",
|
||||
"clientNameDescription": "De weergavenaam van de client die later gewijzigd kan worden.",
|
||||
"clientAddress": "Klant adres (Geavanceerd)",
|
||||
"setupFailedToFetchSubnet": "Kan standaard subnet niet ophalen",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Geen authenticatie",
|
||||
"httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.",
|
||||
"httpDestAuthBearerTitle": "Betere Token",
|
||||
"httpDestAuthBearerDescription": "Voegt een machtiging toe: Drager <token> header aan elke aanvraag.",
|
||||
"httpDestAuthBearerDescription": "Voegt een Authorization: Bearer '<token>' header toe aan elk verzoek.",
|
||||
"httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token",
|
||||
"httpDestAuthBasicTitle": "Basis authenticatie",
|
||||
"httpDestAuthBasicDescription": "Voegt een Authorizatie toe: Basis <credentials> kop. Geef inloggegevens op als gebruikersnaam:wachtwoord.",
|
||||
"httpDestAuthBasicDescription": "Voegt een Authorization: Basic '<credentials>' header toe. Verstrek inloggegevens als gebruikersnaam:wachtwoord.",
|
||||
"httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord",
|
||||
"httpDestAuthCustomTitle": "Aangepaste koptekst",
|
||||
"httpDestAuthCustomDescription": "Specificeer een aangepaste HTTP header naam en waarde voor authenticatie (bijv. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Klucz zaopatrzenia zaktualizowany",
|
||||
"provisioningKeysUpdatedDescription": "Twoje zmiany zostały zapisane.",
|
||||
"provisioningKeysBannerTitle": "Klucze Zaopatrzenia witryny",
|
||||
"provisioningKeysBannerDescription": "Wygeneruj klucz tworzenia rezerw i użyj go z konektorem Newt do automatycznego tworzenia witryn przy pierwszym uruchomieniu — nie ma potrzeby ustawiania oddzielnych poświadczeń dla każdej witryny.",
|
||||
"provisioningKeysBannerDescription": "Wygeneruj klucz provisioning i użyj go z konektorem Newt do automatycznego tworzenia witryn przy pierwszym uruchomieniu - nie ma potrzeby konfigurowania oddzielnych poświadczeń dla każdej witryny.",
|
||||
"provisioningKeysBannerButtonText": "Dowiedz się więcej",
|
||||
"pendingSitesBannerTitle": "Witryny oczekujące",
|
||||
"pendingSitesBannerDescription": "Witryny, które łączą się przy użyciu klucza zaopatrzenia, pojawiają się tutaj, aby przejrzeć. Zatwierdź każdą witrynę, zanim stanie się aktywna i uzyska dostęp do twoich zasobów.",
|
||||
"pendingSitesBannerDescription": "Witryny, które łączą się za pomocą klucza provisioning, pojawią się tutaj do przeglądu.",
|
||||
"pendingSitesBannerButtonText": "Dowiedz się więcej",
|
||||
"apiKeysSettings": "Ustawienia {apiKeyName}",
|
||||
"userTitle": "Zarządzaj wszystkimi użytkownikami",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Nie udało się aktywować klucza licencji",
|
||||
"licenseErrorKeyActivateDescription": "Wystąpił błąd podczas aktywacji klucza licencyjnego.",
|
||||
"licenseAbout": "O licencjonowaniu",
|
||||
"licenseBannerTitle": "Aktywuj swoją licencję Enterprise",
|
||||
"licenseBannerDescription": "Odblokuj funkcje korporacyjne dla swojego autonomicznego wdrożenia Pangolin. Kup klucz licencyjny, aby aktywować możliwości premium, a następnie wprowadź go poniżej.",
|
||||
"licenseBannerGetLicense": "Uzyskaj licencję",
|
||||
"licenseBannerViewDocs": "Zobacz dokumentację",
|
||||
"communityEdition": "Edycja Społecznościowa",
|
||||
"licenseAboutDescription": "Dotyczy to przedsiębiorstw i przedsiębiorstw, którzy stosują Pangolin w środowisku handlowym. Jeśli używasz Pangolin do użytku osobistego, możesz zignorować tę sekcję.",
|
||||
"licenseKeyActivated": "Klucz licencyjny aktywowany",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Wprowadź prawidłowy numer portu",
|
||||
"targetErrorNoSite": "Nie wybrano witryny",
|
||||
"targetErrorNoSiteDescription": "Wybierz witrynę docelową",
|
||||
"targetTargetsCleared": "Cele wyczyszczone",
|
||||
"targetTargetsClearedDescription": "Wszystkie cele zostały usunięte z tego zasobu",
|
||||
"targetCreated": "Cel utworzony",
|
||||
"targetCreatedDescription": "Cel został utworzony pomyślnie",
|
||||
"targetErrorCreate": "Nie udało się utworzyć celu",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Wybierz domenę dla strony uwierzytelniania organizacji",
|
||||
"domainPickerProvidedDomain": "Dostarczona domena",
|
||||
"domainPickerFreeProvidedDomain": "Darmowa oferowana domena",
|
||||
"domainPickerFreeDomainsPaidFeature": "Dostarczane domeny to funkcja płatna. Subskrybuj, aby uzyskać domenę w ramach swojego planu — nie ma potrzeby przynoszenia własnej.",
|
||||
"domainPickerVerified": "Zweryfikowano",
|
||||
"domainPickerUnverified": "Niezweryfikowane",
|
||||
"domainPickerManual": "Podręcznik",
|
||||
"domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.",
|
||||
"domainPickerError": "Błąd",
|
||||
"domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Cechy przedsiębiorstw, 50 użytkowników, 50 obiektów i wsparcie priorytetowe."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Wyłącznie do użytku osobistego (bezpłatna licencja – brak zamówień)",
|
||||
"personalUseOnly": "Tylko do użytku osobistego (darmowa licencja - bez płatności)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Przejdź do zamówienia"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Klienci maszyn",
|
||||
"install": "Zainstaluj",
|
||||
"run": "Uruchom",
|
||||
"envFile": "Plik środowiska",
|
||||
"serviceFile": "Plik serwisu",
|
||||
"enableAndStart": "Włącz i Uruchom",
|
||||
"clientNameDescription": "Wyświetlana nazwa klienta, która może zostać zmieniona później.",
|
||||
"clientAddress": "Adres klienta (Zaawansowany)",
|
||||
"setupFailedToFetchSubnet": "Nie udało się pobrać domyślnej podsieci",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Brak uwierzytelniania",
|
||||
"httpDestAuthNoneDescription": "Wysyła żądania bez nagłówka autoryzacji.",
|
||||
"httpDestAuthBearerTitle": "Token Bearer",
|
||||
"httpDestAuthBearerDescription": "Dodaje autoryzację: nagłówek Bearer <token> do każdego żądania.",
|
||||
"httpDestAuthBearerDescription": "Dodaje nagłówek Authorization: Bearer '<token>' do każdego żądania.",
|
||||
"httpDestAuthBearerPlaceholder": "Twój klucz API lub token",
|
||||
"httpDestAuthBasicTitle": "Podstawowa Autoryzacja",
|
||||
"httpDestAuthBasicDescription": "Dodaje Autoryzacja: Nagłówek Basic <credentials> . Podaj poświadczenia jako nazwę użytkownika: hasło.",
|
||||
"httpDestAuthBasicDescription": "Dodaje nagłówek Authorization: Basic '<credentials>'. Podaj poświadczenia w formacie użytkownik:hasło.",
|
||||
"httpDestAuthBasicPlaceholder": "Nazwa użytkownika:hasło",
|
||||
"httpDestAuthCustomTitle": "Niestandardowy nagłówek",
|
||||
"httpDestAuthCustomDescription": "Określ niestandardową nazwę nagłówka HTTP i wartość dla uwierzytelniania (np. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Chave de provisionamento atualizada",
|
||||
"provisioningKeysUpdatedDescription": "Suas alterações foram salvas.",
|
||||
"provisioningKeysBannerTitle": "Chaves de provisionamento do site",
|
||||
"provisioningKeysBannerDescription": "Gerar uma chave de provisionamento e usá-la com o conector de Newt para criar automaticamente sites na primeira inicialização — não é necessário configurar credenciais separadas para cada site.",
|
||||
"provisioningKeysBannerDescription": "Gere uma chave de provisionamento e use-a com o conector Newt para criar sites automaticamente na primeira inicialização - sem necessidade de configurar credenciais separadas para cada site.",
|
||||
"provisioningKeysBannerButtonText": "Saiba mais",
|
||||
"pendingSitesBannerTitle": "Sites pendentes",
|
||||
"pendingSitesBannerDescription": "Sites que conectam usando uma chave de provisionamento aparecem aqui para revisão. Aprovar cada site antes de se tornar ativo e ganhar acesso a seus recursos.",
|
||||
"pendingSitesBannerDescription": "Sites que se conectam usando uma chave de provisionamento aparecem aqui para revisão.",
|
||||
"pendingSitesBannerButtonText": "Saiba mais",
|
||||
"apiKeysSettings": "Configurações de {apiKeyName}",
|
||||
"userTitle": "Gerir Todos os Utilizadores",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Falha ao ativar a chave de licença",
|
||||
"licenseErrorKeyActivateDescription": "Ocorreu um erro ao ativar a chave da licença.",
|
||||
"licenseAbout": "Sobre Licenciamento",
|
||||
"licenseBannerTitle": "Ative Sua Licença Corporativa",
|
||||
"licenseBannerDescription": "Desbloqueie recursos empresariais para sua instância de Pangolin autohospedada. Compre uma chave de licença para ativar recursos premium e adicione-a abaixo.",
|
||||
"licenseBannerGetLicense": "Obter Licença",
|
||||
"licenseBannerViewDocs": "Ver Documentação",
|
||||
"communityEdition": "Edição da Comunidade",
|
||||
"licenseAboutDescription": "Isto destina-se aos utilizadores empresariais e empresariais que estão a usar o Pangolin num ambiente comercial. Se você estiver usando o Pangolin para uso pessoal, você pode ignorar esta seção.",
|
||||
"licenseKeyActivated": "Chave de licença ativada",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Por favor, digite um número de porta válido",
|
||||
"targetErrorNoSite": "Nenhum site selecionado",
|
||||
"targetErrorNoSiteDescription": "Selecione um site para o destino",
|
||||
"targetTargetsCleared": "Alvos limpos",
|
||||
"targetTargetsClearedDescription": "Todos os alvos foram removidos deste recurso",
|
||||
"targetCreated": "Destino criado",
|
||||
"targetCreatedDescription": "O alvo foi criado com sucesso",
|
||||
"targetErrorCreate": "Falha ao criar destino",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Selecione um domínio para a página de autenticação da organização",
|
||||
"domainPickerProvidedDomain": "Domínio fornecido",
|
||||
"domainPickerFreeProvidedDomain": "Domínio fornecido grátis",
|
||||
"domainPickerFreeDomainsPaidFeature": "Os domínios fornecidos são um recurso pago. Assine para obter um domínio incluído no seu plano — não há necessidade de trazer o seu próprio.",
|
||||
"domainPickerVerified": "Verificada",
|
||||
"domainPickerUnverified": "Não verificado",
|
||||
"domainPickerManual": "Manual",
|
||||
"domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.",
|
||||
"domainPickerError": "ERRO",
|
||||
"domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Apenas uso pessoal (licença gratuita — sem check-out)",
|
||||
"personalUseOnly": "Uso pessoal apenas (licença gratuita - sem checkout)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continuar com checkout"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Clientes de máquina",
|
||||
"install": "Instale",
|
||||
"run": "Executar",
|
||||
"envFile": "Arquivo de Ambiente",
|
||||
"serviceFile": "Arquivo de Serviço",
|
||||
"enableAndStart": "Ativar e Iniciar",
|
||||
"clientNameDescription": "O nome de exibição do cliente que pode ser alterado mais tarde.",
|
||||
"clientAddress": "Endereço do Cliente (Avançado)",
|
||||
"setupFailedToFetchSubnet": "Falha ao buscar a subrede padrão",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Sem Autenticação",
|
||||
"httpDestAuthNoneDescription": "Envia pedidos sem um cabeçalho de autorização.",
|
||||
"httpDestAuthBearerTitle": "Token do portador",
|
||||
"httpDestAuthBearerDescription": "Adiciona uma autorização: Bearer <token> header a cada requisição.",
|
||||
"httpDestAuthBearerDescription": "Adiciona um cabeçalho Authorization: Bearer '<token>' a cada solicitação.",
|
||||
"httpDestAuthBearerPlaceholder": "Sua chave de API ou token",
|
||||
"httpDestAuthBasicTitle": "Autenticação básica",
|
||||
"httpDestAuthBasicDescription": "Adiciona uma Autorização: cabeçalho <credentials> básico. Forneça credenciais como nome de usuário:senha.",
|
||||
"httpDestAuthBasicDescription": "Adiciona um cabeçalho Authorization: Basic '<credentials>'. Forneça as credenciais como username:password.",
|
||||
"httpDestAuthBasicPlaceholder": "Usuário:password",
|
||||
"httpDestAuthCustomTitle": "Cabeçalho personalizado",
|
||||
"httpDestAuthCustomDescription": "Especifique um nome e valor de cabeçalho HTTP personalizado para autenticação (por exemplo, X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Ключ подготовки обновлен",
|
||||
"provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.",
|
||||
"provisioningKeysBannerTitle": "Ключи подготовки сайта",
|
||||
"provisioningKeysBannerDescription": "Генерировать подготовительный ключ и использовать его вместе с Новым коннектором для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.",
|
||||
"provisioningKeysBannerDescription": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.",
|
||||
"provisioningKeysBannerButtonText": "Узнать больше",
|
||||
"pendingSitesBannerTitle": "Ожидающие сайты",
|
||||
"pendingSitesBannerDescription": "Сайты, связанные с использованием ключа подготовки, появляются здесь для проверки. Одобрите каждый сайт, прежде чем он станет активным и получит доступ к вашим ресурсам.",
|
||||
"pendingSitesBannerDescription": "Сайты, подключающиеся с помощью ключа настройки, отображаются здесь для проверки.",
|
||||
"pendingSitesBannerButtonText": "Узнать больше",
|
||||
"apiKeysSettings": "Настройки {apiKeyName}",
|
||||
"userTitle": "Управление всеми пользователями",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Не удалось активировать лицензионный ключ",
|
||||
"licenseErrorKeyActivateDescription": "Произошла ошибка при активации лицензионного ключа.",
|
||||
"licenseAbout": "О лицензировании",
|
||||
"licenseBannerTitle": "Активируйте вашу корпоративную лицензию",
|
||||
"licenseBannerDescription": "Откройте доступ к корпоративным функциям для вашей локально размещаемой версии Pangolin. Приобретите лицензионный ключ, чтобы активировать премиум-функции, затем добавьте его ниже.",
|
||||
"licenseBannerGetLicense": "Получить лицензию",
|
||||
"licenseBannerViewDocs": "Посмотреть документацию",
|
||||
"communityEdition": "Community Edition",
|
||||
"licenseAboutDescription": "Это для бизнес и корпоративных пользователей, использующих Pangolin в коммерческой среде. Если вы используете Pangolin для личного использования, вы можете игнорировать этот раздел.",
|
||||
"licenseKeyActivated": "Лицензионный ключ активирован",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта",
|
||||
"targetErrorNoSite": "Сайт не выбран",
|
||||
"targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели",
|
||||
"targetTargetsCleared": "Цели очищены",
|
||||
"targetTargetsClearedDescription": "Все цели удалены из этого ресурса",
|
||||
"targetCreated": "Цель создана",
|
||||
"targetCreatedDescription": "Цель была успешно создана",
|
||||
"targetErrorCreate": "Не удалось создать цель",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Выберите домен для страницы аутентификации организации",
|
||||
"domainPickerProvidedDomain": "Домен предоставлен",
|
||||
"domainPickerFreeProvidedDomain": "Бесплатный домен",
|
||||
"domainPickerFreeDomainsPaidFeature": "Предоставленные домены являются платной функцией. Подпишитесь, чтобы получить домен, включенный в ваш план — не нужно приносить свой собственный.",
|
||||
"domainPickerVerified": "Подтверждено",
|
||||
"domainPickerUnverified": "Не подтверждено",
|
||||
"domainPickerManual": "Ручной",
|
||||
"domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.",
|
||||
"domainPickerError": "Ошибка",
|
||||
"domainPickerErrorLoadDomains": "Не удалось загрузить домены организации",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Только для личного пользования (бесплатная лицензия — без оформления)",
|
||||
"personalUseOnly": "Только для личного использования (бесплатная лицензия - без оформления на кассе)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Продолжить оформление заказа"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Машинные клиенты",
|
||||
"install": "Установить",
|
||||
"run": "Запустить",
|
||||
"envFile": "Файл окружения",
|
||||
"serviceFile": "Сервисный файл",
|
||||
"enableAndStart": "Включить и запустить",
|
||||
"clientNameDescription": "Отображаемое имя клиента, которое может быть изменено позже.",
|
||||
"clientAddress": "Адрес клиента (Дополнительно)",
|
||||
"setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Нет аутентификации",
|
||||
"httpDestAuthNoneDescription": "Отправляет запросы без заголовка авторизации.",
|
||||
"httpDestAuthBearerTitle": "Жетон носителя",
|
||||
"httpDestAuthBearerDescription": "Добавляет заголовок Authorization: Bearer <token> к каждому запросу.",
|
||||
"httpDestAuthBearerDescription": "Добавляет заголовок Authorization: Bearer '<token>' к каждому запросу.",
|
||||
"httpDestAuthBearerPlaceholder": "Ваш ключ API или токен",
|
||||
"httpDestAuthBasicTitle": "Базовая авторизация",
|
||||
"httpDestAuthBasicDescription": "Добавляет Authorization: Basic <credentials> header. Предоставьте учетные данные в качестве имени пользователя:password.",
|
||||
"httpDestAuthBasicDescription": "Добавляет заголовок Authorization: Basic '<credentials>'. Укажите учетные данные в формате username:password.",
|
||||
"httpDestAuthBasicPlaceholder": "имя пользователя:пароль",
|
||||
"httpDestAuthCustomTitle": "Пользовательский заголовок",
|
||||
"httpDestAuthCustomDescription": "Укажите пользовательское имя заголовка HTTP и значение для аутентификации (например, X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Tedarik anahtarı güncellendi",
|
||||
"provisioningKeysUpdatedDescription": "Değişiklikleriniz kaydedildi.",
|
||||
"provisioningKeysBannerTitle": "Site Tedarik Anahtarları",
|
||||
"provisioningKeysBannerDescription": "Tedarik anahtarı oluşturun ve ilk başlangıçta siteleri otomatik olarak oluşturmak için Newt konektörüyle kullanın — her site için ayrı kimlik bilgileri ayarlamaya gerek yoktur.",
|
||||
"provisioningKeysBannerDescription": "Bir sağlama anahtarı oluşturun ve ilk başlangıçta siteleri otomatik olarak oluşturmak için Newt bağlayıcısını kullanın - her site için ayrı kimlik bilgileri ayarlamaya gerek yok.",
|
||||
"provisioningKeysBannerButtonText": "Daha fazla bilgi",
|
||||
"pendingSitesBannerTitle": "Bekleyen Siteler",
|
||||
"pendingSitesBannerDescription": "Tedarik anahtarı kullanarak bağlanan siteler burada incelenmek için görünür. Aktif hale gelmeden ve kaynaklarınıza erişim kazanmadan önce her siteyi onaylayın.",
|
||||
"pendingSitesBannerDescription": "Bir sağlama anahtarı kullanarak bağlanan siteler, inceleme için burada görünür.",
|
||||
"pendingSitesBannerButtonText": "Daha fazla bilgi",
|
||||
"apiKeysSettings": "{apiKeyName} Ayarları",
|
||||
"userTitle": "Tüm Kullanıcıları Yönet",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Lisans anahtarı etkinleştirilemedi",
|
||||
"licenseErrorKeyActivateDescription": "Lisans anahtarı etkinleştirilirken bir hata oluştu.",
|
||||
"licenseAbout": "Lisans Hakkında",
|
||||
"licenseBannerTitle": "Kurumsal Lisansınızı Etkinleştirin",
|
||||
"licenseBannerDescription": "Kendi barındırdığınız Pangolin örneğiniz için kurumsal özelliklerin kilidini açın. Premium yetenekleri etkinleştirmek için bir lisans anahtarı satın alın, ardından aşağıya ekleyin.",
|
||||
"licenseBannerGetLicense": "Lisans Alın",
|
||||
"licenseBannerViewDocs": "Dokümantasyonu Görüntüleyin",
|
||||
"communityEdition": "Topluluk Sürümü",
|
||||
"licenseAboutDescription": "Bu, Pangolin'i ticari bir ortamda kullanan işletme ve kurumsal kullanıcılar içindir. Pangolin'i kişisel kullanım için kullanıyorsanız, bu bölümü görmezden gelebilirsiniz.",
|
||||
"licenseKeyActivated": "Lisans anahtarı etkinleştirildi",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Lütfen geçerli bir port numarası girin",
|
||||
"targetErrorNoSite": "Hiçbir site seçili değil",
|
||||
"targetErrorNoSiteDescription": "Lütfen hedef için bir site seçin",
|
||||
"targetTargetsCleared": "Hedefler temizlendi",
|
||||
"targetTargetsClearedDescription": "Bu kaynaktan tüm hedefler kaldırıldı",
|
||||
"targetCreated": "Hedef oluşturuldu",
|
||||
"targetCreatedDescription": "Hedef başarıyla oluşturuldu",
|
||||
"targetErrorCreate": "Hedef oluşturma başarısız oldu",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Kuruluşun kimlik doğrulama sayfası için bir alan seçin",
|
||||
"domainPickerProvidedDomain": "Sağlanan Alan Adı",
|
||||
"domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı",
|
||||
"domainPickerFreeDomainsPaidFeature": "Sağlanan alan adları ücretli bir özelliktir. Planınıza dahil bir alan adı almak için abone olun - kendi alan adınızı getirmenize gerek yok.",
|
||||
"domainPickerVerified": "Doğrulandı",
|
||||
"domainPickerUnverified": "Doğrulanmadı",
|
||||
"domainPickerManual": "Manuel",
|
||||
"domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.",
|
||||
"domainPickerError": "Hata",
|
||||
"domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Yalnızca kişisel kullanım (ücretsiz lisans — ödeme yapılmaz)",
|
||||
"personalUseOnly": "Kişisel kullanım için (ücretsiz lisans - ödeme yok)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Ödemeye Devam Et"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "Makine İstemcileri",
|
||||
"install": "Yükle",
|
||||
"run": "Çalıştır",
|
||||
"envFile": "Ortam Dosyası",
|
||||
"serviceFile": "Servis Dosyası",
|
||||
"enableAndStart": "Etkinleştir ve Başlat",
|
||||
"clientNameDescription": "Daha sonra değiştirilebilecek istemcinin görünen adı.",
|
||||
"clientAddress": "İstemci Adresi (Gelişmiş)",
|
||||
"setupFailedToFetchSubnet": "Varsayılan alt ağ alınamadı",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "Kimlik Doğrulama Yok",
|
||||
"httpDestAuthNoneDescription": "Yetkilendirme başlığı olmadan istekler gönderir.",
|
||||
"httpDestAuthBearerTitle": "Taşıyıcı Jetonu",
|
||||
"httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı <token> başlığı ekler.",
|
||||
"httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı '<token>' üst bilgisi ekler.",
|
||||
"httpDestAuthBearerPlaceholder": "API anahtarınız veya jetonunuz",
|
||||
"httpDestAuthBasicTitle": "Temel Kimlik Doğrulama",
|
||||
"httpDestAuthBasicDescription": "Authorization: Temel <belirtecikler> başlığı ekler. Yetkilendirmeleri kullanıcı adı:şifre olarak sağlayın.",
|
||||
"httpDestAuthBasicDescription": "Bir Yetkilendirme: Temel '<credentials>' üst bilgisi ekler. Kimlik bilgilerini kullanıcı adı:şifre olarak sağlayın.",
|
||||
"httpDestAuthBasicPlaceholder": "kullanıcı adı:şifre",
|
||||
"httpDestAuthCustomTitle": "Özel Başlık",
|
||||
"httpDestAuthCustomDescription": "Kimlik doğrulama için özel bir HTTP başlık adı ve değer belirtin (örn. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "置备密钥已更新",
|
||||
"provisioningKeysUpdatedDescription": "您的更改已保存。",
|
||||
"provisioningKeysBannerTitle": "站点置备密钥",
|
||||
"provisioningKeysBannerDescription": "生成一个预配键并使用它来在首次启动时自动创建站点——无需为每个站点设置单独的凭证。",
|
||||
"provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。",
|
||||
"provisioningKeysBannerButtonText": "了解更多",
|
||||
"pendingSitesBannerTitle": "待定站点",
|
||||
"pendingSitesBannerDescription": "使用预配键连接的站点会出现在这里供审核。在站点开始运行之前批准并获取对您资源的访问权限。",
|
||||
"pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。",
|
||||
"pendingSitesBannerButtonText": "了解更多",
|
||||
"apiKeysSettings": "{apiKeyName} 设置",
|
||||
"userTitle": "管理所有用户",
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "激活许可证密钥失败",
|
||||
"licenseErrorKeyActivateDescription": "激活许可证密钥时出错。",
|
||||
"licenseAbout": "关于许可协议",
|
||||
"licenseBannerTitle": "启用您的企业许可证",
|
||||
"licenseBannerDescription": "为您自行托管的Pangolin实例解锁企业功能。购买许可证密钥以激活高级功能,然后在下方添加。",
|
||||
"licenseBannerGetLicense": "获取许可证",
|
||||
"licenseBannerViewDocs": "查看文档",
|
||||
"communityEdition": "社区版",
|
||||
"licenseAboutDescription": "这是针对商业环境中使用Pangolin的商业和企业用户。 如果您正在使用 Pangolin 供个人使用,您可以忽略此部分。",
|
||||
"licenseKeyActivated": "授权密钥已激活",
|
||||
@@ -624,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "请输入有效的端口号",
|
||||
"targetErrorNoSite": "没有选择站点",
|
||||
"targetErrorNoSiteDescription": "请选择目标站点",
|
||||
"targetTargetsCleared": "目标已清除",
|
||||
"targetTargetsClearedDescription": "所有目标已从此资源中移除",
|
||||
"targetCreated": "目标已创建",
|
||||
"targetCreatedDescription": "目标已成功创建",
|
||||
"targetErrorCreate": "创建目标失败",
|
||||
@@ -2112,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "选择组织认证页面的域",
|
||||
"domainPickerProvidedDomain": "提供的域",
|
||||
"domainPickerFreeProvidedDomain": "免费提供的域",
|
||||
"domainPickerFreeDomainsPaidFeature": "提供的域名是付费功能。订阅即可将域名包含在您的计划中—无需自带域名。",
|
||||
"domainPickerVerified": "已验证",
|
||||
"domainPickerUnverified": "未验证",
|
||||
"domainPickerManual": "手动",
|
||||
"domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。",
|
||||
"domainPickerError": "错误",
|
||||
"domainPickerErrorLoadDomains": "加载组织域名失败",
|
||||
@@ -2346,7 +2354,7 @@
|
||||
"description": "企业特征、50个用户、50个站点和优先支持。"
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "仅供个人使用 (免费许可证-无签出)",
|
||||
"personalUseOnly": "仅限个人使用(免费许可 - 无需结账)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "继续签出"
|
||||
},
|
||||
@@ -2607,6 +2615,9 @@
|
||||
"machineClients": "机器客户端",
|
||||
"install": "安装",
|
||||
"run": "运行",
|
||||
"envFile": "环境文件",
|
||||
"serviceFile": "服务文件",
|
||||
"enableAndStart": "启用并启动",
|
||||
"clientNameDescription": "可以稍后更改的客户端的显示名称。",
|
||||
"clientAddress": "客户端地址 (高级)",
|
||||
"setupFailedToFetchSubnet": "获取默认子网失败",
|
||||
@@ -2845,10 +2856,10 @@
|
||||
"httpDestAuthNoneTitle": "无身份验证",
|
||||
"httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。",
|
||||
"httpDestAuthBearerTitle": "持有者令牌",
|
||||
"httpDestAuthBearerDescription": "添加授权:每个请求的标题为 <token>。",
|
||||
"httpDestAuthBearerDescription": "在每个请求中添加授权:Bearer “<token>” 头。",
|
||||
"httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌",
|
||||
"httpDestAuthBasicTitle": "基本认证",
|
||||
"httpDestAuthBasicDescription": "添加授权:基本 <credentials> 头。提供用户名:密码的凭据。",
|
||||
"httpDestAuthBasicDescription": "添加一个Authorization: Basic \"<凭据>\" 标头。 以用户名:密码形式提供凭据。",
|
||||
"httpDestAuthBasicPlaceholder": "用户名:密码",
|
||||
"httpDestAuthCustomTitle": "自定义标题",
|
||||
"httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如,X-API 键)。",
|
||||
|
||||
4790
messages/zh-TW.json
|
Before Width: | Height: | Size: 484 KiB After Width: | Height: | Size: 765 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 742 KiB |
|
Before Width: | Height: | Size: 484 KiB After Width: | Height: | Size: 765 KiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 243 KiB |
@@ -19,7 +19,8 @@ export enum TierFeature {
|
||||
SshPam = "sshPam",
|
||||
FullRbac = "fullRbac",
|
||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
||||
SIEM = "siem" // handle downgrade by disabling SIEM integrations
|
||||
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
||||
DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -56,5 +57,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||
[TierFeature.SIEM]: ["enterprise"]
|
||||
[TierFeature.SIEM]: ["enterprise"],
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -479,10 +479,7 @@ export async function getTraefikConfig(
|
||||
|
||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||
const anySitesOnline = targets.some(
|
||||
(target) =>
|
||||
target.site.online ||
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
(target) => target.site.online
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -495,7 +492,7 @@ export async function getTraefikConfig(
|
||||
if (target.health == "unhealthy") {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
@@ -610,10 +607,7 @@ export async function getTraefikConfig(
|
||||
servers: (() => {
|
||||
// Check if any sites are online
|
||||
const anySitesOnline = targets.some(
|
||||
(target) =>
|
||||
target.site.online ||
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
(target) => target.site.online
|
||||
);
|
||||
|
||||
return targets
|
||||
@@ -621,7 +615,7 @@ export async function getTraefikConfig(
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, gt, desc, max, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
LogType,
|
||||
LOG_TYPES,
|
||||
@@ -127,7 +129,7 @@ export class LogStreamingManager {
|
||||
start(): void {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
logger.info("LogStreamingManager: started");
|
||||
logger.debug("LogStreamingManager: started");
|
||||
this.schedulePoll(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -272,19 +274,20 @@ export class LogStreamingManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse config – skip destination if config is unparseable
|
||||
let config: HttpConfig;
|
||||
// Decrypt and parse config – skip destination if either step fails
|
||||
let configFromDb: HttpConfig;
|
||||
try {
|
||||
config = JSON.parse(dest.config) as HttpConfig;
|
||||
const decryptedConfig = decrypt(dest.config, config.getRawConfig().server.secret!);
|
||||
configFromDb = JSON.parse(decryptedConfig) as HttpConfig;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`LogStreamingManager: destination ${dest.destinationId} has invalid JSON config`,
|
||||
`LogStreamingManager: destination ${dest.destinationId} has invalid or undecryptable config`,
|
||||
err
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = this.createProvider(dest.type, config);
|
||||
const provider = this.createProvider(dest.type, configFromDb);
|
||||
if (!provider) {
|
||||
logger.warn(
|
||||
`LogStreamingManager: unsupported destination type "${dest.type}" ` +
|
||||
@@ -770,4 +773,4 @@ export class LogStreamingManager {
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,10 +671,7 @@ export async function getTraefikConfig(
|
||||
|
||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||
const anySitesOnline = targets.some(
|
||||
(target) =>
|
||||
target.site.online ||
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
(target) => target.site.online
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -802,10 +799,7 @@ export async function getTraefikConfig(
|
||||
servers: (() => {
|
||||
// Check if any sites are online
|
||||
const anySitesOnline = targets.some(
|
||||
(target) =>
|
||||
target.site.online ||
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
(target) => target.site.online
|
||||
);
|
||||
|
||||
return targets
|
||||
|
||||
@@ -22,11 +22,15 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { db, domainNamespaces, resources } from "@server/db";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
|
||||
import { build } from "@server/build";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const paramsSchema = z.strictObject({});
|
||||
|
||||
const querySchema = z.strictObject({
|
||||
subdomain: z.string()
|
||||
subdomain: z.string(),
|
||||
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
@@ -58,6 +62,23 @@ export async function checkDomainNamespaceAvailability(
|
||||
}
|
||||
const { subdomain } = parsedQuery.data;
|
||||
|
||||
// if (
|
||||
// build == "saas" &&
|
||||
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
|
||||
// ) {
|
||||
// // return not available
|
||||
// return response<CheckDomainAvailabilityResponse>(res, {
|
||||
// data: {
|
||||
// available: false,
|
||||
// options: []
|
||||
// },
|
||||
// success: true,
|
||||
// error: false,
|
||||
// message: "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
|
||||
// status: HttpCode.OK
|
||||
// });
|
||||
// }
|
||||
|
||||
const namespaces = await db.select().from(domainNamespaces);
|
||||
let possibleDomains = namespaces.map((ns) => {
|
||||
const desired = `${subdomain}.${ns.domainNamespaceId}`;
|
||||
|
||||
@@ -22,6 +22,9 @@ import { eq, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const paramsSchema = z.strictObject({});
|
||||
|
||||
@@ -37,7 +40,8 @@ const querySchema = z.strictObject({
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.pipe(z.int().nonnegative()),
|
||||
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
|
||||
});
|
||||
|
||||
async function query(limit: number, offset: number) {
|
||||
@@ -99,6 +103,26 @@ export async function listDomainNamespaces(
|
||||
);
|
||||
}
|
||||
|
||||
// if (
|
||||
// build == "saas" &&
|
||||
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
|
||||
// ) {
|
||||
// return response<ListDomainNamespacesResponse>(res, {
|
||||
// data: {
|
||||
// domainNamespaces: [],
|
||||
// pagination: {
|
||||
// total: 0,
|
||||
// limit,
|
||||
// offset
|
||||
// }
|
||||
// },
|
||||
// success: true,
|
||||
// error: false,
|
||||
// message: "No namespaces found. Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
|
||||
// status: HttpCode.OK
|
||||
// });
|
||||
// }
|
||||
|
||||
const domainNamespacesList = await query(limit, offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
|
||||
@@ -22,6 +22,8 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -87,7 +89,10 @@ export async function createEventStreamingDestination(
|
||||
);
|
||||
}
|
||||
|
||||
const { type, config, enabled } = parsedBody.data;
|
||||
const { type, config: configToSet, enabled } = parsedBody.data;
|
||||
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
const encryptedConfig = encrypt(configToSet, key);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
@@ -96,7 +101,7 @@ export async function createEventStreamingDestination(
|
||||
.values({
|
||||
orgId,
|
||||
type,
|
||||
config,
|
||||
config: encryptedConfig,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -22,6 +22,8 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -121,9 +123,22 @@ export async function listEventStreamingDestinations(
|
||||
.from(eventStreamingDestinations)
|
||||
.where(eq(eventStreamingDestinations.orgId, orgId));
|
||||
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
const decryptedList = list.map((dest) => {
|
||||
try {
|
||||
return { ...dest, config: decrypt(dest.config, key) };
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`listEventStreamingDestinations: failed to decrypt config for destination ${dest.destinationId}`,
|
||||
err
|
||||
);
|
||||
return { ...dest, config: "" };
|
||||
}
|
||||
});
|
||||
|
||||
return response<ListEventStreamingDestinationsResponse>(res, {
|
||||
data: {
|
||||
destinations: list,
|
||||
destinations: decryptedList,
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
|
||||
@@ -22,7 +22,8 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -110,14 +111,17 @@ export async function updateEventStreamingDestination(
|
||||
);
|
||||
}
|
||||
|
||||
const { type, config, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
|
||||
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
if (type !== undefined) updateData.type = type;
|
||||
if (config !== undefined) updateData.config = config;
|
||||
if (configToUpdate !== undefined) {
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
updateData.config = encrypt(configToUpdate, key);
|
||||
}
|
||||
if (enabled !== undefined) updateData.enabled = enabled;
|
||||
if (sendAccessLogs !== undefined) updateData.sendAccessLogs = sendAccessLogs;
|
||||
if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs;
|
||||
|
||||
@@ -440,6 +440,12 @@ authenticated.get(
|
||||
resource.getUserResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/user-resource-aliases",
|
||||
verifyOrgAccess,
|
||||
resource.listUserResourceAliases
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/domains",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -171,9 +171,8 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||
}
|
||||
|
||||
// PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk.
|
||||
const valuesList = chunk.map(
|
||||
([publicKey, { bytesIn, bytesOut }]) =>
|
||||
sql`(${publicKey}, ${bytesIn}, ${bytesOut})`
|
||||
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) =>
|
||||
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
|
||||
);
|
||||
const valuesClause = sql.join(valuesList, sql`, `);
|
||||
return dbQueryRows<{ orgId: string; pubKey: string }>(sql`
|
||||
|
||||
@@ -8,6 +8,7 @@ import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { convertTargetsIfNessicary } from "../client/targets";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
@@ -55,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
|
||||
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
|
||||
logger.warn(
|
||||
`handleGetConfigMessage: Site ${existingSite.siteId} last hole punch is too old, skipping`
|
||||
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, newts, sites } from "@server/db";
|
||||
import { db, newts, sites, targetHealthCheck, targets } from "@server/db";
|
||||
import {
|
||||
hasActiveConnections,
|
||||
getClientConfigVersion
|
||||
@@ -78,6 +78,32 @@ export const startNewtOfflineChecker = (): void => {
|
||||
.update(sites)
|
||||
.set({ online: false })
|
||||
.where(eq(sites.siteId, staleSite.siteId));
|
||||
|
||||
const healthChecksOnSite = await db
|
||||
.select()
|
||||
.from(targetHealthCheck)
|
||||
.innerJoin(
|
||||
targets,
|
||||
eq(targets.targetId, targetHealthCheck.targetId)
|
||||
)
|
||||
.innerJoin(sites, eq(sites.siteId, targets.siteId))
|
||||
.where(eq(sites.siteId, staleSite.siteId));
|
||||
|
||||
for (const healthCheck of healthChecksOnSite) {
|
||||
logger.info(
|
||||
`Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline`
|
||||
);
|
||||
await db
|
||||
.update(targetHealthCheck)
|
||||
.set({ hcHealth: "unknown" })
|
||||
.where(
|
||||
eq(
|
||||
targetHealthCheck.targetHealthCheckId,
|
||||
healthCheck.targetHealthCheck
|
||||
.targetHealthCheckId
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
|
||||
@@ -102,7 +128,8 @@ export const startNewtOfflineChecker = (): void => {
|
||||
|
||||
// loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline
|
||||
for (const site of allWireguardSites) {
|
||||
const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate!).getTime() / 1000;
|
||||
const lastBandwidthUpdate =
|
||||
new Date(site.lastBandwidthUpdate!).getTime() / 1000;
|
||||
if (
|
||||
lastBandwidthUpdate < wireguardOfflineThreshold &&
|
||||
site.online
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from "@server/db";
|
||||
import { sites, clients, olms } from "@server/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
/**
|
||||
@@ -21,7 +21,7 @@ import logger from "@server/logger";
|
||||
*/
|
||||
|
||||
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
||||
const MAX_RETRIES = 2;
|
||||
const MAX_RETRIES = 5;
|
||||
const BASE_DELAY_MS = 50;
|
||||
|
||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
||||
@@ -36,6 +36,14 @@ const pendingOlmArchiveResets: Set<string> = new Set();
|
||||
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Guard that prevents two flush cycles from running concurrently.
|
||||
* setInterval does not await async callbacks, so without this a slow flush
|
||||
* (e.g. due to DB latency) would overlap with the next scheduled cycle and
|
||||
* the two concurrent bulk UPDATEs would deadlock each other.
|
||||
*/
|
||||
let isFlushing = false;
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -72,6 +80,12 @@ export function recordClientPing(
|
||||
|
||||
/**
|
||||
* Flush all accumulated site pings to the database.
|
||||
*
|
||||
* Each batch of up to BATCH_SIZE rows is written with a **single** UPDATE
|
||||
* statement. We use the maximum timestamp across the batch so that `lastPing`
|
||||
* reflects the most recent ping seen for any site in the group. This avoids
|
||||
* the multi-statement transaction that previously created additional
|
||||
* row-lock ordering hazards.
|
||||
*/
|
||||
async function flushSitePingsToDb(): Promise<void> {
|
||||
if (pendingSitePings.size === 0) {
|
||||
@@ -83,55 +97,35 @@ async function flushSitePingsToDb(): Promise<void> {
|
||||
const pingsToFlush = new Map(pendingSitePings);
|
||||
pendingSitePings.clear();
|
||||
|
||||
// Sort by siteId for consistent lock ordering (prevents deadlocks)
|
||||
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
||||
([a], [b]) => a - b
|
||||
);
|
||||
const entries = Array.from(pingsToFlush.entries());
|
||||
|
||||
const BATCH_SIZE = 50;
|
||||
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||
const batch = entries.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Use the latest timestamp in the batch so that `lastPing` always
|
||||
// moves forward. Using a single timestamp for the whole batch means
|
||||
// we only ever need one UPDATE statement (no transaction).
|
||||
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
|
||||
const siteIds = batch.map(([id]) => id);
|
||||
|
||||
try {
|
||||
await withRetry(async () => {
|
||||
// Group by timestamp for efficient bulk updates
|
||||
const byTimestamp = new Map<number, number[]>();
|
||||
for (const [siteId, timestamp] of batch) {
|
||||
const group = byTimestamp.get(timestamp) || [];
|
||||
group.push(siteId);
|
||||
byTimestamp.set(timestamp, group);
|
||||
}
|
||||
|
||||
if (byTimestamp.size === 1) {
|
||||
const [timestamp, siteIds] = Array.from(
|
||||
byTimestamp.entries()
|
||||
)[0];
|
||||
await db
|
||||
.update(sites)
|
||||
.set({
|
||||
online: true,
|
||||
lastPing: timestamp
|
||||
})
|
||||
.where(inArray(sites.siteId, siteIds));
|
||||
} else {
|
||||
await db.transaction(async (tx) => {
|
||||
for (const [timestamp, siteIds] of byTimestamp) {
|
||||
await tx
|
||||
.update(sites)
|
||||
.set({
|
||||
online: true,
|
||||
lastPing: timestamp
|
||||
})
|
||||
.where(inArray(sites.siteId, siteIds));
|
||||
}
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(sites)
|
||||
.set({
|
||||
online: true,
|
||||
lastPing: maxTimestamp
|
||||
})
|
||||
.where(inArray(sites.siteId, siteIds));
|
||||
}, "flushSitePingsToDb");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
|
||||
{ error }
|
||||
);
|
||||
// Re-queue only if the preserved timestamp is newer than any
|
||||
// update that may have landed since we snapshotted.
|
||||
for (const [siteId, timestamp] of batch) {
|
||||
const existing = pendingSitePings.get(siteId);
|
||||
if (!existing || existing < timestamp) {
|
||||
@@ -144,6 +138,8 @@ async function flushSitePingsToDb(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Flush all accumulated client (OLM) pings to the database.
|
||||
*
|
||||
* Same single-UPDATE-per-batch approach as `flushSitePingsToDb`.
|
||||
*/
|
||||
async function flushClientPingsToDb(): Promise<void> {
|
||||
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
|
||||
@@ -159,51 +155,25 @@ async function flushClientPingsToDb(): Promise<void> {
|
||||
|
||||
// ── Flush client pings ─────────────────────────────────────────────
|
||||
if (pingsToFlush.size > 0) {
|
||||
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
||||
([a], [b]) => a - b
|
||||
);
|
||||
const entries = Array.from(pingsToFlush.entries());
|
||||
|
||||
const BATCH_SIZE = 50;
|
||||
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||
const batch = entries.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
|
||||
const clientIds = batch.map(([id]) => id);
|
||||
|
||||
try {
|
||||
await withRetry(async () => {
|
||||
const byTimestamp = new Map<number, number[]>();
|
||||
for (const [clientId, timestamp] of batch) {
|
||||
const group = byTimestamp.get(timestamp) || [];
|
||||
group.push(clientId);
|
||||
byTimestamp.set(timestamp, group);
|
||||
}
|
||||
|
||||
if (byTimestamp.size === 1) {
|
||||
const [timestamp, clientIds] = Array.from(
|
||||
byTimestamp.entries()
|
||||
)[0];
|
||||
await db
|
||||
.update(clients)
|
||||
.set({
|
||||
lastPing: timestamp,
|
||||
online: true,
|
||||
archived: false
|
||||
})
|
||||
.where(inArray(clients.clientId, clientIds));
|
||||
} else {
|
||||
await db.transaction(async (tx) => {
|
||||
for (const [timestamp, clientIds] of byTimestamp) {
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({
|
||||
lastPing: timestamp,
|
||||
online: true,
|
||||
archived: false
|
||||
})
|
||||
.where(
|
||||
inArray(clients.clientId, clientIds)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(clients)
|
||||
.set({
|
||||
lastPing: maxTimestamp,
|
||||
online: true,
|
||||
archived: false
|
||||
})
|
||||
.where(inArray(clients.clientId, clientIds));
|
||||
}, "flushClientPingsToDb");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -260,7 +230,12 @@ export async function flushPingsToDb(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Simple retry wrapper with exponential backoff for transient errors
|
||||
* (connection timeouts, unexpected disconnects).
|
||||
* (deadlocks, connection timeouts, unexpected disconnects).
|
||||
*
|
||||
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
|
||||
* guarantees exactly one winner per deadlock pair, so the loser just needs
|
||||
* to try again. MAX_RETRIES is intentionally higher than typical connection
|
||||
* retry budgets to give deadlock victims enough chances to succeed.
|
||||
*/
|
||||
async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
@@ -277,7 +252,8 @@ async function withRetry<T>(
|
||||
const jitter = Math.random() * baseDelay;
|
||||
const delay = baseDelay + jitter;
|
||||
logger.warn(
|
||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`
|
||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
|
||||
{ code: error?.code ?? error?.cause?.code }
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
@@ -288,14 +264,14 @@ async function withRetry<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect transient connection errors that are safe to retry.
|
||||
* Detect transient errors that are safe to retry.
|
||||
*/
|
||||
function isTransientError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
const message = (error.message || "").toLowerCase();
|
||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
||||
const code = error.code || "";
|
||||
const code = error.code || error.cause?.code || "";
|
||||
|
||||
// Connection timeout / terminated
|
||||
if (
|
||||
@@ -308,12 +284,17 @@ function isTransientError(error: any): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PostgreSQL deadlock
|
||||
// PostgreSQL deadlock detected — always safe to retry (one winner guaranteed)
|
||||
if (code === "40P01" || message.includes("deadlock")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ECONNRESET, ECONNREFUSED, EPIPE
|
||||
// PostgreSQL serialization failure
|
||||
if (code === "40001") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||
if (
|
||||
code === "ECONNRESET" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
@@ -337,12 +318,26 @@ export function startPingAccumulator(): void {
|
||||
}
|
||||
|
||||
flushTimer = setInterval(async () => {
|
||||
// Skip this tick if the previous flush is still in progress.
|
||||
// setInterval does not await async callbacks, so without this guard
|
||||
// two flush cycles can run concurrently and deadlock each other on
|
||||
// overlapping bulk UPDATE statements.
|
||||
if (isFlushing) {
|
||||
logger.debug(
|
||||
"Ping accumulator: previous flush still in progress, skipping cycle"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
isFlushing = true;
|
||||
try {
|
||||
await flushPingsToDb();
|
||||
} catch (error) {
|
||||
logger.error("Unhandled error in ping accumulator flush", {
|
||||
error
|
||||
});
|
||||
} finally {
|
||||
isFlushing = false;
|
||||
}
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
|
||||
@@ -364,7 +359,22 @@ export async function stopPingAccumulator(): Promise<void> {
|
||||
flushTimer = null;
|
||||
}
|
||||
|
||||
// Final flush to persist any remaining pings
|
||||
// Final flush to persist any remaining pings.
|
||||
// Wait for any in-progress flush to finish first so we don't race.
|
||||
if (isFlushing) {
|
||||
logger.debug(
|
||||
"Ping accumulator: waiting for in-progress flush before stopping…"
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
const poll = setInterval(() => {
|
||||
if (!isFlushing) {
|
||||
clearInterval(poll);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await flushPingsToDb();
|
||||
} catch (error) {
|
||||
@@ -379,4 +389,4 @@ export async function stopPingAccumulator(): Promise<void> {
|
||||
*/
|
||||
export function getPendingPingCount(): number {
|
||||
return pendingSitePings.size + pendingClientPings.size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { build } from "@server/build";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
import { INSPECT_MAX_BYTES } from "buffer";
|
||||
import { v } from "@faker-js/faker/dist/airline-Dz1uGqgJ";
|
||||
import { getNextAvailableClientSubnet } from "@server/lib/ip";
|
||||
|
||||
const bodySchema = z.object({
|
||||
provisioningKey: z.string().nonempty(),
|
||||
@@ -152,6 +152,11 @@ export async function registerNewt(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||
);
|
||||
}
|
||||
if (!org.subnet) {
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Organization subnet not found")
|
||||
);
|
||||
}
|
||||
|
||||
// SaaS billing check
|
||||
if (build == "saas") {
|
||||
@@ -190,6 +195,20 @@ export async function registerNewt(
|
||||
let newSiteId: number | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
|
||||
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
||||
if (!newClientAddress) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"No available subnet found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let clientAddress = newClientAddress.split("/")[0];
|
||||
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
|
||||
|
||||
// Create the site (type "newt", name = niceId)
|
||||
const [newSite] = await trx
|
||||
.insert(sites)
|
||||
@@ -197,6 +216,7 @@ export async function registerNewt(
|
||||
orgId,
|
||||
name: name || niceId,
|
||||
niceId,
|
||||
address: clientAddress,
|
||||
type: "newt",
|
||||
dockerSocketEnabled: true,
|
||||
status: keyRecord.approveNewSites ? "approved" : "pending",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||
import { Alias } from "@server/lib/ip";
|
||||
import { build } from "@server/build";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
logger.info("Handling register olm message!");
|
||||
@@ -274,7 +275,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
||||
logger.warn(
|
||||
"Client last hole punch is too old and we have sites to send; skipping this register"
|
||||
`Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, loginPage } from "@server/db";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
orgDomains,
|
||||
@@ -24,6 +24,8 @@ import { build } from "@server/build";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { getUniqueResourceName } from "@server/db/names";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const createResourceParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -112,7 +114,10 @@ export async function createResource(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
if (
|
||||
req.user &&
|
||||
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
@@ -193,6 +198,29 @@ async function createHttpResource(
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
|
||||
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
|
||||
// grandfather in existing users
|
||||
const lastAllowedDate = new Date("2026-04-13");
|
||||
const userCreatedDate = new Date(req.user?.dateCreated || new Date());
|
||||
if (userCreatedDate > lastAllowedDate) {
|
||||
// check if this domain id is a namespace domain and if so, reject
|
||||
const domain = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (domain.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(
|
||||
domainId,
|
||||
|
||||
@@ -142,6 +142,7 @@ export async function getUserResources(
|
||||
let siteResourcesData: Array<{
|
||||
siteResourceId: number;
|
||||
name: string;
|
||||
niceId: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
protocol: string | null;
|
||||
@@ -154,6 +155,7 @@ export async function getUserResources(
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name,
|
||||
niceId: siteResources.niceId,
|
||||
destination: siteResources.destination,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
@@ -249,7 +251,7 @@ export async function getUserResources(
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
data: {
|
||||
resources: resourcesWithAuth,
|
||||
siteResources: siteResourcesFormatted
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from "./deleteResourceRule";
|
||||
export * from "./listResourceRules";
|
||||
export * from "./updateResourceRule";
|
||||
export * from "./getUserResources";
|
||||
export * from "./listUserResourceAliases";
|
||||
export * from "./setResourceHeaderAuth";
|
||||
export * from "./addEmailToResourceWhitelist";
|
||||
export * from "./removeEmailFromResourceWhitelist";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resourcePincode,
|
||||
resources,
|
||||
roleResources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
targets,
|
||||
userResources
|
||||
@@ -138,6 +139,7 @@ export type ResourceWithTargets = {
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||
siteName: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -446,14 +448,16 @@ export async function listResources(
|
||||
port: targets.port,
|
||||
enabled: targets.enabled,
|
||||
healthStatus: targetHealthCheck.hcHealth,
|
||||
hcEnabled: targetHealthCheck.hcEnabled
|
||||
hcEnabled: targetHealthCheck.hcEnabled,
|
||||
siteName: sites.name
|
||||
})
|
||||
.from(targets)
|
||||
.where(inArray(targets.resourceId, resourceIdList))
|
||||
.leftJoin(
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
);
|
||||
)
|
||||
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
||||
|
||||
// avoids TS issues with reduce/never[]
|
||||
const map = new Map<number, ResourceWithTargets>();
|
||||
|
||||
262
server/routers/resource/listUserResourceAliases.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
db,
|
||||
siteResources,
|
||||
userSiteResources,
|
||||
roleSiteResources,
|
||||
userOrgRoles,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, asc, isNotNull, ne } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { localCache } from "#dynamic/lib/cache";
|
||||
|
||||
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
|
||||
|
||||
function userResourceAliasesCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
page: number,
|
||||
pageSize: number
|
||||
) {
|
||||
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}`;
|
||||
}
|
||||
|
||||
const listUserResourceAliasesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listUserResourceAliasesQuerySchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
})
|
||||
});
|
||||
|
||||
export type ListUserResourceAliasesResponse = PaginatedResponse<{
|
||||
aliases: string[];
|
||||
}>;
|
||||
|
||||
// registry.registerPath({
|
||||
// method: "get",
|
||||
// path: "/org/{orgId}/user-resource-aliases",
|
||||
// description:
|
||||
// "List private (host-mode) site resource aliases the authenticated user can access in the organization, paginated.",
|
||||
// tags: [OpenAPITags.PrivateResource],
|
||||
// request: {
|
||||
// params: z.object({
|
||||
// orgId: z.string()
|
||||
// }),
|
||||
// query: listUserResourceAliasesQuerySchema
|
||||
// },
|
||||
// responses: {}
|
||||
// });
|
||||
|
||||
export async function listUserResourceAliases(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listUserResourceAliasesQuerySchema.safeParse(
|
||||
req.query
|
||||
);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { page, pageSize } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
const [userOrg] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!userOrg) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User not in organization")
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = userResourceAliasesCacheKey(
|
||||
orgId,
|
||||
userId,
|
||||
page,
|
||||
pageSize
|
||||
);
|
||||
const cachedData: ListUserResourceAliasesResponse | undefined =
|
||||
localCache.get(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data: cachedData,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resource aliases retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const userRoleIds = await db
|
||||
.select({ roleId: userOrgRoles.roleId })
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.map((r) => r.roleId));
|
||||
|
||||
const directSiteResourcesQuery = db
|
||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||
.from(userSiteResources)
|
||||
.where(eq(userSiteResources.userId, userId));
|
||||
|
||||
const roleSiteResourcesQuery =
|
||||
userRoleIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
siteResourceId: roleSiteResources.siteResourceId
|
||||
})
|
||||
.from(roleSiteResources)
|
||||
.where(inArray(roleSiteResources.roleId, userRoleIds))
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [directSiteResourceResults, roleSiteResourceResults] =
|
||||
await Promise.all([
|
||||
directSiteResourcesQuery,
|
||||
roleSiteResourcesQuery
|
||||
]);
|
||||
|
||||
const accessibleSiteResourceIds = [
|
||||
...directSiteResourceResults.map((r) => r.siteResourceId),
|
||||
...roleSiteResourceResults.map((r) => r.siteResourceId)
|
||||
];
|
||||
|
||||
if (accessibleSiteResourceIds.length === 0) {
|
||||
const data: ListUserResourceAliasesResponse = {
|
||||
aliases: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
};
|
||||
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resource aliases retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const whereClause = and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.mode, "host"),
|
||||
isNotNull(siteResources.alias),
|
||||
ne(siteResources.alias, ""),
|
||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||
);
|
||||
|
||||
const baseSelect = () =>
|
||||
db
|
||||
.select({ alias: siteResources.alias })
|
||||
.from(siteResources)
|
||||
.where(whereClause);
|
||||
|
||||
const countQuery = db.$count(baseSelect().as("filtered_aliases"));
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
baseSelect()
|
||||
.orderBy(asc(siteResources.alias))
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const aliases = rows.map((r) => r.alias as string);
|
||||
|
||||
const data: ListUserResourceAliasesResponse = {
|
||||
aliases,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
};
|
||||
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
|
||||
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resource aliases retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, loginPage } from "@server/db";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
Org,
|
||||
@@ -25,6 +25,7 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
|
||||
const updateResourceParamsSchema = z.strictObject({
|
||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||
@@ -120,7 +121,9 @@ const updateHttpResourceBodySchema = z
|
||||
if (data.headers) {
|
||||
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
|
||||
const validHeaderValue = /^[\t\x20-\x7E]*$/;
|
||||
return data.headers.every((h) => validHeaderValue.test(h.value));
|
||||
return data.headers.every((h) =>
|
||||
validHeaderValue.test(h.value)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -318,6 +321,34 @@ async function updateHttpResource(
|
||||
if (updateData.domainId) {
|
||||
const domainId = updateData.domainId;
|
||||
|
||||
if (
|
||||
build == "saas" &&
|
||||
!isSubscribed(resource.orgId, tierMatrix.domainNamespaces)
|
||||
) {
|
||||
// grandfather in existing users
|
||||
const lastAllowedDate = new Date("2026-04-13");
|
||||
const userCreatedDate = new Date(
|
||||
req.user?.dateCreated || new Date()
|
||||
);
|
||||
if (userCreatedDate > lastAllowedDate) {
|
||||
// check if this domain id is a namespace domain and if so, reject
|
||||
const domain = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (domain.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(
|
||||
domainId,
|
||||
@@ -366,7 +397,7 @@ async function updateHttpResource(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (build != "oss") {
|
||||
const existingLoginPages = await db
|
||||
.select()
|
||||
|
||||
@@ -77,7 +77,8 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
const [targetCheck] = await db
|
||||
.select({
|
||||
targetId: targets.targetId,
|
||||
siteId: targets.siteId
|
||||
siteId: targets.siteId,
|
||||
hcStatus: targetHealthCheck.hcHealth
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(
|
||||
@@ -85,6 +86,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
eq(targets.resourceId, resources.resourceId)
|
||||
)
|
||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.innerJoin(targetHealthCheck, eq(targets.targetId, targetHealthCheck.targetId))
|
||||
.where(
|
||||
and(
|
||||
eq(targets.targetId, targetIdNum),
|
||||
@@ -101,6 +103,14 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if the status has changed
|
||||
if (targetCheck.hcStatus === healthStatus.status) {
|
||||
logger.debug(
|
||||
`Health status for target ${targetId} is already ${healthStatus.status}, skipping update`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update the target's health status in the database
|
||||
await db
|
||||
.update(targetHealthCheck)
|
||||
|
||||
@@ -21,7 +21,8 @@ async function queryUser(userId: string) {
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
locale: users.locale
|
||||
locale: users.locale,
|
||||
dateCreated: users.dateCreated
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
|
||||
import {
|
||||
orgs,
|
||||
roles,
|
||||
userInviteRoles,
|
||||
userInvites,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -37,8 +44,7 @@ const inviteUserBodySchema = z
|
||||
regenerate: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
(d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
{ message: "roleIds or roleId is required", path: ["roleIds"] }
|
||||
)
|
||||
.transform((data) => ({
|
||||
@@ -265,7 +271,7 @@ export async function inviteUser(
|
||||
)
|
||||
);
|
||||
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
|
||||
|
||||
if (doEmail) {
|
||||
await sendEmail(
|
||||
@@ -314,12 +320,12 @@ export async function inviteUser(
|
||||
expiresAt,
|
||||
tokenHash
|
||||
});
|
||||
await trx.insert(userInviteRoles).values(
|
||||
uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))
|
||||
);
|
||||
await trx
|
||||
.insert(userInviteRoles)
|
||||
.values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId })));
|
||||
});
|
||||
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
|
||||
|
||||
if (doEmail) {
|
||||
await sendEmail(
|
||||
|
||||
@@ -64,7 +64,8 @@ export async function myDevice(
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
locale: users.locale
|
||||
locale: users.locale,
|
||||
dateCreated: users.dateCreated
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
|
||||
@@ -104,6 +104,42 @@ export default async function migration() {
|
||||
CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId")
|
||||
);
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "eventStreamingCursors" (
|
||||
"cursorId" serial PRIMARY KEY NOT NULL,
|
||||
"destinationId" integer NOT NULL,
|
||||
"logType" varchar(50) NOT NULL,
|
||||
"lastSentId" bigint DEFAULT 0 NOT NULL,
|
||||
"lastSentAt" bigint
|
||||
);
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "eventStreamingDestinations" (
|
||||
"destinationId" serial PRIMARY KEY NOT NULL,
|
||||
"orgId" varchar(255) NOT NULL,
|
||||
"sendConnectionLogs" boolean DEFAULT false NOT NULL,
|
||||
"sendRequestLogs" boolean DEFAULT false NOT NULL,
|
||||
"sendActionLogs" boolean DEFAULT false NOT NULL,
|
||||
"sendAccessLogs" boolean DEFAULT false NOT NULL,
|
||||
"type" varchar(50) NOT NULL,
|
||||
"config" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"createdAt" bigint NOT NULL,
|
||||
"updatedAt" bigint NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "eventStreamingCursors" ADD CONSTRAINT "eventStreamingCursors_destinationId_eventStreamingDestinations_destinationId_fk" FOREIGN KEY ("destinationId") REFERENCES "public"."eventStreamingDestinations"("destinationId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "eventStreamingDestinations" ADD CONSTRAINT "eventStreamingDestinations_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`CREATE UNIQUE INDEX "idx_eventStreamingCursors_dest_type" ON "eventStreamingCursors" USING btree ("destinationId","logType");`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`
|
||||
);
|
||||
@@ -177,8 +213,12 @@ export default async function migration() {
|
||||
sql`CREATE INDEX "idx_accessAuditLog_siteResourceId" ON "connectionAuditLog" USING btree ("siteResourceId");`
|
||||
);
|
||||
await db.execute(sql`ALTER TABLE "userInvites" DROP COLUMN "roleId";`);
|
||||
await db.execute(sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`);
|
||||
await db.execute(sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`
|
||||
);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
@@ -195,7 +235,9 @@ export default async function migration() {
|
||||
for (const row of existingUserInviteRoles) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "userInviteRoles" ("inviteId", "roleId")
|
||||
VALUES (${row.inviteId}, ${row.roleId})
|
||||
SELECT ${row.inviteId}, ${row.roleId}
|
||||
WHERE EXISTS (SELECT 1 FROM "userInvites" WHERE "inviteId" = ${row.inviteId})
|
||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
@@ -218,7 +260,10 @@ export default async function migration() {
|
||||
for (const row of existingUserOrgRoles) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
|
||||
VALUES (${row.userId}, ${row.orgId}, ${row.roleId})
|
||||
SELECT ${row.userId}, ${row.orgId}, ${row.roleId}
|
||||
WHERE EXISTS (SELECT 1 FROM "user" WHERE "id" = ${row.userId})
|
||||
AND EXISTS (SELECT 1 FROM "orgs" WHERE "orgId" = ${row.orgId})
|
||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -76,9 +76,15 @@ export default async function migration() {
|
||||
`
|
||||
).run();
|
||||
|
||||
db.prepare(`CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`).run();
|
||||
db.prepare(`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`).run();
|
||||
db.prepare(`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`).run();
|
||||
db.prepare(
|
||||
`CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`
|
||||
).run();
|
||||
db.prepare(
|
||||
`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`
|
||||
).run();
|
||||
db.prepare(
|
||||
`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
@@ -139,7 +145,7 @@ export default async function migration() {
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';`
|
||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs' WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = userOrgs.userId) AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = userOrgs.orgId);`
|
||||
).run();
|
||||
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
||||
db.prepare(
|
||||
@@ -168,6 +174,42 @@ export default async function migration() {
|
||||
);
|
||||
`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE 'eventStreamingCursors' (
|
||||
'cursorId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'destinationId' integer NOT NULL,
|
||||
'logType' text NOT NULL,
|
||||
'lastSentId' integer DEFAULT 0 NOT NULL,
|
||||
'lastSentAt' integer,
|
||||
FOREIGN KEY ('destinationId') REFERENCES 'eventStreamingDestinations'('destinationId') ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
CREATE UNIQUE INDEX 'idx_eventStreamingCursors_dest_type' ON 'eventStreamingCursors' ('destinationId','logType');--> statement-breakpoint
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE 'eventStreamingDestinations' (
|
||||
'destinationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'orgId' text NOT NULL,
|
||||
'sendConnectionLogs' integer DEFAULT false NOT NULL,
|
||||
'sendRequestLogs' integer DEFAULT false NOT NULL,
|
||||
'sendActionLogs' integer DEFAULT false NOT NULL,
|
||||
'sendAccessLogs' integer DEFAULT false NOT NULL,
|
||||
'type' text NOT NULL,
|
||||
'config' text NOT NULL,
|
||||
'enabled' integer DEFAULT true NOT NULL,
|
||||
'createdAt' integer NOT NULL,
|
||||
'updatedAt' integer NOT NULL,
|
||||
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO '__new_userInvites'("inviteId", "orgId", "email", "expiresAt", "token") SELECT "inviteId", "orgId", "email", "expiresAt", "token" FROM 'userInvites';`
|
||||
).run();
|
||||
@@ -191,8 +233,12 @@ export default async function migration() {
|
||||
`ALTER TABLE 'user' ADD 'marketingEmailConsent' integer DEFAULT false;`
|
||||
).run();
|
||||
db.prepare(`ALTER TABLE 'user' ADD 'locale' text;`).run();
|
||||
db.prepare(`ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`).run();
|
||||
db.prepare(`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`
|
||||
).run();
|
||||
})();
|
||||
|
||||
db.pragma("foreign_keys = ON");
|
||||
@@ -200,12 +246,15 @@ export default async function migration() {
|
||||
// Re-insert the preserved invite role assignments into the new userInviteRoles table
|
||||
if (existingUserInviteRoles.length > 0) {
|
||||
const insertUserInviteRole = db.prepare(
|
||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)`
|
||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId")
|
||||
SELECT ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM 'userInvites' WHERE inviteId = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||
);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const row of existingUserInviteRoles) {
|
||||
insertUserInviteRole.run(row.inviteId, row.roleId);
|
||||
insertUserInviteRole.run(row.inviteId, row.roleId, row.inviteId, row.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -219,12 +268,16 @@ export default async function migration() {
|
||||
// Re-insert the preserved role assignments into the new userOrgRoles table
|
||||
if (existingUserOrgRoles.length > 0) {
|
||||
const insertUserOrgRole = db.prepare(
|
||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)`
|
||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId")
|
||||
SELECT ?, ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||
);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const row of existingUserOrgRoles) {
|
||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId);
|
||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId, row.userId, row.orgId, row.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -491,6 +491,10 @@ export default function BillingPage() {
|
||||
|
||||
const currentPlanId = getCurrentPlanId();
|
||||
|
||||
const visiblePlanOptions = planOptions.filter(
|
||||
(plan) => plan.id !== "home" || currentPlanId === "home"
|
||||
);
|
||||
|
||||
// Check if subscription is in a problematic state that requires attention
|
||||
const hasProblematicSubscription = (): boolean => {
|
||||
if (!tierSubscription?.subscription) return false;
|
||||
@@ -803,8 +807,8 @@ export default function BillingPage() {
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{/* Plan Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{planOptions.map((plan) => {
|
||||
<div className={cn("grid grid-cols-1 gap-4", visiblePlanOptions.length === 5 ? "md:grid-cols-5" : "md:grid-cols-4")}>
|
||||
{visiblePlanOptions.map((plan) => {
|
||||
const isCurrentPlan = plan.id === currentPlanId;
|
||||
const planAction = getPlanAction(plan);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { GetDNSRecordsResponse } from "@server/routers/domain";
|
||||
import DNSRecordsTable from "@app/components/DNSRecordTable";
|
||||
import DomainCertForm from "@app/components/DomainCertForm";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface DomainSettingsPageProps {
|
||||
params: Promise<{ domainId: string; orgId: string }>;
|
||||
@@ -65,12 +66,14 @@ export default async function DomainSettingsPage({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<DomainInfoCard
|
||||
failed={domain.failed}
|
||||
verified={domain.verified}
|
||||
type={domain.type}
|
||||
errorMessage={domain.errorMessage}
|
||||
/>
|
||||
{build != "oss" && env.flags.usePangolinDns ? (
|
||||
<DomainInfoCard
|
||||
failed={domain.failed}
|
||||
verified={domain.verified}
|
||||
type={domain.type}
|
||||
errorMessage={domain.errorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
||||
|
||||
|
||||
@@ -491,7 +491,7 @@ export default function ConnectionLogsPage() {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const clientType = row.original.clientType === "olm" ? "machine" : "user";
|
||||
const clientType = row.original.userId ? "user" : "machine";
|
||||
if (row.original.clientName && row.original.clientNiceId) {
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -106,7 +106,9 @@ function DestinationCard({
|
||||
{/* URL preview */}
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{cfg.url || (
|
||||
<span className="italic">{t("streamingNoUrlConfigured")}</span>
|
||||
<span className="italic">
|
||||
{t("streamingNoUrlConfigured")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -160,7 +162,9 @@ function AddDestinationCard({ onClick }: { onClick: () => void }) {
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-md border-2 border-dashed border-current">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("streamingAddDestination")}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{t("streamingAddDestination")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -186,7 +190,9 @@ function DestinationTypePicker({
|
||||
const t = useTranslations();
|
||||
const [selected, setSelected] = useState<DestinationType>("http");
|
||||
|
||||
const destinationTypeOptions: ReadonlyArray<StrategyOption<DestinationType>> = [
|
||||
const destinationTypeOptions: ReadonlyArray<
|
||||
StrategyOption<DestinationType>
|
||||
> = [
|
||||
{
|
||||
id: "http",
|
||||
title: t("streamingHttpWebhookTitle"),
|
||||
@@ -233,13 +239,19 @@ function DestinationTypePicker({
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent className="sm:max-w-lg">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("streamingAddDestination")}</CredenzaTitle>
|
||||
<CredenzaTitle>
|
||||
{t("streamingAddDestination")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("streamingTypePickerDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className={isPaywalled ? "pointer-events-none opacity-50" : ""}>
|
||||
<div
|
||||
className={
|
||||
isPaywalled ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
>
|
||||
<StrategySelect
|
||||
options={destinationTypeOptions}
|
||||
value={selected}
|
||||
@@ -301,10 +313,7 @@ export default function StreamingDestinationsPage() {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("streamingFailedToLoad"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("streamingUnexpectedError")
|
||||
)
|
||||
description: formatAxiosError(e, t("streamingUnexpectedError"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -341,10 +350,7 @@ export default function StreamingDestinationsPage() {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("streamingFailedToUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("streamingUnexpectedError")
|
||||
)
|
||||
description: formatAxiosError(e, t("streamingUnexpectedError"))
|
||||
});
|
||||
} finally {
|
||||
setTogglingIds((prev) => {
|
||||
@@ -375,10 +381,7 @@ export default function StreamingDestinationsPage() {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("streamingFailedToDelete"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("streamingUnexpectedError")
|
||||
)
|
||||
description: formatAxiosError(e, t("streamingUnexpectedError"))
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
@@ -459,13 +462,14 @@ export default function StreamingDestinationsPage() {
|
||||
if (!v) setDeleteTarget(null);
|
||||
}}
|
||||
string={
|
||||
parseHttpConfig(deleteTarget.config).name || t("streamingDeleteDialogThisDestination")
|
||||
parseHttpConfig(deleteTarget.config).name ||
|
||||
t("streamingDeleteDialogThisDestination")
|
||||
}
|
||||
title={t("streamingDeleteTitle")}
|
||||
dialog={
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
{t("streamingDeleteDialogAreYouSure")}{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
<span>
|
||||
{parseHttpConfig(deleteTarget.config).name ||
|
||||
t("streamingDeleteDialogThisDestination")}
|
||||
</span>
|
||||
@@ -478,4 +482,4 @@ export default function StreamingDestinationsPage() {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import DismissableBanner from "@app/components/DismissableBanner";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, Plug } from "lucide-react";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type PendingSitesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -96,6 +98,10 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) {
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
|
||||
/>
|
||||
|
||||
<PendingSitesTable
|
||||
sites={siteRows}
|
||||
orgId={params.orgId}
|
||||
|
||||
@@ -133,8 +133,7 @@ export default function ResourceAuthenticationPage() {
|
||||
...orgQueries.identityProviders({
|
||||
orgId: org.org.orgId,
|
||||
useOrgOnlyIdp: env.app.identityProviderMode === "org"
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.orgOidc)
|
||||
})
|
||||
});
|
||||
|
||||
const pageLoading =
|
||||
|
||||
@@ -400,7 +400,11 @@ function ProxyResourceTargetsForm({
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId, config)
|
||||
updateTarget(row.original.targetId,
|
||||
config.path === null && config.pathMatchType === null
|
||||
? { ...config, rewritePath: null, rewritePathType: null }
|
||||
: config
|
||||
)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
@@ -424,7 +428,11 @@ function ProxyResourceTargetsForm({
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId, config)
|
||||
updateTarget(row.original.targetId,
|
||||
config.path === null && config.pathMatchType === null
|
||||
? { ...config, rewritePath: null, rewritePathType: null }
|
||||
: config
|
||||
)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
@@ -670,6 +678,7 @@ function ProxyResourceTargetsForm({
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: (row) => String(row.targetId),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
|
||||
@@ -776,7 +776,11 @@ export default function Page() {
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId, config)
|
||||
updateTarget(row.original.targetId,
|
||||
config.path === null && config.pathMatchType === null
|
||||
? { ...config, rewritePath: null, rewritePathType: null }
|
||||
: config
|
||||
)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
@@ -800,7 +804,11 @@ export default function Page() {
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId, config)
|
||||
updateTarget(row.original.targetId,
|
||||
config.path === null && config.pathMatchType === null
|
||||
? { ...config, rewritePath: null, rewritePathType: null }
|
||||
: config
|
||||
)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
@@ -991,6 +999,7 @@ export default function Page() {
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: (row) => String(row.targetId),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
|
||||
@@ -95,7 +95,8 @@ export default async function ProxyResourcesPage(
|
||||
ip: target.ip,
|
||||
port: target.port,
|
||||
enabled: target.enabled,
|
||||
healthStatus: target.healthStatus
|
||||
healthStatus: target.healthStatus,
|
||||
siteName: target.siteName
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
@@ -42,7 +42,9 @@ import {
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Check, Heart, InfoIcon } from "lucide-react";
|
||||
import { ArrowRight, Check, ExternalLink, Heart, InfoIcon, TicketCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import DismissableBanner from "@app/components/DismissableBanner";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { SitePriceCalculator } from "@app/components/SitePriceCalculator";
|
||||
@@ -51,6 +53,10 @@ import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const ENTERPRISE_DOCS_URL =
|
||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||
const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted";
|
||||
|
||||
function obfuscateLicenseKey(key: string): string {
|
||||
if (key.length <= 8) return key;
|
||||
const firstPart = key.substring(0, 4);
|
||||
@@ -336,6 +342,47 @@ export default function LicensePage() {
|
||||
description={t("licenseTitleDescription")}
|
||||
/>
|
||||
|
||||
{!licenseStatus?.isLicenseValid && (
|
||||
<DismissableBanner
|
||||
storageKey="license-banner-dismissed"
|
||||
version={1}
|
||||
title={t("licenseBannerTitle")}
|
||||
titleIcon={
|
||||
<TicketCheck className="w-5 h-5 text-primary" />
|
||||
}
|
||||
description={t("licenseBannerDescription")}
|
||||
>
|
||||
<Link
|
||||
href={ENTERPRISE_PRICING_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
{t("licenseBannerGetLicense")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={ENTERPRISE_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{t("licenseBannerViewDocs")}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
)}
|
||||
|
||||
{/* <Alert variant="neutral" className="mb-6"> */}
|
||||
{/* <InfoIcon className="h-4 w-4" /> */}
|
||||
{/* <AlertTitle className="font-semibold"> */}
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function CreateDomainForm({
|
||||
|
||||
const punycodePreview = useMemo(() => {
|
||||
if (!baseDomain) return "";
|
||||
const punycode = toPunycode(baseDomain);
|
||||
const punycode = toPunycode(baseDomain.toLowerCase());
|
||||
return punycode !== baseDomain.toLowerCase() ? punycode : "";
|
||||
}, [baseDomain]);
|
||||
|
||||
@@ -239,21 +239,24 @@ export default function CreateDomainForm({
|
||||
className="space-y-4"
|
||||
id="create-domain-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build != "oss" && env.flags.usePangolinDns ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseDomain"
|
||||
|
||||
@@ -319,6 +319,7 @@ export default function DeviceLoginForm({
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={9}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
{...field}
|
||||
value={field.value
|
||||
.replace(/-/g, "")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -40,11 +41,15 @@ import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronsUpDown,
|
||||
KeyRound,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePaidStatus } from "@/hooks/usePaidStatus";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { toUnicode } from "punycode";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
type AvailableOption = {
|
||||
domainNamespaceId: string;
|
||||
@@ -93,8 +98,15 @@ export default function DomainPicker({
|
||||
warnOnProvidedDomain = false
|
||||
}: DomainPickerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const { user } = useUserContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
const { hasSaasSubscription } = usePaidStatus();
|
||||
|
||||
const requiresPaywall =
|
||||
build === "saas" &&
|
||||
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
|
||||
new Date(user.dateCreated) > new Date("2026-04-13");
|
||||
|
||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||
orgQueries.domains({ orgId })
|
||||
@@ -509,9 +521,11 @@ export default function DomainPicker({
|
||||
<span className="truncate">
|
||||
{selectedBaseDomain.domain}
|
||||
</span>
|
||||
{selectedBaseDomain.verified && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||
)}
|
||||
{selectedBaseDomain.verified &&
|
||||
selectedBaseDomain.domainType !==
|
||||
"wildcard" && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
t("domainPickerSelectBaseDomain")
|
||||
@@ -574,14 +588,23 @@ export default function DomainPicker({
|
||||
}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
{orgDomain.type ===
|
||||
"wildcard"
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
"domainPickerManual"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
: (
|
||||
<>
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -640,6 +663,7 @@ export default function DomainPicker({
|
||||
})
|
||||
}
|
||||
className="mx-2 rounded-md"
|
||||
disabled={requiresPaywall}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
@@ -680,6 +704,19 @@ export default function DomainPicker({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresPaywall && !hideFreeDomain && (
|
||||
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<KeyRound className="size-4 shrink-0 text-black-500" />
|
||||
<span>
|
||||
{t("domainPickerFreeDomainsPaidFeature")}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/*showProvidedDomainSearch && build === "saas" && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
||||
@@ -614,6 +614,7 @@ export function InternalResourceForm({
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
filterTypes={["newt"]}
|
||||
onSelectSite={(site) => {
|
||||
setSelectedSite(site);
|
||||
field.onChange(site.siteId);
|
||||
|
||||
@@ -39,7 +39,11 @@ export default function InviteStatusCard({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [type, setType] = useState<
|
||||
"rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded"
|
||||
| "rejected"
|
||||
| "wrong_user"
|
||||
| "user_does_not_exist"
|
||||
| "not_logged_in"
|
||||
| "user_limit_exceeded"
|
||||
>("rejected");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,12 +94,12 @@ export default function InviteStatusCard({
|
||||
|
||||
if (!user && type === "user_does_not_exist") {
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
} else if (!user && type === "not_logged_in") {
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
} else {
|
||||
@@ -109,7 +113,7 @@ export default function InviteStatusCard({
|
||||
async function goToLogin() {
|
||||
await api.post("/auth/logout", {});
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
@@ -117,7 +121,7 @@ export default function InviteStatusCard({
|
||||
async function goToSignup() {
|
||||
await api.post("/auth/logout", {});
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
@@ -157,7 +161,9 @@ export default function InviteStatusCard({
|
||||
Cannot Accept Invite
|
||||
</p>
|
||||
<p className="text-center text-sm">
|
||||
This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite.
|
||||
This organization has reached its user limit. Please
|
||||
contact the organization administrator to upgrade their
|
||||
plan before accepting this invite.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { type PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
@@ -63,6 +65,10 @@ export default function PendingSitesTable({
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const canUseSiteProvisioning =
|
||||
isPaidUser(tierMatrix[TierFeature.SiteProvisioningKeys]) &&
|
||||
build !== "oss";
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
@@ -327,7 +333,8 @@ export default function PendingSitesTable({
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
"neptune",
|
||||
"pluto"
|
||||
].includes(originalRow.exitNodeName.toLowerCase());
|
||||
|
||||
if (isCloudNode) {
|
||||
@@ -450,6 +457,7 @@ export default function PendingSitesTable({
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
refreshButtonDisabled={!canUseSiteProvisioning}
|
||||
rowCount={rowCount}
|
||||
columnVisibility={{
|
||||
niceId: false,
|
||||
|
||||
@@ -54,6 +54,7 @@ export type TargetHealth = {
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||
siteName: string | null;
|
||||
};
|
||||
|
||||
export type ResourceRow = {
|
||||
@@ -274,7 +275,9 @@ export default function ProxyResourcesTable({
|
||||
}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{`${target.ip}:${target.port}`}
|
||||
{target.siteName
|
||||
? `${target.siteName} (${target.ip}:${target.port})`
|
||||
: `${target.ip}:${target.port}`}
|
||||
</div>
|
||||
<span
|
||||
className={`capitalize ${
|
||||
@@ -301,7 +304,9 @@ export default function ProxyResourcesTable({
|
||||
status="unknown"
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{`${target.ip}:${target.port}`}
|
||||
{target.siteName
|
||||
? `${target.siteName} (${target.ip}:${target.port})`
|
||||
: `${target.ip}:${target.port}`}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{!target.enabled
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "./ui/button";
|
||||
import { TicketCheck } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Link from "next/link";
|
||||
|
||||
interface SidebarLicenseButtonProps {
|
||||
@@ -20,8 +21,11 @@ export default function SidebarLicenseButton({
|
||||
isCollapsed = false
|
||||
}: SidebarLicenseButtonProps) {
|
||||
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
|
||||
const { user } = useUserContext();
|
||||
|
||||
const url = "https://docs.pangolin.net/self-host/enterprise-edition";
|
||||
const url = user?.serverAdmin
|
||||
? "/admin/license"
|
||||
: "https://docs.pangolin.net/self-host/enterprise-edition";
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
|
||||
@@ -311,6 +311,7 @@ export default function SiteProvisioningKeysTable({
|
||||
addButtonDisabled={!canUseSiteProvisioning}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
refreshButtonDisabled={!canUseSiteProvisioning}
|
||||
addButtonText={t("provisioningKeysAdd")}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="name"
|
||||
|
||||
@@ -342,7 +342,8 @@ export default function SitesTable({
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
"neptune",
|
||||
"pluto"
|
||||
].includes(originalRow.exitNodeName.toLowerCase());
|
||||
|
||||
if (isCloudNode) {
|
||||
|
||||
@@ -388,7 +388,7 @@ export default function UserDevicesTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: t("online"),
|
||||
friendlyName: t("connected"),
|
||||
header: () => {
|
||||
return (
|
||||
<ColumnFilterButton
|
||||
@@ -410,7 +410,7 @@ export default function UserDevicesTable({
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("online")}
|
||||
label={t("connected")}
|
||||
className="p-3"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -164,7 +164,7 @@ const countryClass = cn(
|
||||
|
||||
const highlightedCountryClass = cn(
|
||||
sharedCountryClass,
|
||||
"stroke-2",
|
||||
"stroke-[3]",
|
||||
"fill-[#f4f4f5]",
|
||||
"stroke-[#f36117]",
|
||||
"dark:fill-[#3f3f46]"
|
||||
@@ -194,11 +194,20 @@ function drawInteractiveCountries(
|
||||
const path = setupProjetionPath();
|
||||
const data = parseWorldTopoJsonToGeoJsonFeatures();
|
||||
const svg = d3.select(element);
|
||||
const countriesLayer = svg.append("g");
|
||||
const hoverLayer = svg.append("g").style("pointer-events", "none");
|
||||
const hoverPath = hoverLayer
|
||||
.append("path")
|
||||
.datum(null)
|
||||
.attr("class", highlightedCountryClass)
|
||||
.style("display", "none");
|
||||
|
||||
svg.selectAll("path")
|
||||
countriesLayer
|
||||
.selectAll("path")
|
||||
.data(data)
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("data-country-path", "true")
|
||||
.attr("class", countryClass)
|
||||
.attr("d", path as never)
|
||||
|
||||
@@ -209,9 +218,10 @@ function drawInteractiveCountries(
|
||||
y,
|
||||
hoveredCountryAlpha3Code: country.properties.a3
|
||||
});
|
||||
// brings country to front
|
||||
this.parentNode?.appendChild(this);
|
||||
d3.select(this).attr("class", highlightedCountryClass);
|
||||
hoverPath
|
||||
.datum(country)
|
||||
.attr("d", path(country) as string)
|
||||
.style("display", null);
|
||||
})
|
||||
|
||||
.on("mousemove", function (event) {
|
||||
@@ -221,13 +231,13 @@ function drawInteractiveCountries(
|
||||
|
||||
.on("mouseout", function () {
|
||||
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
|
||||
d3.select(this).attr("class", countryClass);
|
||||
hoverPath.style("display", "none");
|
||||
});
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
type WorldJsonCountryData = { properties: { name: string; a3: string } };
|
||||
type WorldJsonCountryData = d3.ExtendedFeature<d3.GeoGeometryObjects | null, { name: string; a3: string }>;
|
||||
|
||||
function parseWorldTopoJsonToGeoJsonFeatures(): Array<WorldJsonCountryData> {
|
||||
const collection = topojson.feature(
|
||||
@@ -257,7 +267,7 @@ function colorInCountriesWithValues(
|
||||
const svg = d3.select(element);
|
||||
|
||||
return svg
|
||||
.selectAll("path")
|
||||
.selectAll('path[data-country-path="true"]')
|
||||
.style("fill", (countryPath) => {
|
||||
const country = getCountryByCountryPath(countryPath);
|
||||
if (!country?.count) {
|
||||
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||
import { useState } from "react";
|
||||
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
|
||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
||||
|
||||
export type CommandItem = string | { title: string; command: string };
|
||||
|
||||
const PLATFORMS = [
|
||||
"unix",
|
||||
"linux",
|
||||
"macos",
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"podman",
|
||||
@@ -43,7 +43,7 @@ export function NewtSiteInstallCommands({
|
||||
const t = useTranslations();
|
||||
|
||||
const [acceptClients, setAcceptClients] = useState(true);
|
||||
const [platform, setPlatform] = useState<Platform>("unix");
|
||||
const [platform, setPlatform] = useState<Platform>("linux");
|
||||
const [architecture, setArchitecture] = useState(
|
||||
() => getArchitectures(platform)[0]
|
||||
);
|
||||
@@ -54,8 +54,68 @@ export function NewtSiteInstallCommands({
|
||||
: "";
|
||||
|
||||
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
|
||||
unix: {
|
||||
All: [
|
||||
linux: {
|
||||
Run: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
}
|
||||
],
|
||||
"Systemd Service": [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
||||
},
|
||||
{
|
||||
title: t("envFile"),
|
||||
command: `# Create the directory and environment file
|
||||
sudo install -d -m 0755 /etc/newt
|
||||
sudo tee /etc/newt/newt.env > /dev/null << 'EOF'
|
||||
NEWT_ID=${id}
|
||||
NEWT_SECRET=${secret}
|
||||
PANGOLIN_ENDPOINT=${endpoint}${!acceptClients ? `
|
||||
DISABLE_CLIENTS=true` : ""}
|
||||
EOF
|
||||
sudo chmod 600 /etc/newt/newt.env`
|
||||
},
|
||||
{
|
||||
title: t("serviceFile"),
|
||||
command: `sudo tee /etc/systemd/system/newt.service > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=Newt
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/etc/newt/newt.env
|
||||
ExecStart=/usr/local/bin/newt
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
UMask=0077
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF`
|
||||
},
|
||||
{
|
||||
title: t("enableAndStart"),
|
||||
command: `sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now newt`
|
||||
}
|
||||
]
|
||||
},
|
||||
macos: {
|
||||
Run: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
||||
@@ -131,7 +191,7 @@ WantedBy=default.target`
|
||||
]
|
||||
},
|
||||
nixos: {
|
||||
All: [
|
||||
Flake: [
|
||||
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
}
|
||||
@@ -172,9 +232,9 @@ WantedBy=default.target`
|
||||
|
||||
<OptionSelect<string>
|
||||
label={
|
||||
["docker", "podman"].includes(platform)
|
||||
? t("method")
|
||||
: t("architecture")
|
||||
platform === "windows"
|
||||
? t("architecture")
|
||||
: t("method")
|
||||
}
|
||||
options={getArchitectures(platform).map((arch) => ({
|
||||
value: arch,
|
||||
@@ -261,8 +321,10 @@ function getPlatformIcon(platformName: Platform) {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return <FaWindows className="h-4 w-4 mr-2" />;
|
||||
case "unix":
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
case "linux":
|
||||
return <FaLinux className="h-4 w-4 mr-2" />;
|
||||
case "macos":
|
||||
return <FaApple className="h-4 w-4 mr-2" />;
|
||||
case "docker":
|
||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||
case "kubernetes":
|
||||
@@ -272,7 +334,7 @@ function getPlatformIcon(platformName: Platform) {
|
||||
case "nixos":
|
||||
return <SiNixos className="h-4 w-4 mr-2" />;
|
||||
default:
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
return <FaLinux className="h-4 w-4 mr-2" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,8 +342,10 @@ function getPlatformName(platformName: Platform) {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return "Windows";
|
||||
case "unix":
|
||||
return "Unix & macOS";
|
||||
case "linux":
|
||||
return "Linux";
|
||||
case "macos":
|
||||
return "macOS";
|
||||
case "docker":
|
||||
return "Docker";
|
||||
case "kubernetes":
|
||||
@@ -291,14 +355,16 @@ function getPlatformName(platformName: Platform) {
|
||||
case "nixos":
|
||||
return "NixOS";
|
||||
default:
|
||||
return "Unix / macOS";
|
||||
return "Linux";
|
||||
}
|
||||
}
|
||||
|
||||
function getArchitectures(platform: Platform) {
|
||||
switch (platform) {
|
||||
case "unix":
|
||||
return ["All"];
|
||||
case "linux":
|
||||
return ["Run", "Systemd Service"];
|
||||
case "macos":
|
||||
return ["Run"];
|
||||
case "windows":
|
||||
return ["x64"];
|
||||
case "docker":
|
||||
@@ -308,8 +374,8 @@ function getArchitectures(platform: Platform) {
|
||||
case "podman":
|
||||
return ["Podman Quadlet", "Podman Run"];
|
||||
case "nixos":
|
||||
return ["All"];
|
||||
return ["Flake"];
|
||||
default:
|
||||
return ["x64"];
|
||||
return ["Run"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,14 @@ export type SitesSelectorProps = {
|
||||
orgId: string;
|
||||
selectedSite?: Selectedsite | null;
|
||||
onSelectSite: (selected: Selectedsite) => void;
|
||||
filterTypes?: string[];
|
||||
};
|
||||
|
||||
export function SitesSelector({
|
||||
orgId,
|
||||
selectedSite,
|
||||
onSelectSite
|
||||
onSelectSite,
|
||||
filterTypes
|
||||
}: SitesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||
@@ -45,7 +47,9 @@ export function SitesSelector({
|
||||
|
||||
// always include the selected site in the list of sites shown
|
||||
const sitesShown = useMemo(() => {
|
||||
const allSites: Array<Selectedsite> = [...sites];
|
||||
const allSites: Array<Selectedsite> = filterTypes
|
||||
? sites.filter((s) => filterTypes.includes(s.type))
|
||||
: [...sites];
|
||||
if (
|
||||
debouncedQuery.trim().length === 0 &&
|
||||
selectedSite &&
|
||||
@@ -54,7 +58,7 @@ export function SitesSelector({
|
||||
allSites.unshift(selectedSite);
|
||||
}
|
||||
return allSites;
|
||||
}, [debouncedQuery, sites, selectedSite]);
|
||||
}, [debouncedQuery, sites, selectedSite, filterTypes]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
|
||||
@@ -69,6 +69,7 @@ type ControlledDataTableProps<TData, TValue> = {
|
||||
onAdd?: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
refreshButtonDisabled?: boolean;
|
||||
isNavigatingToAddPage?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
filters?: DataTableFilter[];
|
||||
@@ -91,6 +92,7 @@ export function ControlledDataTable<TData, TValue>({
|
||||
onAdd,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
refreshButtonDisabled = false,
|
||||
searchPlaceholder = "Search...",
|
||||
filters,
|
||||
filterDisplayMode = "label",
|
||||
@@ -335,7 +337,7 @@ export function ControlledDataTable<TData, TValue>({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
disabled={isRefreshing || refreshButtonDisabled}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
|
||||
@@ -174,6 +174,7 @@ type DataTableProps<TData, TValue> = {
|
||||
addButtonDisabled?: boolean;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
refreshButtonDisabled?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
searchColumn?: string;
|
||||
defaultSort?: {
|
||||
@@ -207,6 +208,7 @@ export function DataTable<TData, TValue>({
|
||||
addButtonDisabled = false,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
refreshButtonDisabled = false,
|
||||
searchPlaceholder = "Search...",
|
||||
searchColumn = "name",
|
||||
defaultSort,
|
||||
@@ -624,7 +626,7 @@ export function DataTable<TData, TValue>({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
disabled={isRefreshing || refreshButtonDisabled}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
|
||||
@@ -22,12 +22,21 @@ export async function getUserLocale(): Promise<Locale> {
|
||||
const res = await internal.get("/user", await authCookieHeader());
|
||||
const userLocale = res.data?.data?.locale;
|
||||
if (userLocale && locales.includes(userLocale as Locale)) {
|
||||
// Set the cookie so subsequent requests don't need the API call
|
||||
(await cookies()).set(COOKIE_NAME, userLocale, {
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
sameSite: "lax"
|
||||
});
|
||||
// Try to cache in a cookie so subsequent requests skip the API
|
||||
// call. cookies().set() is only permitted in Server Actions and
|
||||
// Route Handlers — not during rendering — so we isolate it so
|
||||
// that a write failure doesn't prevent the locale from being
|
||||
// returned for the current request.
|
||||
try {
|
||||
(await cookies()).set(COOKIE_NAME, userLocale, {
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
sameSite: "lax"
|
||||
});
|
||||
} catch {
|
||||
// Cannot set cookies in this context (e.g. during rendering);
|
||||
// the correct locale is still returned below.
|
||||
}
|
||||
return userLocale as Locale;
|
||||
}
|
||||
} catch {
|
||||
|
||||