mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-07 06:19:52 +00:00
Compare commits
45 Commits
private-re
...
crowdin_de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f3c23c552 | ||
|
|
11dd7241d1 | ||
|
|
6ef539af4a | ||
|
|
06c8a9a6d8 | ||
|
|
908ded8aee | ||
|
|
520507a3e6 | ||
|
|
38ea582265 | ||
|
|
dce63bdbbd | ||
|
|
0edf53f2b5 | ||
|
|
ef8f3f1070 | ||
|
|
1bcf90ad76 | ||
|
|
a2399a082a | ||
|
|
fdee4a166c | ||
|
|
91864ae097 | ||
|
|
3536b9c30f | ||
|
|
3db28e5140 | ||
|
|
2e95257e09 | ||
|
|
dcd6d6486d | ||
|
|
df145ac14c | ||
|
|
00de9ed49f | ||
|
|
3b6ce77972 | ||
|
|
5589e4da1d | ||
|
|
b662a4c06f | ||
|
|
b4d0354740 | ||
|
|
949a0221d8 | ||
|
|
c4b56b2b29 | ||
|
|
da7676de14 | ||
|
|
d2e7438692 | ||
|
|
b0f196b4ec | ||
|
|
ef9d15fded | ||
|
|
04a4a1899d | ||
|
|
f9c3d0647d | ||
|
|
6bac384b19 | ||
|
|
bf4426f1f9 | ||
|
|
7bf419d058 | ||
|
|
e85ebe717c | ||
|
|
2ec527f5d7 | ||
|
|
d334e6c3a5 | ||
|
|
755281abc3 | ||
|
|
7b05ad6b1d | ||
|
|
edbd1a743e | ||
|
|
fa5397095d | ||
|
|
3f914c4eab | ||
|
|
18b1460615 | ||
|
|
267ee63a60 |
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Базово удостоверяване чрез заглавие",
|
||||
"policyAuthHeaderAuthDescription": "Валидирайте собствено HTTP заглавие и стойност при всяка заявка",
|
||||
"policyAuthHeaderAuthSummary": "Конфигурирано заглавие",
|
||||
"policyAuthHeaderName": "Име на заглавието",
|
||||
"policyAuthHeaderValue": "Очаквана стойност",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Задайте парола",
|
||||
"policyAuthSetPincode": "Задайте ПИН код",
|
||||
"policyAuthSetEmailWhitelist": "Задайте списък с имейли",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Домейни",
|
||||
"billingOrganizations": "Организации",
|
||||
"billingRemoteExitNodes": "Дистанционни възли",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Няма конфигуриран лимит",
|
||||
"billingEstimatedPeriod": "Очакван период на фактуриране",
|
||||
"billingIncludedUsage": "Включено използване",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Колко потребители можете да използвате",
|
||||
"billingDomainInfo": "Колко домейни можете да използвате",
|
||||
"billingRemoteExitNodesInfo": "Колко дистанционни възли можете да използвате",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Лицензионни ключове",
|
||||
"billingLicenseKeysDescription": "Управлявайте вашите абонаменти за лицензионни ключове",
|
||||
"billingLicenseSubscription": "Абонамент за лиценз",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Ресурсът е деактивиран",
|
||||
"memberPortalShowingResources": "Показва {start}-{end} от {total} ресурси",
|
||||
"resourceLauncherTitle": "Стартер за Ресурси",
|
||||
"resourceLauncherDescription": "Преглеждайте детайлите на ресурса и ги стартирайте от едно място",
|
||||
"resourceLauncherSearchPlaceholder": "Търсете във всички сайтове...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "По Подразбиране",
|
||||
"resourceLauncherSaveView": "Запазете Изгледа",
|
||||
"resourceLauncherSaveToCurrentView": "Запазете в Текущ Изглед",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Нулирайте Изгледа",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Запазете като Нов Изглед",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Дайте име на този изглед, за да запазите текущите си филтри и оформление.",
|
||||
"resourceLauncherSaveForEveryone": "Запазете за Всеки",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Споделете този изглед с всички членове на организацията. Когато е изключено, изгледът е видим само за вас.",
|
||||
"resourceLauncherMakePersonal": "Направи Личен",
|
||||
"resourceLauncherFilter": "Филтър",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Сортиране",
|
||||
"resourceLauncherSortAscending": "Сортиране възходящо",
|
||||
"resourceLauncherSortDescending": "Сортиране низходящо",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Групирай По",
|
||||
"resourceLauncherGroupBySite": "Сайт",
|
||||
"resourceLauncherGroupByLabel": "Етикет",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Оформление",
|
||||
"resourceLauncherLayoutGrid": "Мрежа",
|
||||
"resourceLauncherLayoutList": "Списък",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Показване на Тагове на Сайт",
|
||||
"resourceLauncherShowRecents": "Показване на Последни",
|
||||
"resourceLauncherDeleteView": "Изтриване на Изглед",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Вижте като Админ",
|
||||
"resourceLauncherResourceDetailsDescription": "Вижте детайлите за този ресурс.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Без Етикет",
|
||||
"resourceLauncherNoSite": "Няма Сайт",
|
||||
"resourceLauncherNoResourcesInGroup": "Няма ресурси в тази група",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Няма Намерени Ресурси",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Никакви ресурси не съвпадат с текущото ви търсене или филтри. Опитайте да ги коригирате, за да намерите това, което търсите.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Никакви ресурси не съвпадат с \"{query}\". Опитайте да коригирате търсенето или да изтриете филтри, за да видите всички ресурси.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Копирано в клипборда",
|
||||
"resourceLauncherCopiedAccessDescription": "Достъпът до ресурса е копиран на вашия клипборд.",
|
||||
"resourceLauncherViewNamePlaceholder": "Име на Изгледа",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Základní Ověření Záhlaví",
|
||||
"policyAuthHeaderAuthDescription": "Ověřit vlastní HTTP hlavičku názvu a hodnoty při každém požadavku",
|
||||
"policyAuthHeaderAuthSummary": "Nastaveno hlavička",
|
||||
"policyAuthHeaderName": "Název hlavičky",
|
||||
"policyAuthHeaderValue": "Očekávaná hodnota",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Nastavit přístupový kód",
|
||||
"policyAuthSetPincode": "Nastavit PIN kód",
|
||||
"policyAuthSetEmailWhitelist": "Nastavit e-mailový whitelist",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Domény",
|
||||
"billingOrganizations": "Tělo",
|
||||
"billingRemoteExitNodes": "Vzdálené uzly",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Žádný limit nenastaven",
|
||||
"billingEstimatedPeriod": "Odhadované období fakturace",
|
||||
"billingIncludedUsage": "Zahrnuto využití",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Kolik uživatelů můžete použít",
|
||||
"billingDomainInfo": "Kolik domén můžete použít",
|
||||
"billingRemoteExitNodesInfo": "Kolik vzdálených uzlů můžete použít",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Licenční klíče",
|
||||
"billingLicenseKeysDescription": "Spravovat předplatné licenčního klíče",
|
||||
"billingLicenseSubscription": "Předplatné licence",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Zdroj je zakázán",
|
||||
"memberPortalShowingResources": "Zobrazeny {start}-{end} z {total} zdrojů",
|
||||
"resourceLauncherTitle": "Spouštěč zdrojů",
|
||||
"resourceLauncherDescription": "Podívejte se na podrobnosti o zdrojích a spusťte je z jednoho místa",
|
||||
"resourceLauncherSearchPlaceholder": "Hledat všechny lokality...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Výchozí",
|
||||
"resourceLauncherSaveView": "Uložit pohled",
|
||||
"resourceLauncherSaveToCurrentView": "Uložit do aktuálního pohledu",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Obnovit pohled",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Uložit jako nový pohled",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Uložte tento pohled k uloženému filtrování a rozvržení.",
|
||||
"resourceLauncherSaveForEveryone": "Uložit pro všechny",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Sdílejte tento pohled se všemi členy organizace. Pokud není zaškrtnuto, pohled je viditelný pouze vám.",
|
||||
"resourceLauncherMakePersonal": "Udělat osobní",
|
||||
"resourceLauncherFilter": "Filtr",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Řadit",
|
||||
"resourceLauncherSortAscending": "Řadit vzestupně",
|
||||
"resourceLauncherSortDescending": "Řadit sestupně",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Seskupit podle",
|
||||
"resourceLauncherGroupBySite": "Lokalita",
|
||||
"resourceLauncherGroupByLabel": "Název",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Rozvržení",
|
||||
"resourceLauncherLayoutGrid": "Mřížka",
|
||||
"resourceLauncherLayoutList": "Seznam",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Zobrazit značky lokality",
|
||||
"resourceLauncherShowRecents": "Zobrazit nedávné",
|
||||
"resourceLauncherDeleteView": "Smazat pohled",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Zobrazit jako administrátor",
|
||||
"resourceLauncherResourceDetailsDescription": "Podívejte se na podrobnosti o tomto zdroji.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Bez nálepky",
|
||||
"resourceLauncherNoSite": "Žádná lokalita",
|
||||
"resourceLauncherNoResourcesInGroup": "V této skupině nejsou žádné zdroje",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Nebyl nalezen žádný zdroj",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Žádný zdroj neodpovídá vašemu aktuálnímu vyhledávání nebo filtrům. Zkuste je upravit, abyste našli to, co hledáte.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Žádné zdroje neodpovídají \"{query}\". Zkuste upravit vyhledávání nebo vymazat filtry, abyste viděli všechny zdroje.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Zkopírováno do schránky",
|
||||
"resourceLauncherCopiedAccessDescription": "Přístup ke zdroji byl zkopírován do vaší schránky.",
|
||||
"resourceLauncherViewNamePlaceholder": "Název pohledu",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Grunnleggende Header Autentificering",
|
||||
"policyAuthHeaderAuthDescription": "Bekræft et tilpasset HTTP-headernavn og værdi ved hver forespørgsel",
|
||||
"policyAuthHeaderAuthSummary": "Header konfigureret",
|
||||
"policyAuthHeaderName": "Headernavn",
|
||||
"policyAuthHeaderValue": "Forventet værdi",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Angiv adgangskode",
|
||||
"policyAuthSetPincode": "Sett PIN-kode",
|
||||
"policyAuthSetEmailWhitelist": "Angiv e-mail hviteliste",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Domæner",
|
||||
"billingOrganizations": "Orger",
|
||||
"billingRemoteExitNodes": "Eksterne noder",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Ingen grænse konfigureret",
|
||||
"billingEstimatedPeriod": "Estimert faktureringsperiode",
|
||||
"billingIncludedUsage": "Inklusive Brug",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Hvor mange brugere du kan bruge",
|
||||
"billingDomainInfo": "Hvor mange domæner du kan bruge",
|
||||
"billingRemoteExitNodesInfo": "Hvor mange fjernnoder du kan bruge",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Licensnøgler",
|
||||
"billingLicenseKeysDescription": "Administrer dine licensnøgleabonnementer",
|
||||
"billingLicenseSubscription": "Licens abonnement",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Ressource deaktiveret",
|
||||
"memberPortalShowingResources": "Viser {start}-{end} af {total} ressourcer",
|
||||
"resourceLauncherTitle": "Ressource Starter",
|
||||
"resourceLauncherDescription": "Se ressource detaljer og start dem fra ét sted",
|
||||
"resourceLauncherSearchPlaceholder": "Søg i alle sites...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Standard",
|
||||
"resourceLauncherSaveView": "Gem Visning",
|
||||
"resourceLauncherSaveToCurrentView": "Gem til nuværende visning",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Nulstil Visning",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Gem som Ny Visning",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Giv denne visning et navn for at gemme dine nuværende filtre og layout.",
|
||||
"resourceLauncherSaveForEveryone": "Gem for Alle",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Del denne visning med alle organisationsmedlemmer. Når den er ikke markeret, er visningen kun synlig for dig.",
|
||||
"resourceLauncherMakePersonal": "Gør Personlig",
|
||||
"resourceLauncherFilter": "Filter",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Sortér",
|
||||
"resourceLauncherSortAscending": "Sortér stigende",
|
||||
"resourceLauncherSortDescending": "Sortér faldende",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Gruppér Efter",
|
||||
"resourceLauncherGroupBySite": "Websted",
|
||||
"resourceLauncherGroupByLabel": "Etikett",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Layout",
|
||||
"resourceLauncherLayoutGrid": "Gitter",
|
||||
"resourceLauncherLayoutList": "Liste",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Vis Site Tags",
|
||||
"resourceLauncherShowRecents": "Vis Seneste",
|
||||
"resourceLauncherDeleteView": "Slet Visning",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Vis som Admin",
|
||||
"resourceLauncherResourceDetailsDescription": "Se detaljer for denne ressource.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Uden Etiket",
|
||||
"resourceLauncherNoSite": "Ingen Site",
|
||||
"resourceLauncherNoResourcesInGroup": "Ingen ressourcer i denne gruppe",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Ingen ressourcer fundet",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Ingen ressourcer matcher din nuværende søgning eller filtre. Prøv at justere dem for at finde det, du leder efter.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Ingen ressourcer matcher \"{query}\". Prøv at justere din søgning eller rydde filtre for at se alle ressourcer.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Kopieret til udklipsholderen",
|
||||
"resourceLauncherCopiedAccessDescription": "Ressourcetilgangen er kopieret til din udklipsholder.",
|
||||
"resourceLauncherViewNamePlaceholder": "Vis navn",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Grundlegende Header-Authentifizierung",
|
||||
"policyAuthHeaderAuthDescription": "Überprüfen Sie einen benutzerdefinierten HTTP-Headernamen und -wert bei jeder Anfrage",
|
||||
"policyAuthHeaderAuthSummary": "Header konfiguriert",
|
||||
"policyAuthHeaderName": "Header-Name",
|
||||
"policyAuthHeaderValue": "Erwarteter Wert",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Passcode setzen",
|
||||
"policyAuthSetPincode": "PIN-Code festlegen",
|
||||
"policyAuthSetEmailWhitelist": "E-Mail-Whitelist festlegen",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Domänen",
|
||||
"billingOrganizations": "Orden",
|
||||
"billingRemoteExitNodes": "Entfernte Knoten",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Kein Limit konfiguriert",
|
||||
"billingEstimatedPeriod": "Geschätzter Abrechnungszeitraum",
|
||||
"billingIncludedUsage": "Inklusive Nutzung",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Wie viele Benutzer Sie verwenden können",
|
||||
"billingDomainInfo": "Wie viele Domains Sie verwenden können",
|
||||
"billingRemoteExitNodesInfo": "Wie viele entfernte Knoten Sie verwenden können",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Lizenzschlüssel",
|
||||
"billingLicenseKeysDescription": "Verwalten Sie Ihre Lizenzschlüssel Abonnements",
|
||||
"billingLicenseSubscription": "Lizenzabonnement",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Ressource deaktiviert",
|
||||
"memberPortalShowingResources": "Zeige {start}-{end} von {total} Ressourcen",
|
||||
"resourceLauncherTitle": "Ressourcenstarter",
|
||||
"resourceLauncherDescription": "Sehen Sie sich Ressourcendetails an und starten Sie sie von einem Ort aus",
|
||||
"resourceLauncherSearchPlaceholder": "Alle Standorte durchsuchen...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Standard",
|
||||
"resourceLauncherSaveView": "Ansicht speichern",
|
||||
"resourceLauncherSaveToCurrentView": "In aktueller Ansicht speichern",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Ansicht zurücksetzen",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Als neue Ansicht speichern",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Geben Sie dieser Ansicht einen Namen, um Ihre aktuellen Filter und das Layout zu speichern.",
|
||||
"resourceLauncherSaveForEveryone": "Für alle speichern",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Teilen Sie diese Ansicht mit allen Organisationsmitgliedern. Wenn nicht aktiviert, ist die Ansicht nur für Sie sichtbar.",
|
||||
"resourceLauncherMakePersonal": "Persönlich machen",
|
||||
"resourceLauncherFilter": "Filter",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Sortieren",
|
||||
"resourceLauncherSortAscending": "Aufsteigend sortieren",
|
||||
"resourceLauncherSortDescending": "Absteigend sortieren",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Gruppieren nach",
|
||||
"resourceLauncherGroupBySite": "Standort",
|
||||
"resourceLauncherGroupByLabel": "Etikett",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Layout",
|
||||
"resourceLauncherLayoutGrid": "Raster",
|
||||
"resourceLauncherLayoutList": "Liste",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Standort-Tags anzeigen",
|
||||
"resourceLauncherShowRecents": "Kürzlich anzeigen",
|
||||
"resourceLauncherDeleteView": "Ansicht löschen",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Ansicht als Administrator anzeigen",
|
||||
"resourceLauncherResourceDetailsDescription": "Anzeigen von Details zu dieser Ressource.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Nicht etikettiert",
|
||||
"resourceLauncherNoSite": "Kein Standort",
|
||||
"resourceLauncherNoResourcesInGroup": "Keine Ressourcen in dieser Gruppe",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Keine Ressourcen gefunden",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Keine Ressourcen entsprechen Ihrer aktuellen Suche oder den Filtern. Versuchen Sie, diese anzupassen, um zu finden, wonach Sie suchen.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Keine Ressourcen entsprechen \"{query}\". Versuchen Sie, Ihre Suche anzupassen oder die Filter zu löschen, um alle Ressourcen anzuzeigen.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "In die Zwischenablage kopiert",
|
||||
"resourceLauncherCopiedAccessDescription": "Der Ressourcenzugriff wurde in Ihre Zwischenablage kopiert.",
|
||||
"resourceLauncherViewNamePlaceholder": "Ansichtsname",
|
||||
|
||||
@@ -266,8 +266,6 @@
|
||||
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||
"resourceCreate": "Create Resource",
|
||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||
"resourcePublicCreate": "Create Public Resource",
|
||||
"resourcePublicCreateDescription": "Follow the steps below to create a new public resource that is accessible through a web browser",
|
||||
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
||||
"resourceSeeAll": "See All Resources",
|
||||
"resourceCreateGeneral": "General",
|
||||
@@ -866,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||
"policyAuthHeaderAuthSummary": "Header configured",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthHeaderName": "Header name",
|
||||
"policyAuthHeaderValue": "Expected value",
|
||||
"policyAuthSetPasscode": "Set Passcode",
|
||||
"policyAuthSetPincode": "Set PIN Code",
|
||||
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
||||
@@ -1917,9 +1915,6 @@
|
||||
"billingDomains": "Domains",
|
||||
"billingOrganizations": "Orgs",
|
||||
"billingRemoteExitNodes": "Remote Nodes",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "No limit configured",
|
||||
"billingEstimatedPeriod": "Estimated Billing Period",
|
||||
"billingIncludedUsage": "Included Usage",
|
||||
@@ -1948,9 +1943,6 @@
|
||||
"billingUsersInfo": "How many users you can use",
|
||||
"billingDomainInfo": "How many domains you can use",
|
||||
"billingRemoteExitNodesInfo": "How many remote nodes you can use",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "License Keys",
|
||||
"billingLicenseKeysDescription": "Manage your license key subscriptions",
|
||||
"billingLicenseSubscription": "License Subscription",
|
||||
@@ -2319,12 +2311,6 @@
|
||||
"createInternalResourceDialogClose": "Close",
|
||||
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
||||
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
||||
"privateResourceCreatePageSeeAll": "See All Private Resources",
|
||||
"privateResourceAllowIcmpPing": "Allow ICMP Ping",
|
||||
"privateResourceNetworkAccess": "Network Access",
|
||||
"privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.",
|
||||
"hostSettings": "Host Settings",
|
||||
"cidrSettings": "CIDR Settings",
|
||||
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
||||
"createInternalResourceDialogName": "Name",
|
||||
"createInternalResourceDialogSite": "Site",
|
||||
@@ -3585,23 +3571,18 @@
|
||||
"memberPortalResourceDisabled": "Resource Disabled",
|
||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||
"resourceLauncherTitle": "Resource Launcher",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDescription": "View resource details and launch them from one place",
|
||||
"resourceLauncherSearchPlaceholder": "Search all sites...",
|
||||
"resourceLauncherDefaultView": "Default",
|
||||
"resourceLauncherSaveView": "Save View",
|
||||
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Reset View",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Save as New View",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
||||
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
||||
"resourceLauncherMakePersonal": "Make Personal",
|
||||
"resourceLauncherFilter": "Filter",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Sort",
|
||||
"resourceLauncherSortAscending": "Sort ascending",
|
||||
"resourceLauncherSortDescending": "Sort descending",
|
||||
@@ -3609,7 +3590,6 @@
|
||||
"resourceLauncherGroupBy": "Group By",
|
||||
"resourceLauncherGroupBySite": "Site",
|
||||
"resourceLauncherGroupByLabel": "Label",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Layout",
|
||||
"resourceLauncherLayoutGrid": "Grid",
|
||||
"resourceLauncherLayoutList": "List",
|
||||
@@ -3617,20 +3597,8 @@
|
||||
"resourceLauncherShowSiteTags": "Show Site Tags",
|
||||
"resourceLauncherShowRecents": "Show Recents",
|
||||
"resourceLauncherDeleteView": "Delete View",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherResourceDetailsDescription": "View details for this resource.",
|
||||
"resourceLauncherUnlabeled": "Unlabeled",
|
||||
"resourceLauncherNoSite": "No Site",
|
||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
||||
@@ -3639,12 +3607,6 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
||||
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
||||
"resourceLauncherViewNamePlaceholder": "View name",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Autenticación Básica del Encabezado",
|
||||
"policyAuthHeaderAuthDescription": "Valida un nombre y valor de encabezado HTTP personalizado en cada petición",
|
||||
"policyAuthHeaderAuthSummary": "Encabezado configurado",
|
||||
"policyAuthHeaderName": "Nombre del encabezado",
|
||||
"policyAuthHeaderValue": "Valor esperado",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Establecer Código de Acceso",
|
||||
"policyAuthSetPincode": "Establecer Código PIN",
|
||||
"policyAuthSetEmailWhitelist": "Establecer Lista Blanca de Correo",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Dominios",
|
||||
"billingOrganizations": "Orgánico",
|
||||
"billingRemoteExitNodes": "Nodos remotos",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "No se ha configurado ningún límite",
|
||||
"billingEstimatedPeriod": "Período de facturación estimado",
|
||||
"billingIncludedUsage": "Uso incluido",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Cuántos usuarios puedes usar",
|
||||
"billingDomainInfo": "Cuántos dominios puedes usar",
|
||||
"billingRemoteExitNodesInfo": "Cuántos nodos remotos puedes usar",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Claves de licencia",
|
||||
"billingLicenseKeysDescription": "Administrar las suscripciones de su clave de licencia",
|
||||
"billingLicenseSubscription": "Suscripción de licencia",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Recurso Deshabilitado",
|
||||
"memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos",
|
||||
"resourceLauncherTitle": "Lanzador de Recursos",
|
||||
"resourceLauncherDescription": "Ve los detalles de los recursos y lánzalos desde un solo lugar",
|
||||
"resourceLauncherSearchPlaceholder": "Buscar en todos los sitios...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Predeterminado",
|
||||
"resourceLauncherSaveView": "Guardar Vista",
|
||||
"resourceLauncherSaveToCurrentView": "Guardar en la Vista Actual",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Restablecer Vista",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Guardar como Nueva Vista",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Ponle un nombre a esta vista para guardar tus filtros y diseño actuales.",
|
||||
"resourceLauncherSaveForEveryone": "Guardar para Todos",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Comparte esta vista con todos los miembros de la organización. Si está desmarcado, la vista solo es visible para ti.",
|
||||
"resourceLauncherMakePersonal": "Hacer Personal",
|
||||
"resourceLauncherFilter": "Filtro",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Ordenar",
|
||||
"resourceLauncherSortAscending": "Ordenar Ascendente",
|
||||
"resourceLauncherSortDescending": "Ordenar Descendente",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Agrupar Por",
|
||||
"resourceLauncherGroupBySite": "Sitio",
|
||||
"resourceLauncherGroupByLabel": "Etiqueta",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Disposición",
|
||||
"resourceLauncherLayoutGrid": "Cuadrícula",
|
||||
"resourceLauncherLayoutList": "Lista",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Mostrar Etiquetas del Sitio",
|
||||
"resourceLauncherShowRecents": "Mostrar Recientes",
|
||||
"resourceLauncherDeleteView": "Eliminar Vista",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Ver como Administrador",
|
||||
"resourceLauncherResourceDetailsDescription": "Ver detalles de este recurso.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Sin Etiqueta",
|
||||
"resourceLauncherNoSite": "Sin Sitio",
|
||||
"resourceLauncherNoResourcesInGroup": "No hay recursos en este grupo",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "No se Encontraron Recursos",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "No hay recursos que coincidan con tu búsqueda o filtros actuales. Intenta ajustarlos para encontrar lo que buscas.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "No hay recursos que coincidan con \"{query}\". Intenta ajustar tu búsqueda o borrar filtros para ver todos los recursos.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Copiado al portapapeles",
|
||||
"resourceLauncherCopiedAccessDescription": "El acceso al recurso ha sido copiado a tu portapapeles.",
|
||||
"resourceLauncherViewNamePlaceholder": "Nombre de la Vista",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Authentification de l'en-tête de base",
|
||||
"policyAuthHeaderAuthDescription": "Validez un nom et une valeur d'en-tête HTTP personnalisé à chaque requête",
|
||||
"policyAuthHeaderAuthSummary": "En-tête configuré",
|
||||
"policyAuthHeaderName": "Nom de l'en-tête",
|
||||
"policyAuthHeaderValue": "Valeur attendue",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Définir le code confidentiel",
|
||||
"policyAuthSetPincode": "Définir le code PIN",
|
||||
"policyAuthSetEmailWhitelist": "Définir la liste blanche des e-mails",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Domaines",
|
||||
"billingOrganizations": "Organes",
|
||||
"billingRemoteExitNodes": "Nœuds distants",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Aucune limite configurée",
|
||||
"billingEstimatedPeriod": "Période de facturation estimée",
|
||||
"billingIncludedUsage": "Utilisation incluse",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Combien d'utilisateurs vous pouvez utiliser",
|
||||
"billingDomainInfo": "Combien de domaines vous pouvez utiliser",
|
||||
"billingRemoteExitNodesInfo": "Combien de nœuds distants vous pouvez utiliser",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Clés de licence",
|
||||
"billingLicenseKeysDescription": "Gérer vos abonnements à la clé de licence",
|
||||
"billingLicenseSubscription": "Abonnement à la licence",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Ressource désactivée",
|
||||
"memberPortalShowingResources": "Affichage de {start}-{end} sur {total} ressources",
|
||||
"resourceLauncherTitle": "Lanceur de ressources",
|
||||
"resourceLauncherDescription": "Afficher les détails des ressources et les lancer depuis un seul endroit",
|
||||
"resourceLauncherSearchPlaceholder": "Rechercher sur tous les sites...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Par défaut",
|
||||
"resourceLauncherSaveView": "Enregistrer la vue",
|
||||
"resourceLauncherSaveToCurrentView": "Enregistrer dans la vue actuelle",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Réinitialiser la vue",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Enregistrer comme nouvelle vue",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Donnez un nom à cette vue pour enregistrer vos filtres et mise en page actuels.",
|
||||
"resourceLauncherSaveForEveryone": "Enregistrer pour tout le monde",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Partagez cette vue avec tous les membres de l'organisation. Lorsque décochée, la vue est visible uniquement par vous.",
|
||||
"resourceLauncherMakePersonal": "Rendre personnel",
|
||||
"resourceLauncherFilter": "Filtrer",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Trier",
|
||||
"resourceLauncherSortAscending": "Trier par ordre croissant",
|
||||
"resourceLauncherSortDescending": "Trier par ordre décroissant",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Grouper par",
|
||||
"resourceLauncherGroupBySite": "Nœud",
|
||||
"resourceLauncherGroupByLabel": "Étiquette",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Mise en page",
|
||||
"resourceLauncherLayoutGrid": "Grille",
|
||||
"resourceLauncherLayoutList": "Liste",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Afficher les tags de site",
|
||||
"resourceLauncherShowRecents": "Afficher les récents",
|
||||
"resourceLauncherDeleteView": "Supprimer la vue",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Voir en tant qu'admin",
|
||||
"resourceLauncherResourceDetailsDescription": "Afficher les détails pour cette ressource.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Non étiqueté",
|
||||
"resourceLauncherNoSite": "Aucun nœud",
|
||||
"resourceLauncherNoResourcesInGroup": "Aucune ressource dans ce groupe",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Aucune ressource trouvée",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Aucune ressource ne correspond à votre recherche ou filtre actuel. Essayez de les ajuster pour trouver ce que vous cherchez.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Aucune ressource ne correspond à \"{query}\". Essayez d'ajuster votre recherche ou de supprimer les filtres pour voir toutes les ressources.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Copié dans le presse-papiers",
|
||||
"resourceLauncherCopiedAccessDescription": "L'accès à la ressource a été copié dans votre presse-papiers.",
|
||||
"resourceLauncherViewNamePlaceholder": "Nom de la vue",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Autenticazione Header Base",
|
||||
"policyAuthHeaderAuthDescription": "Convalida un nome e un valore di intestazione HTTP personalizzato su ogni richiesta",
|
||||
"policyAuthHeaderAuthSummary": "Intestazione configurata",
|
||||
"policyAuthHeaderName": "Nome dell'intestazione",
|
||||
"policyAuthHeaderValue": "Valore atteso",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Imposta Codice di Accesso",
|
||||
"policyAuthSetPincode": "Imposta Codice PIN",
|
||||
"policyAuthSetEmailWhitelist": "Imposta Lista Autorizzazioni Email",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Domini",
|
||||
"billingOrganizations": "Organi",
|
||||
"billingRemoteExitNodes": "Nodi Remoti",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Nessun limite configurato",
|
||||
"billingEstimatedPeriod": "Periodo di Fatturazione Stimato",
|
||||
"billingIncludedUsage": "Utilizzo Incluso",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Quanti utenti puoi usare",
|
||||
"billingDomainInfo": "Quanti domini puoi usare",
|
||||
"billingRemoteExitNodesInfo": "Quanti nodi remoti puoi usare",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Chiavi di Licenza",
|
||||
"billingLicenseKeysDescription": "Gestisci le sottoscrizioni alla chiave di licenza",
|
||||
"billingLicenseSubscription": "Abbonamento Licenza",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Risorsa Disabilitata",
|
||||
"memberPortalShowingResources": "Mostrando {start}-{end} di {total} risorse",
|
||||
"resourceLauncherTitle": "Lanscia Risorse",
|
||||
"resourceLauncherDescription": "Visualizza i dettagli delle risorse e lanciale da un solo posto",
|
||||
"resourceLauncherSearchPlaceholder": "Cerca tutti i siti...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Predefinito",
|
||||
"resourceLauncherSaveView": "Salva Visualizzazione",
|
||||
"resourceLauncherSaveToCurrentView": "Salva alla Visualizzazione Corrente",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Reimposta Visualizzazione",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Salva come Nuova Visualizzazione",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Dai un nome a questa visualizzazione per salvare i tuoi filtri e layout attuali.",
|
||||
"resourceLauncherSaveForEveryone": "Salva per Tutti",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Condividi questa visualizzazione con tutti i membri dell'organizzazione. Quando non è selezionata, la visualizzazione è visibile solo a te.",
|
||||
"resourceLauncherMakePersonal": "Rendi Personale",
|
||||
"resourceLauncherFilter": "Filtro",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Ordina",
|
||||
"resourceLauncherSortAscending": "Ordina in ordine crescente",
|
||||
"resourceLauncherSortDescending": "Ordina in ordine decrescente",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Raggruppa per",
|
||||
"resourceLauncherGroupBySite": "Sito",
|
||||
"resourceLauncherGroupByLabel": "Etichetta",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Layout",
|
||||
"resourceLauncherLayoutGrid": "Griglia",
|
||||
"resourceLauncherLayoutList": "Lista",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Mostra Tag di Sito",
|
||||
"resourceLauncherShowRecents": "Mostra Recenti",
|
||||
"resourceLauncherDeleteView": "Elimina Visualizzazione",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Visualizza come Admin",
|
||||
"resourceLauncherResourceDetailsDescription": "Visualizza i dettagli per questa risorsa.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Non Etichettato",
|
||||
"resourceLauncherNoSite": "Nessun Sito",
|
||||
"resourceLauncherNoResourcesInGroup": "Nessuna risorsa in questo gruppo",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Nessuna risorsa trovata",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Nessuna risorsa corrisponde alla tua ricerca o ai tuoi filtri attuali. Prova a modificarli per trovare ciò che stai cercando.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Nessuna risorsa corrisponde a \"{query}\". Prova a modificare la tua ricerca o a cancellare i filtri per vedere tutte le risorse.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Copiato negli appunti",
|
||||
"resourceLauncherCopiedAccessDescription": "L'accesso alla risorsa è stato copiato nei tuoi appunti.",
|
||||
"resourceLauncherViewNamePlaceholder": "Nome Visualizzazione",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "기본 헤더 인증",
|
||||
"policyAuthHeaderAuthDescription": "각 요청에서 맞춤 HTTP 헤더 이름 및 값을 검증",
|
||||
"policyAuthHeaderAuthSummary": "헤더 구성됨",
|
||||
"policyAuthHeaderName": "헤더 이름",
|
||||
"policyAuthHeaderValue": "예상 값",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "패스코드 설정",
|
||||
"policyAuthSetPincode": "PIN 코드 설정",
|
||||
"policyAuthSetEmailWhitelist": "이메일 화이트리스트 설정",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "도메인",
|
||||
"billingOrganizations": "조직",
|
||||
"billingRemoteExitNodes": "원격 노드",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "구성된 한도가 없습니다.",
|
||||
"billingEstimatedPeriod": "예상 청구 기간",
|
||||
"billingIncludedUsage": "포함 사용량",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "사용할 수 있는 사용자 수",
|
||||
"billingDomainInfo": "사용할 수 있는 도메인 수",
|
||||
"billingRemoteExitNodesInfo": "사용할 수 있는 원격 노드 수",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "라이센스 키",
|
||||
"billingLicenseKeysDescription": "라이센스 키 구독을 관리하세요",
|
||||
"billingLicenseSubscription": "라이센스 구독",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "리소스 비활성화됨",
|
||||
"memberPortalShowingResources": "{start}-{end} 중 {total}개의 리소스를 표시 중",
|
||||
"resourceLauncherTitle": "리소스 런처",
|
||||
"resourceLauncherDescription": "리소스 세부 정보를 보고 한 곳에서 실행하세요",
|
||||
"resourceLauncherSearchPlaceholder": "모든 사이트 검색...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "기본값",
|
||||
"resourceLauncherSaveView": "보기를 저장",
|
||||
"resourceLauncherSaveToCurrentView": "현재 보기로 저장",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "보기를 재설정",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "새 보기로 저장",
|
||||
"resourceLauncherSaveAsNewViewDescription": "현재 필터와 레이아웃을 저장할 이름을 입력하세요.",
|
||||
"resourceLauncherSaveForEveryone": "모두에게 저장",
|
||||
"resourceLauncherSaveForEveryoneDescription": "이 보기를 모든 조직 구성원과 공유합니다. 체크 해제하면 해당 뷰는 사용자에게만 표시됩니다.",
|
||||
"resourceLauncherMakePersonal": "개인적으로 만들기",
|
||||
"resourceLauncherFilter": "필터",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "정렬",
|
||||
"resourceLauncherSortAscending": "오름차순 정렬",
|
||||
"resourceLauncherSortDescending": "내림차순 정렬",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "그룹화 기준",
|
||||
"resourceLauncherGroupBySite": "사이트",
|
||||
"resourceLauncherGroupByLabel": "레이블",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "레이아웃",
|
||||
"resourceLauncherLayoutGrid": "그리드",
|
||||
"resourceLauncherLayoutList": "목록",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "사이트 태그 표시",
|
||||
"resourceLauncherShowRecents": "최근 항목 표시",
|
||||
"resourceLauncherDeleteView": "보기 삭제",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "관리자로 보기",
|
||||
"resourceLauncherResourceDetailsDescription": "이 리소스의 세부정보를 봅니다.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "레이블 없음",
|
||||
"resourceLauncherNoSite": "사이트 없음",
|
||||
"resourceLauncherNoResourcesInGroup": "이 그룹에는 리소스가 없습니다",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "리소스를 찾을 수 없음",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "현재 검색이나 필터에 맞는 리소스가 없습니다. 필터를 조정하여 찾으려는 항목을 확인해보세요.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "\"{query}\"와 일치하는 리소스가 없습니다. 검색을 조정하거나 필터를 지워서 모든 리소스를 확인해보세요.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "클립보드에 복사됨",
|
||||
"resourceLauncherCopiedAccessDescription": "리소스 액세스가 클립보드에 복사되었습니다.",
|
||||
"resourceLauncherViewNamePlaceholder": "보기 이름",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Grunnleggende Header Autentisering",
|
||||
"policyAuthHeaderAuthDescription": "Bekreft et tilpasset HTTP-headernavn og verdi ved hver forespørsel",
|
||||
"policyAuthHeaderAuthSummary": "Header konfigurert",
|
||||
"policyAuthHeaderName": "Headernavn",
|
||||
"policyAuthHeaderValue": "Forventet verdi",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Angi passordkode",
|
||||
"policyAuthSetPincode": "Sett PIN-kode",
|
||||
"policyAuthSetEmailWhitelist": "Angi e-post hviteliste",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Domener",
|
||||
"billingOrganizations": "Orger",
|
||||
"billingRemoteExitNodes": "Eksterne Noder",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Ingen grense konfigurert",
|
||||
"billingEstimatedPeriod": "Estimert faktureringsperiode",
|
||||
"billingIncludedUsage": "Inkludert Bruk",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Hvor mange brukere du kan bruke",
|
||||
"billingDomainInfo": "Hvor mange domener du kan bruke",
|
||||
"billingRemoteExitNodesInfo": "Hvor mange fjernnoder du kan bruke",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Lisensnøkler",
|
||||
"billingLicenseKeysDescription": "Administrer dine lisensnøkkelabonnementer",
|
||||
"billingLicenseSubscription": "Lisens abonnement",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Ressurs deaktivert",
|
||||
"memberPortalShowingResources": "Viser {start}-{end} av {total} ressurser",
|
||||
"resourceLauncherTitle": "Ressurslansering",
|
||||
"resourceLauncherDescription": "Vis ressursdetaljer og start dem fra ett sted",
|
||||
"resourceLauncherSearchPlaceholder": "Søk i alle områder...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Standard",
|
||||
"resourceLauncherSaveView": "Lagre visning",
|
||||
"resourceLauncherSaveToCurrentView": "Lagre til nåværende visning",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Tilbakestill visning",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Lagre som ny visning",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Gi denne visningen et navn for å lagre dine nåværende filtre og oppsett.",
|
||||
"resourceLauncherSaveForEveryone": "Lagre for alle",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Del denne visningen med alle organisasjonsmedlemmer. Når avkrysset, er visningen synlig bare for deg.",
|
||||
"resourceLauncherMakePersonal": "Gjør personlig",
|
||||
"resourceLauncherFilter": "Filter",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Sorter",
|
||||
"resourceLauncherSortAscending": "Sorter stigende",
|
||||
"resourceLauncherSortDescending": "Sorter synkende",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Grupper etter",
|
||||
"resourceLauncherGroupBySite": "Område",
|
||||
"resourceLauncherGroupByLabel": "Etikett",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Oppsett",
|
||||
"resourceLauncherLayoutGrid": "Rutenett",
|
||||
"resourceLauncherLayoutList": "Liste",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Vis områdestikkord",
|
||||
"resourceLauncherShowRecents": "Vis nylige",
|
||||
"resourceLauncherDeleteView": "Slett visning",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Vis som administrator",
|
||||
"resourceLauncherResourceDetailsDescription": "Vis detaljer for denne ressursen.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Umerket",
|
||||
"resourceLauncherNoSite": "Ingen område",
|
||||
"resourceLauncherNoResourcesInGroup": "Ingen ressurser i denne gruppen",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Ingen ressurser funnet",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Ingen ressurser matcher dine nåværende søk eller filtre. Prøv å justere dem for å finne det du leter etter.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Ingen ressurser samsvarer med \"{query}\". Prøv å justere søket eller fjern filtrene for å se alle ressursene.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Kopiert til utklippstavlen",
|
||||
"resourceLauncherCopiedAccessDescription": "Ressurstilgang er kopiert til utklippstavlen din.",
|
||||
"resourceLauncherViewNamePlaceholder": "Visningsnavn",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Basic Header Authenticatie",
|
||||
"policyAuthHeaderAuthDescription": "Valideer een aangepaste HTTP-headernaam en waarde bij elk verzoek",
|
||||
"policyAuthHeaderAuthSummary": "Header geconfigureerd",
|
||||
"policyAuthHeaderName": "Header naam",
|
||||
"policyAuthHeaderValue": "Verwachte waarde",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Stel toegangscode in",
|
||||
"policyAuthSetPincode": "Stel Pincode in",
|
||||
"policyAuthSetEmailWhitelist": "Stel E-mail Whitelist in",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Domeinen",
|
||||
"billingOrganizations": "Ordenen",
|
||||
"billingRemoteExitNodes": "Externe knooppunten",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Geen limiet ingesteld",
|
||||
"billingEstimatedPeriod": "Geschatte Facturatie Periode",
|
||||
"billingIncludedUsage": "Opgenomen Gebruik",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Hoeveel gebruikers je kan gebruiken",
|
||||
"billingDomainInfo": "Hoeveel domeinen je kunt gebruiken",
|
||||
"billingRemoteExitNodesInfo": "Hoeveel externe nodes je kunt gebruiken",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Licentie Sleutels",
|
||||
"billingLicenseKeysDescription": "Beheer uw licentiesleutelabonnementen",
|
||||
"billingLicenseSubscription": "Licentie abonnement",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Bron Uitgeschakeld",
|
||||
"memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen",
|
||||
"resourceLauncherTitle": "Bron Launcher",
|
||||
"resourceLauncherDescription": "Bekijk brongegevens en start ze vanaf één plek",
|
||||
"resourceLauncherSearchPlaceholder": "Zoek alle sites...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Standaard",
|
||||
"resourceLauncherSaveView": "Weergave Opslaan",
|
||||
"resourceLauncherSaveToCurrentView": "Opslaan naar huidige weergave",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Weergave Herstellen",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Opslaan als Nieuwe Weergave",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Geef deze weergave een naam om je huidige filters en indeling op te slaan.",
|
||||
"resourceLauncherSaveForEveryone": "Opslaan voor Iedereen",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Deel deze weergave met alle organisatieleden. Als dit niet is aangevinkt, is de weergave alleen zichtbaar voor jou.",
|
||||
"resourceLauncherMakePersonal": "Persoonlijk Maken",
|
||||
"resourceLauncherFilter": "Filter",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Sorteren",
|
||||
"resourceLauncherSortAscending": "Oplopend sorteren",
|
||||
"resourceLauncherSortDescending": "Aflopend sorteren",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Groep Op",
|
||||
"resourceLauncherGroupBySite": "Site",
|
||||
"resourceLauncherGroupByLabel": "Label",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Lay-out",
|
||||
"resourceLauncherLayoutGrid": "Raster",
|
||||
"resourceLauncherLayoutList": "Lijst",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Site Tags Weergeven",
|
||||
"resourceLauncherShowRecents": "Recente Weergeven",
|
||||
"resourceLauncherDeleteView": "Weergave Verwijderen",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Bekijk als Admin",
|
||||
"resourceLauncherResourceDetailsDescription": "Bekijk details voor deze bron.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Geen label",
|
||||
"resourceLauncherNoSite": "Geen Site",
|
||||
"resourceLauncherNoResourcesInGroup": "Geen bronnen in deze groep",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Geen Bronnen Gevonden",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Geen bronnen komen overeen met je huidige zoekopdracht of filters. Probeer ze aan te passen om te vinden wat je zoekt.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Geen bronnen komen overeen met \"{query}\". Probeer je zoekopdracht aan te passen of filters te wissen om alle bronnen te zien.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Gekopieerd naar klembord",
|
||||
"resourceLauncherCopiedAccessDescription": "Toegang tot bron is gekopieerd naar je klembord.",
|
||||
"resourceLauncherViewNamePlaceholder": "Weergavenaam",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Podstawowe Uwierzytelnianie Nagłówka",
|
||||
"policyAuthHeaderAuthDescription": "Walidacja niestandardowej nazwy i wartości nagłówka HTTP przy każdym żądaniu",
|
||||
"policyAuthHeaderAuthSummary": "Skonfigurowany nagłówek",
|
||||
"policyAuthHeaderName": "Nazwa nagłówka",
|
||||
"policyAuthHeaderValue": "Oczekiwana wartość",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Ustaw hasło dostępu",
|
||||
"policyAuthSetPincode": "Ustaw kod PIN",
|
||||
"policyAuthSetEmailWhitelist": "Ustaw białą listę e-mail",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Domeny",
|
||||
"billingOrganizations": "O masie całkowitej pojazdu przekraczającej 5 ton, ale nieprzekraczającej 5 ton",
|
||||
"billingRemoteExitNodes": "Zdalne węzły",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Nie skonfigurowano limitu",
|
||||
"billingEstimatedPeriod": "Szacowany Okres Rozliczeniowy",
|
||||
"billingIncludedUsage": "Zawarte użycie",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Ile użytkowników możesz użyć",
|
||||
"billingDomainInfo": "Ile domen możesz użyć",
|
||||
"billingRemoteExitNodesInfo": "Ile zdalnych węzłów możesz użyć",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Klucze licencyjne",
|
||||
"billingLicenseKeysDescription": "Zarządzaj subskrypcjami kluczy licencyjnych",
|
||||
"billingLicenseSubscription": "Subskrypcja licencji",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Zasób wyłączony",
|
||||
"memberPortalShowingResources": "Wyświetlanie zasobów od {start} do {end} z {total}",
|
||||
"resourceLauncherTitle": "Uruchamiacz Zasobów",
|
||||
"resourceLauncherDescription": "Przeglądaj szczegóły zasobów i uruchamiaj je z jednego miejsca",
|
||||
"resourceLauncherSearchPlaceholder": "Szukaj we wszystkich stronach...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Domyślny",
|
||||
"resourceLauncherSaveView": "Zapisz Widok",
|
||||
"resourceLauncherSaveToCurrentView": "Zapisz do bieżącego widoku",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Resetuj Widok",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Zapisz jako Nowy Widok",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Nadaj nazwę temu widokowi, aby zapisać swoje bieżące filtry i układ.",
|
||||
"resourceLauncherSaveForEveryone": "Zapisz dla wszystkich",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Udostępnij ten widok wszystkim członkom organizacji. Gdy jest niezaznaczone, widok jest widoczny tylko dla Ciebie.",
|
||||
"resourceLauncherMakePersonal": "Zrób osobisty",
|
||||
"resourceLauncherFilter": "Filtr",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Sortuj",
|
||||
"resourceLauncherSortAscending": "Sortuj rosnąco",
|
||||
"resourceLauncherSortDescending": "Sortuj malejąco",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Grupuj według",
|
||||
"resourceLauncherGroupBySite": "Witryna",
|
||||
"resourceLauncherGroupByLabel": "Etykieta",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Układ",
|
||||
"resourceLauncherLayoutGrid": "Siatka",
|
||||
"resourceLauncherLayoutList": "Lista",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Pokaż tagi stron",
|
||||
"resourceLauncherShowRecents": "Pokaż ostatnie",
|
||||
"resourceLauncherDeleteView": "Usuń Widok",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Przeglądaj jako Administrator",
|
||||
"resourceLauncherResourceDetailsDescription": "Pokaż szczegóły tego zasobu.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Bez etykiety",
|
||||
"resourceLauncherNoSite": "Brak strony",
|
||||
"resourceLauncherNoResourcesInGroup": "W tej grupie nie ma zasobów",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Nie znaleziono zasobów",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Żadne zasoby nie spełniają twojego bieżącego wyszukiwania lub filtrów. Spróbuj je dostosować, aby znaleźć to, czego szukasz.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Żadne zasoby nie odpowiadają \"{query}\". Spróbuj dostosować swoje wyszukiwanie lub usunąć filtry, aby zobaczyć wszystkie zasoby.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Skopiowano do schowka",
|
||||
"resourceLauncherCopiedAccessDescription": "Dostęp do zasobu został skopiowany do schowka.",
|
||||
"resourceLauncherViewNamePlaceholder": "Nazwa widoku",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Autenticação de Cabeçalho Básico",
|
||||
"policyAuthHeaderAuthDescription": "Valide um nome e valor de cabeçalho HTTP personalizado em cada solicitação",
|
||||
"policyAuthHeaderAuthSummary": "Cabeçalho configurado",
|
||||
"policyAuthHeaderName": "Nome do Cabeçalho",
|
||||
"policyAuthHeaderValue": "Valor esperado",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Definir Código de Acesso",
|
||||
"policyAuthSetPincode": "Definir Código PIN",
|
||||
"policyAuthSetEmailWhitelist": "Definir Lista de E-mails Permitidos",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Domínios",
|
||||
"billingOrganizations": "Órgãos",
|
||||
"billingRemoteExitNodes": "Nós remotos",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Nenhum limite configurado",
|
||||
"billingEstimatedPeriod": "Período Estimado de Cobrança",
|
||||
"billingIncludedUsage": "Uso Incluído",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Quantos usuários você pode usar",
|
||||
"billingDomainInfo": "Quantos domínios você pode usar",
|
||||
"billingRemoteExitNodesInfo": "Quantos nós remotos você pode usar",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Chaves de Licença",
|
||||
"billingLicenseKeysDescription": "Gerenciar suas subscrições de chave de licença",
|
||||
"billingLicenseSubscription": "Assinatura de Licença",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Recurso Desativado",
|
||||
"memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos",
|
||||
"resourceLauncherTitle": "Lançador de Recursos",
|
||||
"resourceLauncherDescription": "Veja os detalhes do recurso e lance-os de um só lugar",
|
||||
"resourceLauncherSearchPlaceholder": "Procurar todos os sites...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Padrão",
|
||||
"resourceLauncherSaveView": "Salvar Visualização",
|
||||
"resourceLauncherSaveToCurrentView": "Salvar na Visualização Atual",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Redefinir Visualização",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Salvar como Nova Visualização",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Dê um nome a esta visualização para salvar os filtros e layout atuais.",
|
||||
"resourceLauncherSaveForEveryone": "Salvar para Todos",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Compartilhe esta visualização com todos os membros da organização. Quando desmarcado, a visualização é visível apenas para você.",
|
||||
"resourceLauncherMakePersonal": "Tornar Pessoal",
|
||||
"resourceLauncherFilter": "Filtro",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Ordenar",
|
||||
"resourceLauncherSortAscending": "Ordenar ascendente",
|
||||
"resourceLauncherSortDescending": "Ordenar descendente",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Agrupar por",
|
||||
"resourceLauncherGroupBySite": "Site",
|
||||
"resourceLauncherGroupByLabel": "Marcador",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Layout",
|
||||
"resourceLauncherLayoutGrid": "Grade",
|
||||
"resourceLauncherLayoutList": "Lista",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Mostrar Etiquetas de Site",
|
||||
"resourceLauncherShowRecents": "Mostrar Recents",
|
||||
"resourceLauncherDeleteView": "Excluir Visualização",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Visualizar como Administrador",
|
||||
"resourceLauncherResourceDetailsDescription": "Veja detalhes deste recurso.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Sem Etiqueta",
|
||||
"resourceLauncherNoSite": "Sem Site",
|
||||
"resourceLauncherNoResourcesInGroup": "Nenhum recurso neste grupo",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Nenhum Recurso Encontrado",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Nenhum recurso corresponde à sua busca ou filtros atuais. Experimente ajustá-los para encontrar o que está procurando.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Nenhum recurso corresponde a \"{query}\". Tente ajustar sua busca ou limpar os filtros para ver todos os recursos.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Copiado para a área de transferência",
|
||||
"resourceLauncherCopiedAccessDescription": "O acesso ao recurso foi copiado para sua área de transferência.",
|
||||
"resourceLauncherViewNamePlaceholder": "Nome da Visualização",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Базовая аутентификация заголовка",
|
||||
"policyAuthHeaderAuthDescription": "Проверка пользовательского имени и значения HTTP-заголовка для каждого запроса",
|
||||
"policyAuthHeaderAuthSummary": "Заголовок настроен",
|
||||
"policyAuthHeaderName": "Имя заголовка",
|
||||
"policyAuthHeaderValue": "Ожидаемое значение",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Установить пароль",
|
||||
"policyAuthSetPincode": "Установить ПИН-код",
|
||||
"policyAuthSetEmailWhitelist": "Установить белый список email",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Домены",
|
||||
"billingOrganizations": "Орги",
|
||||
"billingRemoteExitNodes": "Удаленные узлы",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Лимит не установлен",
|
||||
"billingEstimatedPeriod": "Предполагаемый период выставления счетов",
|
||||
"billingIncludedUsage": "Включенное использование",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Сколько пользователей вы можете использовать",
|
||||
"billingDomainInfo": "Сколько доменов вы можете использовать",
|
||||
"billingRemoteExitNodesInfo": "Сколько удаленных узлов вы можете использовать",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Лицензионные ключи",
|
||||
"billingLicenseKeysDescription": "Управление подписками на лицензионные ключи",
|
||||
"billingLicenseSubscription": "Лицензионное соглашение",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Ресурс отключён",
|
||||
"memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов",
|
||||
"resourceLauncherTitle": "Запуск ресурса",
|
||||
"resourceLauncherDescription": "Просмотр деталей ресурса и запуск их из одного места",
|
||||
"resourceLauncherSearchPlaceholder": "Поиск всех сайтов...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "По умолчанию",
|
||||
"resourceLauncherSaveView": "Сохранить вид",
|
||||
"resourceLauncherSaveToCurrentView": "Сохранить в текущий вид",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Сбросить вид",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Сохранить как новый вид",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Дайте этому виду имя, чтобы сохранить текущие фильтры и макет.",
|
||||
"resourceLauncherSaveForEveryone": "Сохранить для всех",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Поделитесь этим видом со всеми членами организации. Если не отмечено, видимость только для вас.",
|
||||
"resourceLauncherMakePersonal": "Сделать личным",
|
||||
"resourceLauncherFilter": "Фильтр",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Сортировать",
|
||||
"resourceLauncherSortAscending": "Сортировать по возрастанию",
|
||||
"resourceLauncherSortDescending": "Сортировать по убыванию",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Группировать по",
|
||||
"resourceLauncherGroupBySite": "Сайт",
|
||||
"resourceLauncherGroupByLabel": "Метка",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Макет",
|
||||
"resourceLauncherLayoutGrid": "Сетка",
|
||||
"resourceLauncherLayoutList": "Список",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Показать теги сайта",
|
||||
"resourceLauncherShowRecents": "Показать недавно",
|
||||
"resourceLauncherDeleteView": "Удалить вид",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Просмотр как администратор",
|
||||
"resourceLauncherResourceDetailsDescription": "Просмотр деталей этого ресурса.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Без меток",
|
||||
"resourceLauncherNoSite": "Без сайта",
|
||||
"resourceLauncherNoResourcesInGroup": "Нет ресурсов в данной группе",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Ресурсы не найдены",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Ни один ресурс не соответствует вашему текущему поисковому запросу или фильтрам. Попробуйте их изменить, чтобы найти нужное.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "Ни один ресурс не соответствует \"{query}\". Попробуйте изменить параметры поиска или очистить фильтры, чтобы увидеть все ресурсы.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Скопировано в буфер обмена",
|
||||
"resourceLauncherCopiedAccessDescription": "Доступ к ресурсу был скопирован в ваш буфер обмена.",
|
||||
"resourceLauncherViewNamePlaceholder": "Имя вида",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "Temel Başlık Kimlik Doğrulama",
|
||||
"policyAuthHeaderAuthDescription": "Her istekte özel bir HTTP başlık adını ve değerini doğrulayın",
|
||||
"policyAuthHeaderAuthSummary": "Başlık yapılandırıldı",
|
||||
"policyAuthHeaderName": "Başlık adı",
|
||||
"policyAuthHeaderValue": "Beklenen değer",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Şifreyi Ayarla",
|
||||
"policyAuthSetPincode": "PIN Kodunu Ayarla",
|
||||
"policyAuthSetEmailWhitelist": "E-posta Beyaz Listesini Ayarla",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "Alan Adları",
|
||||
"billingOrganizations": "Organizasyonlar",
|
||||
"billingRemoteExitNodes": "Uzak Düğümler",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "Hiçbir limit yapılandırılmadı",
|
||||
"billingEstimatedPeriod": "Tahmini Fatura Dönemi",
|
||||
"billingIncludedUsage": "Dahil Kullanım",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "Kaç tane kullanıcı kullanabileceğiniz",
|
||||
"billingDomainInfo": "Kaç tane alan adı kullanabileceğiniz",
|
||||
"billingRemoteExitNodesInfo": "Kaç tane uzaktan düğüm kullanabileceğiniz",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "Lisans Anahtarları",
|
||||
"billingLicenseKeysDescription": "Lisans anahtarı aboneliklerinizi yönetin",
|
||||
"billingLicenseSubscription": "Lisans Aboneliği",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "Kaynak Devre Dışı",
|
||||
"memberPortalShowingResources": "{total} kaynaktan {start}-{end} gösteriliyor",
|
||||
"resourceLauncherTitle": "Kaynak Başlatıcı",
|
||||
"resourceLauncherDescription": "Kaynağın detaylarını görüntüleyin ve tek bir yerden başlatın",
|
||||
"resourceLauncherSearchPlaceholder": "Tüm siteleri ara...",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "Varsayılan",
|
||||
"resourceLauncherSaveView": "Görünümü Kaydet",
|
||||
"resourceLauncherSaveToCurrentView": "Mevcut Görünüme Kaydet",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "Görünümü Sıfırla",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "Yeni Görünüm Olarak Kaydet",
|
||||
"resourceLauncherSaveAsNewViewDescription": "Geçerli filtrelerinizi ve düzeninizi kaydetmek için bu görünüme bir ad verin.",
|
||||
"resourceLauncherSaveForEveryone": "Herkes İçin Kaydet",
|
||||
"resourceLauncherSaveForEveryoneDescription": "Bu görünümü tüm kuruluş üyeleriyle paylaşın. İşaretli değilse, görünüm yalnızca size görünür olur.",
|
||||
"resourceLauncherMakePersonal": "Kişisel Yap",
|
||||
"resourceLauncherFilter": "Filtre",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "Sıralama",
|
||||
"resourceLauncherSortAscending": "Artan sırala",
|
||||
"resourceLauncherSortDescending": "Azalan sırala",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "Grupla",
|
||||
"resourceLauncherGroupBySite": "Site",
|
||||
"resourceLauncherGroupByLabel": "Etiket",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "Düzen",
|
||||
"resourceLauncherLayoutGrid": "Izgara",
|
||||
"resourceLauncherLayoutList": "Liste",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "Site Etiketlerini Göster",
|
||||
"resourceLauncherShowRecents": "Son Eklenenleri Göster",
|
||||
"resourceLauncherDeleteView": "Görünümü Sil",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "Yönetici Olarak Görüntüle",
|
||||
"resourceLauncherResourceDetailsDescription": "Bu kaynağın detaylarını görüntüleyin.",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Etiketsiz",
|
||||
"resourceLauncherNoSite": "Site Yok",
|
||||
"resourceLauncherNoResourcesInGroup": "Bu grupta kaynak yok",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "Kaynak Bulunamadı",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "Mevcut arama veya filtrelerinizle eşleşen kaynak yok. Aradığınızı bulmak için ayarları değiştirmeyi deneyin.",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "\"{query}\" ile eşleşen kaynak yok. Tüm kaynakları görmek için aramayı düzenlemeyi veya filtreleri temizlemeyi deneyin.",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "Panoya kopyalandı",
|
||||
"resourceLauncherCopiedAccessDescription": "Kaynağa erişim panonuza kopyalandı.",
|
||||
"resourceLauncherViewNamePlaceholder": "Görünüm adı",
|
||||
|
||||
@@ -864,8 +864,8 @@
|
||||
"policyAuthHeaderAuthTitle": "基础标题认证",
|
||||
"policyAuthHeaderAuthDescription": "在每次请求上验证自定义 HTTP 标头名称和值",
|
||||
"policyAuthHeaderAuthSummary": "标头已配置",
|
||||
"policyAuthHeaderName": "标头名称",
|
||||
"policyAuthHeaderValue": "预期值",
|
||||
"policyAuthHeaderName": "Username",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "设定密码",
|
||||
"policyAuthSetPincode": "设置 PIN 码",
|
||||
"policyAuthSetEmailWhitelist": "设置电子邮件白名单",
|
||||
@@ -1915,6 +1915,9 @@
|
||||
"billingDomains": "域",
|
||||
"billingOrganizations": "球队",
|
||||
"billingRemoteExitNodes": "远程节点",
|
||||
"billingPublicResources": "Public Resources",
|
||||
"billingPrivateResources": "Private Resources",
|
||||
"billingMachineClients": "Machine Clients",
|
||||
"billingNoLimitConfigured": "未配置限制",
|
||||
"billingEstimatedPeriod": "估计结算周期",
|
||||
"billingIncludedUsage": "包含的使用量",
|
||||
@@ -1943,6 +1946,9 @@
|
||||
"billingUsersInfo": "您可以使用多少用户",
|
||||
"billingDomainInfo": "您可以使用多少域",
|
||||
"billingRemoteExitNodesInfo": "您可以使用多少远程节点",
|
||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
||||
"billingLicenseKeys": "许可证密钥",
|
||||
"billingLicenseKeysDescription": "管理您的许可证密钥订阅",
|
||||
"billingLicenseSubscription": "许可订阅",
|
||||
@@ -3571,18 +3577,23 @@
|
||||
"memberPortalResourceDisabled": "资源已禁用",
|
||||
"memberPortalShowingResources": "显示 {start}-{end} 共 {total} 个资源",
|
||||
"resourceLauncherTitle": "资源启动器",
|
||||
"resourceLauncherDescription": "查看资源详情并从一个地方启动它们",
|
||||
"resourceLauncherSearchPlaceholder": "搜索所有站点……",
|
||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||
"resourceLauncherDefaultView": "默认",
|
||||
"resourceLauncherSaveView": "保存视图",
|
||||
"resourceLauncherSaveToCurrentView": "保存至当前视图",
|
||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||
"resourceLauncherResetView": "重置视图",
|
||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||
"resourceLauncherSaveAsNewView": "另存为新视图",
|
||||
"resourceLauncherSaveAsNewViewDescription": "为此视图命名,以便保存您当前的过滤器和布局。",
|
||||
"resourceLauncherSaveForEveryone": "为所有人保存",
|
||||
"resourceLauncherSaveForEveryoneDescription": "与所有组织成员共享此视图。如果未选中,此视图仅对您可见。",
|
||||
"resourceLauncherMakePersonal": "创建个人",
|
||||
"resourceLauncherFilter": "筛选",
|
||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||
"resourceLauncherSort": "排序",
|
||||
"resourceLauncherSortAscending": "按升序排序",
|
||||
"resourceLauncherSortDescending": "按降序排序",
|
||||
@@ -3590,6 +3601,7 @@
|
||||
"resourceLauncherGroupBy": "按组",
|
||||
"resourceLauncherGroupBySite": "站点",
|
||||
"resourceLauncherGroupByLabel": "标签",
|
||||
"resourceLauncherGroupByNone": "None",
|
||||
"resourceLauncherLayout": "布局",
|
||||
"resourceLauncherLayoutGrid": "网格",
|
||||
"resourceLauncherLayoutList": "列表",
|
||||
@@ -3597,8 +3609,20 @@
|
||||
"resourceLauncherShowSiteTags": "显示站点标签",
|
||||
"resourceLauncherShowRecents": "显示最近使用",
|
||||
"resourceLauncherDeleteView": "删除视图",
|
||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||
"resourceLauncherViewAsAdmin": "以管理员身份查看",
|
||||
"resourceLauncherResourceDetailsDescription": "查看此资源的详细信息。",
|
||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||
"resourceLauncherResourceDetails": "Resource Details",
|
||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||
"resourceLauncherDownloadClient": "Download client",
|
||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "未标记",
|
||||
"resourceLauncherNoSite": "无站点",
|
||||
"resourceLauncherNoResourcesInGroup": "此组中没有资源",
|
||||
@@ -3607,6 +3631,12 @@
|
||||
"resourceLauncherEmptyStateNoResultsTitle": "未找到资源",
|
||||
"resourceLauncherEmptyStateNoResultsDescription": "没有资源与您当前的搜索或过滤器匹配。尝试调整它们以找到您想要的内容。",
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery": "没有资源匹配\"{query}\"。尝试调整您的搜索或清除过滤器以查看所有资源。",
|
||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||
"resourceLauncherCopiedToClipboard": "已复制到剪贴板",
|
||||
"resourceLauncherCopiedAccessDescription": "资源访问权限已复制到剪贴板。",
|
||||
"resourceLauncherViewNamePlaceholder": "查看名称",
|
||||
|
||||
@@ -18,17 +18,7 @@ export function createPoolConfig(
|
||||
keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle
|
||||
// Allow connections to be released and recreated more aggressively
|
||||
// to avoid stale connections building up
|
||||
allowExitOnIdle: false,
|
||||
// Disable JIT compilation for this connection. Our hot-path queries
|
||||
// (e.g. resource-by-domain lookups) join many tables but only ever
|
||||
// return a handful of rows. When planner row estimates drift (e.g.
|
||||
// due to autovacuum lag under write-heavy load), Postgres decides
|
||||
// these plans are expensive enough to JIT-compile, which can add
|
||||
// multiple seconds of pure compilation overhead per query and
|
||||
// saturate the connection pool. JIT never pays off for these
|
||||
// short-lived OLTP queries, so it's disabled outright rather than
|
||||
// relying on statistics staying fresh.
|
||||
options: "-c jit=off"
|
||||
allowExitOnIdle: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +232,6 @@ export const launcherViews = pgTable("launcherViews", {
|
||||
}),
|
||||
name: varchar("name").notNull(),
|
||||
config: text("config").notNull(),
|
||||
isDefault: boolean("isDefault").notNull().default(false),
|
||||
createdAt: varchar("createdAt").notNull(),
|
||||
updatedAt: varchar("updatedAt").notNull()
|
||||
});
|
||||
|
||||
@@ -233,9 +233,6 @@ export const launcherViews = sqliteTable("launcherViews", {
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
config: text("config").notNull(),
|
||||
isDefault: integer("isDefault", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
@@ -27,12 +27,6 @@ export async function getFeatureDisplayName(
|
||||
return "Remote Exit Nodes";
|
||||
case LimitId.ORGANIZATIONS:
|
||||
return "Organizations";
|
||||
case LimitId.PUBLIC_RESOURCES:
|
||||
return "Public Resources";
|
||||
case LimitId.PRIVATE_RESOURCES:
|
||||
return "Private Resources";
|
||||
case LimitId.MACHINE_CLIENTS:
|
||||
return "Machine Clients";
|
||||
case LimitId.TIER1:
|
||||
return "Home Lab";
|
||||
default:
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import logger from "@server/logger";
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
const BASE_DELAY_MS = 50;
|
||||
|
||||
/**
|
||||
* Detect transient errors that are safe to retry (connection drops, deadlocks,
|
||||
* serialization failures). 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.
|
||||
*/
|
||||
export function isTransientError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
const message = (error.message || "").toLowerCase();
|
||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
||||
const code = error.code || error.cause?.code || "";
|
||||
|
||||
// Connection timeout / terminated
|
||||
if (
|
||||
message.includes("connection timeout") ||
|
||||
message.includes("connection terminated") ||
|
||||
message.includes("timeout exceeded when trying to connect") ||
|
||||
causeMessage.includes("connection terminated unexpectedly") ||
|
||||
causeMessage.includes("connection timeout")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
||||
if (code === "40P01" || message.includes("deadlock")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PostgreSQL serialization failure
|
||||
if (code === "40001") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||
if (
|
||||
code === "ECONNRESET" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "EPIPE" ||
|
||||
code === "ETIMEDOUT"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple retry wrapper with exponential backoff for transient errors
|
||||
* (deadlocks, connection timeouts, unexpected disconnects).
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
context: string,
|
||||
maxRetries: number = MAX_RETRIES,
|
||||
baseDelayMs: number = BASE_DELAY_MS
|
||||
): Promise<T> {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error: any) {
|
||||
if (isTransientError(error) && attempt < maxRetries) {
|
||||
attempt++;
|
||||
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
|
||||
const jitter = Math.random() * baseDelay;
|
||||
const delay = baseDelay + jitter;
|
||||
logger.warn(
|
||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||
{ code: error?.code ?? error?.cause?.code }
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,30 @@
|
||||
import { db, exitNodes, Transaction } from "@server/db";
|
||||
import { db, exitNodes } from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
|
||||
/**
|
||||
* Reserves the next available exit node subnet.
|
||||
*
|
||||
* Exit node subnets must never overlap with one another - regardless of
|
||||
* which org(s) they belong to - since HA exit nodes can end up routing for
|
||||
* the same org. This acquires a lock that the caller MUST release (via the
|
||||
* returned `release`) only after the chosen address has been durably
|
||||
* persisted (e.g. after the enclosing transaction commits), otherwise
|
||||
* concurrent callers can race and pick the same subnet.
|
||||
*/
|
||||
export async function getNextAvailableSubnet(
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||
const lockKey = "exit-node-subnet-allocation";
|
||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||
if (!acquired) {
|
||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||
export async function getNextAvailableSubnet(): Promise<string> {
|
||||
// Get all existing subnets from routes table
|
||||
const existingAddresses = await db
|
||||
.select({
|
||||
address: exitNodes.address
|
||||
})
|
||||
.from(exitNodes);
|
||||
|
||||
const addresses = existingAddresses.map((a) => a.address);
|
||||
let subnet = findNextAvailableCidr(
|
||||
addresses,
|
||||
config.getRawConfig().gerbil.block_size,
|
||||
config.getRawConfig().gerbil.subnet_group
|
||||
);
|
||||
if (!subnet) {
|
||||
throw new Error("No available subnets remaining in space");
|
||||
}
|
||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
||||
|
||||
try {
|
||||
// Get all existing subnets from routes table
|
||||
const existingAddresses = await trx
|
||||
.select({
|
||||
address: exitNodes.address
|
||||
})
|
||||
.from(exitNodes);
|
||||
|
||||
const addresses = existingAddresses.map((a) => a.address);
|
||||
let subnet = findNextAvailableCidr(
|
||||
addresses,
|
||||
config.getRawConfig().gerbil.block_size,
|
||||
config.getRawConfig().gerbil.subnet_group
|
||||
);
|
||||
if (!subnet) {
|
||||
throw new Error("No available subnets remaining in space");
|
||||
}
|
||||
|
||||
// replace the last octet with 1
|
||||
subnet =
|
||||
subnet.split(".").slice(0, 3).join(".") +
|
||||
".1" +
|
||||
"/" +
|
||||
subnet.split("/")[1];
|
||||
return { value: subnet, release };
|
||||
} catch (e) {
|
||||
await release();
|
||||
throw e;
|
||||
}
|
||||
// replace the last octet with 1
|
||||
subnet =
|
||||
subnet.split(".").slice(0, 3).join(".") +
|
||||
".1" +
|
||||
"/" +
|
||||
subnet.split("/")[1];
|
||||
return subnet;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
} from "@server/routers/client/targets";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
|
||||
import { withRetry, isTransientError } from "@server/lib/dbRetry";
|
||||
import {
|
||||
checkOrgRebuildRateLimit,
|
||||
decrementOrgRebuildCount,
|
||||
@@ -286,20 +285,10 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
) {
|
||||
await incrementOrgRebuildCount(siteResource.orgId);
|
||||
try {
|
||||
// The whole locked rebuild is idempotent (it diffs full expected vs.
|
||||
// actual state each time), so on a transient DB error it's safe to
|
||||
// retry the entire thing rather than just the failed query.
|
||||
return await withRetry(
|
||||
() =>
|
||||
lockManager.withLock(
|
||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||
() =>
|
||||
rebuildClientAssociationsFromSiteResourceImpl(
|
||||
siteResource
|
||||
),
|
||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||
),
|
||||
`rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}`
|
||||
return await lockManager.withLock(
|
||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource),
|
||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (
|
||||
@@ -315,17 +304,6 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
});
|
||||
return { mergedAllClients: [] };
|
||||
}
|
||||
if (isTransientError(err)) {
|
||||
logger.warn(
|
||||
`rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`,
|
||||
err
|
||||
);
|
||||
await rebuildQueue.enqueue({
|
||||
type: "site-resource",
|
||||
id: siteResource.siteResourceId
|
||||
});
|
||||
return { mergedAllClients: [] };
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await decrementOrgRebuildCount(siteResource.orgId);
|
||||
@@ -485,7 +463,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||
await trx
|
||||
.insert(clientSiteResourcesAssociationsCache)
|
||||
.values(clientSiteResourcesToInsert)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
||||
@@ -533,148 +510,121 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||
for (const site of sitesToProcess) {
|
||||
const siteId = site.siteId;
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
||||
);
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
||||
);
|
||||
|
||||
const existingClientSites = await trx
|
||||
.select({
|
||||
clientId: clientSitesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||
const existingClientSites = await trx
|
||||
.select({
|
||||
clientId: clientSitesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||
|
||||
const existingClientSiteIds = existingClientSites.map(
|
||||
(row) => row.clientId
|
||||
);
|
||||
const existingClientSiteIds = existingClientSites.map(
|
||||
(row) => row.clientId
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
|
||||
);
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
|
||||
);
|
||||
|
||||
// Get full client details for existing clients (needed for sending delete messages)
|
||||
const existingClients =
|
||||
existingClientSiteIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
inArray(clients.clientId, existingClientSiteIds)
|
||||
)
|
||||
: [];
|
||||
|
||||
const otherResourceClientIds =
|
||||
clientsFromOtherResourcesBySite.get(siteId) ??
|
||||
new Set<number>();
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
||||
);
|
||||
|
||||
// Expected clients from this resource are site-scoped: if this site is
|
||||
// no longer attached to the resource, the expected set is empty.
|
||||
const expectedClientIdsForSite = currentSiteIdSet.has(siteId)
|
||||
? mergedAllClientIds
|
||||
// Get full client details for existing clients (needed for sending delete messages)
|
||||
const existingClients =
|
||||
existingClientSiteIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(inArray(clients.clientId, existingClientSiteIds))
|
||||
: [];
|
||||
|
||||
// Note: we deliberately do NOT exclude clients covered by another
|
||||
// site resource here (unlike clientSitesToRemove below). Doing so
|
||||
// previously caused a permanent gap: if resource A saw resource B's
|
||||
// cache row and skipped adding (assuming B would maintain it), and
|
||||
// B's own rebuild made the same assumption about A, the site-level
|
||||
// row could end up never inserted by anyone even though both
|
||||
// resources' client associations were otherwise correct.
|
||||
// onConflictDoNothing makes a redundant insert harmless, so there's
|
||||
// no correctness reason to skip here.
|
||||
const clientSitesToAdd = expectedClientIdsForSite.filter(
|
||||
(clientId) => !existingClientSiteIds.includes(clientId)
|
||||
);
|
||||
const otherResourceClientIds =
|
||||
clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
|
||||
|
||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||
clientId,
|
||||
siteId
|
||||
}));
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
||||
);
|
||||
|
||||
// Expected clients from this resource are site-scoped: if this site is
|
||||
// no longer attached to the resource, the expected set is empty.
|
||||
const expectedClientIdsForSite = currentSiteIdSet.has(siteId)
|
||||
? mergedAllClientIds
|
||||
: [];
|
||||
|
||||
const clientSitesToAdd = expectedClientIdsForSite.filter(
|
||||
(clientId) =>
|
||||
!existingClientSiteIds.includes(clientId) &&
|
||||
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
|
||||
);
|
||||
|
||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||
clientId,
|
||||
siteId
|
||||
}));
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSitesToInsert.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
|
||||
);
|
||||
|
||||
if (clientSitesToInsert.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
|
||||
);
|
||||
await trx
|
||||
.insert(clientSitesAssociationsCache)
|
||||
.values(clientSitesToInsert)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
|
||||
);
|
||||
}
|
||||
|
||||
// Now remove any client-site associations that should no longer exist
|
||||
const clientSitesToRemove = existingClientSiteIds.filter(
|
||||
(clientId) =>
|
||||
!expectedClientIdsForSite.includes(clientId) &&
|
||||
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
|
||||
);
|
||||
|
||||
await trx
|
||||
.insert(clientSitesAssociationsCache)
|
||||
.values(clientSitesToInsert)
|
||||
.returning();
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
||||
);
|
||||
|
||||
if (clientSitesToRemove.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
|
||||
);
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSitesAssociationsCache.siteId, siteId),
|
||||
inArray(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clientSitesToRemove
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Now handle the messages to add/remove peers on both the newt and olm sides
|
||||
await handleMessagesForSiteClients(
|
||||
site,
|
||||
siteId,
|
||||
mergedAllClients,
|
||||
existingClients,
|
||||
clientSitesToAdd,
|
||||
clientSitesToRemove,
|
||||
trx
|
||||
} else {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
|
||||
);
|
||||
} catch (err) {
|
||||
// Don't let a failure on one site abort processing of every
|
||||
// other site queued after it in this run. Since we're not
|
||||
// re-throwing, the outer wrapper's retry/requeue logic never
|
||||
// sees this failure, so explicitly queue this resource for a
|
||||
// follow-up pass to reconcile whatever this site didn't get to.
|
||||
logger.error(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`,
|
||||
err
|
||||
);
|
||||
await rebuildQueue.enqueue({
|
||||
type: "site-resource",
|
||||
id: siteResource.siteResourceId
|
||||
});
|
||||
}
|
||||
|
||||
// Now remove any client-site associations that should no longer exist
|
||||
const clientSitesToRemove = existingClientSiteIds.filter(
|
||||
(clientId) =>
|
||||
!expectedClientIdsForSite.includes(clientId) &&
|
||||
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSitesToRemove.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
|
||||
);
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSitesAssociationsCache.siteId, siteId),
|
||||
inArray(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clientSitesToRemove
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Now handle the messages to add/remove peers on both the newt and olm sides
|
||||
await handleMessagesForSiteClients(
|
||||
site,
|
||||
siteId,
|
||||
mergedAllClients,
|
||||
existingClients,
|
||||
clientSitesToAdd,
|
||||
clientSitesToRemove,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
// Handle subnet proxy target updates for the resource associations
|
||||
@@ -707,7 +657,7 @@ async function handleMessagesForSiteClients(
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
if (!site.exitNodeId) {
|
||||
logger.debug(
|
||||
logger.warn(
|
||||
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
|
||||
);
|
||||
return;
|
||||
@@ -721,14 +671,14 @@ async function handleMessagesForSiteClients(
|
||||
.limit(1);
|
||||
|
||||
if (!exitNode) {
|
||||
logger.debug(
|
||||
logger.warn(
|
||||
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!site.publicKey) {
|
||||
logger.debug(
|
||||
logger.warn(
|
||||
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
|
||||
);
|
||||
return;
|
||||
@@ -742,7 +692,7 @@ async function handleMessagesForSiteClients(
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
if (!newt) {
|
||||
logger.debug(
|
||||
logger.warn(
|
||||
`Newt not found for site ${siteId} so cannot add peers to clients`
|
||||
);
|
||||
return;
|
||||
@@ -967,7 +917,7 @@ export async function updateClientSiteDestinations(
|
||||
|
||||
for (const site of sitesData) {
|
||||
if (!site.sites.subnet) {
|
||||
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
|
||||
logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1706,17 +1656,10 @@ export async function rebuildClientAssociationsFromClient(
|
||||
await incrementOrgRebuildCount(client.orgId);
|
||||
try {
|
||||
const trx = primaryDb;
|
||||
// The whole locked rebuild is idempotent (it diffs full expected vs.
|
||||
// actual state each time), so on a transient DB error it's safe to
|
||||
// retry the entire thing rather than just the failed query.
|
||||
return await withRetry(
|
||||
() =>
|
||||
lockManager.withLock(
|
||||
`rebuild-client-associations:client:${client.clientId}`,
|
||||
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||
),
|
||||
`rebuildClientAssociationsFromClient:${client.clientId}`
|
||||
return await lockManager.withLock(
|
||||
`rebuild-client-associations:client:${client.clientId}`,
|
||||
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (
|
||||
@@ -1732,17 +1675,6 @@ export async function rebuildClientAssociationsFromClient(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isTransientError(err)) {
|
||||
logger.warn(
|
||||
`rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`,
|
||||
err
|
||||
);
|
||||
await rebuildQueue.enqueue({
|
||||
type: "client",
|
||||
id: client.clientId
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await decrementOrgRebuildCount(client.orgId);
|
||||
@@ -1894,15 +1826,12 @@ async function rebuildClientAssociationsFromClientImpl(
|
||||
|
||||
// Insert new associations
|
||||
if (resourcesToAdd.length > 0) {
|
||||
await trx
|
||||
.insert(clientSiteResourcesAssociationsCache)
|
||||
.values(
|
||||
resourcesToAdd.map((siteResourceId) => ({
|
||||
clientId: client.clientId,
|
||||
siteResourceId
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await trx.insert(clientSiteResourcesAssociationsCache).values(
|
||||
resourcesToAdd.map((siteResourceId) => ({
|
||||
clientId: client.clientId,
|
||||
siteResourceId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Remove old associations
|
||||
@@ -1940,15 +1869,12 @@ async function rebuildClientAssociationsFromClientImpl(
|
||||
|
||||
// Insert new site associations
|
||||
if (sitesToAdd.length > 0) {
|
||||
await trx
|
||||
.insert(clientSitesAssociationsCache)
|
||||
.values(
|
||||
sitesToAdd.map((siteId) => ({
|
||||
clientId: client.clientId,
|
||||
siteId
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
await trx.insert(clientSitesAssociationsCache).values(
|
||||
sitesToAdd.map((siteId) => ({
|
||||
clientId: client.clientId,
|
||||
siteId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Remove old site associations
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import logger from "@server/logger";
|
||||
import { isTransientError } from "@server/lib/dbRetry";
|
||||
|
||||
export type RebuildJobType = "site-resource" | "client";
|
||||
|
||||
export interface RebuildJob {
|
||||
type: RebuildJobType;
|
||||
id: number;
|
||||
// Number of times this job has already been re-queued after a transient
|
||||
// failure. Absent/0 means it has not failed yet.
|
||||
attempt?: number;
|
||||
}
|
||||
|
||||
export interface RebuildJobHandlers {
|
||||
@@ -28,10 +24,6 @@ export interface RebuildQueueManager {
|
||||
// retried shortly after against fresh DB state.
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const BATCH_SIZE = 5;
|
||||
// A job that fails with a transient DB error gets re-queued with backoff
|
||||
// instead of being dropped, up to this many times.
|
||||
const MAX_JOB_ATTEMPTS = 5;
|
||||
const JOB_RETRY_BASE_DELAY_MS = 1000;
|
||||
|
||||
function dedupeKey(job: RebuildJob): string {
|
||||
return `${job.type}:${job.id}`;
|
||||
@@ -114,29 +106,10 @@ class InMemoryRebuildQueue implements RebuildQueueManager {
|
||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
||||
);
|
||||
} catch (err) {
|
||||
const attempt = (job.attempt ?? 0) + 1;
|
||||
if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) {
|
||||
const delay =
|
||||
JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
||||
logger.warn(
|
||||
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
|
||||
err
|
||||
);
|
||||
setTimeout(() => {
|
||||
this.enqueue({ ...job, attempt }).catch(
|
||||
(enqueueErr) =>
|
||||
logger.error(
|
||||
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
|
||||
enqueueErr
|
||||
)
|
||||
);
|
||||
}, delay);
|
||||
} else {
|
||||
logger.error(
|
||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
logger.error(
|
||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { redis } from "#private/lib/redis";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10;
|
||||
export const ORG_REBUILD_CONCURRENCY_LIMIT = 5;
|
||||
|
||||
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
|
||||
// server process dies while holding a rebuild, this ensures the counter key
|
||||
|
||||
@@ -14,16 +14,12 @@
|
||||
import { redis } from "#private/lib/redis";
|
||||
import { lockManager } from "#private/lib/lock";
|
||||
import logger from "@server/logger";
|
||||
import { isTransientError } from "@server/lib/dbRetry";
|
||||
|
||||
export type RebuildJobType = "site-resource" | "client";
|
||||
|
||||
export interface RebuildJob {
|
||||
type: RebuildJobType;
|
||||
id: number;
|
||||
// Number of times this job has already been re-queued after a transient
|
||||
// failure. Absent/0 means it has not failed yet.
|
||||
attempt?: number;
|
||||
}
|
||||
|
||||
export interface RebuildJobHandlers {
|
||||
@@ -47,11 +43,6 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
|
||||
// A job that fails with a transient DB error gets re-queued with backoff
|
||||
// instead of being dropped, up to this many times.
|
||||
const MAX_JOB_ATTEMPTS = 5;
|
||||
const JOB_RETRY_BASE_DELAY_MS = 1000;
|
||||
|
||||
class RedisRebuildQueue {
|
||||
private processingStarted = false;
|
||||
|
||||
@@ -189,33 +180,10 @@ class RedisRebuildQueue {
|
||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
||||
);
|
||||
} catch (err) {
|
||||
const attempt = (job.attempt ?? 0) + 1;
|
||||
if (
|
||||
isTransientError(err) &&
|
||||
attempt <= MAX_JOB_ATTEMPTS
|
||||
) {
|
||||
const delay =
|
||||
JOB_RETRY_BASE_DELAY_MS *
|
||||
Math.pow(2, attempt - 1);
|
||||
logger.warn(
|
||||
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
|
||||
err
|
||||
);
|
||||
setTimeout(() => {
|
||||
this.enqueue({ ...job, attempt }).catch(
|
||||
(enqueueErr) =>
|
||||
logger.error(
|
||||
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
|
||||
enqueueErr
|
||||
)
|
||||
);
|
||||
}, delay);
|
||||
} else {
|
||||
logger.error(
|
||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
logger.error(
|
||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -894,19 +894,6 @@ class RegionalRedisManager {
|
||||
return opts;
|
||||
}
|
||||
|
||||
// The regional Redis StatefulSet's "redis" service pins to pod redis-0
|
||||
// (primary). The replica (redis-1) is only reachable through the
|
||||
// per-pod headless service: <svc>.<namespace>.svc.cluster.local ->
|
||||
// redis-1.redis-headless.<namespace>.svc.cluster.local. Returns null
|
||||
// if the configured host doesn't match that pattern (e.g. local dev),
|
||||
// in which case callers should fall back to the primary for reads.
|
||||
private getReplicaHost(primaryHost: string): string | null {
|
||||
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
|
||||
if (!match) return null;
|
||||
const namespace = match[1];
|
||||
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
|
||||
}
|
||||
|
||||
private initializeClients(): void {
|
||||
const cfg = this.getConfig();
|
||||
const baseOpts = {
|
||||
@@ -920,42 +907,35 @@ class RegionalRedisManager {
|
||||
|
||||
try {
|
||||
this.writeClient = new Redis(baseOpts);
|
||||
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
||||
this.readClient = new Redis({
|
||||
...baseOpts,
|
||||
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
||||
// Derive replica hostname from the headless service pattern:
|
||||
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
||||
// If it doesn't look like a k8s service, just use the same host
|
||||
return h + rest;
|
||||
})
|
||||
});
|
||||
|
||||
const replicaHost = this.getReplicaHost(cfg.host!);
|
||||
this.readClient = replicaHost
|
||||
? new Redis({ ...baseOpts, host: replicaHost })
|
||||
: this.writeClient;
|
||||
// For simplicity use same host for both; callers can always read from primary
|
||||
// The real replica routing is handled by the StatefulSet headless service
|
||||
this.readClient = this.writeClient;
|
||||
|
||||
this.writeClient.on("ready", () => {
|
||||
logger.info("Regional Redis write client ready");
|
||||
logger.info("Regional Redis client ready");
|
||||
this.isHealthy = true;
|
||||
});
|
||||
this.writeClient.on("error", (err) => {
|
||||
logger.error("Regional Redis write client error:", err);
|
||||
logger.error("Regional Redis client error:", err);
|
||||
this.isHealthy = false;
|
||||
});
|
||||
this.writeClient.on("reconnecting", () => {
|
||||
logger.info("Regional Redis write client reconnecting...");
|
||||
logger.info("Regional Redis client reconnecting...");
|
||||
this.isHealthy = false;
|
||||
});
|
||||
|
||||
if (this.readClient !== this.writeClient) {
|
||||
this.readClient.on("ready", () => {
|
||||
logger.info("Regional Redis read client ready");
|
||||
});
|
||||
this.readClient.on("error", (err) => {
|
||||
logger.error("Regional Redis read client error:", err);
|
||||
});
|
||||
this.readClient.on("reconnecting", () => {
|
||||
logger.info("Regional Redis read client reconnecting...");
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
replicaHost
|
||||
? `Regional Redis client initialized (reads routed to replica ${replicaHost})`
|
||||
: "Regional Redis client initialized (no replica resolvable, reads routed to primary)"
|
||||
);
|
||||
logger.info("Regional Redis client initialized");
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize regional Redis client:", error);
|
||||
this.isEnabled = false;
|
||||
@@ -1061,14 +1041,11 @@ class RegionalRedisManager {
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
try {
|
||||
if (this.readClient && this.readClient !== this.writeClient) {
|
||||
await this.readClient.quit();
|
||||
}
|
||||
this.readClient = null;
|
||||
if (this.writeClient) {
|
||||
await this.writeClient.quit();
|
||||
this.writeClient = null;
|
||||
}
|
||||
this.readClient = null;
|
||||
logger.info("Regional Redis client disconnected");
|
||||
} catch (error) {
|
||||
logger.error("Error disconnecting regional Redis client:", error);
|
||||
|
||||
@@ -104,18 +104,6 @@ export async function getOrgUsage(
|
||||
orgId,
|
||||
LimitId.ORGANIZATIONS
|
||||
);
|
||||
const publicResources = await usageService.getUsage(
|
||||
orgId,
|
||||
LimitId.PUBLIC_RESOURCES
|
||||
);
|
||||
const privateResources = await usageService.getUsage(
|
||||
orgId,
|
||||
LimitId.PRIVATE_RESOURCES
|
||||
);
|
||||
const machineClients = await usageService.getUsage(
|
||||
orgId,
|
||||
LimitId.MACHINE_CLIENTS
|
||||
);
|
||||
// const egressData = await usageService.getUsage(
|
||||
// orgId,
|
||||
// FeatureId.EGRESS_DATA_MB
|
||||
@@ -139,15 +127,6 @@ export async function getOrgUsage(
|
||||
if (organizations) {
|
||||
usageData.push(organizations);
|
||||
}
|
||||
if (publicResources) {
|
||||
usageData.push(publicResources);
|
||||
}
|
||||
if (privateResources) {
|
||||
usageData.push(privateResources);
|
||||
}
|
||||
if (machineClients) {
|
||||
usageData.push(machineClients);
|
||||
}
|
||||
|
||||
const orgLimits = await db
|
||||
.select()
|
||||
|
||||
@@ -29,41 +29,37 @@ export async function createExitNode(
|
||||
.where(eq(exitNodes.publicKey, publicKey));
|
||||
let exitNode: ExitNode;
|
||||
if (!exitNodeQuery) {
|
||||
const { value: address, release } = await getNextAvailableSubnet();
|
||||
try {
|
||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||
// const listenPort = await getNextAvailablePort();
|
||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||
let subEndpoint = "";
|
||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||
}
|
||||
|
||||
const exitNodeName =
|
||||
config.getRawConfig().gerbil.exit_node_name ||
|
||||
`Exit Node ${publicKey.slice(0, 8)}`;
|
||||
|
||||
// create a new exit node
|
||||
[exitNode] = await db
|
||||
.insert(exitNodes)
|
||||
.values({
|
||||
publicKey,
|
||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||
address,
|
||||
listenPort,
|
||||
online: true,
|
||||
reachableAt,
|
||||
name: exitNodeName
|
||||
})
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
logger.info(
|
||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||
);
|
||||
} finally {
|
||||
await release();
|
||||
const address = await getNextAvailableSubnet();
|
||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||
// const listenPort = await getNextAvailablePort();
|
||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||
let subEndpoint = "";
|
||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||
}
|
||||
|
||||
const exitNodeName =
|
||||
config.getRawConfig().gerbil.exit_node_name ||
|
||||
`Exit Node ${publicKey.slice(0, 8)}`;
|
||||
|
||||
// create a new exit node
|
||||
[exitNode] = await db
|
||||
.insert(exitNodes)
|
||||
.values({
|
||||
publicKey,
|
||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||
address,
|
||||
listenPort,
|
||||
online: true,
|
||||
reachableAt,
|
||||
name: exitNodeName
|
||||
})
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
logger.info(
|
||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||
);
|
||||
} else {
|
||||
// update the reachable at
|
||||
[exitNode] = await db
|
||||
|
||||
@@ -114,6 +114,8 @@ export async function createRemoteExitNode(
|
||||
}
|
||||
|
||||
const secretHash = await hashPassword(secret);
|
||||
// const address = await getNextAvailableSubnet();
|
||||
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
|
||||
|
||||
const [existingRemoteExitNode] = await db
|
||||
.select()
|
||||
@@ -189,106 +191,89 @@ export async function createRemoteExitNode(
|
||||
);
|
||||
}
|
||||
|
||||
// If this remote exit node isn't already backing an exit node in
|
||||
// another org, we're about to create a brand new one. Reserve a
|
||||
// subnet for it up front so the allocation lock is held across the
|
||||
// whole insert - this guarantees exit node subnets never overlap,
|
||||
// even under concurrent creation, which matters for HA setups.
|
||||
let releaseSubnetLock: (() => Promise<void>) | null = null;
|
||||
let newExitNodeAddress: string | null = null;
|
||||
if (!existingExitNode) {
|
||||
const { value, release } = await getNextAvailableSubnet();
|
||||
newExitNodeAddress = value;
|
||||
releaseSubnetLock = release;
|
||||
}
|
||||
await db.transaction(async (trx) => {
|
||||
if (!existingExitNode) {
|
||||
const [res] = await trx
|
||||
.insert(exitNodes)
|
||||
.values({
|
||||
name: remoteExitNodeId,
|
||||
address,
|
||||
endpoint: "",
|
||||
publicKey: "",
|
||||
listenPort: 0,
|
||||
online: false,
|
||||
type: "remoteExitNode"
|
||||
})
|
||||
.returning();
|
||||
existingExitNode = res;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
if (!existingExitNode) {
|
||||
const [res] = await trx
|
||||
.insert(exitNodes)
|
||||
.values({
|
||||
name: remoteExitNodeId,
|
||||
address: newExitNodeAddress!,
|
||||
endpoint: "",
|
||||
publicKey: "",
|
||||
listenPort: 0,
|
||||
online: false,
|
||||
type: "remoteExitNode"
|
||||
})
|
||||
.returning();
|
||||
existingExitNode = res;
|
||||
}
|
||||
|
||||
if (!existingRemoteExitNode) {
|
||||
await trx.insert(remoteExitNodes).values({
|
||||
remoteExitNodeId: remoteExitNodeId,
|
||||
secretHash,
|
||||
dateCreated: moment().toISOString(),
|
||||
if (!existingRemoteExitNode) {
|
||||
await trx.insert(remoteExitNodes).values({
|
||||
remoteExitNodeId: remoteExitNodeId,
|
||||
secretHash,
|
||||
dateCreated: moment().toISOString(),
|
||||
exitNodeId: existingExitNode.exitNodeId
|
||||
});
|
||||
} else {
|
||||
// update the existing remote exit node
|
||||
await trx
|
||||
.update(remoteExitNodes)
|
||||
.set({
|
||||
exitNodeId: existingExitNode.exitNodeId
|
||||
});
|
||||
} else {
|
||||
// update the existing remote exit node
|
||||
await trx
|
||||
.update(remoteExitNodes)
|
||||
.set({
|
||||
exitNodeId: existingExitNode.exitNodeId
|
||||
})
|
||||
.where(
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
remoteExitNodes.remoteExitNodeId,
|
||||
existingRemoteExitNode.remoteExitNodeId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!existingExitNodeOrg) {
|
||||
await trx.insert(exitNodeOrgs).values({
|
||||
exitNodeId: existingExitNode.exitNodeId,
|
||||
orgId: orgId
|
||||
});
|
||||
}
|
||||
|
||||
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
|
||||
if (org.billingOrgId) {
|
||||
const otherBillingOrgs = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(
|
||||
and(
|
||||
eq(orgs.billingOrgId, org.billingOrgId),
|
||||
ne(orgs.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||
|
||||
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
remoteExitNodes.remoteExitNodeId,
|
||||
existingRemoteExitNode.remoteExitNodeId
|
||||
)
|
||||
);
|
||||
exitNodeOrgs.exitNodeId,
|
||||
existingExitNode.exitNodeId
|
||||
),
|
||||
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
||||
await usageService.add(
|
||||
orgId,
|
||||
LimitId.REMOTE_EXIT_NODES,
|
||||
1,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
if (!existingExitNodeOrg) {
|
||||
await trx.insert(exitNodeOrgs).values({
|
||||
exitNodeId: existingExitNode.exitNodeId,
|
||||
orgId: orgId
|
||||
});
|
||||
}
|
||||
|
||||
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
|
||||
if (org.billingOrgId) {
|
||||
const otherBillingOrgs = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(
|
||||
and(
|
||||
eq(orgs.billingOrgId, org.billingOrgId),
|
||||
ne(orgs.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||
|
||||
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
exitNodeOrgs.exitNodeId,
|
||||
existingExitNode.exitNodeId
|
||||
),
|
||||
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
||||
await usageService.add(
|
||||
orgId,
|
||||
LimitId.REMOTE_EXIT_NODES,
|
||||
1,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await releaseSubnetLock?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const token = generateSessionToken();
|
||||
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
||||
|
||||
@@ -327,14 +327,6 @@ authenticated.get(
|
||||
siteResource.listAllSiteResourcesByOrg
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/site-resource/:siteResourceId",
|
||||
verifyOrgAccess,
|
||||
verifySiteResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.getSiteResource),
|
||||
siteResource.getSiteResource
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/site-resource/:siteResourceId",
|
||||
verifySiteResourceAccess,
|
||||
@@ -478,12 +470,6 @@ authenticated.get(
|
||||
launcher.listLauncherGroups
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/scale",
|
||||
verifyOrgAccess,
|
||||
launcher.listLauncherScale
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/resources",
|
||||
verifyOrgAccess,
|
||||
@@ -508,12 +494,6 @@ authenticated.get(
|
||||
launcher.listLauncherViews
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/launcher/invalidate-cache",
|
||||
verifyOrgAccess,
|
||||
launcher.invalidateLauncherCache
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/launcher/views",
|
||||
verifyOrgAccess,
|
||||
@@ -526,18 +506,6 @@ authenticated.put(
|
||||
launcher.updateLauncherView
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/launcher/default-view",
|
||||
verifyOrgAccess,
|
||||
launcher.upsertLauncherDefaultView
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/launcher/default-view",
|
||||
verifyOrgAccess,
|
||||
launcher.deleteLauncherDefaultView
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/launcher/views/:viewId",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -13,41 +13,37 @@ export async function createExitNode(
|
||||
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
|
||||
let exitNode: ExitNode;
|
||||
if (!exitNodeQuery) {
|
||||
const { value: address, release } = await getNextAvailableSubnet();
|
||||
try {
|
||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||
// const listenPort = await getNextAvailablePort();
|
||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||
let subEndpoint = "";
|
||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||
}
|
||||
|
||||
const exitNodeName =
|
||||
config.getRawConfig().gerbil.exit_node_name ||
|
||||
`Exit Node ${publicKey.slice(0, 8)}`;
|
||||
|
||||
// create a new exit node
|
||||
[exitNode] = await db
|
||||
.insert(exitNodes)
|
||||
.values({
|
||||
publicKey,
|
||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||
address,
|
||||
online: true,
|
||||
listenPort,
|
||||
reachableAt,
|
||||
name: exitNodeName
|
||||
})
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
logger.info(
|
||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||
);
|
||||
} finally {
|
||||
await release();
|
||||
const address = await getNextAvailableSubnet();
|
||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||
// const listenPort = await getNextAvailablePort();
|
||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||
let subEndpoint = "";
|
||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||
}
|
||||
|
||||
const exitNodeName =
|
||||
config.getRawConfig().gerbil.exit_node_name ||
|
||||
`Exit Node ${publicKey.slice(0, 8)}`;
|
||||
|
||||
// create a new exit node
|
||||
[exitNode] = await db
|
||||
.insert(exitNodes)
|
||||
.values({
|
||||
publicKey,
|
||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||
address,
|
||||
online: true,
|
||||
listenPort,
|
||||
reachableAt,
|
||||
name: exitNodeName
|
||||
})
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
logger.info(
|
||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||
);
|
||||
} else {
|
||||
// update the existing exit node
|
||||
[exitNode] = await db
|
||||
|
||||
@@ -79,8 +79,7 @@ export async function createLauncherView(
|
||||
),
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
isOrgWide: created.userId == null,
|
||||
isDefault: created.isDefault
|
||||
isOrgWide: created.userId == null
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { response } from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||
import {
|
||||
deleteAllDefaultViewOverrides,
|
||||
deleteDefaultViewOverride
|
||||
} from "./launcherDefaultView";
|
||||
|
||||
const deleteLauncherDefaultViewBodySchema = z.strictObject({
|
||||
orgWide: z.boolean().optional().default(false),
|
||||
all: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export async function deleteLauncherDefaultView(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = req.userOrgId;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.all) {
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
ActionsEnum.createOrgWideLauncherView,
|
||||
req
|
||||
);
|
||||
if (!canManageOrgWide) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await deleteAllDefaultViewOverrides(orgId, userId);
|
||||
} else if (parsed.data.orgWide) {
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
ActionsEnum.createOrgWideLauncherView,
|
||||
req
|
||||
);
|
||||
if (!canManageOrgWide) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await deleteDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide: true
|
||||
});
|
||||
} else {
|
||||
await deleteDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide: false
|
||||
});
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher default view reset successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error resetting launcher default view:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,15 +46,6 @@ export async function deleteLauncherView(
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.isDefault) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"The default view cannot be deleted from here"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const isPersonalView = existing.userId === userId;
|
||||
const isOrgWideView = existing.userId == null;
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * from "./types";
|
||||
export { listLauncherGroups } from "./listLauncherGroups";
|
||||
export { listLauncherScale } from "./listLauncherScale";
|
||||
export { listLauncherResources } from "./listLauncherResources";
|
||||
export { listLauncherSites } from "./listLauncherSites";
|
||||
export { listLauncherLabels } from "./listLauncherLabels";
|
||||
@@ -8,6 +7,3 @@ export { listLauncherViews } from "./listLauncherViews";
|
||||
export { createLauncherView } from "./createLauncherView";
|
||||
export { updateLauncherView } from "./updateLauncherView";
|
||||
export { deleteLauncherView } from "./deleteLauncherView";
|
||||
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
|
||||
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
|
||||
export { invalidateLauncherCache } from "./invalidateLauncherCache";
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import { response } from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
|
||||
async function invalidateLauncherCacheForUser(
|
||||
orgId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const prefixes = [
|
||||
`launcherAccessibleIds:${orgId}:${userId}:`,
|
||||
`launcher:groups:${orgId}:${userId}:`,
|
||||
`launcher:results:${orgId}:${userId}:`,
|
||||
`launcher:scale:counts:${orgId}:${userId}:`
|
||||
];
|
||||
|
||||
const keys = (
|
||||
await Promise.all(
|
||||
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
|
||||
)
|
||||
).flat();
|
||||
|
||||
if (keys.length > 0) {
|
||||
await cache.del(keys);
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateLauncherCache(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = req.userOrgId;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
await invalidateLauncherCacheForUser(orgId, userId);
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher cache invalidated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error invalidating launcher cache:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { db, launcherViews } from "@server/db";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import {
|
||||
launcherViewConfigSchema,
|
||||
type LauncherDefaultViewOverrides,
|
||||
type LauncherViewConfig,
|
||||
type LauncherViewRecord
|
||||
} from "./types";
|
||||
|
||||
export function mapViewRow(
|
||||
row: typeof launcherViews.$inferSelect
|
||||
): LauncherViewRecord {
|
||||
return {
|
||||
viewId: row.viewId,
|
||||
orgId: row.orgId,
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isOrgWide: row.userId == null,
|
||||
isDefault: row.isDefault
|
||||
};
|
||||
}
|
||||
|
||||
export function extractDefaultViewOverrides(
|
||||
rows: Array<typeof launcherViews.$inferSelect>
|
||||
): LauncherDefaultViewOverrides {
|
||||
const overrideRows = rows.filter((row) => row.isDefault);
|
||||
|
||||
const personalRow = overrideRows.find((row) => row.userId !== null);
|
||||
const orgWideRow = overrideRows.find((row) => row.userId === null);
|
||||
|
||||
return {
|
||||
personal: personalRow ? mapViewRow(personalRow) : null,
|
||||
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
|
||||
};
|
||||
}
|
||||
|
||||
export function listVisibleLauncherViews(
|
||||
rows: Array<typeof launcherViews.$inferSelect>
|
||||
): LauncherViewRecord[] {
|
||||
return rows.filter((row) => !row.isDefault).map(mapViewRow);
|
||||
}
|
||||
|
||||
export async function findDefaultViewOverride(
|
||||
orgId: string,
|
||||
orgWide: boolean,
|
||||
userId: string
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(launcherViews)
|
||||
.where(
|
||||
and(
|
||||
eq(launcherViews.orgId, orgId),
|
||||
eq(launcherViews.isDefault, true),
|
||||
orgWide
|
||||
? isNull(launcherViews.userId)
|
||||
: eq(launcherViews.userId, userId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return existing ?? null;
|
||||
}
|
||||
|
||||
export async function upsertDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide,
|
||||
config
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
orgWide: boolean;
|
||||
config: LauncherViewConfig;
|
||||
}) {
|
||||
const now = moment().toISOString();
|
||||
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(launcherViews)
|
||||
.set({
|
||||
config: JSON.stringify(config),
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(launcherViews.viewId, existing.viewId))
|
||||
.returning();
|
||||
|
||||
return mapViewRow(updated);
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(launcherViews)
|
||||
.values({
|
||||
orgId,
|
||||
userId: orgWide ? null : userId,
|
||||
name: "",
|
||||
isDefault: true,
|
||||
config: JSON.stringify(config),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapViewRow(created);
|
||||
}
|
||||
|
||||
export async function deleteDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
orgWide: boolean;
|
||||
}) {
|
||||
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(launcherViews)
|
||||
.where(eq(launcherViews.viewId, existing.viewId));
|
||||
}
|
||||
|
||||
export async function deleteAllDefaultViewOverrides(
|
||||
orgId: string,
|
||||
userId: string
|
||||
) {
|
||||
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
|
||||
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { alias, db } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
exitNodes,
|
||||
labels,
|
||||
launcherViews,
|
||||
resourceLabels,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
rolePolicies,
|
||||
roleResources,
|
||||
@@ -32,17 +30,14 @@ import {
|
||||
inArray,
|
||||
isNull,
|
||||
like,
|
||||
not,
|
||||
or,
|
||||
sql,
|
||||
type SQL
|
||||
sql
|
||||
} from "drizzle-orm";
|
||||
import {
|
||||
formatPublicResourceAccess,
|
||||
formatSiteResourceAccess
|
||||
} from "./formatLauncherAccess";
|
||||
import {
|
||||
LAUNCHER_FLAT_GROUP_KEY,
|
||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
||||
LAUNCHER_UNLABELED_GROUP_KEY,
|
||||
type LauncherFilterListQuery,
|
||||
@@ -64,68 +59,6 @@ export type AccessibleIds = {
|
||||
};
|
||||
|
||||
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
|
||||
const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60;
|
||||
const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60;
|
||||
|
||||
type LauncherResourcesCacheEntry = {
|
||||
items: LauncherResource[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
type LauncherGroupsCacheEntry = {
|
||||
groups: LauncherGroup[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
function launcherListQueryHash(
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery,
|
||||
extra?: Record<string, string>
|
||||
) {
|
||||
const payload = JSON.stringify({
|
||||
roles: [...userRoleIds].sort((a, b) => a - b),
|
||||
query: query.query,
|
||||
groupBy: query.groupBy,
|
||||
siteIds: query.siteIds ?? "",
|
||||
labelIds: query.labelIds ?? "",
|
||||
sort_by: query.sort_by,
|
||||
order: query.order,
|
||||
...extra
|
||||
});
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function launcherResourcesQueryHash(
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery & { groupKey: string }
|
||||
) {
|
||||
return launcherListQueryHash(userRoleIds, query, {
|
||||
groupKey: query.groupKey
|
||||
});
|
||||
}
|
||||
|
||||
function launcherGroupsQueryHash(
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery
|
||||
) {
|
||||
return launcherListQueryHash(userRoleIds, query);
|
||||
}
|
||||
|
||||
function launcherResourcesCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
queryHash: string
|
||||
) {
|
||||
return `launcher:results:${orgId}:${userId}:${queryHash}`;
|
||||
}
|
||||
|
||||
function launcherGroupsCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
queryHash: string
|
||||
) {
|
||||
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
|
||||
}
|
||||
|
||||
function launcherAccessibleIdsCacheKey(
|
||||
orgId: string,
|
||||
@@ -136,48 +69,6 @@ function launcherAccessibleIdsCacheKey(
|
||||
return `launcherAccessibleIds:${orgId}:${userId}:${rolesKey}`;
|
||||
}
|
||||
|
||||
async function fetchOrgNonSsoPublicResourceIds(
|
||||
orgId: string
|
||||
): Promise<number[]> {
|
||||
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
||||
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
||||
|
||||
const effectiveSso = sql<boolean>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso}
|
||||
ELSE ${defaultPolicy.sso}
|
||||
END,
|
||||
false
|
||||
)
|
||||
`;
|
||||
|
||||
const rows = await db
|
||||
.select({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
sharedPolicy,
|
||||
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
|
||||
)
|
||||
.leftJoin(
|
||||
defaultPolicy,
|
||||
eq(
|
||||
defaultPolicy.resourcePolicyId,
|
||||
resources.defaultResourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true),
|
||||
eq(resources.blockAccess, false),
|
||||
not(effectiveSso)
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.resourceId);
|
||||
}
|
||||
|
||||
async function resolveAccessibleIdsUncached(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
@@ -189,8 +80,7 @@ async function resolveAccessibleIdsUncached(
|
||||
directPolicyResourceResults,
|
||||
rolePolicyResourceResults,
|
||||
directSiteResourceResults,
|
||||
roleSiteResourceResults,
|
||||
orgNonSsoPublicResources
|
||||
roleSiteResourceResults
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select({ resourceId: userResources.resourceId })
|
||||
@@ -259,8 +149,7 @@ async function resolveAccessibleIdsUncached(
|
||||
})
|
||||
.from(roleSiteResources)
|
||||
.where(inArray(roleSiteResources.roleId, userRoleIds))
|
||||
: Promise.resolve([]),
|
||||
fetchOrgNonSsoPublicResourceIds(orgId)
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -269,8 +158,7 @@ async function resolveAccessibleIdsUncached(
|
||||
...directResources.map((r) => r.resourceId),
|
||||
...roleResourceResults.map((r) => r.resourceId),
|
||||
...directPolicyResourceResults.map((r) => r.resourceId),
|
||||
...rolePolicyResourceResults.map((r) => r.resourceId),
|
||||
...orgNonSsoPublicResources
|
||||
...rolePolicyResourceResults.map((r) => r.resourceId)
|
||||
])
|
||||
),
|
||||
siteResourceIds: Array.from(
|
||||
@@ -306,21 +194,6 @@ function searchPattern(query: string) {
|
||||
return `%${query.trim()}%`;
|
||||
}
|
||||
|
||||
function combineOrConditions(
|
||||
...conditions: (SQL | undefined)[]
|
||||
): SQL | undefined {
|
||||
const parts = conditions.filter(
|
||||
(condition): condition is SQL => !!condition
|
||||
);
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return parts[0];
|
||||
}
|
||||
return or(...parts);
|
||||
}
|
||||
|
||||
function buildSearchConditionForPublic(
|
||||
query: string,
|
||||
labelsFeatureEnabled: boolean
|
||||
@@ -332,17 +205,7 @@ function buildSearchConditionForPublic(
|
||||
const queryList = [
|
||||
like(sql`LOWER(${resources.name})`, pattern),
|
||||
like(sql`LOWER(${resources.fullDomain})`, pattern),
|
||||
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
|
||||
inArray(
|
||||
resources.resourceId,
|
||||
db
|
||||
.select({ id: resources.resourceId })
|
||||
.from(resources)
|
||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
|
||||
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
|
||||
)
|
||||
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern)
|
||||
];
|
||||
|
||||
if (labelsFeatureEnabled) {
|
||||
@@ -375,11 +238,6 @@ function buildSearchConditionForSiteResource(
|
||||
const queryList = [
|
||||
like(sql`LOWER(${siteResources.name})`, pattern),
|
||||
like(sql`LOWER(${siteResources.destination})`, pattern),
|
||||
like(
|
||||
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
|
||||
pattern
|
||||
),
|
||||
like(sql`LOWER(${siteResources.scheme})`, pattern),
|
||||
like(sql`LOWER(${siteResources.alias})`, pattern),
|
||||
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
|
||||
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
|
||||
@@ -404,79 +262,6 @@ function buildSearchConditionForSiteResource(
|
||||
return or(...queryList);
|
||||
}
|
||||
|
||||
async function filterPublicResourceIdsByTextSearch(
|
||||
orgId: string,
|
||||
resourceIds: number[],
|
||||
query: string,
|
||||
labelsFeatureEnabled: boolean
|
||||
): Promise<number[]> {
|
||||
if (!query.trim() || resourceIds.length === 0) {
|
||||
return resourceIds;
|
||||
}
|
||||
|
||||
const textMatch = combineOrConditions(
|
||||
buildSearchConditionForPublic(query, labelsFeatureEnabled),
|
||||
buildSiteNameSearchCondition(query)
|
||||
);
|
||||
if (!textMatch) {
|
||||
return resourceIds;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.selectDistinct({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true),
|
||||
textMatch
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.resourceId);
|
||||
}
|
||||
|
||||
async function filterSiteResourceIdsByTextSearch(
|
||||
orgId: string,
|
||||
siteResourceIds: number[],
|
||||
query: string,
|
||||
labelsFeatureEnabled: boolean
|
||||
): Promise<number[]> {
|
||||
if (!query.trim() || siteResourceIds.length === 0) {
|
||||
return siteResourceIds;
|
||||
}
|
||||
|
||||
const textMatch = combineOrConditions(
|
||||
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
|
||||
buildSiteNameSearchCondition(query)
|
||||
);
|
||||
if (!textMatch) {
|
||||
return siteResourceIds;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
|
||||
.from(siteResources)
|
||||
.leftJoin(
|
||||
siteNetworks,
|
||||
eq(siteResources.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
textMatch
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.siteResourceId);
|
||||
}
|
||||
|
||||
async function labelsEnabled(orgId: string): Promise<boolean> {
|
||||
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
|
||||
}
|
||||
@@ -803,8 +588,9 @@ async function listSiteGroups(
|
||||
});
|
||||
|
||||
const total = groups.length;
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
groups,
|
||||
groups: groups.slice(offset, offset + query.pageSize),
|
||||
total
|
||||
};
|
||||
}
|
||||
@@ -1002,53 +788,26 @@ async function listLabelGroups(
|
||||
});
|
||||
|
||||
const total = groups.length;
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
groups,
|
||||
groups: groups.slice(offset, offset + query.pageSize),
|
||||
total
|
||||
};
|
||||
}
|
||||
|
||||
async function listLauncherGroupsForUserUncached(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery
|
||||
): Promise<LauncherGroupsCacheEntry> {
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
|
||||
if (query.groupBy === "label") {
|
||||
return listLabelGroups(orgId, accessible, query);
|
||||
}
|
||||
|
||||
return listSiteGroups(orgId, accessible, query);
|
||||
}
|
||||
|
||||
export async function listLauncherGroupsForUser(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery
|
||||
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
||||
const queryHash = launcherGroupsQueryHash(userRoleIds, query);
|
||||
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
|
||||
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
|
||||
let result = cached;
|
||||
if (!result) {
|
||||
result = await listLauncherGroupsForUserUncached(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds,
|
||||
query
|
||||
);
|
||||
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
|
||||
if (query.groupBy === "label") {
|
||||
return listLabelGroups(orgId, accessible, query);
|
||||
}
|
||||
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
groups: result.groups.slice(offset, offset + query.pageSize),
|
||||
total: result.total
|
||||
};
|
||||
return listSiteGroups(orgId, accessible, query);
|
||||
}
|
||||
|
||||
async function mapPublicResources(
|
||||
@@ -1259,6 +1018,26 @@ function filterResourcesByLabel(
|
||||
);
|
||||
}
|
||||
|
||||
function filterResourcesBySearch(
|
||||
items: LauncherResource[],
|
||||
query: string
|
||||
): LauncherResource[] {
|
||||
if (!query.trim()) {
|
||||
return items;
|
||||
}
|
||||
const pattern = query.trim().toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(pattern) ||
|
||||
item.accessDisplay.toLowerCase().includes(pattern) ||
|
||||
item.accessCopyValue.toLowerCase().includes(pattern) ||
|
||||
item.labels.some((label) =>
|
||||
label.name.toLowerCase().includes(pattern)
|
||||
) ||
|
||||
item.site?.name.toLowerCase().includes(pattern)
|
||||
);
|
||||
}
|
||||
|
||||
function sortLauncherResources(
|
||||
items: LauncherResource[],
|
||||
order: "asc" | "desc"
|
||||
@@ -1271,12 +1050,12 @@ function sortLauncherResources(
|
||||
});
|
||||
}
|
||||
|
||||
async function listLauncherResourcesForUserUncached(
|
||||
export async function listLauncherResourcesForUser(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery & { groupKey: string }
|
||||
): Promise<LauncherResourcesCacheEntry> {
|
||||
): Promise<{ resources: LauncherResource[]; total: number }> {
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
|
||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||
@@ -1349,27 +1128,6 @@ async function listLauncherResourcesForUserUncached(
|
||||
}
|
||||
}
|
||||
|
||||
const labelsFeatureEnabled = await labelsEnabled(orgId);
|
||||
|
||||
if (query.query.trim()) {
|
||||
if (filteredResourceIds.length > 0) {
|
||||
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
|
||||
orgId,
|
||||
filteredResourceIds,
|
||||
query.query,
|
||||
labelsFeatureEnabled
|
||||
);
|
||||
}
|
||||
if (filteredSiteResourceIds.length > 0) {
|
||||
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
|
||||
orgId,
|
||||
filteredSiteResourceIds,
|
||||
query.query,
|
||||
labelsFeatureEnabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const labelMaps = await fetchLabelsForResources(
|
||||
orgId,
|
||||
filteredResourceIds,
|
||||
@@ -1401,48 +1159,21 @@ async function listLauncherResourcesForUserUncached(
|
||||
]);
|
||||
|
||||
let items = [...publicItems, ...siteItems];
|
||||
items = filterResourcesBySearch(items, query.query);
|
||||
|
||||
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
|
||||
if (query.groupBy === "label") {
|
||||
items = filterResourcesByLabel(items, query.groupKey);
|
||||
} else if (query.groupBy === "site") {
|
||||
items = filterResourcesBySite(items, query.groupKey);
|
||||
}
|
||||
if (query.groupBy === "label") {
|
||||
items = filterResourcesByLabel(items, query.groupKey);
|
||||
} else if (query.groupBy === "site") {
|
||||
items = filterResourcesBySite(items, query.groupKey);
|
||||
}
|
||||
|
||||
items = sortLauncherResources(items, query.order);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLauncherResourcesForUser(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery & { groupKey: string }
|
||||
): Promise<{ resources: LauncherResource[]; total: number }> {
|
||||
const queryHash = launcherResourcesQueryHash(userRoleIds, query);
|
||||
const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash);
|
||||
const cached = await cache.get<LauncherResourcesCacheEntry>(cacheKey);
|
||||
|
||||
let result = cached;
|
||||
if (!result) {
|
||||
result = await listLauncherResourcesForUserUncached(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds,
|
||||
query
|
||||
);
|
||||
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
|
||||
}
|
||||
|
||||
const total = items.length;
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
resources: result.items.slice(offset, offset + query.pageSize),
|
||||
total: result.total
|
||||
resources: items.slice(offset, offset + query.pageSize),
|
||||
total
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import {
|
||||
listLauncherGroupsForUser,
|
||||
resolveAccessibleIds
|
||||
} from "./launcherResourceAccess";
|
||||
import {
|
||||
parseIdListParam,
|
||||
type LauncherScaleInfo,
|
||||
type LauncherScaleQuery
|
||||
} from "./types";
|
||||
|
||||
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
|
||||
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
|
||||
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
|
||||
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
|
||||
|
||||
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
|
||||
|
||||
type LauncherScaleCountsCacheEntry = {
|
||||
resourceCount: number;
|
||||
siteGroupCount: number;
|
||||
labelGroupCount: number;
|
||||
mode: LauncherScaleInfo["mode"];
|
||||
};
|
||||
|
||||
function launcherScaleCountsCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
roleIds: number[]
|
||||
) {
|
||||
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
|
||||
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
|
||||
}
|
||||
|
||||
function buildScaleCapabilities(
|
||||
counts: LauncherScaleCountsCacheEntry,
|
||||
query: LauncherScaleQuery
|
||||
): LauncherScaleInfo["capabilities"] {
|
||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||
const labelFilterIds = parseIdListParam(query.labelIds);
|
||||
|
||||
return {
|
||||
allowSiteGrouping:
|
||||
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
|
||||
(siteFilterIds.length > 0 &&
|
||||
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
|
||||
allowLabelGrouping:
|
||||
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
|
||||
(labelFilterIds.length > 0 &&
|
||||
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
|
||||
requireSearchOrFilter:
|
||||
counts.mode === "compact" &&
|
||||
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
|
||||
};
|
||||
}
|
||||
|
||||
async function getLauncherScaleCountsForUser(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[]
|
||||
): Promise<LauncherScaleCountsCacheEntry> {
|
||||
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
|
||||
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
const resourceCount =
|
||||
accessible.resourceIds.length + accessible.siteResourceIds.length;
|
||||
|
||||
const baselineQuery = {
|
||||
query: "",
|
||||
groupBy: "site" as const,
|
||||
siteIds: undefined,
|
||||
labelIds: undefined,
|
||||
sort_by: "name" as const,
|
||||
order: "asc" as const,
|
||||
page: 1,
|
||||
pageSize: 1
|
||||
};
|
||||
|
||||
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
|
||||
await Promise.all([
|
||||
listLauncherGroupsForUser(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds,
|
||||
baselineQuery
|
||||
),
|
||||
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
|
||||
...baselineQuery,
|
||||
groupBy: "label"
|
||||
})
|
||||
]);
|
||||
|
||||
const mode =
|
||||
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
|
||||
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
|
||||
? "full"
|
||||
: "compact";
|
||||
|
||||
const result: LauncherScaleCountsCacheEntry = {
|
||||
resourceCount,
|
||||
siteGroupCount,
|
||||
labelGroupCount,
|
||||
mode
|
||||
};
|
||||
|
||||
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getLauncherScaleForUser(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherScaleQuery
|
||||
): Promise<LauncherScaleInfo> {
|
||||
const counts = await getLauncherScaleCountsForUser(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds
|
||||
);
|
||||
|
||||
return {
|
||||
mode: counts.mode,
|
||||
resourceCount: counts.resourceCount,
|
||||
siteGroupCount: counts.siteGroupCount,
|
||||
labelGroupCount: counts.labelGroupCount,
|
||||
capabilities: buildScaleCapabilities(counts, query)
|
||||
};
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { response } from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { getLauncherScaleForUser } from "./launcherScale";
|
||||
import { launcherScaleQuerySchema } from "./types";
|
||||
|
||||
export async function listLauncherScale(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = req.userOrgId;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = launcherScaleQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const scale = await getLauncherScaleForUser(
|
||||
orgId,
|
||||
userId,
|
||||
req.userOrgRoleIds ?? [],
|
||||
parsed.data
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
data: { scale },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher scale retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error listing launcher scale:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,22 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import {
|
||||
extractDefaultViewOverrides,
|
||||
listVisibleLauncherViews
|
||||
} from "./launcherDefaultView";
|
||||
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
|
||||
|
||||
function mapViewRow(
|
||||
row: typeof launcherViews.$inferSelect
|
||||
): LauncherViewRecord {
|
||||
return {
|
||||
viewId: row.viewId,
|
||||
orgId: row.orgId,
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isOrgWide: row.userId == null
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLauncherViews(
|
||||
req: Request,
|
||||
@@ -39,8 +51,7 @@ export async function listLauncherViews(
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
views: listVisibleLauncherViews(rows),
|
||||
defaultViewOverrides: extractDefaultViewOverrides(rows)
|
||||
views: rows.map(mapViewRow)
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -2,10 +2,9 @@ import { z } from "zod";
|
||||
|
||||
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
||||
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
||||
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
|
||||
|
||||
export const launcherViewConfigSchema = z.object({
|
||||
groupBy: z.enum(["site", "label", "none"]).default("site"),
|
||||
groupBy: z.enum(["site", "label"]).default("site"),
|
||||
layout: z.enum(["grid", "list"]).default("grid"),
|
||||
sortBy: z.literal("name").default("name"),
|
||||
order: z.enum(["asc", "desc"]).default("asc"),
|
||||
@@ -89,31 +88,10 @@ export type LauncherViewRecord = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isOrgWide: boolean;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type LauncherDefaultViewOverrides = {
|
||||
personal: LauncherViewRecord | null;
|
||||
orgWide: LauncherViewRecord | null;
|
||||
};
|
||||
|
||||
export function getEffectiveDefaultLauncherConfig(
|
||||
overrides: LauncherDefaultViewOverrides
|
||||
): LauncherViewConfig {
|
||||
if (overrides.personal) {
|
||||
return overrides.personal.config;
|
||||
}
|
||||
|
||||
if (overrides.orgWide) {
|
||||
return overrides.orgWide.config;
|
||||
}
|
||||
|
||||
return defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
export type ListLauncherViewsResponse = {
|
||||
views: LauncherViewRecord[];
|
||||
defaultViewOverrides: LauncherDefaultViewOverrides;
|
||||
};
|
||||
|
||||
export const launcherFilterListQuerySchema = z.strictObject({
|
||||
@@ -122,8 +100,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20),
|
||||
.catch(500)
|
||||
.default(500),
|
||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||
query: z.string().optional().default("")
|
||||
});
|
||||
@@ -160,7 +138,7 @@ export const launcherListQuerySchema = z.strictObject({
|
||||
.default(20),
|
||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||
query: z.string().optional().default(""),
|
||||
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
|
||||
groupBy: z.enum(["site", "label"]).optional().default("site"),
|
||||
groupKey: z.string().optional(),
|
||||
siteIds: z.string().optional(),
|
||||
labelIds: z.string().optional(),
|
||||
@@ -185,32 +163,3 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
||||
export type LauncherViewSelection =
|
||||
| { type: "default" }
|
||||
| { type: "saved"; viewId: number };
|
||||
|
||||
export type LauncherScaleCapabilities = {
|
||||
allowSiteGrouping: boolean;
|
||||
allowLabelGrouping: boolean;
|
||||
requireSearchOrFilter: boolean;
|
||||
};
|
||||
|
||||
export type LauncherScaleInfo = {
|
||||
mode: "full" | "compact";
|
||||
resourceCount: number;
|
||||
siteGroupCount: number;
|
||||
labelGroupCount: number;
|
||||
capabilities: LauncherScaleCapabilities;
|
||||
};
|
||||
|
||||
export type ListLauncherScaleResponse = {
|
||||
scale: LauncherScaleInfo;
|
||||
};
|
||||
|
||||
export const launcherScaleQuerySchema = z.strictObject({
|
||||
query: z.string().optional().default(""),
|
||||
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
|
||||
siteIds: z.string().optional(),
|
||||
labelIds: z.string().optional(),
|
||||
sort_by: z.literal("name").optional().default("name"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("asc")
|
||||
});
|
||||
|
||||
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;
|
||||
|
||||
@@ -66,15 +66,6 @@ export async function updateLauncherView(
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.isDefault) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Use the default view save endpoint for this view"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const isPersonalView = existing.userId === userId;
|
||||
const isOrgWideView = existing.userId == null;
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
@@ -144,8 +135,7 @@ export async function updateLauncherView(
|
||||
),
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
isOrgWide: updated.userId == null,
|
||||
isDefault: updated.isDefault
|
||||
isOrgWide: updated.userId == null
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { response } from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||
import { upsertDefaultViewOverride } from "./launcherDefaultView";
|
||||
import { launcherViewConfigSchema } from "./types";
|
||||
|
||||
const upsertLauncherDefaultViewBodySchema = z.strictObject({
|
||||
config: launcherViewConfigSchema,
|
||||
orgWide: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export async function upsertLauncherDefaultView(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = req.userOrgId;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.orgWide) {
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
ActionsEnum.createOrgWideLauncherView,
|
||||
req
|
||||
);
|
||||
if (!canManageOrgWide) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const view = await upsertDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide: parsed.data.orgWide,
|
||||
config: parsed.data.config
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: view,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher default view saved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error saving launcher default view:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,13 +52,13 @@ export async function buildClientConfigurationForNewtClient(
|
||||
clientsRes
|
||||
.filter((client) => {
|
||||
if (!client.clients.pubKey) {
|
||||
logger.debug(
|
||||
logger.warn(
|
||||
`Client ${client.clients.clientId} has no public key, skipping`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!client.clients.subnet) {
|
||||
logger.debug(
|
||||
logger.warn(
|
||||
`Client ${client.clients.clientId} has no subnet, skipping`
|
||||
);
|
||||
return false;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!newt.siteId) {
|
||||
logger.warn("Newt has no site ID!");
|
||||
logger.warn("Newt has no client ID!");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,12 +34,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
||||
.where(eq(sites.siteId, newt.siteId!))
|
||||
.returning();
|
||||
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Could not find site ${newt.siteId} to update disconnection from disconnect message`
|
||||
);
|
||||
}
|
||||
|
||||
await fireSiteOfflineAlert(
|
||||
site.orgId,
|
||||
site.siteId,
|
||||
@@ -49,6 +43,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error handling site disconnecting message", error);
|
||||
logger.error("Error handling disconnecting message", { error });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,21 +5,8 @@ import { Newt } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { sendNewtSyncMessage } from "./sync";
|
||||
import semver from "semver";
|
||||
import { recordSitePing } from "./pingAccumulator";
|
||||
|
||||
const NEWT_SUPPORTS_SYNC_VERSION = ">=1.14.0";
|
||||
const PONG = {
|
||||
message: {
|
||||
type: "pong",
|
||||
data: {
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
broadcast: false,
|
||||
excludeSender: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles ping messages from newt clients.
|
||||
*
|
||||
@@ -50,14 +37,6 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
|
||||
// cross-region latency to the database.
|
||||
recordSitePing(newt.siteId);
|
||||
|
||||
if (
|
||||
newt.version &&
|
||||
!semver.satisfies(newt.version, NEWT_SUPPORTS_SYNC_VERSION)
|
||||
) {
|
||||
// Newt does not support the sync message so not checking - stop here -
|
||||
return PONG;
|
||||
}
|
||||
|
||||
// Check config version and sync if stale.
|
||||
const configVersion = await getClientConfigVersion(newt.newtId);
|
||||
|
||||
@@ -86,5 +65,14 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
|
||||
await sendNewtSyncMessage(newt, site);
|
||||
}
|
||||
|
||||
return PONG;
|
||||
return {
|
||||
message: {
|
||||
type: "pong",
|
||||
data: {
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
broadcast: false,
|
||||
excludeSender: false
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { sites, clients, olms } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fireSiteOnlineAlert } from "@server/lib/alerts";
|
||||
import { withRetry } from "@server/lib/dbRetry";
|
||||
|
||||
/**
|
||||
* Ping Accumulator
|
||||
@@ -23,6 +22,8 @@ import { withRetry } from "@server/lib/dbRetry";
|
||||
*/
|
||||
|
||||
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
||||
const MAX_RETRIES = 5;
|
||||
const BASE_DELAY_MS = 50;
|
||||
|
||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
||||
// Map of siteId -> latest ping timestamp (unix seconds)
|
||||
@@ -265,7 +266,85 @@ export async function flushPingsToDb(): Promise<void> {
|
||||
}
|
||||
|
||||
// ── Retry / Error Helpers ──────────────────────────────────────────────
|
||||
// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers.
|
||||
|
||||
/**
|
||||
* Simple retry wrapper with exponential backoff for transient errors
|
||||
* (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>,
|
||||
context: string
|
||||
): Promise<T> {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error: any) {
|
||||
if (isTransientError(error) && attempt < MAX_RETRIES) {
|
||||
attempt++;
|
||||
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
|
||||
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`,
|
||||
{ code: error?.code ?? error?.cause?.code }
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 || error.cause?.code || "";
|
||||
|
||||
// Connection timeout / terminated
|
||||
if (
|
||||
message.includes("connection timeout") ||
|
||||
message.includes("connection terminated") ||
|
||||
message.includes("timeout exceeded when trying to connect") ||
|
||||
causeMessage.includes("connection terminated unexpectedly") ||
|
||||
causeMessage.includes("connection timeout")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
||||
if (code === "40P01" || message.includes("deadlock")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PostgreSQL serialization failure
|
||||
if (code === "40001") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||
if (
|
||||
code === "ECONNRESET" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "EPIPE" ||
|
||||
code === "ETIMEDOUT"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -9,45 +9,45 @@ import {
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
|
||||
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||
const {
|
||||
tcpTargets,
|
||||
udpTargets,
|
||||
validHealthCheckTargets,
|
||||
browserGatewayTargets,
|
||||
remoteExitNodeSubnets
|
||||
} = await buildTargetConfigurationForNewtClient(site.siteId);
|
||||
let exitNode: ExitNode | undefined;
|
||||
if (site.exitNodeId) {
|
||||
[exitNode] = await db
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||
.limit(1);
|
||||
}
|
||||
const { peers, targets } = await buildClientConfigurationForNewtClient(
|
||||
site,
|
||||
exitNode
|
||||
);
|
||||
await sendToClient(
|
||||
newt.newtId,
|
||||
{
|
||||
type: "newt/sync",
|
||||
data: {
|
||||
proxyTargets: {
|
||||
udp: udpTargets,
|
||||
tcp: tcpTargets
|
||||
},
|
||||
healthCheckTargets: validHealthCheckTargets,
|
||||
peers: peers,
|
||||
clientTargets: targets,
|
||||
browserGatewayTargets: browserGatewayTargets,
|
||||
remoteExitNodeSubnets: remoteExitNodeSubnets
|
||||
}
|
||||
},
|
||||
{
|
||||
compress: canCompress(newt.version, "newt")
|
||||
}
|
||||
).catch((error) => {
|
||||
logger.warn(`Error sending newt sync message:`, error);
|
||||
});
|
||||
// const {
|
||||
// tcpTargets,
|
||||
// udpTargets,
|
||||
// validHealthCheckTargets,
|
||||
// browserGatewayTargets,
|
||||
// remoteExitNodeSubnets
|
||||
// } = await buildTargetConfigurationForNewtClient(site.siteId);
|
||||
// let exitNode: ExitNode | undefined;
|
||||
// if (site.exitNodeId) {
|
||||
// [exitNode] = await db
|
||||
// .select()
|
||||
// .from(exitNodes)
|
||||
// .where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||
// .limit(1);
|
||||
// }
|
||||
// const { peers, targets } = await buildClientConfigurationForNewtClient(
|
||||
// site,
|
||||
// exitNode
|
||||
// );
|
||||
// await sendToClient(
|
||||
// newt.newtId,
|
||||
// {
|
||||
// type: "newt/sync",
|
||||
// data: {
|
||||
// proxyTargets: {
|
||||
// udp: udpTargets,
|
||||
// tcp: tcpTargets
|
||||
// },
|
||||
// healthCheckTargets: validHealthCheckTargets,
|
||||
// peers: peers,
|
||||
// clientTargets: targets,
|
||||
// browserGatewayTargets: browserGatewayTargets,
|
||||
// remoteExitNodeSubnets: remoteExitNodeSubnets
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// compress: canCompress(newt.version, "newt")
|
||||
// }
|
||||
// ).catch((error) => {
|
||||
// logger.warn(`Error sending newt sync message:`, error);
|
||||
// });
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
}
|
||||
|
||||
if (!site.subnet) {
|
||||
logger.debug(`Site ${site.siteId} has no subnet, skipping`);
|
||||
logger.warn(`Site ${site.siteId} has no subnet, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ import logger from "@server/logger";
|
||||
/**
|
||||
* Handles disconnecting messages from clients to show disconnected in the ui
|
||||
*/
|
||||
export const handleOlmDisconnectingMessage: MessageHandler = async (
|
||||
context
|
||||
) => {
|
||||
export const handleOlmDisconnectingMessage: MessageHandler = async (context) => {
|
||||
const { message, client: c, sendToClient } = context;
|
||||
const olm = c as Olm;
|
||||
|
||||
@@ -31,6 +29,6 @@ export const handleOlmDisconnectingMessage: MessageHandler = async (
|
||||
})
|
||||
.where(eq(clients.clientId, olm.clientId));
|
||||
} catch (error) {
|
||||
logger.error("Error handling client disconnecting message", error);
|
||||
logger.error("Error handling disconnecting message", { error });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -616,8 +616,8 @@ export async function listResources(
|
||||
and(
|
||||
inArray(resources.mode, browserGatewayModes),
|
||||
or(
|
||||
effectiveSso,
|
||||
effectiveWhitelist,
|
||||
eq(effectiveSso, true),
|
||||
eq(effectiveWhitelist, true),
|
||||
not(isNull(effectiveHeaderAuthId)),
|
||||
not(isNull(effectivePincodeId)),
|
||||
not(isNull(effectivePasswordId))
|
||||
@@ -629,8 +629,8 @@ export async function listResources(
|
||||
conditions.push(
|
||||
and(
|
||||
inArray(resources.mode, browserGatewayModes),
|
||||
not(effectiveSso),
|
||||
not(effectiveWhitelist),
|
||||
not(eq(effectiveSso, true)),
|
||||
not(eq(effectiveWhitelist, true)),
|
||||
isNull(effectiveHeaderAuthId),
|
||||
isNull(effectivePincodeId),
|
||||
isNull(effectivePasswordId)
|
||||
|
||||
@@ -268,11 +268,7 @@ export async function createSite(
|
||||
|
||||
let newSite: Site | undefined;
|
||||
try {
|
||||
if (type === "wireguard" && subnet && exitNodeId) {
|
||||
// Only wireguard sites actually persist the provided subnet/exitNodeId.
|
||||
// Newt sites have their subnet/exit node chosen (under a lock) when the
|
||||
// newt connects, so validating them here is both unnecessary and racy,
|
||||
// since pickSiteDefaults does not lock the subnet it suggests.
|
||||
if (subnet && exitNodeId) {
|
||||
//make sure the subnet is in the range of the exit node if provided
|
||||
const [exitNode] = await db
|
||||
.select()
|
||||
|
||||
@@ -55,7 +55,7 @@ const createSiteResourceSchema = z
|
||||
siteIds: z.array(z.int()).optional(),
|
||||
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
||||
destinationPort: z.int().positive().optional(),
|
||||
destination: z.string().min(1).nullish(),
|
||||
destination: z.string().min(1).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
alias: z
|
||||
.string()
|
||||
|
||||
@@ -54,7 +54,7 @@ const updateSiteResourceSchema = z
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).nullish(),
|
||||
destinationPort: z.int().positive().nullish(),
|
||||
destination: z.string().min(1).nullish(),
|
||||
destination: z.string().min(1).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
alias: z
|
||||
.string()
|
||||
|
||||
@@ -3,12 +3,8 @@ import { db, orgs } from "@server/db";
|
||||
import { actions, roles, roleActions } from "@server/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export async function ensureActions() {
|
||||
if (build == "saas") {
|
||||
return;
|
||||
}
|
||||
await db.transaction(async (trx) => {
|
||||
const actionIds = Object.values(ActionsEnum);
|
||||
const existingActions = await trx.select().from(actions).execute();
|
||||
|
||||
@@ -82,11 +82,9 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
orgId={orgId}
|
||||
isAdmin={isAdminOrOwner}
|
||||
views={launcherData.views}
|
||||
defaultViewOverrides={launcherData.defaultViewOverrides}
|
||||
activeViewId={launcherData.activeViewId}
|
||||
config={launcherData.config}
|
||||
savedConfig={launcherData.savedConfig}
|
||||
scale={launcherData.scale}
|
||||
groups={launcherData.groups}
|
||||
groupsPagination={launcherData.groupsPagination}
|
||||
/>
|
||||
|
||||
@@ -158,9 +158,6 @@ const tierLimits: Record<
|
||||
domains: number;
|
||||
remoteNodes: number;
|
||||
organizations: number;
|
||||
publicResources: number;
|
||||
privateResources: number;
|
||||
machineClients: number;
|
||||
}
|
||||
> = {
|
||||
basic: {
|
||||
@@ -168,50 +165,35 @@ const tierLimits: Record<
|
||||
sites: freeLimitSet[LimitId.SITES]?.value ?? 0,
|
||||
domains: freeLimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: freeLimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: freeLimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||
publicResources: freeLimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||
privateResources: freeLimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||
machineClients: freeLimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||
organizations: freeLimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier1: {
|
||||
users: tier1LimitSet[LimitId.USERS]?.value ?? 0,
|
||||
sites: tier1LimitSet[LimitId.SITES]?.value ?? 0,
|
||||
domains: tier1LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier1LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier1LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||
publicResources: tier1LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||
privateResources: tier1LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||
machineClients: tier1LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||
organizations: tier1LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier2: {
|
||||
users: tier2LimitSet[LimitId.USERS]?.value ?? 0,
|
||||
sites: tier2LimitSet[LimitId.SITES]?.value ?? 0,
|
||||
domains: tier2LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier2LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier2LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||
publicResources: tier2LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||
privateResources: tier2LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||
machineClients: tier2LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||
organizations: tier2LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier3: {
|
||||
users: tier3LimitSet[LimitId.USERS]?.value ?? 0,
|
||||
sites: tier3LimitSet[LimitId.SITES]?.value ?? 0,
|
||||
domains: tier3LimitSet[LimitId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier3LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier3LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0,
|
||||
publicResources: tier3LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0,
|
||||
privateResources: tier3LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0,
|
||||
machineClients: tier3LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0
|
||||
organizations: tier3LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0
|
||||
},
|
||||
enterprise: {
|
||||
users: 0, // Custom for enterprise
|
||||
sites: 0, // Custom for enterprise
|
||||
domains: 0, // Custom for enterprise
|
||||
remoteNodes: 0, // Custom for enterprise
|
||||
organizations: 0, // Custom for enterprise
|
||||
publicResources: 0, // Custom for enterprise
|
||||
privateResources: 0, // Custom for enterprise
|
||||
machineClients: 0 // Custom for enterprise
|
||||
organizations: 0 // Custom for enterprise
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,9 +234,6 @@ export default function BillingPage() {
|
||||
const DOMAINS = "domains";
|
||||
const REMOTE_EXIT_NODES = "remoteExitNodes";
|
||||
const ORGINIZATIONS = "organizations";
|
||||
const PUBLIC_RESOURCES = "publicResources";
|
||||
const PRIVATE_RESOURCES = "privateResources";
|
||||
const MACHINE_CLIENTS = "machineClients";
|
||||
|
||||
// Confirmation dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
@@ -290,13 +269,7 @@ export default function BillingPage() {
|
||||
setHasSubscription(
|
||||
tierSub.subscription.status === "active"
|
||||
);
|
||||
// expiresAt is only meaningful while the trial hasn't
|
||||
// actually run out yet; a stale row with a past
|
||||
// expiresAt should no longer be treated as a live trial
|
||||
const expiresAt = tierSub.subscription.expiresAt;
|
||||
setIsTrial(
|
||||
expiresAt != null && expiresAt * 1000 > Date.now()
|
||||
);
|
||||
setIsTrial(tierSub.subscription.expiresAt != null);
|
||||
}
|
||||
|
||||
// Find license subscription
|
||||
@@ -824,45 +797,6 @@ export default function BillingPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// Check public resources
|
||||
const publicResourcesUsage = getUsageValue(PUBLIC_RESOURCES);
|
||||
if (
|
||||
limits.publicResources > 0 &&
|
||||
publicResourcesUsage > limits.publicResources
|
||||
) {
|
||||
violations.push({
|
||||
feature: "Public Resources",
|
||||
currentUsage: publicResourcesUsage,
|
||||
newLimit: limits.publicResources
|
||||
});
|
||||
}
|
||||
|
||||
// Check private resources
|
||||
const privateResourcesUsage = getUsageValue(PRIVATE_RESOURCES);
|
||||
if (
|
||||
limits.privateResources > 0 &&
|
||||
privateResourcesUsage > limits.privateResources
|
||||
) {
|
||||
violations.push({
|
||||
feature: "Private Resources",
|
||||
currentUsage: privateResourcesUsage,
|
||||
newLimit: limits.privateResources
|
||||
});
|
||||
}
|
||||
|
||||
// Check machine clients
|
||||
const machineClientsUsage = getUsageValue(MACHINE_CLIENTS);
|
||||
if (
|
||||
limits.machineClients > 0 &&
|
||||
machineClientsUsage > limits.machineClients
|
||||
) {
|
||||
violations.push({
|
||||
feature: "Machine Clients",
|
||||
currentUsage: machineClientsUsage,
|
||||
newLimit: limits.machineClients
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
};
|
||||
|
||||
@@ -1063,23 +997,21 @@ export default function BillingPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Current Usage */}
|
||||
<div className="border rounded-lg p-4 md:col-span-1">
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{t("billingCurrentUsage") || "Current Usage"}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-semibold">
|
||||
{getUserCount()}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{t("billingUsers") || "Users"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-semibold">
|
||||
{getUserCount()}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{t("billingUsers") || "Users"}
|
||||
</span>
|
||||
{hasSubscription && getPricePerUser() > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
x ${getPricePerUser()} / month = $
|
||||
{getUserCount() * getPricePerUser()} /
|
||||
month
|
||||
@@ -1089,11 +1021,11 @@ export default function BillingPage() {
|
||||
</div>
|
||||
|
||||
{/* Maximum Limits */}
|
||||
<div className="border rounded-lg p-4 md:col-span-3">
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
{t("billingMaximumLimits") || "Maximum Limits"}
|
||||
</div>
|
||||
<InfoSections cols={8}>
|
||||
<InfoSections cols={5}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingUsers") || "Users"}
|
||||
@@ -1376,168 +1308,6 @@ export default function BillingPage() {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingPublicResources") ||
|
||||
"Public Resources"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{isOverLimit(PUBLIC_RESOURCES) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}
|
||||
>
|
||||
{getLimitValue(
|
||||
PUBLIC_RESOURCES
|
||||
) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
"∞"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
PUBLIC_RESOURCES
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
PUBLIC_RESOURCES
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(PUBLIC_RESOURCES)}) exceeds limit (${getLimitValue(PUBLIC_RESOURCES)})`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(
|
||||
PUBLIC_RESOURCES
|
||||
) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingPrivateResources") ||
|
||||
"Private Resources"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{isOverLimit(PRIVATE_RESOURCES) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}
|
||||
>
|
||||
{getLimitValue(
|
||||
PRIVATE_RESOURCES
|
||||
) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
"∞"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
PRIVATE_RESOURCES
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
PRIVATE_RESOURCES
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(PRIVATE_RESOURCES)}) exceeds limit (${getLimitValue(PRIVATE_RESOURCES)})`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(
|
||||
PRIVATE_RESOURCES
|
||||
) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingMachineClients") ||
|
||||
"Machine Clients"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{isOverLimit(MACHINE_CLIENTS) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}
|
||||
>
|
||||
{getLimitValue(
|
||||
MACHINE_CLIENTS
|
||||
) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
"∞"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
MACHINE_CLIENTS
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
MACHINE_CLIENTS
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(MACHINE_CLIENTS)}) exceeds limit (${getLimitValue(MACHINE_CLIENTS)})`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(
|
||||
MACHINE_CLIENTS
|
||||
) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1737,45 +1507,6 @@ export default function BillingPage() {
|
||||
"Remote Nodes"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].publicResources
|
||||
}{" "}
|
||||
{t(
|
||||
"billingPublicResources"
|
||||
) || "Public Resources"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].privateResources
|
||||
}{" "}
|
||||
{t(
|
||||
"billingPrivateResources"
|
||||
) || "Private Resources"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].machineClients
|
||||
}{" "}
|
||||
{t(
|
||||
"billingMachineClients"
|
||||
) || "Machine Clients"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { build } from "@server/build";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import axios from "axios";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
@@ -344,10 +343,7 @@ export default function GeneralPage() {
|
||||
<Link
|
||||
href={
|
||||
row.original.siteResourceId != null
|
||||
? getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)
|
||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||
}
|
||||
>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
@@ -324,10 +323,7 @@ export default function ConnectionLogsPage() {
|
||||
if (row.original.resourceName && row.original.resourceNiceId) {
|
||||
return (
|
||||
<Link
|
||||
href={getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)}
|
||||
href={`/${row.original.orgId}/settings/resources/private/?query=${row.original.resourceNiceId}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -396,10 +395,7 @@ export default function GeneralPage() {
|
||||
<Link
|
||||
href={
|
||||
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
||||
? getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)
|
||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MachinesSelector } from "@app/components/machines-selector";
|
||||
import { RolesSelector } from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import type { Tag } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import type { PrivateResourceClient } from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
type AccessFormValues = {
|
||||
roles?: Tag[];
|
||||
users?: Tag[];
|
||||
clients?: PrivateResourceClient[];
|
||||
};
|
||||
|
||||
type PrivateResourceAccessFieldsProps = {
|
||||
control: Control<AccessFormValues>;
|
||||
orgId: string;
|
||||
loading?: boolean;
|
||||
hasMachineClients?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAccessFields({
|
||||
control,
|
||||
orgId,
|
||||
loading = false,
|
||||
hasMachineClients = false
|
||||
}: PrivateResourceAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">{t("loading")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<RolesSelector
|
||||
selectedRoles={field.value ?? []}
|
||||
orgId={orgId}
|
||||
restrictAdminRole
|
||||
onSelectRoles={(newRoles) => {
|
||||
field.onChange(newRoles);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<UsersSelector
|
||||
selectedUsers={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectUsers={(newUsers) => {
|
||||
field.onChange(newUsers);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{hasMachineClients && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="clients"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("machineClients")}</FormLabel>
|
||||
<MachinesSelector
|
||||
selectedMachines={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectMachines={(machines) => {
|
||||
field.onChange(machines);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceAliasFieldProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAliasField({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
disabled = false
|
||||
}: PrivateResourceAliasFieldProps) {
|
||||
const t = useTranslations();
|
||||
const aliasLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAlias"
|
||||
: "editInternalResourceDialogAlias";
|
||||
const aliasDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAliasDescription"
|
||||
: "editInternalResourceDialogAliasDescription";
|
||||
|
||||
const aliasValue = watch("alias");
|
||||
const aliasEndsWithLocal =
|
||||
typeof aliasValue === "string" &&
|
||||
aliasValue.trim().toLowerCase().endsWith(".local");
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="alias"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(aliasLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{aliasEndsWithLocal && (
|
||||
<p className="text-xs text-amber-700/80 mt-1">
|
||||
{t("internalResourceAliasLocalWarning")}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
<FormDescription>{t(aliasDescriptionKey)}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PrivateResourceHostDestinationFieldsProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideAlias?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceHostDestinationFields({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
hideAlias = false
|
||||
}: PrivateResourceHostDestinationFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
const destinationField = (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (hideAlias) {
|
||||
return destinationField;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">{destinationField}</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceCidrDestinationField({
|
||||
control,
|
||||
labelPrefix = "edit"
|
||||
}: {
|
||||
control: Control<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceHttpFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
orgId: string;
|
||||
watch: UseFormWatch<any>;
|
||||
disabled?: boolean;
|
||||
siteResourceId?: number;
|
||||
resourceDomainId?: string | null;
|
||||
resourceFullDomain?: string | null;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideDomainPicker?: boolean;
|
||||
hidePaidFeaturesAlert?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceHttpFields({
|
||||
control,
|
||||
setValue,
|
||||
orgId,
|
||||
watch,
|
||||
disabled = false,
|
||||
siteResourceId,
|
||||
resourceDomainId,
|
||||
resourceFullDomain,
|
||||
labelPrefix = "edit",
|
||||
hideDomainPicker = false,
|
||||
hidePaidFeaturesAlert = false
|
||||
}: PrivateResourceHttpFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const schemeLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogScheme"
|
||||
: "editInternalResourceDialogScheme";
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
const httpConfigurationTitleKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfiguration"
|
||||
: "editInternalResourceDialogHttpConfiguration";
|
||||
const httpConfigurationDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfigurationDescription"
|
||||
: "editInternalResourceDialogHttpConfigurationDescription";
|
||||
const enableSslLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSsl"
|
||||
: "editInternalResourceDialogEnableSsl";
|
||||
const enableSslDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSslDescription"
|
||||
: "editInternalResourceDialogEnableSslDescription";
|
||||
|
||||
const httpConfigSubdomain = watch("httpConfigSubdomain");
|
||||
const httpConfigDomainId = watch("httpConfigDomainId");
|
||||
const httpConfigFullDomain = watch("httpConfigFullDomain");
|
||||
const ssl = watch("ssl");
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
{!hidePaidFeaturesAlert && (
|
||||
<SettingsFormCell span="full">
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="scheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(schemeLabelKey)}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? "http"}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{!hideDomainPicker && (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t(httpConfigurationTitleKey)}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t(httpConfigurationDescriptionKey)}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<div
|
||||
className={
|
||||
disabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DomainPicker
|
||||
key={
|
||||
siteResourceId
|
||||
? `http-domain-${siteResourceId}`
|
||||
: "http-domain-create"
|
||||
}
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ?? undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (res === null) {
|
||||
setValue("httpConfigSubdomain", null);
|
||||
setValue("httpConfigDomainId", null);
|
||||
setValue("httpConfigFullDomain", null);
|
||||
return;
|
||||
}
|
||||
setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
);
|
||||
setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(
|
||||
enableSslDescriptionKey
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{siteResourceId &&
|
||||
resourceDomainId &&
|
||||
httpConfigFullDomain &&
|
||||
httpConfigDomainId === resourceDomainId &&
|
||||
httpConfigFullDomain === resourceFullDomain &&
|
||||
build != "oss" &&
|
||||
ssl && (
|
||||
<SettingsFormCell span="half">
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
<CertificateStatus
|
||||
orgId={orgId}
|
||||
domainId={resourceDomainId}
|
||||
fullDomain={httpConfigFullDomain}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hideDomainPicker && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(enableSslDescriptionKey)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function PrivateResourceMultiSiteRoutingHelp() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t("internalResourceFormMultiSiteRoutingHelp")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/private/multi-site-routing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("internalResourceFormMultiSiteRoutingHelpLearnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import {
|
||||
getPortModeFromString,
|
||||
getPortStringFromMode,
|
||||
type PortMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue } from "react-hook-form";
|
||||
|
||||
type PrivateResourceNetworkAccessFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
showPortRanges?: boolean;
|
||||
initialTcp?: string | null;
|
||||
initialUdp?: string | null;
|
||||
disabled?: boolean;
|
||||
icmpId?: string;
|
||||
embedInParentGrid?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAllowIcmpField({
|
||||
control,
|
||||
id = "private-resource-allow-icmp",
|
||||
disabled = false
|
||||
}: {
|
||||
control: Control<any>;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id={id}
|
||||
label={t("privateResourceAllowIcmpPing")}
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(!checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateResourceNetworkAccessHeader() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("privateResourceNetworkAccess")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("privateResourceNetworkAccessDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceNetworkAccessFields({
|
||||
control,
|
||||
setValue,
|
||||
showPortRanges = true,
|
||||
initialTcp,
|
||||
initialUdp,
|
||||
disabled = false,
|
||||
icmpId = "private-resource-allow-icmp",
|
||||
embedInParentGrid = false
|
||||
}: PrivateResourceNetworkAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(initialTcp)
|
||||
);
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(initialUdp)
|
||||
);
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState(() =>
|
||||
initialTcp && initialTcp !== "*" ? initialTcp : ""
|
||||
);
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState(() =>
|
||||
initialUdp && initialUdp !== "*" ? initialUdp : ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"tcpPortRangeString",
|
||||
getPortStringFromMode(tcpPortMode, tcpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, tcpPortMode, tcpCustomPorts, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"udpPortRangeString",
|
||||
getPortStringFromMode(udpPortMode, udpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, udpPortMode, udpCustomPorts, setValue]);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
<PrivateResourceNetworkAccessHeader />
|
||||
|
||||
{showPortRanges ? (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="tcpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogTcp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setTcpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="udpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogUdp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setUdpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceAllowIcmpField
|
||||
control={control}
|
||||
id={icmpId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
|
||||
export function PrivateResourcePortRanges(
|
||||
props: Omit<
|
||||
PrivateResourceNetworkAccessFieldsProps,
|
||||
"showPortRanges" | "embedInParentGrid"
|
||||
>
|
||||
) {
|
||||
return <PrivateResourceNetworkAccessFields showPortRanges {...props} />;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "@app/components/multi-site-selector";
|
||||
import { SitesSelector } from "@app/components/site-selector";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
|
||||
|
||||
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
orgId: string;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
siteIdsFieldName?: FieldPath<T>;
|
||||
singleSite?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSitesField<T extends FieldValues>({
|
||||
control,
|
||||
orgId,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
siteIdsFieldName = "siteIds" as FieldPath<T>,
|
||||
singleSite = false
|
||||
}: PrivateResourceSitesFieldProps<T>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={siteIdsFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
{singleSite ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedSites[0]?.name ??
|
||||
t("selectSite")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSites[0] ?? null}
|
||||
filterTypes={["newt"]}
|
||||
onSelectSite={(site) => {
|
||||
onSelectedSitesChange([site]);
|
||||
field.onChange([site.siteId]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<MultiSitesSelector
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
filterTypes={["newt"]}
|
||||
onSelectionChange={(sites) => {
|
||||
onSelectedSitesChange(sites);
|
||||
field.onChange(
|
||||
sites.map((s) => s.siteId)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<FormMessage />
|
||||
{!singleSite && selectedSites.length > 1 ? (
|
||||
<PrivateResourceMultiSiteRoutingHelp />
|
||||
) : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
|
||||
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
|
||||
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
|
||||
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
type PrivateResourceSshFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
orgId?: string;
|
||||
disabled?: boolean;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
labelPrefix?: "create" | "edit";
|
||||
showSshSettings?: boolean;
|
||||
layout?: "default" | "wizard";
|
||||
showPaidFeaturesAlert?: boolean;
|
||||
hideAlias?: boolean;
|
||||
embedInParentGrid?: boolean;
|
||||
isNativeSsh?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSshFields({
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
orgId,
|
||||
disabled = false,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
labelPrefix = "edit",
|
||||
showSshSettings = true,
|
||||
layout = "default",
|
||||
showPaidFeaturesAlert = true,
|
||||
hideAlias = false,
|
||||
embedInParentGrid = false,
|
||||
isNativeSsh: isNativeSshProp
|
||||
}: PrivateResourceSshFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
|
||||
const authDaemonMode = watch("authDaemonMode") ?? "site";
|
||||
const pamMode = inferSshPamMode(authDaemonMode, watch("pamMode"));
|
||||
const standardDaemonLocation =
|
||||
watch("standardDaemonLocation") ??
|
||||
(authDaemonMode === "remote" ? "remote" : "site");
|
||||
const formAuthDaemonPort = watch("authDaemonPort");
|
||||
const [authDaemonPortInput, setAuthDaemonPortInput] = useState(() =>
|
||||
formAuthDaemonPort != null ? String(formAuthDaemonPort) : "22123"
|
||||
);
|
||||
const isEditLayout = layout === "default";
|
||||
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
() => (authDaemonMode === "native" ? "native" : "standard")
|
||||
);
|
||||
|
||||
const isNative =
|
||||
isNativeSshProp ??
|
||||
(isEditLayout
|
||||
? authDaemonMode === "native"
|
||||
: sshServerMode === "native");
|
||||
const useMultiSiteTargetForm = getSshUseMultiSiteTargetForm(
|
||||
isNative,
|
||||
authDaemonMode,
|
||||
pamMode
|
||||
);
|
||||
|
||||
function trimSitesToFirst() {
|
||||
if (selectedSites.length <= 1) return;
|
||||
|
||||
const first = selectedSites.slice(0, 1);
|
||||
onSelectedSitesChange(first);
|
||||
setValue(
|
||||
"siteIds",
|
||||
first.map((s: Selectedsite) => s.siteId),
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
|
||||
function handlePamModeChange(value: "passthrough" | "push") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("pamMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "passthrough") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
return;
|
||||
}
|
||||
|
||||
if (standardDaemonLocation !== "remote" && selectedSites.length > 1) {
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("standardDaemonLocation", value, { shouldValidate: true });
|
||||
setValue("authDaemonMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "site") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthDaemonPortChange(value: string) {
|
||||
if (disabled) return;
|
||||
|
||||
setAuthDaemonPortInput(value);
|
||||
const trimmed = value.trim();
|
||||
setValue("authDaemonPort", trimmed ? Number(trimmed) : null, {
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
|
||||
function handleServerModeChange(mode: "standard" | "native") {
|
||||
if (disabled) return;
|
||||
|
||||
setSshServerMode(mode);
|
||||
if (mode === "native") {
|
||||
setValue("authDaemonMode", "native", { shouldValidate: true });
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setValue("destination", null, { shouldValidate: true });
|
||||
setValue("destinationPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue("authDaemonMode", standardDaemonLocation, {
|
||||
shouldValidate: true
|
||||
});
|
||||
setValue("destinationPort", 22, { shouldValidate: true });
|
||||
}
|
||||
|
||||
const aliasField = hideAlias ? null : (
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const standardSshTargetRow =
|
||||
orgId && !isNative ? (
|
||||
<div className="grid grid-cols-3 gap-4 items-start">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite={!useMultiSiteTargetForm}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const sshSettingsFields = showSshSettings ? (
|
||||
<SshServerSettingsFields
|
||||
idPrefix={
|
||||
layout === "wizard"
|
||||
? "private-ssh-create"
|
||||
: "private-ssh-fields"
|
||||
}
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={standardDaemonLocation}
|
||||
authDaemonPort={authDaemonPortInput}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={handleDaemonLocationChange}
|
||||
onAuthDaemonPortChange={handleAuthDaemonPortChange}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay={layout === "wizard" ? "select" : "badge"}
|
||||
onServerModeChange={handleServerModeChange}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const destinationSection = (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerDestination")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("sshServerDestinationDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
|
||||
{isNative && orgId ? (
|
||||
<>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isNative && orgId ? (
|
||||
<SettingsFormCell span="full">
|
||||
{standardSshTargetRow}
|
||||
</SettingsFormCell>
|
||||
) : null}
|
||||
|
||||
{!isNative && !hideAlias ? (
|
||||
<SettingsFormCell span="half">{aliasField}</SettingsFormCell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
{showPaidFeaturesAlert && layout === "default" && (
|
||||
<SettingsFormCell span="full">
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
{sshSettingsFields}
|
||||
{destinationSection}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
accessTagsToIds,
|
||||
buildUpdateSiteResourcePayload,
|
||||
createAccessFormSchema,
|
||||
mergeFormValuesWithResource
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { resourceQueries, orgQueries } from "@app/lib/queries";
|
||||
import { useAccessFormDefaults } from "@app/providers/SiteResourceProvider";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
|
||||
|
||||
export default function PrivateResourceAccessPage() {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const { siteResource, setAccess } = useSiteResourceContext();
|
||||
const { loading, roles, users, clients, hasMachineClients } =
|
||||
useAccessFormDefaults(siteResource.orgId, siteResource.id);
|
||||
|
||||
const machineClientsQuery = useQuery(
|
||||
orgQueries.machineClients({
|
||||
orgId: siteResource.orgId,
|
||||
perPage: 1
|
||||
})
|
||||
);
|
||||
const hasMachineClientsResolved =
|
||||
(machineClientsQuery.data ?? []).filter((c) => !c.userId).length > 0;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(createAccessFormSchema()),
|
||||
defaultValues: {
|
||||
roles: [] as typeof roles,
|
||||
users: [] as typeof users,
|
||||
clients: [] as typeof clients
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
form.reset({ roles, users, clients });
|
||||
}
|
||||
}, [loading, roles, users, clients, form]);
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
const access = accessTagsToIds({
|
||||
roles: data.roles,
|
||||
users: data.users,
|
||||
clients: data.clients
|
||||
});
|
||||
|
||||
const merged = mergeFormValuesWithResource(siteResource, {});
|
||||
const payload = buildUpdateSiteResourcePayload(merged, access);
|
||||
|
||||
try {
|
||||
await api.post(`/site-resource/${siteResource.id}`, payload);
|
||||
setAccess(access);
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceRoles({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceUsers({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceClients({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
)
|
||||
]);
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
)
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("authentication")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogAccessControlDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-access-form"
|
||||
>
|
||||
<PrivateResourceAccessFields
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
loading={loading}
|
||||
hasMachineClients={
|
||||
hasMachineClients ||
|
||||
hasMachineClientsResolved
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-access-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { createCidrFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceCidrPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createCidrFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "cidr",
|
||||
destination: siteResource.destination ?? "",
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
|
||||
disableIcmp: siteResource.disableIcmp ?? false
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "cidr",
|
||||
destination: data.destination,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("cidrSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogDestinationCidrDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-cidr-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceCidrDestinationField
|
||||
control={asAnyControl(form.control)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
initialTcp={
|
||||
siteResource.tcpPortRangeString
|
||||
}
|
||||
initialUdp={
|
||||
siteResource.udpPortRangeString
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-cidr-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceGeneralPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
|
||||
const formSchema = useMemo(() => createGeneralFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: siteResource.name,
|
||||
niceId: siteResource.niceId
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
name: data.name,
|
||||
niceId: data.niceId
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-general-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"editInternalResourceDialogName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("identifier")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-general-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { createHostFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceHostPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createHostFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "host",
|
||||
destination: siteResource.destination ?? "",
|
||||
alias: siteResource.alias ?? null,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
|
||||
disableIcmp: siteResource.disableIcmp ?? false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? "site",
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "host",
|
||||
destination: data.destination,
|
||||
alias: data.alias,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp,
|
||||
authDaemonMode: data.authDaemonMode,
|
||||
authDaemonPort: data.authDaemonPort
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("hostSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("editInternalResourceDialogDestinationDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-host-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHostDestinationFields
|
||||
control={asAnyControl(form.control)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
initialTcp={
|
||||
siteResource.tcpPortRangeString
|
||||
}
|
||||
initialUdp={
|
||||
siteResource.udpPortRangeString
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-host-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { createHttpFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceHttpPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const httpSectionDisabled = !isPaidUser(
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createHttpFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "http",
|
||||
destination: siteResource.destination ?? "",
|
||||
destinationPort: siteResource.destinationPort ?? null,
|
||||
scheme: siteResource.scheme ?? "http",
|
||||
ssl: siteResource.ssl ?? false,
|
||||
httpConfigSubdomain: siteResource.subdomain ?? null,
|
||||
httpConfigDomainId: siteResource.domainId ?? null,
|
||||
httpConfigFullDomain: siteResource.fullDomain ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "http",
|
||||
destination: data.destination,
|
||||
destinationPort: data.destinationPort,
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl,
|
||||
httpConfigSubdomain: data.httpConfigSubdomain,
|
||||
httpConfigDomainId: data.httpConfigDomainId,
|
||||
httpConfigFullDomain: data.httpConfigFullDomain
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("httpSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogHttpConfigurationDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-http-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHttpFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
orgId={siteResource.orgId}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
disabled={httpSectionDisabled}
|
||||
siteResourceId={siteResource.id}
|
||||
resourceDomainId={
|
||||
siteResource.domainId
|
||||
}
|
||||
resourceFullDomain={
|
||||
siteResource.fullDomain
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-http-form"
|
||||
loading={saveLoading}
|
||||
disabled={httpSectionDisabled}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { fetchSiteResourceByNiceId } from "@app/lib/fetchSiteResourceByNiceId";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import SiteResourceProvider from "@app/providers/SiteResourceProvider";
|
||||
import SiteResourceInfoBox from "@app/components/SiteResourceInfoBox";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Private Resource"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PrivateResourceLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function PrivateResourceLayout(
|
||||
props: PrivateResourceLayoutProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const { children } = props;
|
||||
|
||||
const siteResource = await fetchSiteResourceByNiceId(
|
||||
params.orgId,
|
||||
params.niceId
|
||||
);
|
||||
|
||||
if (!siteResource) {
|
||||
redirect(`/${params.orgId}/settings/resources/private`);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const res = await getCachedOrg(params.orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources/private`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${params.orgId}/settings/resources/private`);
|
||||
}
|
||||
|
||||
const modeSettingsKey = `${siteResource.mode}Settings` as
|
||||
| "hostSettings"
|
||||
| "cidrSettings"
|
||||
| "httpSettings"
|
||||
| "sshSettings";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("general"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(modeSettingsKey),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("resourceSetting", {
|
||||
resourceName: siteResource.name
|
||||
})}
|
||||
description={t("resourceSettingDescription")}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<SiteResourceProvider siteResource={siteResource}>
|
||||
<div className="space-y-6">
|
||||
<SiteResourceInfoBox />
|
||||
<HorizontalTabs items={navItems}>
|
||||
{children}
|
||||
</HorizontalTabs>
|
||||
</div>
|
||||
</SiteResourceProvider>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Private Resource"
|
||||
};
|
||||
|
||||
export default async function PrivateResourcePage(props: {
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/${params.orgId}/settings/resources/private/${params.niceId}/general`
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsFormGrid
|
||||
} from "@app/components/Settings";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import {
|
||||
createSshFormSchema,
|
||||
inferSshPamMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
export default function PrivateResourceSshPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const isNative = siteResource.authDaemonMode === "native";
|
||||
const [sshServerMode] = useState<"standard" | "native">(
|
||||
isNative ? "native" : "standard"
|
||||
);
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(
|
||||
() => createSshFormSchema(t, { isNative }),
|
||||
[t, isNative]
|
||||
);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "ssh",
|
||||
destination: siteResource.destination ?? "",
|
||||
alias: siteResource.alias ?? null,
|
||||
destinationPort: siteResource.destinationPort ?? null,
|
||||
pamMode: inferSshPamMode(
|
||||
siteResource.authDaemonMode,
|
||||
siteResource.pamMode
|
||||
),
|
||||
standardDaemonLocation: isNative
|
||||
? "site"
|
||||
: siteResource.authDaemonMode === "remote"
|
||||
? "remote"
|
||||
: "site",
|
||||
authDaemonPort: siteResource.authDaemonPort
|
||||
? String(siteResource.authDaemonPort)
|
||||
: "22123"
|
||||
}
|
||||
});
|
||||
|
||||
const pamMode = form.watch("pamMode");
|
||||
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
||||
const authDaemonPort = form.watch("authDaemonPort");
|
||||
|
||||
function trimSitesToFirst() {
|
||||
if (selectedSites.length <= 1) return;
|
||||
|
||||
const first = selectedSites.slice(0, 1);
|
||||
setSelectedSites(first);
|
||||
form.setValue(
|
||||
"siteIds",
|
||||
first.map((s: Selectedsite) => s.siteId),
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
|
||||
function handlePamModeChange(value: "passthrough" | "push") {
|
||||
form.setValue("pamMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "push") {
|
||||
if (
|
||||
standardDaemonLocation !== "remote" &&
|
||||
selectedSites.length > 1
|
||||
) {
|
||||
trimSitesToFirst();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
|
||||
}
|
||||
|
||||
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||
form.setValue("standardDaemonLocation", value, {
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
if (value === "site") {
|
||||
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
const effectiveAuthDaemonMode = isNative
|
||||
? "native"
|
||||
: data.standardDaemonLocation;
|
||||
const effectiveAuthDaemonPort =
|
||||
!isNative &&
|
||||
data.pamMode === "push" &&
|
||||
data.standardDaemonLocation === "remote"
|
||||
? Number(data.authDaemonPort)
|
||||
: null;
|
||||
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "ssh",
|
||||
destination: isNative ? null : data.destination,
|
||||
alias: data.alias,
|
||||
destinationPort: isNative ? null : data.destinationPort,
|
||||
authDaemonMode: effectiveAuthDaemonMode,
|
||||
authDaemonPort: effectiveAuthDaemonPort,
|
||||
pamMode: data.pamMode
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("sshSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("editInternalResourceDialogDestinationDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<fieldset
|
||||
disabled={sshSectionDisabled}
|
||||
className={
|
||||
sshSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SshServerSettingsFields
|
||||
idPrefix="private-ssh-edit"
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={
|
||||
standardDaemonLocation
|
||||
}
|
||||
authDaemonPort={authDaemonPort}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={
|
||||
handleDaemonLocationChange
|
||||
}
|
||||
onAuthDaemonPortChange={(value) =>
|
||||
form.setValue(
|
||||
"authDaemonPort",
|
||||
value,
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
}
|
||||
authDaemonPortError={
|
||||
form.formState.errors.authDaemonPort
|
||||
?.message
|
||||
}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay="badge"
|
||||
/>
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(form.setValue)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={setSelectedSites}
|
||||
showSshSettings={false}
|
||||
embedInParentGrid
|
||||
showPaidFeaturesAlert={false}
|
||||
isNativeSsh={isNative}
|
||||
/>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<form action={formAction}>
|
||||
<Button type="submit" loading={saveLoading}>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSectionFooter>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,615 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
OptionSelect,
|
||||
type OptionSelectOption
|
||||
} from "@app/components/OptionSelect";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
buildCreateSiteResourcePayload,
|
||||
createCreateFormSchema,
|
||||
type PrivateResourceMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { SiteResource } from "@server/db";
|
||||
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
|
||||
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
|
||||
import {
|
||||
PrivateResourceAliasField,
|
||||
PrivateResourceCidrDestinationField,
|
||||
PrivateResourceHostDestinationFields
|
||||
} from "../PrivateResourceDestinationFields";
|
||||
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
|
||||
|
||||
export default function CreatePrivateResourcePage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const orgId = params.orgId as string;
|
||||
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const httpSectionDisabled = !isPaidUser(
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
const siteIdParam = searchParams.get("siteId");
|
||||
const siteIdNumber =
|
||||
siteIdParam && Number.isInteger(Number(siteIdParam))
|
||||
? Number(siteIdParam)
|
||||
: null;
|
||||
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
|
||||
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
siteIds: [],
|
||||
mode: "host",
|
||||
destination: "",
|
||||
alias: null,
|
||||
destinationPort: null,
|
||||
scheme: "http",
|
||||
ssl: true,
|
||||
httpConfigSubdomain: null,
|
||||
httpConfigDomainId: null,
|
||||
httpConfigFullDomain: null,
|
||||
authDaemonMode: "native",
|
||||
standardDaemonLocation: "site",
|
||||
authDaemonPort: null,
|
||||
pamMode: "passthrough",
|
||||
disableIcmp: false
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteIdNumber) return;
|
||||
|
||||
void api
|
||||
.get<ResponseT<GetSiteResponse>>(`/site/${siteIdNumber}`)
|
||||
.then((res) => {
|
||||
const site = res.data.data;
|
||||
if (!site || site.orgId !== orgId) return;
|
||||
const selected: Selectedsite = {
|
||||
siteId: site.siteId,
|
||||
name: site.name,
|
||||
type: site.type as Selectedsite["type"]
|
||||
};
|
||||
setSelectedSites([selected]);
|
||||
form.setValue("siteIds", [site.siteId]);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [api, form, orgId, siteIdNumber]);
|
||||
|
||||
const mode = form.watch("mode");
|
||||
const authDaemonMode = form.watch("authDaemonMode");
|
||||
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
|
||||
|
||||
const modeOptions: OptionSelectOption<PrivateResourceMode>[] = [
|
||||
{ value: "host", label: t("createInternalResourceDialogModeHost") },
|
||||
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") },
|
||||
...(!disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
value: "http" as const,
|
||||
label: t("createInternalResourceDialogModeHttp")
|
||||
},
|
||||
{
|
||||
value: "ssh" as const,
|
||||
label: t("createInternalResourceDialogModeSsh")
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
const submitDisabled =
|
||||
isSubmitting ||
|
||||
(mode === "http" && httpSectionDisabled) ||
|
||||
(mode === "ssh" && sshSectionDisabled);
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await api.put<
|
||||
AxiosResponse<ResponseT<SiteResource>>
|
||||
>(
|
||||
`/org/${orgId}/site-resource`,
|
||||
buildCreateSiteResourcePayload({
|
||||
...values,
|
||||
destination:
|
||||
values.destination?.trim() &&
|
||||
values.destination.trim().length > 0
|
||||
? values.destination.trim()
|
||||
: null
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
)
|
||||
});
|
||||
|
||||
const created = (res.data as unknown as ResponseT<SiteResource>)
|
||||
.data;
|
||||
if (!created) {
|
||||
throw new Error("Failed to create private resource");
|
||||
}
|
||||
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<HeaderTitle
|
||||
title={t(
|
||||
"createInternalResourceDialogCreateClientResource"
|
||||
)}
|
||||
description={t(
|
||||
"createInternalResourceDialogCreateClientResourceDescription"
|
||||
)}
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/${orgId}/settings/resources/private`}>
|
||||
{t("privateResourceCreatePageSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="create-private-resource-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* General */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceCreateGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceCreateGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceNameDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("type")}
|
||||
</FormLabel>
|
||||
<OptionSelect<PrivateResourceMode>
|
||||
options={modeOptions}
|
||||
value={field.value}
|
||||
onChange={(newMode) => {
|
||||
field.onChange(
|
||||
newMode
|
||||
);
|
||||
if (
|
||||
newMode ===
|
||||
"ssh"
|
||||
) {
|
||||
form.setValue(
|
||||
"authDaemonMode",
|
||||
"native"
|
||||
);
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
"site"
|
||||
);
|
||||
form.setValue(
|
||||
"destination",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
} else if (
|
||||
newMode ===
|
||||
"http"
|
||||
) {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
443
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
}
|
||||
}}
|
||||
cols={4}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{mode === "http" && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
onDomainChange={(res) => {
|
||||
if (!res) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ??
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceDomainDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{(mode === "host" ||
|
||||
(mode === "ssh" && !isNativeSsh)) && (
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="create"
|
||||
disabled={
|
||||
mode === "ssh" &&
|
||||
sshSectionDisabled
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Host destination */}
|
||||
{mode === "host" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("hostSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogDestinationDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceHostDestinationFields
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="create"
|
||||
hideAlias
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* CIDR destination */}
|
||||
{mode === "cidr" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("cidrSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogDestinationCidrDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceCidrDestinationField
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
labelPrefix="create"
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* HTTP configuration */}
|
||||
{mode === "http" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("httpSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogHttpConfigurationDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={httpSectionDisabled}
|
||||
className={
|
||||
httpSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={
|
||||
selectedSites
|
||||
}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHttpFields
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
orgId={orgId}
|
||||
watch={asAnyWatch(
|
||||
form.watch
|
||||
)}
|
||||
disabled={
|
||||
httpSectionDisabled
|
||||
}
|
||||
labelPrefix="create"
|
||||
hideDomainPicker
|
||||
hidePaidFeaturesAlert
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* SSH server */}
|
||||
{mode === "ssh" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("sshServer")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("sshServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={sshSectionDisabled}
|
||||
className={
|
||||
sshSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={orgId}
|
||||
disabled={sshSectionDisabled}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
labelPrefix="create"
|
||||
showSshSettings={true}
|
||||
layout="wizard"
|
||||
showPaidFeaturesAlert={false}
|
||||
hideAlias
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private`
|
||||
)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-private-resource-form"
|
||||
disabled={submitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type {
|
||||
Control,
|
||||
FieldValues,
|
||||
UseFormSetValue,
|
||||
UseFormWatch
|
||||
} from "react-hook-form";
|
||||
|
||||
export function asAnyControl<T extends FieldValues>(
|
||||
control: Control<T>
|
||||
): Control<any> {
|
||||
return control as Control<any>;
|
||||
}
|
||||
|
||||
export function asAnySetValue<T extends FieldValues>(
|
||||
setValue: UseFormSetValue<T>
|
||||
): UseFormSetValue<any> {
|
||||
return setValue as UseFormSetValue<any>;
|
||||
}
|
||||
|
||||
export function asAnyWatch<T extends FieldValues>(
|
||||
watch: UseFormWatch<T>
|
||||
): UseFormWatch<any> {
|
||||
return watch as UseFormWatch<any>;
|
||||
}
|
||||
@@ -122,7 +122,6 @@ export default async function ClientResourcesPage(
|
||||
aliasAddress: siteResource.aliasAddress || null,
|
||||
siteNiceIds: siteResource.siteNiceIds,
|
||||
niceId: siteResource.niceId,
|
||||
enabled: siteResource.enabled,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import type { SiteResourceData } from "@app/lib/privateResourceForm";
|
||||
|
||||
export function buildSelectedSitesForResource(
|
||||
resource: Pick<SiteResourceData, "siteIds" | "siteNames">
|
||||
): Selectedsite[] {
|
||||
return resource.siteIds.map((siteId, idx) => ({
|
||||
name: resource.siteNames[idx] ?? "",
|
||||
siteId,
|
||||
type: "newt" as const
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSshSingleSiteMode(
|
||||
authDaemonMode?: string | null,
|
||||
pamMode?: string | null
|
||||
): boolean {
|
||||
return (
|
||||
authDaemonMode === "native" ||
|
||||
(pamMode === "push" && authDaemonMode === "site")
|
||||
);
|
||||
}
|
||||
|
||||
export function getSshUseMultiSiteTargetForm(
|
||||
isNative: boolean,
|
||||
authDaemonMode?: string | null,
|
||||
pamMode?: string | null
|
||||
): boolean {
|
||||
if (isNative) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authDaemonMode !== "site" || pamMode === "passthrough";
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import {
|
||||
buildUpdateSiteResourcePayload,
|
||||
mergeFormValuesWithResource,
|
||||
type PrivateResourceFormValues
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function useSaveSiteResource() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { siteResource, updateSiteResource, access } =
|
||||
useSiteResourceContext();
|
||||
|
||||
async function save(
|
||||
partial: Partial<PrivateResourceFormValues>,
|
||||
options?: { successMessage?: string }
|
||||
) {
|
||||
const merged = mergeFormValuesWithResource(siteResource, partial);
|
||||
const isNativeSsh =
|
||||
merged.mode === "ssh" && merged.authDaemonMode === "native";
|
||||
const trimmedDestination = merged.destination?.trim();
|
||||
|
||||
const payload = buildUpdateSiteResourcePayload(
|
||||
{
|
||||
...merged,
|
||||
destination: isNativeSsh
|
||||
? null
|
||||
: trimmedDestination && trimmedDestination.length > 0
|
||||
? trimmedDestination
|
||||
: null
|
||||
},
|
||||
access
|
||||
);
|
||||
|
||||
try {
|
||||
await api.post(`/site-resource/${siteResource.id}`, payload);
|
||||
|
||||
updateSiteResource({
|
||||
name: merged.name,
|
||||
niceId: merged.niceId ?? siteResource.niceId,
|
||||
enabled: merged.enabled ?? siteResource.enabled,
|
||||
siteIds: merged.siteIds,
|
||||
mode: merged.mode,
|
||||
destination: merged.destination ?? null,
|
||||
alias: merged.alias ?? null,
|
||||
destinationPort: merged.destinationPort ?? null,
|
||||
scheme: merged.scheme ?? siteResource.scheme,
|
||||
ssl: merged.ssl ?? siteResource.ssl,
|
||||
subdomain: merged.httpConfigSubdomain ?? null,
|
||||
domainId: merged.httpConfigDomainId ?? null,
|
||||
fullDomain: merged.httpConfigFullDomain ?? null,
|
||||
tcpPortRangeString: merged.tcpPortRangeString ?? null,
|
||||
udpPortRangeString: merged.udpPortRangeString ?? null,
|
||||
disableIcmp: merged.disableIcmp ?? false,
|
||||
authDaemonMode: merged.authDaemonMode ?? null,
|
||||
authDaemonPort: merged.authDaemonPort ?? null,
|
||||
pamMode: merged.pamMode ?? null
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description:
|
||||
options?.successMessage ??
|
||||
t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
)
|
||||
});
|
||||
|
||||
if (merged.niceId && merged.niceId !== siteResource.niceId) {
|
||||
router.replace(
|
||||
getPrivateResourceSettingsHref(
|
||||
siteResource.orgId,
|
||||
merged.niceId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return { save, siteResource, access };
|
||||
}
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useActionState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -137,21 +137,11 @@ function ProxyResourceHttpForm({
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||
const [headersValid, setHeadersValid] = useState(true);
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
if (!headersValid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: t("headersValidationError")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
const res = await api
|
||||
@@ -328,9 +318,6 @@ function ProxyResourceHttpForm({
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
onValidityChange={
|
||||
setHeadersValid
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -354,7 +341,7 @@ function ProxyResourceHttpForm({
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading || !headersValid}
|
||||
disabled={saveLoading}
|
||||
form="http-settings-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
|
||||
@@ -14,13 +14,14 @@ import {
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SitesSelector } from "@app/components/site-selector";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -34,7 +35,8 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
@@ -221,7 +223,6 @@ function SshServerForm({
|
||||
|
||||
const pamMode = form.watch("pamMode");
|
||||
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
||||
const authDaemonPort = form.watch("authDaemonPort");
|
||||
const selectedNativeSite = form.watch("selectedNativeSite");
|
||||
|
||||
async function save() {
|
||||
@@ -363,6 +364,35 @@ function SshServerForm({
|
||||
}
|
||||
}
|
||||
|
||||
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
|
||||
{
|
||||
id: "passthrough",
|
||||
title: t("sshAuthMethodManual"),
|
||||
description: t("sshAuthMethodManualDescription")
|
||||
},
|
||||
{
|
||||
id: "push",
|
||||
title: t("sshAuthMethodAutomated"),
|
||||
description: t("sshAuthMethodAutomatedDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
|
||||
{
|
||||
id: "site",
|
||||
title: t("internalResourceAuthDaemonSite"),
|
||||
description: t("sshDaemonLocationSiteDescription")
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
title: t("sshDaemonLocationRemote"),
|
||||
description: t("sshDaemonLocationRemoteDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const showDaemonLocation = !isNative && pamMode === "push";
|
||||
const showDaemonPort =
|
||||
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
|
||||
const useMultiSiteTargetForm =
|
||||
!isNative &&
|
||||
(standardDaemonLocation !== "site" || pamMode === "passthrough");
|
||||
@@ -383,37 +413,97 @@ function SshServerForm({
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SshServerSettingsFields
|
||||
idPrefix="public-ssh-edit"
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={
|
||||
standardDaemonLocation
|
||||
}
|
||||
authDaemonPort={authDaemonPort}
|
||||
onPamModeChange={(value) =>
|
||||
form.setValue("pamMode", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
onStandardDaemonLocationChange={(value) =>
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
value,
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
}
|
||||
onAuthDaemonPortChange={(value) =>
|
||||
form.setValue("authDaemonPort", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
authDaemonPortError={
|
||||
form.formState.errors.authDaemonPort
|
||||
?.message
|
||||
}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay="badge"
|
||||
/>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode == "standard"
|
||||
? t("sshServerModeStandard")
|
||||
: t("sshServerModePangolin")}
|
||||
</Badge>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthenticationMethod")}
|
||||
</p>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
onChange={(value) =>
|
||||
form.setValue("pamMode", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
cols={2}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
{showDaemonLocation && (
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</p>
|
||||
<StrategySelect<"site" | "remote">
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
value,
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
)
|
||||
}
|
||||
cols={2}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{showDaemonPort && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshDaemonPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
|
||||
@@ -777,8 +777,8 @@ export default function Page() {
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title={t("resourcePublicCreate")}
|
||||
description={t("resourcePublicCreateDescription")}
|
||||
title={t("resourceCreate")}
|
||||
description={t("resourceCreateDescription")}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
@@ -8,14 +7,12 @@ type CopyToClipboardProps = {
|
||||
text: string;
|
||||
displayText?: string;
|
||||
isLink?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CopyToClipboard = ({
|
||||
text,
|
||||
displayText,
|
||||
isLink,
|
||||
className
|
||||
isLink
|
||||
}: CopyToClipboardProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -51,20 +48,14 @@ const CopyToClipboard = ({
|
||||
href={text}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"truncate hover:underline text-sm min-w-0 max-w-full",
|
||||
className
|
||||
)}
|
||||
className="truncate hover:underline text-sm min-w-0 max-w-full"
|
||||
title={text} // Shows full text on hover
|
||||
>
|
||||
{displayValue}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm min-w-0 max-w-full",
|
||||
className
|
||||
)}
|
||||
className="truncate text-sm min-w-0 max-w-full"
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
|
||||
206
src/components/CreatePrivateResourceDialog.tsx
Normal file
206
src/components/CreatePrivateResourceDialog.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
cleanForFQDN,
|
||||
PrivateResourceForm,
|
||||
isHostname,
|
||||
type InternalResourceFormValues
|
||||
} from "./PrivateResourceForm";
|
||||
import type { Selectedsite } from "./site-selector";
|
||||
|
||||
type CreateInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
orgId: string;
|
||||
onSuccess?: () => void;
|
||||
initialSites?: Selectedsite[];
|
||||
};
|
||||
|
||||
export default function CreatePrivateResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
onSuccess,
|
||||
initialSites
|
||||
}: CreateInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
function handleSubmit(values: InternalResourceFormValues) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" ||
|
||||
data.mode === "http" ||
|
||||
data.mode === "ssh") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination?.toLowerCase() === "localhost") {
|
||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||
}
|
||||
data = { ...data, alias: aliasValue };
|
||||
}
|
||||
}
|
||||
|
||||
await api.put<
|
||||
AxiosResponse<{ data: { siteResourceId: number } }>
|
||||
>(`/org/${orgId}/site-resource`, {
|
||||
name: data.name,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
destination: data.destination ?? undefined,
|
||||
enabled: true,
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.destinationPort ?? undefined,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
subdomain: data.httpConfigSubdomain
|
||||
? data.httpConfigSubdomain
|
||||
: undefined
|
||||
}),
|
||||
...(data.mode === "host" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" &&
|
||||
data.authDaemonPort != null && {
|
||||
authDaemonPort: data.authDaemonPort
|
||||
})
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
destinationPort: data.destinationPort ?? undefined,
|
||||
pamMode: data.pamMode ?? undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" &&
|
||||
data.authDaemonPort != null && {
|
||||
authDaemonPort: data.authDaemonPort
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: data.roles
|
||||
? data.roles.map((r) => parseInt(r.id))
|
||||
: [],
|
||||
userIds: data.users ? data.users.map((u) => u.id) : [],
|
||||
clientIds: data.clients
|
||||
? data.clients.map((c) => parseInt(c.id))
|
||||
: []
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-3xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("createInternalResourceDialogCreateClientResource")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t(
|
||||
"createInternalResourceDialogCreateClientResourceDescription"
|
||||
)}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PrivateResourceForm
|
||||
variant="create"
|
||||
open={open}
|
||||
orgId={orgId}
|
||||
formId="create-internal-resource-form"
|
||||
onSubmit={handleSubmit}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
initialSites={initialSites}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-internal-resource-form"
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
225
src/components/EditPrivateResourceDialog.tsx
Normal file
225
src/components/EditPrivateResourceDialog.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
cleanForFQDN,
|
||||
PrivateResourceForm,
|
||||
type InternalResourceData,
|
||||
type InternalResourceFormValues,
|
||||
isHostname
|
||||
} from "./PrivateResourceForm";
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
resource: InternalResourceData;
|
||||
orgId: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function EditPrivateResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
resource,
|
||||
orgId,
|
||||
onSuccess
|
||||
}: EditInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
|
||||
async function handleSubmit(values: InternalResourceFormValues) {
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" ||
|
||||
data.mode === "http" ||
|
||||
data.mode === "ssh") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination?.toLowerCase() === "localhost") {
|
||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||
}
|
||||
data = { ...data, alias: aliasValue };
|
||||
}
|
||||
}
|
||||
|
||||
await api.post(`/site-resource/${resource.id}`, {
|
||||
name: data.name,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
niceId: data.niceId,
|
||||
destination: data.destination ?? undefined,
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.destinationPort ?? null,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
subdomain: data.httpConfigSubdomain
|
||||
? data.httpConfigSubdomain
|
||||
: undefined
|
||||
}),
|
||||
...(data.mode === "host" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" && {
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
})
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
destinationPort: data.destinationPort ?? null,
|
||||
pamMode: data.pamMode ?? undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" && {
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceRoles({
|
||||
siteResourceId: resource.id
|
||||
})
|
||||
);
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceUsers({
|
||||
siteResourceId: resource.id
|
||||
})
|
||||
);
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceClients({
|
||||
siteResourceId: resource.id
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent className="max-w-3xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("editInternalResourceDialogEditClientResource")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogUpdateResourceProperties",
|
||||
{
|
||||
resourceName: resource.name
|
||||
}
|
||||
)}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PrivateResourceForm
|
||||
variant="edit"
|
||||
open={open}
|
||||
resource={resource}
|
||||
orgId={orgId}
|
||||
siteResourceId={resource.id}
|
||||
formId="edit-internal-resource-form"
|
||||
onSubmit={(values) =>
|
||||
startTransition(() => handleSubmit(values))
|
||||
}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-internal-resource-form"
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogSaveResource")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -2,36 +2,24 @@
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface HeadersInputProps {
|
||||
value?: { name: string; value: string }[] | null;
|
||||
onChange: (value: { name: string; value: string }[] | null) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Mirrors the server side validation in updateResource.ts so that invalid
|
||||
// input is caught (and shown to the user) before it is ever submitted,
|
||||
// instead of being silently dropped in favor of the last known good value.
|
||||
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
|
||||
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
|
||||
const templatePattern = /\{\{[^}]+\}\}/;
|
||||
|
||||
export function HeadersInput({
|
||||
value = [],
|
||||
onChange,
|
||||
onValidityChange,
|
||||
placeholder = `X-Example-Header: example-value
|
||||
X-Another-Header: another-value`,
|
||||
rows = 4,
|
||||
className
|
||||
}: HeadersInputProps) {
|
||||
const t = useTranslations();
|
||||
const [internalValue, setInternalValue] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isUserEditingRef = useRef(false);
|
||||
|
||||
@@ -46,56 +34,37 @@ X-Another-Header: another-value`,
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
// Parse newline-separated text into header objects, validating each line
|
||||
// against the same rules enforced by the server. Returns either the
|
||||
// parsed headers or an error message describing the first invalid line.
|
||||
const parseHeaders = (
|
||||
// Convert newline-separated string to header objects array
|
||||
const convertToHeadersArray = (
|
||||
newlineSeparated: string
|
||||
):
|
||||
| { headers: { name: string; value: string }[]; error: null }
|
||||
| { headers: null; error: string } => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") {
|
||||
return { headers: [], error: null };
|
||||
}
|
||||
): { name: string; value: string }[] | null => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
|
||||
|
||||
const lines = newlineSeparated
|
||||
return newlineSeparated
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
.filter((line) => line.length > 0 && line.includes(":"))
|
||||
.map((line) => {
|
||||
const colonIndex = line.indexOf(":");
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
const headers: { name: string; value: string }[] = [];
|
||||
// Ensure header name conforms to HTTP header requirements
|
||||
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
|
||||
const normalizedName = name
|
||||
.replace(/[^a-zA-Z0-9\-]/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return { headers: null, error: t("headersValidationError") };
|
||||
}
|
||||
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
if (
|
||||
!validHeaderNamePattern.test(name) ||
|
||||
!validHeaderValuePattern.test(value) ||
|
||||
templatePattern.test(name) ||
|
||||
templatePattern.test(value)
|
||||
) {
|
||||
return { headers: null, error: t("headersValidationError") };
|
||||
}
|
||||
|
||||
headers.push({ name, value });
|
||||
}
|
||||
|
||||
return { headers, error: null };
|
||||
return { name: normalizedName, value };
|
||||
})
|
||||
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
|
||||
};
|
||||
|
||||
// Update internal value when external value changes
|
||||
// But only if the user is not currently editing (textarea not focused)
|
||||
useEffect(() => {
|
||||
if (!isUserEditingRef.current) {
|
||||
setInternalValue(convertToNewlineSeparated(value ?? []));
|
||||
setError(null);
|
||||
onValidityChange?.(true);
|
||||
setInternalValue(convertToNewlineSeparated(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
@@ -106,20 +75,31 @@ X-Another-Header: another-value`,
|
||||
// Mark that user is actively editing
|
||||
isUserEditingRef.current = true;
|
||||
|
||||
const result = parseHeaders(newValue);
|
||||
// Only update parent if the input is in a valid state
|
||||
// Valid states: empty/whitespace only, or contains properly formatted headers
|
||||
|
||||
if (result.error) {
|
||||
// Surface the error and do not touch the last known good value.
|
||||
// Silently dropping the update here (without telling the user)
|
||||
// is what previously let stale data get saved without warning.
|
||||
setError(result.error);
|
||||
onValidityChange?.(false);
|
||||
return;
|
||||
if (newValue.trim() === "") {
|
||||
// Empty input is valid - represents no headers
|
||||
onChange([]);
|
||||
} else {
|
||||
// Check if all non-empty lines are properly formatted (contain ':')
|
||||
const lines = newValue.split("\n");
|
||||
const nonEmptyLines = lines
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
|
||||
const isValid =
|
||||
nonEmptyLines.length === 0 ||
|
||||
nonEmptyLines.every((line) => line.includes(":"));
|
||||
|
||||
if (isValid) {
|
||||
// Safe to convert and update parent
|
||||
const headersArray = convertToHeadersArray(newValue);
|
||||
onChange(headersArray);
|
||||
}
|
||||
// If not valid, don't call onChange - let user continue typing
|
||||
}
|
||||
|
||||
setError(null);
|
||||
onValidityChange?.(true);
|
||||
onChange(result.headers);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -134,20 +114,15 @@ X-Another-Header: another-value`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-1.5">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,12 @@ import { cn } from "@app/lib/cn";
|
||||
export function InfoSections({
|
||||
children,
|
||||
cols,
|
||||
columnSizing = "content",
|
||||
layout = "default"
|
||||
columnSizing = "content"
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols?: number;
|
||||
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
||||
columnSizing?: "fill" | "content";
|
||||
/** panel: fixed 2-column grid for narrow containers such as side panels */
|
||||
layout?: "default" | "panel";
|
||||
}) {
|
||||
const n = cols || 1;
|
||||
const track =
|
||||
@@ -22,14 +19,9 @@ export function InfoSections({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full min-w-0 gap-4",
|
||||
layout === "panel"
|
||||
? "grid-cols-2 items-start"
|
||||
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
|
||||
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
|
||||
columnSizing === "content" &&
|
||||
(layout === "panel"
|
||||
? "justify-items-start justify-start"
|
||||
: "md:justify-items-start md:justify-start")
|
||||
"md:justify-items-start md:justify-start"
|
||||
)}
|
||||
style={{
|
||||
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
||||
@@ -25,7 +25,6 @@ type LabelsFilterSelectorProps = {
|
||||
orgId: string;
|
||||
isSelected: (label: LabelFilterOption) => boolean;
|
||||
onToggle: (label: LabelFilterOption) => void;
|
||||
selectedLabels?: LabelFilterOption[];
|
||||
onClear?: () => void;
|
||||
showClear?: boolean;
|
||||
scope?: "org" | "launcher";
|
||||
@@ -35,7 +34,6 @@ export function LabelsFilterSelector({
|
||||
orgId,
|
||||
isSelected,
|
||||
onToggle,
|
||||
selectedLabels = [],
|
||||
onClear,
|
||||
showClear = false,
|
||||
scope = "org"
|
||||
@@ -56,7 +54,7 @@ export function LabelsFilterSelector({
|
||||
...launcherQueries.labels({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 20
|
||||
perPage: 500
|
||||
}),
|
||||
enabled: scope === "launcher"
|
||||
});
|
||||
@@ -65,18 +63,6 @@ export function LabelsFilterSelector({
|
||||
? (launcherLabelsQuery.data ?? [])
|
||||
: (orgLabelsQuery.data ?? []);
|
||||
|
||||
const labelsShown = useMemo(() => {
|
||||
const base = [...labels];
|
||||
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
|
||||
const selectedNotInBase = selectedLabels.filter(
|
||||
(selected) =>
|
||||
!base.some((label) => label.labelId === selected.labelId)
|
||||
);
|
||||
return [...selectedNotInBase, ...base];
|
||||
}
|
||||
return base;
|
||||
}, [debouncedQuery, labels, selectedLabels]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
@@ -95,7 +81,7 @@ export function LabelsFilterSelector({
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{labelsShown.map((label) => (
|
||||
{labels.map((label) => (
|
||||
<CommandItem
|
||||
key={label.labelId}
|
||||
value={label.name}
|
||||
|
||||
@@ -175,46 +175,34 @@ export function LayoutSidebar({
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p>
|
||||
{t("resourceLauncherTitle")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
|
||||
isSidebarCollapsed
|
||||
? "px-2 py-2 justify-center"
|
||||
: "px-3 py-1.5"
|
||||
)}
|
||||
title={
|
||||
isSidebarCollapsed
|
||||
? t("resourceLauncherTitle")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
|
||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
|
||||
!isSidebarCollapsed && "mr-3"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="flex-1">
|
||||
{t("resourceLauncherTitle")}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
@@ -224,44 +212,36 @@ export function LayoutSidebar({
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p>{t("serverAdmin")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Link
|
||||
href="/admin"
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
|
||||
isSidebarCollapsed
|
||||
? "px-2 py-2 justify-center"
|
||||
: "px-3 py-1.5"
|
||||
)}
|
||||
title={
|
||||
isSidebarCollapsed
|
||||
? t("serverAdmin")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
|
||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
|
||||
!isSidebarCollapsed && "mr-3"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("serverAdmin")}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">
|
||||
{t("serverAdmin")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav
|
||||
|
||||
2147
src/components/PrivateResourceForm.tsx
Normal file
2147
src/components/PrivateResourceForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog";
|
||||
import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog";
|
||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||
import {
|
||||
ResourceSitesStatusCell,
|
||||
@@ -32,14 +34,12 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowRight,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpDown,
|
||||
ChevronsUpDownIcon,
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startTransition, useMemo, useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
@@ -79,7 +78,6 @@ export type InternalResourceRow = {
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
niceId: string;
|
||||
enabled: boolean;
|
||||
tcpPortRangeString: string | null;
|
||||
udpPortRangeString: string | null;
|
||||
disableIcmp: boolean;
|
||||
@@ -142,10 +140,13 @@ export default function PrivateResourcesTable({
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const [selectedInternalResource, setSelectedInternalResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
|
||||
@@ -449,17 +450,6 @@ export default function PrivateResourcesTable({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={getPrivateResourceSettingsHref(
|
||||
resourceRow.orgId,
|
||||
resourceRow.niceId
|
||||
)}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedInternalResource(
|
||||
@@ -474,17 +464,15 @@ export default function PrivateResourcesTable({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={getPrivateResourceSettingsHref(
|
||||
resourceRow.orgId,
|
||||
resourceRow.niceId
|
||||
)}
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setEditingResource(resourceRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -592,15 +580,8 @@ export default function PrivateResourcesTable({
|
||||
tableId="internal-resources"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private/create`
|
||||
)
|
||||
)
|
||||
}
|
||||
onAdd={() => setIsCreateDialogOpen(true)}
|
||||
addButtonText={t("resourceAdd")}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
@@ -616,6 +597,34 @@ export default function PrivateResourcesTable({
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
|
||||
{editingResource && (
|
||||
<EditPrivateResourceDialog
|
||||
open={isEditDialogOpen}
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
orgId={orgId}
|
||||
onSuccess={() => {
|
||||
// Delay refresh to allow modal to close smoothly
|
||||
setTimeout(() => {
|
||||
router.refresh();
|
||||
setEditingResource(null);
|
||||
}, 150);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreatePrivateResourceDialog
|
||||
open={isCreateDialogOpen}
|
||||
setOpen={setIsCreateDialogOpen}
|
||||
orgId={orgId}
|
||||
onSuccess={() => {
|
||||
// Delay refresh to allow modal to close smoothly
|
||||
setTimeout(() => {
|
||||
router.refresh();
|
||||
}, 150);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { formatPortRestrictionDisplay } from "@app/lib/launcherResourceDetails";
|
||||
import {
|
||||
formatSiteResourceAccess,
|
||||
formatSiteResourceDestinationDisplay,
|
||||
isSafeUrlForLink,
|
||||
type LauncherAccessFields
|
||||
} from "@app/lib/launcherResourceAccess";
|
||||
import type { PrivateResourceMode } from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SiteResourceInfoInput = {
|
||||
mode: PrivateResourceMode;
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
fullDomain?: string | null;
|
||||
alias?: string | null;
|
||||
aliasAddress?: string | null;
|
||||
authDaemonMode?: "site" | "remote" | "native" | null;
|
||||
tcpPortRangeString?: string | null;
|
||||
udpPortRangeString?: string | null;
|
||||
};
|
||||
|
||||
type SiteResourceInfoBoxVariant = "settings" | "panel";
|
||||
|
||||
type SiteResourceInfoSectionsProps = {
|
||||
siteResource: SiteResourceInfoInput;
|
||||
access: LauncherAccessFields;
|
||||
variant: SiteResourceInfoBoxVariant;
|
||||
accessClassName?: string;
|
||||
};
|
||||
|
||||
function AccessMethodContent({
|
||||
accessDisplay,
|
||||
accessCopyValue,
|
||||
accessUrl,
|
||||
className
|
||||
}: LauncherAccessFields & { className?: string }) {
|
||||
if (!accessDisplay) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const href = accessUrl ?? undefined;
|
||||
const canLink = Boolean(href && isSafeUrlForLink(href));
|
||||
|
||||
if (canLink && href) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={href}
|
||||
displayText={accessDisplay}
|
||||
isLink
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={accessCopyValue || accessDisplay}
|
||||
displayText={accessDisplay}
|
||||
isLink={false}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteResourceInfoSections({
|
||||
siteResource,
|
||||
access,
|
||||
variant,
|
||||
accessClassName
|
||||
}: SiteResourceInfoSectionsProps) {
|
||||
const t = useTranslations();
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
const modeLabel: Record<PrivateResourceMode, string> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
};
|
||||
|
||||
const destination = formatSiteResourceDestinationDisplay({
|
||||
mode: siteResource.mode,
|
||||
destination: siteResource.destination,
|
||||
destinationPort: siteResource.destinationPort,
|
||||
scheme: siteResource.scheme
|
||||
});
|
||||
|
||||
const portRestrictions = formatPortRestrictionDisplay({
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*"
|
||||
});
|
||||
const showAlias =
|
||||
siteResource.mode !== "cidr" && siteResource.mode !== "http";
|
||||
const showDestination = !(
|
||||
siteResource.mode === "ssh" && siteResource.authDaemonMode === "native"
|
||||
);
|
||||
|
||||
const numSections =
|
||||
2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + (isPanel ? 1 : 0);
|
||||
|
||||
const sections = (
|
||||
<InfoSections cols={numSections} layout={isPanel ? "panel" : "default"}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{modeLabel[siteResource.mode]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<AccessMethodContent
|
||||
accessDisplay={access.accessDisplay}
|
||||
accessCopyValue={access.accessCopyValue}
|
||||
accessUrl={access.accessUrl}
|
||||
className={accessClassName}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
{showDestination ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("editInternalResourceDialogDestination")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{destination || "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
|
||||
{showAlias ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("editInternalResourceDialogAlias")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{siteResource.alias?.trim() ? siteResource.alias : "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
|
||||
{isPanel ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("portRestrictions")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{!portRestrictions.hasNonDefaultPorts ? (
|
||||
<span>
|
||||
{t("resourceLauncherNoPortRestrictions")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{portRestrictions.tcp.state !== "all" ? (
|
||||
<div>
|
||||
{t("resourceLauncherTcp")}:{" "}
|
||||
{portRestrictions.tcp.state ===
|
||||
"blocked"
|
||||
? t("blocked")
|
||||
: portRestrictions.tcp.ports}
|
||||
</div>
|
||||
) : null}
|
||||
{portRestrictions.udp.state !== "all" ? (
|
||||
<div>
|
||||
{t("resourceLauncherUdp")}:{" "}
|
||||
{portRestrictions.udp.state ===
|
||||
"blocked"
|
||||
? t("blocked")
|
||||
: portRestrictions.udp.ports}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
</InfoSections>
|
||||
);
|
||||
|
||||
if (isPanel) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{sections}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
type SiteResourceInfoBoxProps = {
|
||||
variant?: "settings";
|
||||
};
|
||||
|
||||
export default function SiteResourceInfoBox({
|
||||
variant = "settings"
|
||||
}: SiteResourceInfoBoxProps) {
|
||||
const { siteResource } = useSiteResourceContext();
|
||||
|
||||
const access = formatSiteResourceAccess({
|
||||
mode: siteResource.mode,
|
||||
destination: siteResource.destination,
|
||||
destinationPort: siteResource.destinationPort,
|
||||
scheme: siteResource.scheme,
|
||||
ssl: siteResource.ssl,
|
||||
fullDomain: siteResource.fullDomain ?? null,
|
||||
alias: siteResource.alias ?? null,
|
||||
aliasAddress: siteResource.aliasAddress ?? null
|
||||
});
|
||||
|
||||
return (
|
||||
<SiteResourceInfoSections
|
||||
siteResource={siteResource}
|
||||
access={access}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { SettingsContainer } from "@app/components/Settings";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
@@ -433,13 +432,19 @@ export default function SiteResourcesOverview({
|
||||
editHref: `/${orgId}/settings/resources/public/${r.niceId}`
|
||||
}));
|
||||
|
||||
const privateRows = privateList.map((row) => ({
|
||||
key: row.siteResourceId,
|
||||
meta: <PrivateResourceMeta row={row} />,
|
||||
name: row.name,
|
||||
access: <PrivateAccessMethod row={row} />,
|
||||
editHref: getPrivateResourceSettingsHref(orgId, row.niceId)
|
||||
}));
|
||||
const privateRows = privateList.map((row) => {
|
||||
const qs = new URLSearchParams({
|
||||
siteId: String(siteId),
|
||||
query: row.niceId
|
||||
});
|
||||
return {
|
||||
key: row.siteResourceId,
|
||||
meta: <PrivateResourceMeta row={row} />,
|
||||
name: row.name,
|
||||
access: <PrivateAccessMethod row={row} />,
|
||||
editHref: `/${orgId}/settings/resources/private?${qs.toString()}`
|
||||
};
|
||||
});
|
||||
|
||||
if (showEmptyPlaceholder) {
|
||||
return (
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsFormCell } from "@app/components/Settings";
|
||||
import {
|
||||
StrategySelect,
|
||||
type StrategyOption
|
||||
} from "@app/components/StrategySelect";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type SshServerSettingsFormFields = {
|
||||
pamMode: "passthrough" | "push";
|
||||
standardDaemonLocation: "site" | "remote";
|
||||
authDaemonPort: string;
|
||||
};
|
||||
|
||||
type SshServerSettingsFieldsProps = {
|
||||
pamMode: "passthrough" | "push";
|
||||
standardDaemonLocation: "site" | "remote";
|
||||
authDaemonPort: string;
|
||||
onPamModeChange: (value: "passthrough" | "push") => void;
|
||||
onStandardDaemonLocationChange: (value: "site" | "remote") => void;
|
||||
onAuthDaemonPortChange: (value: string) => void;
|
||||
authDaemonPortError?: string;
|
||||
sshServerMode: "standard" | "native";
|
||||
serverModeDisplay: "badge" | "select";
|
||||
onServerModeChange?: (mode: "standard" | "native") => void;
|
||||
sshServerModeOptions?: StrategyOption<"standard" | "native">[];
|
||||
idPrefix?: string;
|
||||
};
|
||||
|
||||
export function SshServerSettingsFields({
|
||||
pamMode,
|
||||
standardDaemonLocation,
|
||||
authDaemonPort,
|
||||
onPamModeChange,
|
||||
onStandardDaemonLocationChange,
|
||||
onAuthDaemonPortChange,
|
||||
authDaemonPortError,
|
||||
sshServerMode,
|
||||
serverModeDisplay,
|
||||
onServerModeChange,
|
||||
sshServerModeOptions,
|
||||
idPrefix = "ssh-server"
|
||||
}: SshServerSettingsFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const isNative = sshServerMode === "native";
|
||||
const showDaemonLocation = !isNative && pamMode === "push";
|
||||
const showDaemonPort =
|
||||
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
|
||||
|
||||
const authMethodOptions = useMemo(
|
||||
(): StrategyOption<"passthrough" | "push">[] => [
|
||||
{
|
||||
id: "passthrough",
|
||||
title: t("sshAuthMethodManual"),
|
||||
description: t("sshAuthMethodManualDescription")
|
||||
},
|
||||
{
|
||||
id: "push",
|
||||
title: t("sshAuthMethodAutomated"),
|
||||
description: t("sshAuthMethodAutomatedDescription")
|
||||
}
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const daemonLocationOptions = useMemo(
|
||||
(): StrategyOption<"site" | "remote">[] => [
|
||||
{
|
||||
id: "site",
|
||||
title: t("internalResourceAuthDaemonSite"),
|
||||
description: t("sshDaemonLocationSiteDescription")
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
title: t("sshDaemonLocationRemote"),
|
||||
description: t("sshDaemonLocationRemoteDescription")
|
||||
}
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const defaultSshServerModeOptions = useMemo(
|
||||
(): StrategyOption<"standard" | "native">[] => [
|
||||
{
|
||||
id: "native",
|
||||
title: t("sshServerModePangolin"),
|
||||
description: t("sshServerModeNativeDescription")
|
||||
},
|
||||
{
|
||||
id: "standard",
|
||||
title: t("sshServerModeStandard"),
|
||||
description: t("sshServerModeStandardDescription")
|
||||
}
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const modeOptions = sshServerModeOptions ?? defaultSshServerModeOptions;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
{serverModeDisplay === "badge" ? (
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode === "standard"
|
||||
? t("sshServerModeStandard")
|
||||
: t("sshServerModePangolin")}
|
||||
</Badge>
|
||||
) : (
|
||||
<StrategySelect<"standard" | "native">
|
||||
idPrefix={`${idPrefix}-mode`}
|
||||
value={sshServerMode}
|
||||
options={modeOptions}
|
||||
onChange={(value) => onServerModeChange?.(value)}
|
||||
cols={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthenticationMethod")}
|
||||
</p>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
idPrefix={`${idPrefix}-auth`}
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
onChange={onPamModeChange}
|
||||
cols={2}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
{showDaemonLocation && (
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</p>
|
||||
<StrategySelect<"site" | "remote">
|
||||
idPrefix={`${idPrefix}-daemon`}
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
onChange={onStandardDaemonLocationChange}
|
||||
cols={2}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{showDaemonPort && (
|
||||
<SettingsFormCell span="half">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-daemon-port`}>
|
||||
{t("sshDaemonPort")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-daemon-port`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={authDaemonPort}
|
||||
onChange={(e) =>
|
||||
onAuthDaemonPortChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
{authDaemonPortError ? (
|
||||
<p className="text-destructive text-sm">
|
||||
{authDaemonPortError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ interface StrategySelectProps<TValue extends string> {
|
||||
defaultValue?: TValue;
|
||||
onChange?: (value: TValue) => void;
|
||||
cols?: number;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
export function StrategySelect<TValue extends string>({
|
||||
@@ -26,8 +25,7 @@ export function StrategySelect<TValue extends string>({
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
cols = 1,
|
||||
idPrefix = "strategy"
|
||||
cols = 1
|
||||
}: StrategySelectProps<TValue>) {
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<
|
||||
TValue | undefined
|
||||
@@ -51,49 +49,43 @@ export function StrategySelect<TValue extends string>({
|
||||
}}
|
||||
className="grid md:grid-cols-(--cols) gap-4"
|
||||
>
|
||||
{options.map((option: StrategyOption<TValue>) => {
|
||||
const optionId = `${idPrefix}-${option.id}`;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
data-state={
|
||||
selected === option.id ? "checked" : "unchecked"
|
||||
}
|
||||
className={cn(
|
||||
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
|
||||
option.disabled
|
||||
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: selected === option.id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-input hover:bg-accent"
|
||||
{options.map((option: StrategyOption<TValue>) => (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={option.id}
|
||||
data-state={
|
||||
selected === option.id ? "checked" : "unchecked"
|
||||
}
|
||||
className={cn(
|
||||
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
|
||||
option.disabled
|
||||
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: selected === option.id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-input hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={option.id}
|
||||
disabled={option.disabled}
|
||||
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
||||
/>
|
||||
<div className="flex gap-3 pl-7">
|
||||
{option.icon && (
|
||||
<div className="mt-1">{option.icon}</div>
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={optionId}
|
||||
disabled={option.disabled}
|
||||
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
||||
/>
|
||||
<div className="flex gap-3 pl-7">
|
||||
{option.icon && (
|
||||
<div className="mt-1">{option.icon}</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{option.title}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{typeof option.description === "string"
|
||||
? option.description
|
||||
: option.description}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{option.title}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{typeof option.description === "string"
|
||||
? option.description
|
||||
: option.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ export type MultiSitesSelectorProps = {
|
||||
onSelectionChange: (sites: Selectedsite[]) => void;
|
||||
filterTypes?: string[];
|
||||
scope?: "org" | "launcher";
|
||||
onClear?: () => void;
|
||||
showClear?: boolean;
|
||||
};
|
||||
|
||||
export function formatMultiSitesSelectorLabel(
|
||||
@@ -44,9 +42,7 @@ export function MultiSitesSelector({
|
||||
selectedSites,
|
||||
onSelectionChange,
|
||||
filterTypes,
|
||||
scope = "org",
|
||||
onClear,
|
||||
showClear = false
|
||||
scope = "org"
|
||||
}: MultiSitesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||
@@ -64,7 +60,7 @@ export function MultiSitesSelector({
|
||||
...launcherQueries.sites({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 20
|
||||
perPage: 500
|
||||
}),
|
||||
enabled: scope === "launcher"
|
||||
});
|
||||
@@ -111,14 +107,6 @@ export function MultiSitesSelector({
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showClear && onClear && (
|
||||
<CommandItem
|
||||
onSelect={onClear}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{sitesShown.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type SelectedLabel
|
||||
} from "@app/components/labels-selector";
|
||||
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
@@ -47,14 +46,14 @@ export function LauncherFilterPopover({
|
||||
const { data: labels = [] } = useQuery(
|
||||
launcherQueries.labels({
|
||||
orgId,
|
||||
perPage: 20
|
||||
perPage: 500
|
||||
})
|
||||
);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
launcherQueries.sites({
|
||||
orgId,
|
||||
perPage: 20
|
||||
perPage: 500
|
||||
})
|
||||
);
|
||||
|
||||
@@ -97,32 +96,14 @@ export function LauncherFilterPopover({
|
||||
[labels, selectedLabels]
|
||||
);
|
||||
|
||||
const activeFilterCount = selectedSites.length + selectedLabels.length;
|
||||
|
||||
return (
|
||||
<Popover modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative shrink-0"
|
||||
>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<Funnel className="size-4" />
|
||||
<span className="sr-only">
|
||||
{activeFilterCount > 0
|
||||
? t("resourceLauncherFilterWithCount", {
|
||||
count: activeFilterCount
|
||||
})
|
||||
: t("resourceLauncherFilter")}
|
||||
{t("resourceLauncherFilter")}
|
||||
</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1.5 text-xs"
|
||||
>
|
||||
{activeFilterCount > 99 ? "99+" : activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
@@ -158,10 +139,6 @@ export function LauncherFilterPopover({
|
||||
selectedSites={resolvedSelectedSites}
|
||||
onSelectionChange={onSitesChange}
|
||||
scope="launcher"
|
||||
showClear={selectedSites.length > 0}
|
||||
onClear={() => {
|
||||
onSitesChange([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -195,7 +172,6 @@ export function LauncherFilterPopover({
|
||||
<LabelsFilterSelector
|
||||
orgId={orgId}
|
||||
scope="launcher"
|
||||
selectedLabels={resolvedSelectedLabels}
|
||||
isSelected={(label) =>
|
||||
selectedLabelIds.has(label.labelId)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user