mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-25 05:25:39 +02:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e9a040174 | |||
| a5332bb0cc | |||
| b3963cc34b | |||
| ddb132f9fa | |||
| cdc50ed47a | |||
| e2441ce284 | |||
| 0b6a3234a5 | |||
| ae8599c723 | |||
| 938e9b0d49 | |||
| 05e4ad3200 | |||
| 9eb55ba68c | |||
| 6d14a4df49 | |||
| 94949aa3fd | |||
| df098f55ba | |||
| facbb8f0a4 | |||
| 36fbd8818c | |||
| 91883397e6 | |||
| fd1813f3a7 | |||
| 0d820df797 | |||
| 76aea311a4 | |||
| 1a3cf2094b | |||
| 09cb20a084 | |||
| d1fb2e19d3 | |||
| 2934bbdd20 | |||
| 2b46e8eaba | |||
| 3b89104a59 | |||
| 5bf8b336c5 | |||
| 21a144753d | |||
| c1b8dfc863 | |||
| 5efcd4479a | |||
| e4e8b33e9f | |||
| af13790c93 | |||
| 87bcd8ec1b | |||
| b3cfe82dff | |||
| d65128671c | |||
| 41fdd5de74 | |||
| 2704202ba9 | |||
| 72ef0ae020 | |||
| 1442faa740 | |||
| 6aa589e612 | |||
| 4b1a8e14c4 | |||
| 1a0db10b1a | |||
| b7634086db | |||
| c47c411161 | |||
| e88e262abe | |||
| a163cc3678 | |||
| 1dfb3408e8 | |||
| 67fb2beba1 | |||
| 6cacc9b83f | |||
| 1f1791feb7 | |||
| 1ba75092f9 | |||
| 08a08e73b3 | |||
| c500979099 | |||
| 2d9c082607 | |||
| 7968c4357b | |||
| 25c08e7279 | |||
| 82745c701a | |||
| 18d380ce30 | |||
| 68d7b0a416 | |||
| 43546c84eb | |||
| eac36ee442 | |||
| 9a88394efe | |||
| 173562654b | |||
| 8f7e5ab1ed | |||
| 4334480675 | |||
| 6aa406927a | |||
| 5b50024712 | |||
| ce746a2a21 | |||
| 7120ab4b22 | |||
| 12e777b32e | |||
| 9378103ddd | |||
| ec794d5de2 | |||
| 12b18a3e8c | |||
| 91e8a13e59 | |||
| 931ba0f540 | |||
| d321d7275c | |||
| 3855486a00 | |||
| ab494521b1 | |||
| 549e1ead1d | |||
| a0759a79a1 | |||
| 14e1a119d3 | |||
| 6e066d38b0 | |||
| 21f72639b6 | |||
| 8a0c2031d4 | |||
| 56d3a466e5 | |||
| 563e505cc1 | |||
| c44c02b8ba | |||
| b9ab35a05b | |||
| 2fd519e102 | |||
| a63c1ec364 | |||
| e61ef2ca2a | |||
| 39b09b7f3f | |||
| 840cc214e3 | |||
| 72524db52d | |||
| ab8fc11ab3 | |||
| 1831ca4e75 | |||
| 0d04cc365f | |||
| 09baf2f32e | |||
| 3253d60900 | |||
| 81274960f6 |
@@ -14,12 +14,13 @@ body:
|
|||||||
label: Environment
|
label: Environment
|
||||||
description: Please fill out the relevant details below for your environment.
|
description: Please fill out the relevant details below for your environment.
|
||||||
value: |
|
value: |
|
||||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
- OS Type & Version:
|
||||||
- Pangolin Version:
|
- Pangolin Version:
|
||||||
|
- Edition (Community or Enterprise):
|
||||||
- Gerbil Version:
|
- Gerbil Version:
|
||||||
- Traefik Version:
|
- Traefik Version:
|
||||||
- Newt Version:
|
- Newt Version:
|
||||||
- Olm Version: (if applicable)
|
- Client Version:
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "./server/lib/consts";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.42.0
|
golang.org/x/term v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
|||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
+5
-32
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Отидете към ресурса",
|
"resourceGoTo": "Отидете към ресурса",
|
||||||
"resourceDelete": "Изтрийте ресурс",
|
"resourceDelete": "Изтрийте ресурс",
|
||||||
"resourceDeleteConfirm": "Потвърдете изтриване на ресурс",
|
"resourceDeleteConfirm": "Потвърдете изтриване на ресурс",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Видимост",
|
"visibility": "Видимост",
|
||||||
"enabled": "Активиран",
|
"enabled": "Активиран",
|
||||||
"disabled": "Деактивиран",
|
"disabled": "Деактивиран",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Създаден на",
|
"createdAt": "Създаден на",
|
||||||
"proxyErrorInvalidHeader": "Невалидна стойност за заглавие на хоста. Използвайте формат на име на домейн, или оставете празно поле за да премахнете персонализирано заглавие на хост.",
|
"proxyErrorInvalidHeader": "Невалидна стойност за заглавие на хоста. Използвайте формат на име на домейн, или оставете празно поле за да премахнете персонализирано заглавие на хост.",
|
||||||
"proxyErrorTls": "Невалидно име на TLS сървър. Използвайте формат на име на домейн, или оставете празно за да премахнете името на TLS сървъра.",
|
"proxyErrorTls": "Невалидно име на TLS сървър. Използвайте формат на име на домейн, или оставете празно за да премахнете името на TLS сървъра.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "Активиране на TLS",
|
||||||
"proxyEnableSSLDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целите.",
|
"proxyEnableSSLDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целите.",
|
||||||
"target": "Цел",
|
"target": "Цел",
|
||||||
"configureTarget": "Конфигуриране на цели",
|
"configureTarget": "Конфигуриране на цели",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Имаше проблем със свързването към {name}. Моля, свържете се с вашия администратор.",
|
"idpErrorConnectingTo": "Имаше проблем със свързването към {name}. Моля, свържете се с вашия администратор.",
|
||||||
"idpErrorNotFound": "Не е намерен идентификационен доставчик",
|
"idpErrorNotFound": "Не е намерен идентификационен доставчик",
|
||||||
"inviteInvalid": "Невалидна покана",
|
"inviteInvalid": "Невалидна покана",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Линкът към поканата е невалиден.",
|
"inviteInvalidDescription": "Линкът към поканата е невалиден.",
|
||||||
"inviteErrorWrongUser": "Поканата не е за този потребител",
|
"inviteErrorWrongUser": "Поканата не е за този потребител",
|
||||||
"inviteErrorUserNotExists": "Потребителят не съществува. Моля, създайте акаунт първо.",
|
"inviteErrorUserNotExists": "Потребителят не съществува. Моля, създайте акаунт първо.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Сертификат",
|
"certificateStatus": "Сертификат",
|
||||||
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
|
"certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.",
|
||||||
"loading": "Зареждане",
|
"loading": "Зареждане",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Зареждане на анализи",
|
"loadingAnalytics": "Зареждане на анализи",
|
||||||
"restart": "Рестарт",
|
"restart": "Рестарт",
|
||||||
"domains": "Домейни",
|
"domains": "Домейни",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Метод",
|
"editInternalResourceDialogScheme": "Метод",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Активирайте TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
|
"editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
|
||||||
"editInternalResourceDialogDestination": "Дестинация",
|
"editInternalResourceDialogDestination": "Дестинация",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.",
|
"editInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Метод",
|
"scheme": "Метод",
|
||||||
"createInternalResourceDialogScheme": "Метод",
|
"createInternalResourceDialogScheme": "Метод",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Активирайте TLS",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
|
"createInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
|
||||||
"createInternalResourceDialogDestination": "Дестинация",
|
"createInternalResourceDialogDestination": "Дестинация",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.",
|
"createInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "По-надежден и по-нисък поддръжка на Самостоятелно-хостван Панголиин сървър с допълнителни екстри",
|
"description": "По-надежден и по-нисък поддръжка на Самостоятелно-хостван Панголиин сървър с допълнителни екстри",
|
||||||
"introTitle": "Управлявано Самостоятелно-хостван Панголиин",
|
"introTitle": "Управлявано Самостоятелно-хостван Панголиин",
|
||||||
"introDescription": "е опция за внедряване, предназначена за хора, които искат простота и допълнителна надеждност, като същевременно запазят данните си частни и самостоятелно-хоствани.",
|
"introDescription": "е опция за внедряване, предназначена за хора, които искат простота и допълнителна надеждност, като същевременно запазят данните си частни и самостоятелно-хоствани.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "С тази опция все още управлявате свой собствен Панголиин възел - вашите тунели, TLS терминатора и трафик остават на вашия сървър. Разликата е, че управлението и мониторингът се обработват чрез нашия облачен панел за контрол, който отключва редица предимства:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "По-прости операции",
|
"title": "По-прости операции",
|
||||||
"description": "Няма нужда да управлявате свой собствен имейл сървър или да настройвате сложни аларми. Ще получите проверки и предупреждения при прекъсване от самото начало."
|
"description": "Няма нужда да управлявате свой собствен имейл сървър или да настройвате сложни аларми. Ще получите проверки и предупреждения при прекъсване от самото начало."
|
||||||
@@ -3163,7 +3136,7 @@
|
|||||||
"httpDestNamePlaceholder": "Моята HTTP дестинация",
|
"httpDestNamePlaceholder": "Моята HTTP дестинация",
|
||||||
"httpDestUrlLabel": "Дестинация URL",
|
"httpDestUrlLabel": "Дестинация URL",
|
||||||
"httpDestUrlErrorHttpRequired": "URL адресът трябва да използва http или https",
|
"httpDestUrlErrorHttpRequired": "URL адресът трябва да използва http или https",
|
||||||
"httpDestUrlErrorHttpsRequired": "SSL е необходимо за облачни инсталации",
|
"httpDestUrlErrorHttpsRequired": "HTTPS е необходимо за облачни инсталации",
|
||||||
"httpDestUrlErrorInvalid": "Въведете валиден URL (напр. https://example.com/webhook)",
|
"httpDestUrlErrorInvalid": "Въведете валиден URL (напр. https://example.com/webhook)",
|
||||||
"httpDestAuthTitle": "Удостоверяване",
|
"httpDestAuthTitle": "Удостоверяване",
|
||||||
"httpDestAuthDescription": "Изберете как заявленията ви се удостоверяват.",
|
"httpDestAuthDescription": "Изберете как заявленията ви се удостоверяват.",
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Přejít na dokument",
|
"resourceGoTo": "Přejít na dokument",
|
||||||
"resourceDelete": "Odstranit dokument",
|
"resourceDelete": "Odstranit dokument",
|
||||||
"resourceDeleteConfirm": "Potvrdit odstranění dokumentu",
|
"resourceDeleteConfirm": "Potvrdit odstranění dokumentu",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Viditelnost",
|
"visibility": "Viditelnost",
|
||||||
"enabled": "Povoleno",
|
"enabled": "Povoleno",
|
||||||
"disabled": "Zakázáno",
|
"disabled": "Zakázáno",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Vytvořeno v",
|
"createdAt": "Vytvořeno v",
|
||||||
"proxyErrorInvalidHeader": "Neplatná hodnota hlavičky hostitele. Použijte formát názvu domény, nebo uložte prázdné pro zrušení vlastního hlavičky hostitele.",
|
"proxyErrorInvalidHeader": "Neplatná hodnota hlavičky hostitele. Použijte formát názvu domény, nebo uložte prázdné pro zrušení vlastního hlavičky hostitele.",
|
||||||
"proxyErrorTls": "Neplatné jméno TLS serveru. Použijte formát doménového jména nebo uložte prázdné pro odstranění názvu TLS serveru.",
|
"proxyErrorTls": "Neplatné jméno TLS serveru. Použijte formát doménového jména nebo uložte prázdné pro odstranění názvu TLS serveru.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "Povolit TLS",
|
||||||
"proxyEnableSSLDescription": "Povolit šifrování SSL/TLS pro zabezpečená připojení HTTPS k cílům.",
|
"proxyEnableSSLDescription": "Povolit šifrování SSL/TLS pro zabezpečená připojení HTTPS k cílům.",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "Konfigurace cílů",
|
"configureTarget": "Konfigurace cílů",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Při připojování k {name}došlo k chybě. Obraťte se na správce.",
|
"idpErrorConnectingTo": "Při připojování k {name}došlo k chybě. Obraťte se na správce.",
|
||||||
"idpErrorNotFound": "IdP nenalezen",
|
"idpErrorNotFound": "IdP nenalezen",
|
||||||
"inviteInvalid": "Neplatná pozvánka",
|
"inviteInvalid": "Neplatná pozvánka",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Odkaz pro pozvání je neplatný.",
|
"inviteInvalidDescription": "Odkaz pro pozvání je neplatný.",
|
||||||
"inviteErrorWrongUser": "Pozvat není pro tohoto uživatele",
|
"inviteErrorWrongUser": "Pozvat není pro tohoto uživatele",
|
||||||
"inviteErrorUserNotExists": "Uživatel neexistuje. Nejprve si vytvořte účet.",
|
"inviteErrorUserNotExists": "Uživatel neexistuje. Nejprve si vytvořte účet.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Certifikát",
|
"certificateStatus": "Certifikát",
|
||||||
"certificateStatusAutoRefreshHint": "Stav se automaticky obnovuje.",
|
"certificateStatusAutoRefreshHint": "Stav se automaticky obnovuje.",
|
||||||
"loading": "Načítání",
|
"loading": "Načítání",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Načítání analytiky",
|
"loadingAnalytics": "Načítání analytiky",
|
||||||
"restart": "Restartovat",
|
"restart": "Restartovat",
|
||||||
"domains": "Domény",
|
"domains": "Domény",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Schéma",
|
"editInternalResourceDialogScheme": "Schéma",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Povolit SSL",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
|
"editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
|
||||||
"editInternalResourceDialogDestination": "Místo určení",
|
"editInternalResourceDialogDestination": "Místo určení",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.",
|
"editInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Schéma",
|
"scheme": "Schéma",
|
||||||
"createInternalResourceDialogScheme": "Schéma",
|
"createInternalResourceDialogScheme": "Schéma",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Povolit SSL",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
|
"createInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
|
||||||
"createInternalResourceDialogDestination": "Místo určení",
|
"createInternalResourceDialogDestination": "Místo určení",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.",
|
"createInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Spolehlivější a nízko udržovaný Pangolinův server s dalšími zvony a bičkami",
|
"description": "Spolehlivější a nízko udržovaný Pangolinův server s dalšími zvony a bičkami",
|
||||||
"introTitle": "Spravovaný Pangolin",
|
"introTitle": "Spravovaný Pangolin",
|
||||||
"introDescription": "je možnost nasazení určená pro lidi, kteří chtějí jednoduchost a spolehlivost při zachování soukromých a samoobslužných dat.",
|
"introDescription": "je možnost nasazení určená pro lidi, kteří chtějí jednoduchost a spolehlivost při zachování soukromých a samoobslužných dat.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Pomocí této volby stále provozujete vlastní uzel Pangolin - tunely, SSL terminály a provoz všech pobytů na vašem serveru. Rozdíl spočívá v tom, že řízení a monitorování se řeší prostřednictvím našeho cloudového panelu, který odemkne řadu výhod:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Jednoduchý provoz",
|
"title": "Jednoduchý provoz",
|
||||||
"description": "Není třeba spouštět svůj vlastní poštovní server nebo nastavit komplexní upozornění. Ze schránky dostanete upozornění na zdravotní kontrolu a výpadek."
|
"description": "Není třeba spouštět svůj vlastní poštovní server nebo nastavit komplexní upozornění. Ze schránky dostanete upozornění na zdravotní kontrolu a výpadek."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Zu Ressource gehen",
|
"resourceGoTo": "Zu Ressource gehen",
|
||||||
"resourceDelete": "Ressource löschen",
|
"resourceDelete": "Ressource löschen",
|
||||||
"resourceDeleteConfirm": "Ressource löschen bestätigen",
|
"resourceDeleteConfirm": "Ressource löschen bestätigen",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Sichtbarkeit",
|
"visibility": "Sichtbarkeit",
|
||||||
"enabled": "Aktiviert",
|
"enabled": "Aktiviert",
|
||||||
"disabled": "Deaktiviert",
|
"disabled": "Deaktiviert",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Erstellt am",
|
"createdAt": "Erstellt am",
|
||||||
"proxyErrorInvalidHeader": "Ungültiger benutzerdefinierter Host-Header-Wert. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den benutzerdefinierten Host-Header zu deaktivieren.",
|
"proxyErrorInvalidHeader": "Ungültiger benutzerdefinierter Host-Header-Wert. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den benutzerdefinierten Host-Header zu deaktivieren.",
|
||||||
"proxyErrorTls": "Ungültiger TLS-Servername. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den TLS-Servernamen zu entfernen.",
|
"proxyErrorTls": "Ungültiger TLS-Servername. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den TLS-Servernamen zu entfernen.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "TLS aktivieren",
|
||||||
"proxyEnableSSLDescription": "Aktiviere SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zu den Zielen.",
|
"proxyEnableSSLDescription": "Aktiviere SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zu den Zielen.",
|
||||||
"target": "Ziel",
|
"target": "Ziel",
|
||||||
"configureTarget": "Ziele konfigurieren",
|
"configureTarget": "Ziele konfigurieren",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.",
|
"idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.",
|
||||||
"idpErrorNotFound": "IdP nicht gefunden",
|
"idpErrorNotFound": "IdP nicht gefunden",
|
||||||
"inviteInvalid": "Ungültige Einladung",
|
"inviteInvalid": "Ungültige Einladung",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Der Einladungslink ist ungültig.",
|
"inviteInvalidDescription": "Der Einladungslink ist ungültig.",
|
||||||
"inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer",
|
"inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer",
|
||||||
"inviteErrorUserNotExists": "Benutzer existiert nicht. Bitte erstelle zuerst ein Konto.",
|
"inviteErrorUserNotExists": "Benutzer existiert nicht. Bitte erstelle zuerst ein Konto.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Zertifikat",
|
"certificateStatus": "Zertifikat",
|
||||||
"certificateStatusAutoRefreshHint": "Der Status wird automatisch aktualisiert.",
|
"certificateStatusAutoRefreshHint": "Der Status wird automatisch aktualisiert.",
|
||||||
"loading": "Laden",
|
"loading": "Laden",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Analytik wird geladen",
|
"loadingAnalytics": "Analytik wird geladen",
|
||||||
"restart": "Neustart",
|
"restart": "Neustart",
|
||||||
"domains": "Domänen",
|
"domains": "Domänen",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Schema",
|
"editInternalResourceDialogScheme": "Schema",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "TLS aktivieren",
|
||||||
"editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
|
"editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
|
||||||
"editInternalResourceDialogDestination": "Ziel",
|
"editInternalResourceDialogDestination": "Ziel",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.",
|
"editInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Schema",
|
"scheme": "Schema",
|
||||||
"createInternalResourceDialogScheme": "Schema",
|
"createInternalResourceDialogScheme": "Schema",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "TLS aktivieren",
|
||||||
"createInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
|
"createInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
|
||||||
"createInternalResourceDialogDestination": "Ziel",
|
"createInternalResourceDialogDestination": "Ziel",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.",
|
"createInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen",
|
"description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen",
|
||||||
"introTitle": "Verwalteter selbstgehosteter Pangolin",
|
"introTitle": "Verwalteter selbstgehosteter Pangolin",
|
||||||
"introDescription": "ist eine Deployment-Option, die für Personen konzipiert wurde, die Einfachheit und zusätzliche Zuverlässigkeit wünschen, während sie ihre Daten privat und selbstgehostet halten.",
|
"introDescription": "ist eine Deployment-Option, die für Personen konzipiert wurde, die Einfachheit und zusätzliche Zuverlässigkeit wünschen, während sie ihre Daten privat und selbstgehostet halten.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Mit dieser Option haben Sie immer noch Ihren eigenen Pangolin-Knoten – Ihre Tunnel, TLS-Terminierung und Traffic bleiben auf Ihrem Server. Der Unterschied besteht darin, dass Verwaltung und Überwachung über unser Cloud-Dashboard abgewickelt werden, das eine Reihe von Vorteilen freischaltet:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Einfachere Operationen",
|
"title": "Einfachere Operationen",
|
||||||
"description": "Sie brauchen keinen eigenen Mail-Server auszuführen oder komplexe Warnungen einzurichten. Sie erhalten Gesundheitschecks und Ausfallwarnungen aus dem Box."
|
"description": "Sie brauchen keinen eigenen Mail-Server auszuführen oder komplexe Warnungen einzurichten. Sie erhalten Gesundheitschecks und Ausfallwarnungen aus dem Box."
|
||||||
|
|||||||
@@ -255,6 +255,23 @@
|
|||||||
"resourceGoTo": "Go to Resource",
|
"resourceGoTo": "Go to Resource",
|
||||||
"resourceDelete": "Delete Resource",
|
"resourceDelete": "Delete Resource",
|
||||||
"resourceDeleteConfirm": "Confirm Delete Resource",
|
"resourceDeleteConfirm": "Confirm Delete Resource",
|
||||||
|
"labelDelete": "Delete Label",
|
||||||
|
"labelAdd": "Add Label",
|
||||||
|
"labelCreateSuccessMessage": "Label Created Successfully",
|
||||||
|
"labelEditSuccessMessage": "Label Modified Successfully",
|
||||||
|
"labelNameField": "Label Name",
|
||||||
|
"labelColorField": "Label Color",
|
||||||
|
"labelPlaceholder": "Ex: homelab",
|
||||||
|
"labelCreate": "Create Label",
|
||||||
|
"createLabelDialogTitle": "Create Label",
|
||||||
|
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
||||||
|
"labelEdit": "Edit Label",
|
||||||
|
"editLabelDialogTitle": "Update Label",
|
||||||
|
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
||||||
|
"labelDeleteConfirm": "Confirm Delete Label",
|
||||||
|
"labelErrorDelete": "Failed to delete label",
|
||||||
|
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
||||||
|
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
||||||
"visibility": "Visibility",
|
"visibility": "Visibility",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
@@ -1140,6 +1157,18 @@
|
|||||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||||
"idpErrorNotFound": "IdP not found",
|
"idpErrorNotFound": "IdP not found",
|
||||||
"inviteInvalid": "Invalid Invite",
|
"inviteInvalid": "Invalid Invite",
|
||||||
|
"labels": "Labels",
|
||||||
|
"orgLabelsDescription": "Manage labels in this organization.",
|
||||||
|
"addLabels": "Add labels",
|
||||||
|
"siteLabelsTab": "Labels",
|
||||||
|
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||||
|
"labelsNotFound": "Labels not found",
|
||||||
|
"labelSearch": "Search labels",
|
||||||
|
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||||
|
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||||
|
"accessLabelFilterClear": "Clear label filters",
|
||||||
|
"selectColor": "Select color",
|
||||||
|
"createNewLabel": "Create new org label \"{label}\"",
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
"inviteErrorWrongUser": "Invite is not for this user",
|
"inviteErrorWrongUser": "Invite is not for this user",
|
||||||
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
|
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
|
||||||
@@ -1325,6 +1354,29 @@
|
|||||||
"otpAuthBack": "Back to Password",
|
"otpAuthBack": "Back to Password",
|
||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
|
"commandPaletteTitle": "Command palette",
|
||||||
|
"commandPaletteDescription": "Search for pages, organizations, resources, and actions",
|
||||||
|
"commandPaletteSearchPlaceholder": "Search pages, resources, actions...",
|
||||||
|
"commandPaletteNoResults": "No results found.",
|
||||||
|
"commandPaletteSearching": "Searching...",
|
||||||
|
"commandPaletteNavigation": "Navigation",
|
||||||
|
"commandPaletteOrganizations": "Organizations",
|
||||||
|
"commandPaletteSites": "Sites",
|
||||||
|
"commandPaletteResources": "Resources",
|
||||||
|
"commandPaletteUsers": "Users",
|
||||||
|
"commandPaletteClients": "Machine clients",
|
||||||
|
"commandPaletteActions": "Actions",
|
||||||
|
"commandPaletteCreateSite": "Create site",
|
||||||
|
"commandPaletteCreateProxyResource": "Create public resource",
|
||||||
|
"commandPaletteCreateUser": "Create user",
|
||||||
|
"commandPaletteCreateApiKey": "Create API key",
|
||||||
|
"commandPaletteCreateMachineClient": "Create machine client",
|
||||||
|
"commandPaletteCreateAlertRule": "Create alert rule",
|
||||||
|
"commandPaletteCreateIdentityProvider": "Create identity provider",
|
||||||
|
"commandPaletteToggleTheme": "Toggle theme",
|
||||||
|
"commandPaletteChooseOrganization": "Choose organization",
|
||||||
|
"commandPaletteShortcutMac": "⌘K",
|
||||||
|
"commandPaletteShortcutWindows": "Ctrl K",
|
||||||
"navbarDocsLink": "Documentation",
|
"navbarDocsLink": "Documentation",
|
||||||
"otpErrorEnable": "Unable to enable 2FA",
|
"otpErrorEnable": "Unable to enable 2FA",
|
||||||
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
||||||
@@ -1620,6 +1672,7 @@
|
|||||||
"certificateStatus": "Certificate",
|
"certificateStatus": "Certificate",
|
||||||
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
|
"loadingEllipsis": "Loading...",
|
||||||
"loadingAnalytics": "Loading Analytics",
|
"loadingAnalytics": "Loading Analytics",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"domains": "Domains",
|
"domains": "Domains",
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Ir a Recurso",
|
"resourceGoTo": "Ir a Recurso",
|
||||||
"resourceDelete": "Eliminar Recurso",
|
"resourceDelete": "Eliminar Recurso",
|
||||||
"resourceDeleteConfirm": "Confirmar Borrar Recurso",
|
"resourceDeleteConfirm": "Confirmar Borrar Recurso",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Visibilidad",
|
"visibility": "Visibilidad",
|
||||||
"enabled": "Activado",
|
"enabled": "Activado",
|
||||||
"disabled": "Deshabilitado",
|
"disabled": "Deshabilitado",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Creado el",
|
"createdAt": "Creado el",
|
||||||
"proxyErrorInvalidHeader": "Valor de cabecera de host personalizado no válido. Utilice el formato de nombre de dominio, o guarde en blanco para desestablecer cabecera de host personalizada.",
|
"proxyErrorInvalidHeader": "Valor de cabecera de host personalizado no válido. Utilice el formato de nombre de dominio, o guarde en blanco para desestablecer cabecera de host personalizada.",
|
||||||
"proxyErrorTls": "Nombre de servidor TLS inválido. Utilice el formato de nombre de dominio o guarde en blanco para eliminar el nombre de servidor TLS.",
|
"proxyErrorTls": "Nombre de servidor TLS inválido. Utilice el formato de nombre de dominio o guarde en blanco para eliminar el nombre de servidor TLS.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "Activar TLS",
|
||||||
"proxyEnableSSLDescription": "Habilita el cifrado SSL/TLS para conexiones seguras HTTPS a los objetivos.",
|
"proxyEnableSSLDescription": "Habilita el cifrado SSL/TLS para conexiones seguras HTTPS a los objetivos.",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "Configurar objetivos",
|
"configureTarget": "Configurar objetivos",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.",
|
"idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.",
|
||||||
"idpErrorNotFound": "IdP no encontrado",
|
"idpErrorNotFound": "IdP no encontrado",
|
||||||
"inviteInvalid": "Invitación inválida",
|
"inviteInvalid": "Invitación inválida",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "El enlace de invitación no es válido.",
|
"inviteInvalidDescription": "El enlace de invitación no es válido.",
|
||||||
"inviteErrorWrongUser": "La invitación no es para este usuario",
|
"inviteErrorWrongUser": "La invitación no es para este usuario",
|
||||||
"inviteErrorUserNotExists": "El usuario no existe. Por favor, cree una cuenta primero.",
|
"inviteErrorUserNotExists": "El usuario no existe. Por favor, cree una cuenta primero.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Certificado",
|
"certificateStatus": "Certificado",
|
||||||
"certificateStatusAutoRefreshHint": "El estado se actualiza automáticamente.",
|
"certificateStatusAutoRefreshHint": "El estado se actualiza automáticamente.",
|
||||||
"loading": "Cargando",
|
"loading": "Cargando",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Cargando analíticas",
|
"loadingAnalytics": "Cargando analíticas",
|
||||||
"restart": "Reiniciar",
|
"restart": "Reiniciar",
|
||||||
"domains": "Dominios",
|
"domains": "Dominios",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Esquema",
|
"editInternalResourceDialogScheme": "Esquema",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Activar TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
|
"editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
|
||||||
"editInternalResourceDialogDestination": "Destino",
|
"editInternalResourceDialogDestination": "Destino",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.",
|
"editInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Esquema",
|
"scheme": "Esquema",
|
||||||
"createInternalResourceDialogScheme": "Esquema",
|
"createInternalResourceDialogScheme": "Esquema",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Activar TLS",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
|
"createInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
|
||||||
"createInternalResourceDialogDestination": "Destino",
|
"createInternalResourceDialogDestination": "Destino",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.",
|
"createInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra",
|
"description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra",
|
||||||
"introTitle": "Pangolin autogestionado",
|
"introTitle": "Pangolin autogestionado",
|
||||||
"introDescription": "es una opción de despliegue diseñada para personas que quieren simplicidad y fiabilidad extra mientras mantienen sus datos privados y autoalojados.",
|
"introDescription": "es una opción de despliegue diseñada para personas que quieren simplicidad y fiabilidad extra mientras mantienen sus datos privados y autoalojados.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Con esta opción, todavía ejecuta su propio nodo Pangolin, sus túneles, terminación TLS y tráfico permanecen en su servidor. La diferencia es que la gestión y el control se gestionan a través de nuestro panel de control en la nube, que desbloquea una serie de ventajas:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Operaciones simples",
|
"title": "Operaciones simples",
|
||||||
"description": "No necesitas ejecutar tu propio servidor de correo o configurar alertas complejas. Recibirás cheques de salud y alertas de tiempo de inactividad."
|
"description": "No necesitas ejecutar tu propio servidor de correo o configurar alertas complejas. Recibirás cheques de salud y alertas de tiempo de inactividad."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Aller à la ressource",
|
"resourceGoTo": "Aller à la ressource",
|
||||||
"resourceDelete": "Supprimer la ressource",
|
"resourceDelete": "Supprimer la ressource",
|
||||||
"resourceDeleteConfirm": "Confirmer la suppression de la ressource",
|
"resourceDeleteConfirm": "Confirmer la suppression de la ressource",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Visibilité",
|
"visibility": "Visibilité",
|
||||||
"enabled": "Activé",
|
"enabled": "Activé",
|
||||||
"disabled": "Désactivé",
|
"disabled": "Désactivé",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Créé le",
|
"createdAt": "Créé le",
|
||||||
"proxyErrorInvalidHeader": "Valeur d'en-tête Host personnalisée invalide. Utilisez le format de nom de domaine, ou laissez vide pour désactiver l'en-tête Host personnalisé.",
|
"proxyErrorInvalidHeader": "Valeur d'en-tête Host personnalisée invalide. Utilisez le format de nom de domaine, ou laissez vide pour désactiver l'en-tête Host personnalisé.",
|
||||||
"proxyErrorTls": "Nom de serveur TLS invalide. Utilisez le format de nom de domaine, ou laissez vide pour supprimer le nom de serveur TLS.",
|
"proxyErrorTls": "Nom de serveur TLS invalide. Utilisez le format de nom de domaine, ou laissez vide pour supprimer le nom de serveur TLS.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "Activer TLS",
|
||||||
"proxyEnableSSLDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers les cibles.",
|
"proxyEnableSSLDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers les cibles.",
|
||||||
"target": "Cible",
|
"target": "Cible",
|
||||||
"configureTarget": "Configurer les cibles",
|
"configureTarget": "Configurer les cibles",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.",
|
"idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.",
|
||||||
"idpErrorNotFound": "IdP introuvable",
|
"idpErrorNotFound": "IdP introuvable",
|
||||||
"inviteInvalid": "Invitation invalide",
|
"inviteInvalid": "Invitation invalide",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Le lien d'invitation n'est pas valide.",
|
"inviteInvalidDescription": "Le lien d'invitation n'est pas valide.",
|
||||||
"inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur",
|
"inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur",
|
||||||
"inviteErrorUserNotExists": "L'utilisateur n'existe pas. Veuillez d'abord créer un compte.",
|
"inviteErrorUserNotExists": "L'utilisateur n'existe pas. Veuillez d'abord créer un compte.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Certificat",
|
"certificateStatus": "Certificat",
|
||||||
"certificateStatusAutoRefreshHint": "L'état se rafraîchit automatiquement.",
|
"certificateStatusAutoRefreshHint": "L'état se rafraîchit automatiquement.",
|
||||||
"loading": "Chargement",
|
"loading": "Chargement",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Chargement de l'analyse",
|
"loadingAnalytics": "Chargement de l'analyse",
|
||||||
"restart": "Redémarrer",
|
"restart": "Redémarrer",
|
||||||
"domains": "Domaines",
|
"domains": "Domaines",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Méthode HTTP",
|
"editInternalResourceDialogScheme": "Méthode HTTP",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Activer TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
|
"editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
|
||||||
"editInternalResourceDialogDestination": "Destination",
|
"editInternalResourceDialogDestination": "Destination",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.",
|
"editInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Méthode HTTP",
|
"scheme": "Méthode HTTP",
|
||||||
"createInternalResourceDialogScheme": "Méthode HTTP",
|
"createInternalResourceDialogScheme": "Méthode HTTP",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Activer TLS",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
|
"createInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
|
||||||
"createInternalResourceDialogDestination": "Destination",
|
"createInternalResourceDialogDestination": "Destination",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.",
|
"createInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires",
|
"description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires",
|
||||||
"introTitle": "Pangolin auto-hébergé géré",
|
"introTitle": "Pangolin auto-hébergé géré",
|
||||||
"introDescription": "est une option de déploiement conçue pour les personnes qui veulent de la simplicité et de la fiabilité tout en gardant leurs données privées et auto-hébergées.",
|
"introDescription": "est une option de déploiement conçue pour les personnes qui veulent de la simplicité et de la fiabilité tout en gardant leurs données privées et auto-hébergées.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin - vos tunnels, la terminaison TLS et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Opérations plus simples",
|
"title": "Opérations plus simples",
|
||||||
"description": "Pas besoin de faire tourner votre propre serveur de messagerie ou de configurer des alertes complexes. Vous obtiendrez des contrôles de santé et des alertes de temps d'arrêt par la suite."
|
"description": "Pas besoin de faire tourner votre propre serveur de messagerie ou de configurer des alertes complexes. Vous obtiendrez des contrôles de santé et des alertes de temps d'arrêt par la suite."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Vai alla Risorsa",
|
"resourceGoTo": "Vai alla Risorsa",
|
||||||
"resourceDelete": "Elimina Risorsa",
|
"resourceDelete": "Elimina Risorsa",
|
||||||
"resourceDeleteConfirm": "Conferma Eliminazione Risorsa",
|
"resourceDeleteConfirm": "Conferma Eliminazione Risorsa",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Visibilità",
|
"visibility": "Visibilità",
|
||||||
"enabled": "Abilitato",
|
"enabled": "Abilitato",
|
||||||
"disabled": "Disabilitato",
|
"disabled": "Disabilitato",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Creato Il",
|
"createdAt": "Creato Il",
|
||||||
"proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.",
|
"proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.",
|
||||||
"proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.",
|
"proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "Abilita TLS",
|
||||||
"proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alle risorse interne target.",
|
"proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alle risorse interne target.",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "Configura Risorse Interne",
|
"configureTarget": "Configura Risorse Interne",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.",
|
"idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.",
|
||||||
"idpErrorNotFound": "IdP non trovato",
|
"idpErrorNotFound": "IdP non trovato",
|
||||||
"inviteInvalid": "Invito Non Valido",
|
"inviteInvalid": "Invito Non Valido",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Il link di invito non è valido.",
|
"inviteInvalidDescription": "Il link di invito non è valido.",
|
||||||
"inviteErrorWrongUser": "L'invito non è per questo utente",
|
"inviteErrorWrongUser": "L'invito non è per questo utente",
|
||||||
"inviteErrorUserNotExists": "L'utente non esiste. Si prega di creare prima un account.",
|
"inviteErrorUserNotExists": "L'utente non esiste. Si prega di creare prima un account.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Certificato",
|
"certificateStatus": "Certificato",
|
||||||
"certificateStatusAutoRefreshHint": "Lo stato si aggiorna automaticamente.",
|
"certificateStatusAutoRefreshHint": "Lo stato si aggiorna automaticamente.",
|
||||||
"loading": "Caricamento",
|
"loading": "Caricamento",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Caricamento Delle Analisi",
|
"loadingAnalytics": "Caricamento Delle Analisi",
|
||||||
"restart": "Riavvia",
|
"restart": "Riavvia",
|
||||||
"domains": "Domini",
|
"domains": "Domini",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Metodo HTTP",
|
"editInternalResourceDialogScheme": "Metodo HTTP",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Abilitare TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
|
"editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
|
||||||
"editInternalResourceDialogDestination": "Destinazione",
|
"editInternalResourceDialogDestination": "Destinazione",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.",
|
"editInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Metodo HTTP",
|
"scheme": "Metodo HTTP",
|
||||||
"createInternalResourceDialogScheme": "Metodo HTTP",
|
"createInternalResourceDialogScheme": "Metodo HTTP",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Abilitare TLS",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
|
"createInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
|
||||||
"createInternalResourceDialogDestination": "Destinazione",
|
"createInternalResourceDialogDestination": "Destinazione",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.",
|
"createInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra",
|
"description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra",
|
||||||
"introTitle": "Managed Self-Hosted Pangolin",
|
"introTitle": "Managed Self-Hosted Pangolin",
|
||||||
"introDescription": "è un'opzione di distribuzione progettata per le persone che vogliono la semplicità e l'affidabilità extra mantenendo i loro dati privati e self-hosted.",
|
"introDescription": "è un'opzione di distribuzione progettata per le persone che vogliono la semplicità e l'affidabilità extra mantenendo i loro dati privati e self-hosted.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin - i tunnel, la terminazione TLS e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Operazioni più semplici",
|
"title": "Operazioni più semplici",
|
||||||
"description": "Non è necessario eseguire il proprio server di posta o impostare un avviso complesso. Otterrai controlli di salute e avvisi di inattività fuori dalla casella."
|
"description": "Non è necessario eseguire il proprio server di posta o impostare un avviso complesso. Otterrai controlli di salute e avvisi di inattività fuori dalla casella."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "리소스로 이동",
|
"resourceGoTo": "리소스로 이동",
|
||||||
"resourceDelete": "리소스 삭제",
|
"resourceDelete": "리소스 삭제",
|
||||||
"resourceDeleteConfirm": "리소스 삭제 확인",
|
"resourceDeleteConfirm": "리소스 삭제 확인",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "가시성",
|
"visibility": "가시성",
|
||||||
"enabled": "활성화됨",
|
"enabled": "활성화됨",
|
||||||
"disabled": "비활성화됨",
|
"disabled": "비활성화됨",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "생성일",
|
"createdAt": "생성일",
|
||||||
"proxyErrorInvalidHeader": "잘못된 사용자 정의 호스트 헤더 값입니다. 도메인 이름 형식을 사용하거나 사용자 정의 호스트 헤더를 해제하려면 비워 두십시오.",
|
"proxyErrorInvalidHeader": "잘못된 사용자 정의 호스트 헤더 값입니다. 도메인 이름 형식을 사용하거나 사용자 정의 호스트 헤더를 해제하려면 비워 두십시오.",
|
||||||
"proxyErrorTls": "유효하지 않은 TLS 서버 이름입니다. 도메인 이름 형식을 사용하거나 비워 두어 TLS 서버 이름을 제거하십시오.",
|
"proxyErrorTls": "유효하지 않은 TLS 서버 이름입니다. 도메인 이름 형식을 사용하거나 비워 두어 TLS 서버 이름을 제거하십시오.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "TLS 활성화",
|
||||||
"proxyEnableSSLDescription": "타겟과의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화를 활성화하세요.",
|
"proxyEnableSSLDescription": "타겟과의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화를 활성화하세요.",
|
||||||
"target": "대상",
|
"target": "대상",
|
||||||
"configureTarget": "대상 구성",
|
"configureTarget": "대상 구성",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.",
|
"idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.",
|
||||||
"idpErrorNotFound": "IdP를 찾을 수 없습니다.",
|
"idpErrorNotFound": "IdP를 찾을 수 없습니다.",
|
||||||
"inviteInvalid": "유효하지 않은 초대",
|
"inviteInvalid": "유효하지 않은 초대",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "초대 링크가 유효하지 않습니다.",
|
"inviteInvalidDescription": "초대 링크가 유효하지 않습니다.",
|
||||||
"inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다",
|
"inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다",
|
||||||
"inviteErrorUserNotExists": "사용자가 존재하지 않습니다. 먼저 계정을 생성해 주세요.",
|
"inviteErrorUserNotExists": "사용자가 존재하지 않습니다. 먼저 계정을 생성해 주세요.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "인증서",
|
"certificateStatus": "인증서",
|
||||||
"certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.",
|
"certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.",
|
||||||
"loading": "로딩 중",
|
"loading": "로딩 중",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "분석 로딩 중",
|
"loadingAnalytics": "분석 로딩 중",
|
||||||
"restart": "재시작",
|
"restart": "재시작",
|
||||||
"domains": "도메인",
|
"domains": "도메인",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "스킴",
|
"editInternalResourceDialogScheme": "스킴",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "TLS 활성화",
|
||||||
"editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
|
"editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
|
||||||
"editInternalResourceDialogDestination": "대상지",
|
"editInternalResourceDialogDestination": "대상지",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.",
|
"editInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "스킴",
|
"scheme": "스킴",
|
||||||
"createInternalResourceDialogScheme": "스킴",
|
"createInternalResourceDialogScheme": "스킴",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "TLS 활성화",
|
||||||
"createInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
|
"createInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
|
||||||
"createInternalResourceDialogDestination": "대상지",
|
"createInternalResourceDialogDestination": "대상지",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.",
|
"createInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함",
|
"description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함",
|
||||||
"introTitle": "관리 자체 호스팅 팡골린",
|
"introTitle": "관리 자체 호스팅 팡골린",
|
||||||
"introDescription": "는 자신의 데이터를 프라이빗하고 자체 호스팅을 유지하면서 더 간단하고 추가적인 신뢰성을 원하는 사람들을 위한 배포 옵션입니다.",
|
"introDescription": "는 자신의 데이터를 프라이빗하고 자체 호스팅을 유지하면서 더 간단하고 추가적인 신뢰성을 원하는 사람들을 위한 배포 옵션입니다.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "이 옵션을 사용하면 여전히 자신의 팡골린 노드를 운영하고 - 터널, TLS 종료 및 트래픽 모두 서버에 유지됩니다. 차이점은 관리 및 모니터링이 클라우드 대시보드를 통해 처리되어 여러 혜택을 제공합니다.",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "더 간단한 운영",
|
"title": "더 간단한 운영",
|
||||||
"description": "자체 메일 서버를 운영하거나 복잡한 경고를 설정할 필요가 없습니다. 기본적으로 상태 점검 및 다운타임 경고를 받을 수 있습니다."
|
"description": "자체 메일 서버를 운영하거나 복잡한 경고를 설정할 필요가 없습니다. 기본적으로 상태 점검 및 다운타임 경고를 받을 수 있습니다."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Gå til ressurs",
|
"resourceGoTo": "Gå til ressurs",
|
||||||
"resourceDelete": "Slett ressurs",
|
"resourceDelete": "Slett ressurs",
|
||||||
"resourceDeleteConfirm": "Bekreft sletting av ressurs",
|
"resourceDeleteConfirm": "Bekreft sletting av ressurs",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Synlighet",
|
"visibility": "Synlighet",
|
||||||
"enabled": "Aktivert",
|
"enabled": "Aktivert",
|
||||||
"disabled": "Deaktivert",
|
"disabled": "Deaktivert",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Opprettet",
|
"createdAt": "Opprettet",
|
||||||
"proxyErrorInvalidHeader": "Ugyldig verdi for egendefinert vertsoverskrift. Bruk domenenavnformat, eller lagre tomt for å fjerne den egendefinerte vertsoverskriften.",
|
"proxyErrorInvalidHeader": "Ugyldig verdi for egendefinert vertsoverskrift. Bruk domenenavnformat, eller lagre tomt for å fjerne den egendefinerte vertsoverskriften.",
|
||||||
"proxyErrorTls": "Ugyldig TLS-servernavn. Bruk domenenavnformat, eller la stå tomt for å fjerne TLS-servernavnet.",
|
"proxyErrorTls": "Ugyldig TLS-servernavn. Bruk domenenavnformat, eller la stå tomt for å fjerne TLS-servernavnet.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "Aktiver TLS",
|
||||||
"proxyEnableSSLDescription": "Aktivere SSL/TLS-kryptering for sikker HTTPS tilkobling til målene.",
|
"proxyEnableSSLDescription": "Aktivere SSL/TLS-kryptering for sikker HTTPS tilkobling til målene.",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "Konfigurer mål",
|
"configureTarget": "Konfigurer mål",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.",
|
"idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.",
|
||||||
"idpErrorNotFound": "IdP ikke funnet",
|
"idpErrorNotFound": "IdP ikke funnet",
|
||||||
"inviteInvalid": "Ugyldig invitasjon",
|
"inviteInvalid": "Ugyldig invitasjon",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Invitasjonslenken er ugyldig.",
|
"inviteInvalidDescription": "Invitasjonslenken er ugyldig.",
|
||||||
"inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren",
|
"inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren",
|
||||||
"inviteErrorUserNotExists": "Brukeren eksisterer ikke. Vennligst opprett en konto først.",
|
"inviteErrorUserNotExists": "Brukeren eksisterer ikke. Vennligst opprett en konto først.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Sertifikat",
|
"certificateStatus": "Sertifikat",
|
||||||
"certificateStatusAutoRefreshHint": "Status oppdateres automatisk.",
|
"certificateStatusAutoRefreshHint": "Status oppdateres automatisk.",
|
||||||
"loading": "Laster inn",
|
"loading": "Laster inn",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Laster inn analyser",
|
"loadingAnalytics": "Laster inn analyser",
|
||||||
"restart": "Start på nytt",
|
"restart": "Start på nytt",
|
||||||
"domains": "Domener",
|
"domains": "Domener",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Skjema",
|
"editInternalResourceDialogScheme": "Skjema",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Aktiver TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
|
"editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
|
||||||
"editInternalResourceDialogDestination": "Destinasjon",
|
"editInternalResourceDialogDestination": "Destinasjon",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.",
|
"editInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Skjema",
|
"scheme": "Skjema",
|
||||||
"createInternalResourceDialogScheme": "Skjema",
|
"createInternalResourceDialogScheme": "Skjema",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Aktiver TLS",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
|
"createInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
|
||||||
"createInternalResourceDialogDestination": "Destinasjon",
|
"createInternalResourceDialogDestination": "Destinasjon",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.",
|
"createInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell",
|
"description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell",
|
||||||
"introTitle": "Administrert Self-Hosted Pangolin",
|
"introTitle": "Administrert Self-Hosted Pangolin",
|
||||||
"introDescription": "er et alternativ for bruk utviklet for personer som ønsker enkel og ekstra pålitelighet mens de fortsatt holder sine data privat og selvdrevne.",
|
"introDescription": "er et alternativ for bruk utviklet for personer som ønsker enkel og ekstra pålitelighet mens de fortsatt holder sine data privat og selvdrevne.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Med dette valget kjører du fortsatt din egen Pangolin-node - tunneler, TLS-terminering og trafikken ligger på serveren din. Forskjellen er at behandling og overvåking håndteres gjennom vårt skydashbord, som låser opp en rekke fordeler:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Enklere operasjoner",
|
"title": "Enklere operasjoner",
|
||||||
"description": "Ingen grunn til å kjøre din egen e-postserver eller sette opp kompleks varsling. Du vil få helsesjekk og nedetid varsler ut av boksen."
|
"description": "Ingen grunn til å kjøre din egen e-postserver eller sette opp kompleks varsling. Du vil få helsesjekk og nedetid varsler ut av boksen."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Ga naar Resource",
|
"resourceGoTo": "Ga naar Resource",
|
||||||
"resourceDelete": "Document verwijderen",
|
"resourceDelete": "Document verwijderen",
|
||||||
"resourceDeleteConfirm": "Bevestig Verwijderen Document",
|
"resourceDeleteConfirm": "Bevestig Verwijderen Document",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Zichtbaarheid",
|
"visibility": "Zichtbaarheid",
|
||||||
"enabled": "Ingeschakeld",
|
"enabled": "Ingeschakeld",
|
||||||
"disabled": "Uitgeschakeld",
|
"disabled": "Uitgeschakeld",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Aangemaakt op",
|
"createdAt": "Aangemaakt op",
|
||||||
"proxyErrorInvalidHeader": "Ongeldige aangepaste Header waarde. Gebruik het domeinnaam formaat, of sla leeg op om de aangepaste Host header ongedaan te maken.",
|
"proxyErrorInvalidHeader": "Ongeldige aangepaste Header waarde. Gebruik het domeinnaam formaat, of sla leeg op om de aangepaste Host header ongedaan te maken.",
|
||||||
"proxyErrorTls": "Ongeldige TLS servernaam. Gebruik de domeinnaam of sla leeg op om de TLS servernaam te verwijderen.",
|
"proxyErrorTls": "Ongeldige TLS servernaam. Gebruik de domeinnaam of sla leeg op om de TLS servernaam te verwijderen.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "TLS inschakelen",
|
||||||
"proxyEnableSSLDescription": "SSL/TLS-versleuteling inschakelen voor beveiligde HTTPS-verbindingen naar de doelen.",
|
"proxyEnableSSLDescription": "SSL/TLS-versleuteling inschakelen voor beveiligde HTTPS-verbindingen naar de doelen.",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "Doelstellingen configureren",
|
"configureTarget": "Doelstellingen configureren",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.",
|
"idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.",
|
||||||
"idpErrorNotFound": "IdP niet gevonden",
|
"idpErrorNotFound": "IdP niet gevonden",
|
||||||
"inviteInvalid": "Ongeldige uitnodiging",
|
"inviteInvalid": "Ongeldige uitnodiging",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Uitnodigingslink is ongeldig.",
|
"inviteInvalidDescription": "Uitnodigingslink is ongeldig.",
|
||||||
"inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker",
|
"inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker",
|
||||||
"inviteErrorUserNotExists": "Gebruiker bestaat niet. Maak eerst een account aan.",
|
"inviteErrorUserNotExists": "Gebruiker bestaat niet. Maak eerst een account aan.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Certificaat",
|
"certificateStatus": "Certificaat",
|
||||||
"certificateStatusAutoRefreshHint": "Status ververst automatisch.",
|
"certificateStatusAutoRefreshHint": "Status ververst automatisch.",
|
||||||
"loading": "Bezig met laden",
|
"loading": "Bezig met laden",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Laden van Analytics",
|
"loadingAnalytics": "Laden van Analytics",
|
||||||
"restart": "Herstarten",
|
"restart": "Herstarten",
|
||||||
"domains": "Domeinen",
|
"domains": "Domeinen",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Schema",
|
"editInternalResourceDialogScheme": "Schema",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "TLS inschakelen",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
|
"editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
|
||||||
"editInternalResourceDialogDestination": "Bestemming",
|
"editInternalResourceDialogDestination": "Bestemming",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.",
|
"editInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Schema",
|
"scheme": "Schema",
|
||||||
"createInternalResourceDialogScheme": "Schema",
|
"createInternalResourceDialogScheme": "Schema",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "TLS inschakelen",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
|
"createInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
|
||||||
"createInternalResourceDialogDestination": "Bestemming",
|
"createInternalResourceDialogDestination": "Bestemming",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.",
|
"createInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders",
|
"description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders",
|
||||||
"introTitle": "Beheerde zelfgehoste pangolin",
|
"introTitle": "Beheerde zelfgehoste pangolin",
|
||||||
"introDescription": "is een implementatieoptie ontworpen voor mensen die eenvoud en extra betrouwbaarheid willen, terwijl hun gegevens privé en zelf georganiseerd blijven.",
|
"introDescription": "is een implementatieoptie ontworpen voor mensen die eenvoud en extra betrouwbaarheid willen, terwijl hun gegevens privé en zelf georganiseerd blijven.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Met deze optie beheert u nog steeds uw eigen Pangolin node - uw tunnels, TLS-verbinding en verkeer alles op uw server. Het verschil is dat beheer en monitoring worden behandeld via onze cloud dashboard, wat een aantal voordelen oplevert:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Simpler operaties",
|
"title": "Simpler operaties",
|
||||||
"description": "Je hoeft geen eigen mailserver te draaien of complexe waarschuwingen in te stellen. Je krijgt gezondheidscontroles en downtime meldingen uit de box."
|
"description": "Je hoeft geen eigen mailserver te draaien of complexe waarschuwingen in te stellen. Je krijgt gezondheidscontroles en downtime meldingen uit de box."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Przejdź do zasobu",
|
"resourceGoTo": "Przejdź do zasobu",
|
||||||
"resourceDelete": "Usuń zasób",
|
"resourceDelete": "Usuń zasób",
|
||||||
"resourceDeleteConfirm": "Potwierdź usunięcie zasobu",
|
"resourceDeleteConfirm": "Potwierdź usunięcie zasobu",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Widoczność",
|
"visibility": "Widoczność",
|
||||||
"enabled": "Włączone",
|
"enabled": "Włączone",
|
||||||
"disabled": "Wyłączone",
|
"disabled": "Wyłączone",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Utworzono",
|
"createdAt": "Utworzono",
|
||||||
"proxyErrorInvalidHeader": "Nieprawidłowa wartość niestandardowego nagłówka hosta. Użyj formatu nazwy domeny lub zapisz pusty, aby usunąć niestandardowy nagłówek hosta.",
|
"proxyErrorInvalidHeader": "Nieprawidłowa wartość niestandardowego nagłówka hosta. Użyj formatu nazwy domeny lub zapisz pusty, aby usunąć niestandardowy nagłówek hosta.",
|
||||||
"proxyErrorTls": "Nieprawidłowa nazwa serwera TLS. Użyj formatu nazwy domeny lub zapisz pusty, aby usunąć nazwę serwera TLS.",
|
"proxyErrorTls": "Nieprawidłowa nazwa serwera TLS. Użyj formatu nazwy domeny lub zapisz pusty, aby usunąć nazwę serwera TLS.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "Włącz TLS",
|
||||||
"proxyEnableSSLDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z celami.",
|
"proxyEnableSSLDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z celami.",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "Konfiguruj Targety",
|
"configureTarget": "Konfiguruj Targety",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.",
|
"idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.",
|
||||||
"idpErrorNotFound": "Nie znaleziono IdP",
|
"idpErrorNotFound": "Nie znaleziono IdP",
|
||||||
"inviteInvalid": "Nieprawidłowe zaproszenie",
|
"inviteInvalid": "Nieprawidłowe zaproszenie",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.",
|
"inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.",
|
||||||
"inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika",
|
"inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika",
|
||||||
"inviteErrorUserNotExists": "Użytkownik nie istnieje. Najpierw utwórz konto.",
|
"inviteErrorUserNotExists": "Użytkownik nie istnieje. Najpierw utwórz konto.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Certyfikat",
|
"certificateStatus": "Certyfikat",
|
||||||
"certificateStatusAutoRefreshHint": "Status odświeża się automatycznie.",
|
"certificateStatusAutoRefreshHint": "Status odświeża się automatycznie.",
|
||||||
"loading": "Ładowanie",
|
"loading": "Ładowanie",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Ładowanie Analityki",
|
"loadingAnalytics": "Ładowanie Analityki",
|
||||||
"restart": "Uruchom ponownie",
|
"restart": "Uruchom ponownie",
|
||||||
"domains": "Domeny",
|
"domains": "Domeny",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Schemat",
|
"editInternalResourceDialogScheme": "Schemat",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Włącz TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
|
"editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
|
||||||
"editInternalResourceDialogDestination": "Miejsce docelowe",
|
"editInternalResourceDialogDestination": "Miejsce docelowe",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.",
|
"editInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Schemat",
|
"scheme": "Schemat",
|
||||||
"createInternalResourceDialogScheme": "Schemat",
|
"createInternalResourceDialogScheme": "Schemat",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Włącz TLS",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
|
"createInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
|
||||||
"createInternalResourceDialogDestination": "Miejsce docelowe",
|
"createInternalResourceDialogDestination": "Miejsce docelowe",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.",
|
"createInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami",
|
"description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami",
|
||||||
"introTitle": "Zarządzany samowystarczalny Pangolin",
|
"introTitle": "Zarządzany samowystarczalny Pangolin",
|
||||||
"introDescription": "jest opcją wdrażania zaprojektowaną dla osób, które chcą prostoty i dodatkowej niezawodności, przy jednoczesnym utrzymaniu swoich danych prywatnych i samodzielnych.",
|
"introDescription": "jest opcją wdrażania zaprojektowaną dla osób, które chcą prostoty i dodatkowej niezawodności, przy jednoczesnym utrzymaniu swoich danych prywatnych i samodzielnych.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin - tunele, zakończenie TLS i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Uproszczone operacje",
|
"title": "Uproszczone operacje",
|
||||||
"description": "Nie ma potrzeby uruchamiania własnego serwera pocztowego lub ustawiania skomplikowanych powiadomień. Będziesz mieć kontrolę zdrowia i powiadomienia o przestoju."
|
"description": "Nie ma potrzeby uruchamiania własnego serwera pocztowego lub ustawiania skomplikowanych powiadomień. Będziesz mieć kontrolę zdrowia i powiadomienia o przestoju."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Ir para o Recurso",
|
"resourceGoTo": "Ir para o Recurso",
|
||||||
"resourceDelete": "Excluir Recurso",
|
"resourceDelete": "Excluir Recurso",
|
||||||
"resourceDeleteConfirm": "Confirmar que pretende apagar o recurso",
|
"resourceDeleteConfirm": "Confirmar que pretende apagar o recurso",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Visibilidade",
|
"visibility": "Visibilidade",
|
||||||
"enabled": "Ativado",
|
"enabled": "Ativado",
|
||||||
"disabled": "Desabilitado",
|
"disabled": "Desabilitado",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Criado Em",
|
"createdAt": "Criado Em",
|
||||||
"proxyErrorInvalidHeader": "Valor do cabeçalho Host personalizado inválido. Use o formato de nome de domínio ou salve vazio para remover o cabeçalho Host personalizado.",
|
"proxyErrorInvalidHeader": "Valor do cabeçalho Host personalizado inválido. Use o formato de nome de domínio ou salve vazio para remover o cabeçalho Host personalizado.",
|
||||||
"proxyErrorTls": "Nome do Servidor TLS inválido. Use o formato de nome de domínio ou salve vazio para remover o Nome do Servidor TLS.",
|
"proxyErrorTls": "Nome do Servidor TLS inválido. Use o formato de nome de domínio ou salve vazio para remover o Nome do Servidor TLS.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "Habilitar TLS",
|
||||||
"proxyEnableSSLDescription": "Habilitar criptografia SSL/TLS para conexões HTTPS seguras aos alvos.",
|
"proxyEnableSSLDescription": "Habilitar criptografia SSL/TLS para conexões HTTPS seguras aos alvos.",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "Configurar Alvos",
|
"configureTarget": "Configurar Alvos",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.",
|
"idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.",
|
||||||
"idpErrorNotFound": "IdP não encontrado",
|
"idpErrorNotFound": "IdP não encontrado",
|
||||||
"inviteInvalid": "Convite Inválido",
|
"inviteInvalid": "Convite Inválido",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "O link do convite é inválido.",
|
"inviteInvalidDescription": "O link do convite é inválido.",
|
||||||
"inviteErrorWrongUser": "O convite não é para este utilizador",
|
"inviteErrorWrongUser": "O convite não é para este utilizador",
|
||||||
"inviteErrorUserNotExists": "O utilizador não existe. Por favor, crie uma conta primeiro.",
|
"inviteErrorUserNotExists": "O utilizador não existe. Por favor, crie uma conta primeiro.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Certificado",
|
"certificateStatus": "Certificado",
|
||||||
"certificateStatusAutoRefreshHint": "Status atualiza automaticamente.",
|
"certificateStatusAutoRefreshHint": "Status atualiza automaticamente.",
|
||||||
"loading": "Carregando",
|
"loading": "Carregando",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Carregando Analytics",
|
"loadingAnalytics": "Carregando Analytics",
|
||||||
"restart": "Reiniciar",
|
"restart": "Reiniciar",
|
||||||
"domains": "Domínios",
|
"domains": "Domínios",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Esquema",
|
"editInternalResourceDialogScheme": "Esquema",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Ativar TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
|
"editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
|
||||||
"editInternalResourceDialogDestination": "Destino",
|
"editInternalResourceDialogDestination": "Destino",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.",
|
"editInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Esquema",
|
"scheme": "Esquema",
|
||||||
"createInternalResourceDialogScheme": "Esquema",
|
"createInternalResourceDialogScheme": "Esquema",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Ativar TLS",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
|
"createInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
|
||||||
"createInternalResourceDialogDestination": "Destino",
|
"createInternalResourceDialogDestination": "Destino",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.",
|
"createInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos",
|
"description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos",
|
||||||
"introTitle": "Pangolin Auto-Hospedado Gerenciado",
|
"introTitle": "Pangolin Auto-Hospedado Gerenciado",
|
||||||
"introDescription": "é uma opção de implantação projetada para pessoas que querem simplicidade e confiança adicional, mantendo os seus dados privados e auto-hospedados.",
|
"introDescription": "é uma opção de implantação projetada para pessoas que querem simplicidade e confiança adicional, mantendo os seus dados privados e auto-hospedados.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin - seus túneis, terminação TLS e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Operações simples",
|
"title": "Operações simples",
|
||||||
"description": "Não é necessário executar o seu próprio servidor de e-mail ou configurar um alerta complexo. Você receberá fora de caixa verificações de saúde e alertas de tempo de inatividade."
|
"description": "Não é necessário executar o seu próprio servidor de e-mail ou configurar um alerta complexo. Você receberá fora de caixa verificações de saúde e alertas de tempo de inatividade."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Перейти к ресурсу",
|
"resourceGoTo": "Перейти к ресурсу",
|
||||||
"resourceDelete": "Удалить ресурс",
|
"resourceDelete": "Удалить ресурс",
|
||||||
"resourceDeleteConfirm": "Подтвердить удаление",
|
"resourceDeleteConfirm": "Подтвердить удаление",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Видимость",
|
"visibility": "Видимость",
|
||||||
"enabled": "Включено",
|
"enabled": "Включено",
|
||||||
"disabled": "Отключено",
|
"disabled": "Отключено",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Создано в",
|
"createdAt": "Создано в",
|
||||||
"proxyErrorInvalidHeader": "Неверное значение пользовательского заголовка Host. Используйте формат доменного имени или оставьте пустым для сброса пользовательского заголовка Host.",
|
"proxyErrorInvalidHeader": "Неверное значение пользовательского заголовка Host. Используйте формат доменного имени или оставьте пустым для сброса пользовательского заголовка Host.",
|
||||||
"proxyErrorTls": "Неверное имя TLS сервера. Используйте формат доменного имени или оставьте пустым для удаления имени TLS сервера.",
|
"proxyErrorTls": "Неверное имя TLS сервера. Используйте формат доменного имени или оставьте пустым для удаления имени TLS сервера.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "Включить TLS",
|
||||||
"proxyEnableSSLDescription": "Включить шифрование SSL/TLS для безопасных HTTPS соединений с целями.",
|
"proxyEnableSSLDescription": "Включить шифрование SSL/TLS для безопасных HTTPS соединений с целями.",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "Настроить адресаты",
|
"configureTarget": "Настроить адресаты",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
||||||
"idpErrorNotFound": "IdP не найден",
|
"idpErrorNotFound": "IdP не найден",
|
||||||
"inviteInvalid": "Недействительное приглашение",
|
"inviteInvalid": "Недействительное приглашение",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Ссылка на приглашение недействительна.",
|
"inviteInvalidDescription": "Ссылка на приглашение недействительна.",
|
||||||
"inviteErrorWrongUser": "Приглашение не для этого пользователя",
|
"inviteErrorWrongUser": "Приглашение не для этого пользователя",
|
||||||
"inviteErrorUserNotExists": "Пользователь не существует. Пожалуйста, сначала создайте учетную запись.",
|
"inviteErrorUserNotExists": "Пользователь не существует. Пожалуйста, сначала создайте учетную запись.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Сертификат",
|
"certificateStatus": "Сертификат",
|
||||||
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
|
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
|
||||||
"loading": "Загрузка",
|
"loading": "Загрузка",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Загрузка аналитики",
|
"loadingAnalytics": "Загрузка аналитики",
|
||||||
"restart": "Перезагрузка",
|
"restart": "Перезагрузка",
|
||||||
"domains": "Домены",
|
"domains": "Домены",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Схема",
|
"editInternalResourceDialogScheme": "Схема",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Включить TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
|
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
|
||||||
"editInternalResourceDialogDestination": "Пункт назначения",
|
"editInternalResourceDialogDestination": "Пункт назначения",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.",
|
"editInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Схема",
|
"scheme": "Схема",
|
||||||
"createInternalResourceDialogScheme": "Схема",
|
"createInternalResourceDialogScheme": "Схема",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Включить TLS",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Включите SSL/TLS шифрование для защищенных HTTPS соединений с конечной точкой.",
|
"createInternalResourceDialogEnableSslDescription": "Включите SSL/TLS шифрование для защищенных HTTPS соединений с конечной точкой.",
|
||||||
"createInternalResourceDialogDestination": "Пункт назначения",
|
"createInternalResourceDialogDestination": "Пункт назначения",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.",
|
"createInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
|
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
|
||||||
"introTitle": "Управляемый Само-Хост Панголина",
|
"introTitle": "Управляемый Само-Хост Панголина",
|
||||||
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
|
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin - туннели, TLS, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Более простые операции",
|
"title": "Более простые операции",
|
||||||
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
|
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Kaynağa Git",
|
"resourceGoTo": "Kaynağa Git",
|
||||||
"resourceDelete": "Kaynağı Sil",
|
"resourceDelete": "Kaynağı Sil",
|
||||||
"resourceDeleteConfirm": "Kaynak Silmeyi Onayla",
|
"resourceDeleteConfirm": "Kaynak Silmeyi Onayla",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Görünürlük",
|
"visibility": "Görünürlük",
|
||||||
"enabled": "Etkin",
|
"enabled": "Etkin",
|
||||||
"disabled": "Devre Dışı",
|
"disabled": "Devre Dışı",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "Oluşturulma Tarihi",
|
"createdAt": "Oluşturulma Tarihi",
|
||||||
"proxyErrorInvalidHeader": "Geçersiz özel Ana Bilgisayar Başlığı değeri. Alan adı formatını kullanın veya özel Ana Bilgisayar Başlığını ayarlamak için boş bırakın.",
|
"proxyErrorInvalidHeader": "Geçersiz özel Ana Bilgisayar Başlığı değeri. Alan adı formatını kullanın veya özel Ana Bilgisayar Başlığını ayarlamak için boş bırakın.",
|
||||||
"proxyErrorTls": "Geçersiz TLS Sunucu Adı. Alan adı formatını kullanın veya TLS Sunucu Adını kaldırmak için boş bırakılsın.",
|
"proxyErrorTls": "Geçersiz TLS Sunucu Adı. Alan adı formatını kullanın veya TLS Sunucu Adını kaldırmak için boş bırakılsın.",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "TLS Etkinleştir",
|
||||||
"proxyEnableSSLDescription": "Hedeflere güvenli HTTPS bağlantıları için SSL/TLS şifrelemesini etkinleştirin.",
|
"proxyEnableSSLDescription": "Hedeflere güvenli HTTPS bağlantıları için SSL/TLS şifrelemesini etkinleştirin.",
|
||||||
"target": "Hedef",
|
"target": "Hedef",
|
||||||
"configureTarget": "Hedefleri Yapılandır",
|
"configureTarget": "Hedefleri Yapılandır",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.",
|
"idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.",
|
||||||
"idpErrorNotFound": "IdP bulunamadı",
|
"idpErrorNotFound": "IdP bulunamadı",
|
||||||
"inviteInvalid": "Geçersiz Davet",
|
"inviteInvalid": "Geçersiz Davet",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "Davet bağlantısı geçersiz.",
|
"inviteInvalidDescription": "Davet bağlantısı geçersiz.",
|
||||||
"inviteErrorWrongUser": "Davet bu kullanıcı için değil",
|
"inviteErrorWrongUser": "Davet bu kullanıcı için değil",
|
||||||
"inviteErrorUserNotExists": "Kullanıcı mevcut değil. Lütfen önce bir hesap oluşturun.",
|
"inviteErrorUserNotExists": "Kullanıcı mevcut değil. Lütfen önce bir hesap oluşturun.",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "Sertifika",
|
"certificateStatus": "Sertifika",
|
||||||
"certificateStatusAutoRefreshHint": "Durum otomatik olarak yenilenir.",
|
"certificateStatusAutoRefreshHint": "Durum otomatik olarak yenilenir.",
|
||||||
"loading": "Yükleniyor",
|
"loading": "Yükleniyor",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "Analiz Yükleniyor",
|
"loadingAnalytics": "Analiz Yükleniyor",
|
||||||
"restart": "Yeniden Başlat",
|
"restart": "Yeniden Başlat",
|
||||||
"domains": "Alan Adları",
|
"domains": "Alan Adları",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "Şema",
|
"editInternalResourceDialogScheme": "Şema",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "TLS Etkinleştir",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
|
"editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
|
||||||
"editInternalResourceDialogDestination": "Hedef",
|
"editInternalResourceDialogDestination": "Hedef",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.",
|
"editInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "Şema",
|
"scheme": "Şema",
|
||||||
"createInternalResourceDialogScheme": "Şema",
|
"createInternalResourceDialogScheme": "Şema",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir",
|
||||||
"createInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
|
"createInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
|
||||||
"createInternalResourceDialogDestination": "Hedef",
|
"createInternalResourceDialogDestination": "Hedef",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.",
|
"createInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu",
|
"description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu",
|
||||||
"introTitle": "Yönetilen Kendi Kendine Barındırılan Pangolin",
|
"introTitle": "Yönetilen Kendi Kendine Barındırılan Pangolin",
|
||||||
"introDescription": "Bu, basitlik ve ekstra güvenilirlik arayan, ancak verilerini gizli tutmak ve kendi sunucularında barındırmak isteyen kişiler için tasarlanmış bir dağıtım seçeneğidir.",
|
"introDescription": "Bu, basitlik ve ekstra güvenilirlik arayan, ancak verilerini gizli tutmak ve kendi sunucularında barındırmak isteyen kişiler için tasarlanmış bir dağıtım seçeneğidir.",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz - tünelleriniz, TLS bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Daha basit işlemler",
|
"title": "Daha basit işlemler",
|
||||||
"description": "Kendi e-posta sunucunuzu çalıştırmanıza veya karmaşık uyarılar kurmanıza gerek yok. Sağlık kontrolleri ve kesinti uyarılarını kutudan çıktığı gibi alırsınız."
|
"description": "Kendi e-posta sunucunuzu çalıştırmanıza veya karmaşık uyarılar kurmanıza gerek yok. Sağlık kontrolleri ve kesinti uyarılarını kutudan çıktığı gibi alırsınız."
|
||||||
|
|||||||
+4
-31
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "转到资源",
|
"resourceGoTo": "转到资源",
|
||||||
"resourceDelete": "删除资源",
|
"resourceDelete": "删除资源",
|
||||||
"resourceDeleteConfirm": "确认删除资源",
|
"resourceDeleteConfirm": "确认删除资源",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "可见性",
|
"visibility": "可见性",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
@@ -647,7 +630,7 @@
|
|||||||
"createdAt": "创建于",
|
"createdAt": "创建于",
|
||||||
"proxyErrorInvalidHeader": "无效的自定义主机头值。使用域名格式,或将空保存为取消自定义主机头。",
|
"proxyErrorInvalidHeader": "无效的自定义主机头值。使用域名格式,或将空保存为取消自定义主机头。",
|
||||||
"proxyErrorTls": "无效的 TLS 服务器名称。使用域名格式,或保存空以删除 TLS 服务器名称。",
|
"proxyErrorTls": "无效的 TLS 服务器名称。使用域名格式,或保存空以删除 TLS 服务器名称。",
|
||||||
"proxyEnableSSL": "Enable TLS",
|
"proxyEnableSSL": "启用 TLS",
|
||||||
"proxyEnableSSLDescription": "启用 SSL/TLS 加密以确保目标的 HTTPS 连接。",
|
"proxyEnableSSLDescription": "启用 SSL/TLS 加密以确保目标的 HTTPS 连接。",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "配置目标",
|
"configureTarget": "配置目标",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。",
|
"idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。",
|
||||||
"idpErrorNotFound": "找不到 IdP",
|
"idpErrorNotFound": "找不到 IdP",
|
||||||
"inviteInvalid": "无效邀请",
|
"inviteInvalid": "无效邀请",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "邀请链接无效。",
|
"inviteInvalidDescription": "邀请链接无效。",
|
||||||
"inviteErrorWrongUser": "邀请不是该用户的",
|
"inviteErrorWrongUser": "邀请不是该用户的",
|
||||||
"inviteErrorUserNotExists": "用户不存在。请先创建帐户。",
|
"inviteErrorUserNotExists": "用户不存在。请先创建帐户。",
|
||||||
@@ -1646,7 +1620,6 @@
|
|||||||
"certificateStatus": "证书",
|
"certificateStatus": "证书",
|
||||||
"certificateStatusAutoRefreshHint": "状态自动刷新。",
|
"certificateStatusAutoRefreshHint": "状态自动刷新。",
|
||||||
"loading": "加载中",
|
"loading": "加载中",
|
||||||
"loadingEllipsis": "Loading...",
|
|
||||||
"loadingAnalytics": "加载分析",
|
"loadingAnalytics": "加载分析",
|
||||||
"restart": "重启",
|
"restart": "重启",
|
||||||
"domains": "域",
|
"domains": "域",
|
||||||
@@ -2077,7 +2050,7 @@
|
|||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"editInternalResourceDialogScheme": "方案",
|
"editInternalResourceDialogScheme": "方案",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "启用 TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
||||||
"editInternalResourceDialogDestination": "目标",
|
"editInternalResourceDialogDestination": "目标",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
|
"editInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
|
||||||
@@ -2127,7 +2100,7 @@
|
|||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
"scheme": "方案",
|
"scheme": "方案",
|
||||||
"createInternalResourceDialogScheme": "方案",
|
"createInternalResourceDialogScheme": "方案",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "启用 TLS",
|
||||||
"createInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
"createInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
||||||
"createInternalResourceDialogDestination": "目标",
|
"createInternalResourceDialogDestination": "目标",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
|
"createInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
|
||||||
@@ -2260,7 +2233,7 @@
|
|||||||
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
|
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
|
||||||
"introTitle": "托管自托管的潘戈林公司",
|
"introTitle": "托管自托管的潘戈林公司",
|
||||||
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
|
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
|
||||||
"introDetail": "With this option, you still run your own Pangolin node - your tunnels, TLS termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 - - 您的隧道、TLS 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "简单的操作",
|
"title": "简单的操作",
|
||||||
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
|
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
|
||||||
|
|||||||
+1
-6
@@ -5,12 +5,7 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
eslint: {
|
reactCompiler: true,
|
||||||
ignoreDuringBuilds: true
|
|
||||||
},
|
|
||||||
experimental: {
|
|
||||||
reactCompiler: true
|
|
||||||
},
|
|
||||||
output: "standalone"
|
output: "standalone"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+2193
-3403
File diff suppressed because it is too large
Load Diff
+52
-50
@@ -32,10 +32,10 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@aws-sdk/client-s3": "3.1011.0",
|
"@aws-sdk/client-s3": "3.1047.0",
|
||||||
"@faker-js/faker": "10.3.0",
|
"@faker-js/faker": "10.4.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.10",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
@@ -59,16 +59,17 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.8",
|
"@react-email/body": "0.3.0",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/components": "1.0.12",
|
||||||
"@react-email/tailwind": "2.0.5",
|
"@react-email/render": "2.0.8",
|
||||||
|
"@react-email/tailwind": "2.0.7",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@simplewebauthn/server": "13.3.0",
|
"@simplewebauthn/server": "13.3.0",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.100.10",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.16.1",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -80,76 +81,76 @@
|
|||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.3.0",
|
"express-rate-limit": "8.5.2",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.10.0",
|
"ioredis": "5.10.1",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.577.0",
|
"lucide-react": "0.577.0",
|
||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.6",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.15",
|
"next": "16.2.6",
|
||||||
"next-intl": "4.8.3",
|
"next-intl": "4.12.0",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.7",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.20.0",
|
"pg": "8.20.0",
|
||||||
"posthog-node": "5.28.0",
|
"posthog-node": "5.34.1",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "9.14.0",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.6",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.2",
|
"react-hook-form": "7.75.0",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.6.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "3.8.1",
|
||||||
"reodotdev": "1.1.0",
|
"reodotdev": "1.1.0",
|
||||||
"resend": "6.9.2",
|
"resend": "6.12.3",
|
||||||
"semver": "7.7.4",
|
"semver": "7.8.0",
|
||||||
"sshpk": "1.18.0",
|
"sshpk": "1.18.0",
|
||||||
"stripe": "20.4.1",
|
"stripe": "20.4.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"use-debounce": "10.1.0",
|
"use-debounce": "10.1.1",
|
||||||
"uuid": "13.0.0",
|
"uuid": "14.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
"winston": "3.19.0",
|
"winston": "3.19.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.19.0",
|
"ws": "8.20.1",
|
||||||
"yaml": "2.8.3",
|
"yaml": "2.9.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.3.6",
|
"zod": "4.4.3",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.54.1",
|
"@dotenvx/dotenvx": "1.66.0",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/preview-server": "5.2.10",
|
"@react-email/ui": "^6.1.4",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.100.10",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/d3": "7.4.3",
|
"@types/d3": "7.4.3",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.3.5",
|
"@types/node": "25.8.0",
|
||||||
"@types/nodemailer": "7.0.11",
|
"@types/nodemailer": "8.0.0",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.18.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
@@ -160,21 +161,22 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.22.0",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.3.0",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.2.6",
|
||||||
"postcss": "8.5.8",
|
"postcss": "8.5.14",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.3",
|
||||||
"react-email": "5.2.10",
|
"react-email": "6.1.4",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.3.0",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.17",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.22.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.56.1"
|
"typescript-eslint": "8.59.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"dompurify": "3.3.2"
|
"dompurify": "3.4.0",
|
||||||
|
"postcss": "8.5.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,12 @@ export enum ActionsEnum {
|
|||||||
updateAlertRule = "updateAlertRule",
|
updateAlertRule = "updateAlertRule",
|
||||||
deleteAlertRule = "deleteAlertRule",
|
deleteAlertRule = "deleteAlertRule",
|
||||||
listAlertRules = "listAlertRules",
|
listAlertRules = "listAlertRules",
|
||||||
|
listOrgLabels = "listOrgLabels",
|
||||||
|
createOrgLabel = "createOrgLabel",
|
||||||
|
updateOrgLabel = "updateOrgLabel",
|
||||||
|
deleteOrgLabel = "deleteOrgLabel",
|
||||||
|
attachLabelToItem = "attachLabelToItem",
|
||||||
|
detachLabelFromItem = "detachLabelFromItem",
|
||||||
getAlertRule = "getAlertRule",
|
getAlertRule = "getAlertRule",
|
||||||
createHealthCheck = "createHealthCheck",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
|
|||||||
+113
-16
@@ -162,6 +162,89 @@ export const resources = pgTable("resources", {
|
|||||||
wildcard: boolean("wildcard").notNull().default(false)
|
wildcard: boolean("wildcard").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const labels = pgTable("labels", {
|
||||||
|
labelId: serial("labelId").primaryKey(),
|
||||||
|
name: varchar("name").notNull(),
|
||||||
|
color: varchar("color").notNull(),
|
||||||
|
orgId: varchar("orgId")
|
||||||
|
.references(() => orgs.orgId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const siteLabels = pgTable(
|
||||||
|
"siteLabels",
|
||||||
|
{
|
||||||
|
siteLabelId: serial("siteLabelId").primaryKey(),
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const resourceLabels = pgTable(
|
||||||
|
"resourceLabels",
|
||||||
|
{
|
||||||
|
resourceLabelId: serial("resourceLabelId").primaryKey(),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.references(() => resources.resourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const siteResourceLabels = pgTable(
|
||||||
|
"siteResourceLabels",
|
||||||
|
{
|
||||||
|
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
|
||||||
|
siteResourceId: integer("siteResourceId")
|
||||||
|
.references(() => siteResources.siteResourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const clientLabels = pgTable(
|
||||||
|
"clientLabels",
|
||||||
|
{
|
||||||
|
clientLabelId: serial("clientLabelId").primaryKey(),
|
||||||
|
clientId: integer("clientId")
|
||||||
|
.references(() => clients.clientId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const targets = pgTable("targets", {
|
export const targets = pgTable("targets", {
|
||||||
targetId: serial("targetId").primaryKey(),
|
targetId: serial("targetId").primaryKey(),
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
@@ -196,9 +279,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId").references(() => sites.siteId, {
|
siteId: integer("siteId")
|
||||||
onDelete: "cascade"
|
.references(() => sites.siteId, {
|
||||||
}).notNull(),
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
name: varchar("name"),
|
name: varchar("name"),
|
||||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||||
hcPath: varchar("hcPath"),
|
hcPath: varchar("hcPath"),
|
||||||
@@ -1097,19 +1182,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
|||||||
complete: boolean("complete").notNull().default(false)
|
complete: boolean("complete").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statusHistory = pgTable("statusHistory", {
|
export const statusHistory = pgTable(
|
||||||
id: serial("id").primaryKey(),
|
"statusHistory",
|
||||||
entityType: varchar("entityType").notNull(),
|
{
|
||||||
entityId: integer("entityId").notNull(),
|
id: serial("id").primaryKey(),
|
||||||
orgId: varchar("orgId")
|
entityType: varchar("entityType").notNull(),
|
||||||
.notNull()
|
entityId: integer("entityId").notNull(),
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
orgId: varchar("orgId")
|
||||||
status: varchar("status").notNull(),
|
.notNull()
|
||||||
timestamp: integer("timestamp").notNull(),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
}, (table) => [
|
status: varchar("status").notNull(),
|
||||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
timestamp: integer("timestamp").notNull()
|
||||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
},
|
||||||
]);
|
(table) => [
|
||||||
|
index("idx_statusHistory_entity").on(
|
||||||
|
table.entityType,
|
||||||
|
table.entityId,
|
||||||
|
table.timestamp
|
||||||
|
),
|
||||||
|
index("idx_statusHistory_org_timestamp").on(
|
||||||
|
table.orgId,
|
||||||
|
table.timestamp
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
@@ -1179,3 +1275,4 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type Network = InferSelectModel<typeof networks>;
|
export type Network = InferSelectModel<typeof networks>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
|
export type Label = InferSelectModel<typeof labels>;
|
||||||
|
|||||||
@@ -183,6 +183,95 @@ export const resources = sqliteTable("resources", {
|
|||||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const labels = sqliteTable("labels", {
|
||||||
|
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
color: text("color").notNull(),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.references(() => orgs.orgId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const siteLabels = sqliteTable(
|
||||||
|
"siteLabels",
|
||||||
|
{
|
||||||
|
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const resourceLabels = sqliteTable(
|
||||||
|
"resourceLabels",
|
||||||
|
{
|
||||||
|
resourceLabelId: integer("resourceLabelId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.references(() => resources.resourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const siteResourceLabels = sqliteTable(
|
||||||
|
"siteResourceLabels",
|
||||||
|
{
|
||||||
|
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
siteResourceId: integer("siteResourceId")
|
||||||
|
.references(() => siteResources.siteResourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const clientLabels = sqliteTable(
|
||||||
|
"clientLabels",
|
||||||
|
{
|
||||||
|
clientLabelId: integer("clientLabelId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
clientId: integer("clientId")
|
||||||
|
.references(() => clients.clientId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
@@ -219,9 +308,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId").references(() => sites.siteId, {
|
siteId: integer("siteId")
|
||||||
onDelete: "cascade"
|
.references(() => sites.siteId, {
|
||||||
}).notNull(),
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -1196,19 +1287,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
|||||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statusHistory = sqliteTable("statusHistory", {
|
export const statusHistory = sqliteTable(
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
"statusHistory",
|
||||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
{
|
||||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
orgId: text("orgId")
|
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||||
.notNull()
|
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
orgId: text("orgId")
|
||||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
.notNull()
|
||||||
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
}, (table) => [
|
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
timestamp: integer("timestamp").notNull() // unix epoch seconds
|
||||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
},
|
||||||
]);
|
(table) => [
|
||||||
|
index("idx_statusHistory_entity").on(
|
||||||
|
table.entityType,
|
||||||
|
table.entityId,
|
||||||
|
table.timestamp
|
||||||
|
),
|
||||||
|
index("idx_statusHistory_org_timestamp").on(
|
||||||
|
table.orgId,
|
||||||
|
table.timestamp
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
@@ -1278,3 +1380,4 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
typeof roundTripMessageTracker
|
typeof roundTripMessageTracker
|
||||||
>;
|
>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
|
export type Label = InferSelectModel<typeof labels>;
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
#! /usr/bin/env node
|
#! /usr/bin/env node
|
||||||
import "./extendZod.ts";
|
import "./extendZod";
|
||||||
|
|
||||||
import { runSetupFunctions } from "./setup";
|
import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ export enum TierFeature {
|
|||||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain"
|
WildcardSubdomain = "wildcardSubdomain",
|
||||||
|
Labels = "labels"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
|
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||||
|
|||||||
@@ -154,8 +154,19 @@ class AdaptiveCache {
|
|||||||
keys(): string[] {
|
keys(): string[] {
|
||||||
return localCache.keys();
|
return localCache.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get keys with a specific prefix
|
||||||
|
* @param prefix - Key prefix to match
|
||||||
|
* @returns Array of matching keys
|
||||||
|
*/
|
||||||
|
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||||
|
const allKeys = localCache.keys();
|
||||||
|
return allKeys.filter((key) => key.startsWith(prefix));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
|
export const regionalCache = cache; // Alias for compatability with the private version
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||||
|
|
||||||
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,11 @@ import {
|
|||||||
removePeerData,
|
removePeerData,
|
||||||
removeTargets as removeSubnetProxyTargets
|
removeTargets as removeSubnetProxyTargets
|
||||||
} from "@server/routers/client/targets";
|
} from "@server/routers/client/targets";
|
||||||
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
|
|
||||||
|
// TTL for rebuild-association locks. These functions can fan out into many
|
||||||
|
// peer/proxy updates, so give them a generous window.
|
||||||
|
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
|
||||||
|
|
||||||
export async function getClientSiteResourceAccess(
|
export async function getClientSiteResourceAccess(
|
||||||
siteResource: SiteResource,
|
siteResource: SiteResource,
|
||||||
@@ -161,6 +166,23 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
pubKey: string | null;
|
pubKey: string | null;
|
||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
}> {
|
||||||
|
return await lockManager.withLock(
|
||||||
|
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||||
|
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
|
||||||
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||||
|
siteResource: SiteResource,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<{
|
||||||
|
mergedAllClients: {
|
||||||
|
clientId: number;
|
||||||
|
pubKey: string | null;
|
||||||
|
subnet: string | null;
|
||||||
|
}[];
|
||||||
}> {
|
}> {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||||
@@ -539,6 +561,29 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
|
||||||
|
const clientSiteCounts: Record<number, number> = {};
|
||||||
|
if (clientsToProcess.size > 0) {
|
||||||
|
const clientIdsToProcess = Array.from(clientsToProcess.keys());
|
||||||
|
const siteCounts = await trx
|
||||||
|
.select({
|
||||||
|
clientId: clientSitesAssociationsCache.clientId,
|
||||||
|
siteCount: count(clientSitesAssociationsCache.siteId)
|
||||||
|
})
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
clientSitesAssociationsCache.clientId,
|
||||||
|
clientIdsToProcess
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(clientSitesAssociationsCache.clientId);
|
||||||
|
|
||||||
|
for (const row of siteCounts) {
|
||||||
|
clientSiteCounts[row.clientId] = Number(row.siteCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const client of clientsToProcess.values()) {
|
for (const client of clientsToProcess.values()) {
|
||||||
// UPDATE THE NEWT
|
// UPDATE THE NEWT
|
||||||
if (!client.subnet || !client.pubKey) {
|
if (!client.subnet || !client.pubKey) {
|
||||||
@@ -582,7 +627,14 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAdd) {
|
if (isAdd) {
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
if (clientSiteCounts[client.clientId] > 250) {
|
||||||
|
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
|
||||||
|
logger.info(
|
||||||
|
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -600,9 +652,24 @@ async function handleMessagesForSiteClients(
|
|||||||
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(exitNodeJobs);
|
Promise.all(exitNodeJobs).catch((error) => {
|
||||||
await Promise.all(newtJobs); // do the servers first to make sure they are ready?
|
logger.error(
|
||||||
await Promise.all(olmJobs);
|
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(newtJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(olmJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeerDestination {
|
interface PeerDestination {
|
||||||
@@ -885,6 +952,17 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
export async function rebuildClientAssociationsFromClient(
|
export async function rebuildClientAssociationsFromClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<void> {
|
||||||
|
return await lockManager.withLock(
|
||||||
|
`rebuild-client-associations:client:${client.clientId}`,
|
||||||
|
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||||
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildClientAssociationsFromClientImpl(
|
||||||
|
client: Client,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let newSiteResourceIds: number[] = [];
|
let newSiteResourceIds: number[] = [];
|
||||||
|
|
||||||
@@ -1157,6 +1235,12 @@ async function handleMessagesForClientSites(
|
|||||||
const olmJobs: Promise<any>[] = [];
|
const olmJobs: Promise<any>[] = [];
|
||||||
const exitNodeJobs: Promise<any>[] = [];
|
const exitNodeJobs: Promise<any>[] = [];
|
||||||
|
|
||||||
|
const totalSitesOnClient = await trx
|
||||||
|
.select({ count: count(clientSitesAssociationsCache.siteId) })
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
|
||||||
|
.then((rows) => Number(rows[0].count));
|
||||||
|
|
||||||
for (const siteData of sitesData) {
|
for (const siteData of sitesData) {
|
||||||
const site = siteData.sites;
|
const site = siteData.sites;
|
||||||
const exitNode = siteData.exitNodes;
|
const exitNode = siteData.exitNodes;
|
||||||
@@ -1217,7 +1301,14 @@ async function handleMessagesForClientSites(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
if (totalSitesOnClient > 250) {
|
||||||
|
// skip adding the site if we have more than 250 because we are in jit mode anyway
|
||||||
|
logger.info(
|
||||||
|
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -1245,9 +1336,24 @@ async function handleMessagesForClientSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(exitNodeJobs);
|
Promise.all(exitNodeJobs).catch((error) => {
|
||||||
await Promise.all(newtJobs);
|
logger.error(
|
||||||
await Promise.all(olmJobs);
|
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(newtJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(olmJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessagesForClientResources(
|
async function handleMessagesForClientResources(
|
||||||
@@ -1528,3 +1634,195 @@ async function handleMessagesForClientResources(
|
|||||||
|
|
||||||
await Promise.all([...proxyJobs, ...olmJobs]);
|
await Promise.all([...proxyJobs, ...olmJobs]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ClientAssociationsCacheVerification = {
|
||||||
|
clientId: number;
|
||||||
|
consistent: boolean;
|
||||||
|
// What permissions say the cache should contain
|
||||||
|
expectedSiteResourceIds: number[];
|
||||||
|
expectedSiteIds: number[];
|
||||||
|
// What the cache currently contains
|
||||||
|
actualSiteResourceIds: number[];
|
||||||
|
actualSiteIds: number[];
|
||||||
|
// Diff
|
||||||
|
missingSiteResourceIds: number[]; // present in expected, missing from cache
|
||||||
|
extraSiteResourceIds: number[]; // present in cache, not in expected
|
||||||
|
missingSiteIds: number[];
|
||||||
|
extraSiteIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// verifyClientAssociationsCache walks the same permission-derivation logic as
|
||||||
|
// rebuildClientAssociationsFromClient but does NOT modify the database. It
|
||||||
|
// returns the expected vs actual cache contents and a boolean indicating
|
||||||
|
// whether the cache is in sync with what permissions imply.
|
||||||
|
export async function verifyClientAssociationsCache(
|
||||||
|
client: Client,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<ClientAssociationsCacheVerification> {
|
||||||
|
let newSiteResourceIds: number[] = [];
|
||||||
|
|
||||||
|
// 1. Direct client associations
|
||||||
|
const directSiteResources = await trx
|
||||||
|
.select({ siteResourceId: clientSiteResources.siteResourceId })
|
||||||
|
.from(clientSiteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSiteResources.clientId, client.clientId),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
newSiteResourceIds.push(
|
||||||
|
...directSiteResources.map((r) => r.siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. User-based and role-based access (if client has a userId)
|
||||||
|
if (client.userId) {
|
||||||
|
const userSiteResourceIds = await trx
|
||||||
|
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||||
|
.from(userSiteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
userSiteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userSiteResources.userId, client.userId),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
newSiteResourceIds.push(
|
||||||
|
...userSiteResourceIds.map((r) => r.siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleIds = await trx
|
||||||
|
.select({ roleId: userOrgRoles.roleId })
|
||||||
|
.from(userOrgRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgRoles.userId, client.userId),
|
||||||
|
eq(userOrgRoles.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.roleId));
|
||||||
|
|
||||||
|
if (roleIds.length > 0) {
|
||||||
|
const roleSiteResourceIds = await trx
|
||||||
|
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
roleSiteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(roleSiteResources.roleId, roleIds),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
newSiteResourceIds.push(
|
||||||
|
...roleSiteResourceIds.map((r) => r.siteResourceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
|
||||||
|
|
||||||
|
const newSiteResources =
|
||||||
|
newSiteResourceIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
inArray(siteResources.siteResourceId, newSiteResourceIds)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const networkIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
newSiteResources
|
||||||
|
.map((sr) => sr.networkId)
|
||||||
|
.filter((id): id is number => id !== null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const newSiteIds =
|
||||||
|
networkIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select({ siteId: siteNetworks.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(inArray(siteNetworks.networkId, networkIds))
|
||||||
|
.then((rows) =>
|
||||||
|
Array.from(new Set(rows.map((r) => r.siteId)))
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Read the existing cache state
|
||||||
|
const existingResourceAssociations = await trx
|
||||||
|
.select({
|
||||||
|
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
|
||||||
|
})
|
||||||
|
.from(clientSiteResourcesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
|
||||||
|
);
|
||||||
|
const existingSiteResourceIds = existingResourceAssociations.map(
|
||||||
|
(r) => r.siteResourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingSiteAssociations = await trx
|
||||||
|
.select({ siteId: clientSitesAssociationsCache.siteId })
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
|
||||||
|
|
||||||
|
const expectedSiteResourceSet = new Set(newSiteResourceIds);
|
||||||
|
const actualSiteResourceSet = new Set(existingSiteResourceIds);
|
||||||
|
const expectedSiteSet = new Set(newSiteIds);
|
||||||
|
const actualSiteSet = new Set(existingSiteIds);
|
||||||
|
|
||||||
|
const missingSiteResourceIds = newSiteResourceIds.filter(
|
||||||
|
(id) => !actualSiteResourceSet.has(id)
|
||||||
|
);
|
||||||
|
const extraSiteResourceIds = existingSiteResourceIds.filter(
|
||||||
|
(id) => !expectedSiteResourceSet.has(id)
|
||||||
|
);
|
||||||
|
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
|
||||||
|
const extraSiteIds = existingSiteIds.filter(
|
||||||
|
(id) => !expectedSiteSet.has(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const consistent =
|
||||||
|
missingSiteResourceIds.length === 0 &&
|
||||||
|
extraSiteResourceIds.length === 0 &&
|
||||||
|
missingSiteIds.length === 0 &&
|
||||||
|
extraSiteIds.length === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId: client.clientId,
|
||||||
|
consistent,
|
||||||
|
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
|
||||||
|
(a, b) => a - b
|
||||||
|
),
|
||||||
|
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
|
||||||
|
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
|
||||||
|
(a, b) => a - b
|
||||||
|
),
|
||||||
|
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
|
||||||
|
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
|
||||||
|
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
|
||||||
|
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
|
||||||
|
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function getFirstString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value) && typeof value[0] === "string") {
|
||||||
|
return value[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, logsDb, statusHistory } from "@server/db";
|
import { db, logsDb, statusHistory } from "@server/db";
|
||||||
import { and, eq, gte, asc } from "drizzle-orm";
|
import { and, eq, gte, asc } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||||
|
|
||||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
|
|||||||
entityId: number
|
entityId: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||||
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
const keys = await cache.keysWithPrefix(prefix);
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await cache.del(keys);
|
await cache.del(keys);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resourceAccessToken, resources, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyAccessTokenAccess(
|
export async function verifyApiKeyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,7 @@ export async function verifyApiKeyAccessTokenAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const accessTokenId = req.params.accessTokenId;
|
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,6 +21,12 @@ export async function verifyApiKeyAccessTokenAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accessTokenId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [accessToken] = await db
|
const [accessToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiKeys, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq, or } from "drizzle-orm";
|
import { and, eq, or } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyApiKeyAccess(
|
export async function verifyApiKeyApiKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,8 +15,10 @@ export async function verifyApiKeyApiKeyAccess(
|
|||||||
const { apiKey: callerApiKey } = req;
|
const { apiKey: callerApiKey } = req;
|
||||||
|
|
||||||
const apiKeyId =
|
const apiKeyId =
|
||||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
getFirstString(req.params.apiKeyId) ||
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.body.apiKeyId) ||
|
||||||
|
getFirstString(req.query.apiKeyId);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!callerApiKey) {
|
if (!callerApiKey) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyDomainAccess(
|
export async function verifyApiKeyDomainAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,8 +13,10 @@ export async function verifyApiKeyDomainAccess(
|
|||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const domainId =
|
const domainId =
|
||||||
req.params.domainId || req.body.domainId || req.query.domainId;
|
getFirstString(req.params.domainId) ||
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.body.domainId) ||
|
||||||
|
getFirstString(req.query.domainId);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -27,6 +30,12 @@ export async function verifyApiKeyDomainAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (apiKey.isRoot) {
|
if (apiKey.isRoot) {
|
||||||
// Root keys can access any domain in any org
|
// Root keys can access any domain in any org
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { idp, idpOrg, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyIdpAccess(
|
export async function verifyApiKeyIdpAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,8 +13,12 @@ export async function verifyApiKeyIdpAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
const idpIdRaw =
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.params.idpId) ||
|
||||||
|
getFirstString(req.body.idpId) ||
|
||||||
|
getFirstString(req.query.idpId);
|
||||||
|
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -27,7 +32,7 @@ export async function verifyApiKeyIdpAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idpId) {
|
if (Number.isNaN(idpId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyOrgAccess(
|
export async function verifyApiKeyOrgAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,7 @@ export async function verifyApiKeyOrgAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKeyId = req.apiKey?.apiKeyId;
|
const apiKeyId = req.apiKey?.apiKeyId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKeyId) {
|
if (!apiKeyId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -45,7 +46,7 @@ export async function verifyApiKeyOrgAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!req.apiKeyOrg) {
|
if (!req.apiKeyOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"Key does not have access to this organization"
|
"Key does not have access to this organization"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { siteResources, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeySiteResourceAccess(
|
export async function verifyApiKeySiteResourceAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,8 @@ export async function verifyApiKeySiteResourceAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const siteResourceId = parseInt(req.params.siteResourceId);
|
const siteResourceIdRaw = getFirstString(req.params.siteResourceId);
|
||||||
|
const siteResourceId = Number.parseInt(siteResourceIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,7 +22,7 @@ export async function verifyApiKeySiteResourceAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!siteResourceId) {
|
if (Number.isNaN(siteResourceId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resources, targets, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyTargetAccess(
|
export async function verifyApiKeyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,8 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const targetId = parseInt(req.params.targetId);
|
const targetIdRaw = getFirstString(req.params.targetId);
|
||||||
|
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,7 +22,7 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(targetId)) {
|
if (Number.isNaN(targetId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyAccessTokenAccess(
|
export async function verifyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,7 +15,7 @@ export async function verifyAccessTokenAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const accessTokenId = req.params.accessTokenId;
|
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -22,6 +23,12 @@ export async function verifyAccessTokenAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accessTokenId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [accessToken] = await db
|
const [accessToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
@@ -87,7 +94,7 @@ export async function verifyAccessTokenAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have access to this organization"
|
"User does not have access to this organization"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyAccess(
|
export async function verifyApiKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,9 +15,24 @@ export async function verifyApiKeyAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const apiKeyId =
|
const apiKeyIdFromParams = getFirstString(req.params?.apiKeyId);
|
||||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
const apiKeyIdFromBody = getFirstString(req.body?.apiKeyId);
|
||||||
const orgId = req.params.orgId;
|
|
||||||
|
if (
|
||||||
|
apiKeyIdFromParams &&
|
||||||
|
apiKeyIdFromBody &&
|
||||||
|
apiKeyIdFromParams !== apiKeyIdFromBody
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"API key ID provided in both URL and body with different values"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyId = apiKeyIdFromParams || apiKeyIdFromBody;
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -104,10 +120,7 @@ export async function verifyApiKeyAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||||
req.userOrg.userId,
|
|
||||||
orgId
|
|
||||||
);
|
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyDomainAccess(
|
export async function verifyDomainAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,9 +15,8 @@ export async function verifyDomainAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const domainId =
|
const domainId = getFirstString(req.params.domainId);
|
||||||
req.params.domainId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const orgId = req.params.orgId;
|
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -62,10 +62,7 @@ export async function verifyDomainAccess(
|
|||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||||
eq(userOrgs.userId, userId),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
req.userOrg = userOrgRole[0];
|
req.userOrg = userOrgRole[0];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyLimits(
|
export async function verifyLimits(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,7 +14,10 @@ export async function verifyLimits(
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId;
|
const orgId =
|
||||||
|
req.userOrgId ||
|
||||||
|
req.apiKeyOrg?.orgId ||
|
||||||
|
getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
|
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyOrgAccess(
|
export async function verifyOrgAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,7 +14,7 @@ export async function verifyOrgAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { db, userOrgs, siteProvisioningKeys, siteProvisioningKeyOrg } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
userOrgs,
|
||||||
|
siteProvisioningKeys,
|
||||||
|
siteProvisioningKeyOrg
|
||||||
|
} from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifySiteProvisioningKeyAccess(
|
export async function verifySiteProvisioningKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,8 +19,10 @@ export async function verifySiteProvisioningKeyAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const siteProvisioningKeyId = req.params.siteProvisioningKeyId;
|
const siteProvisioningKeyId = getFirstString(
|
||||||
const orgId = req.params.orgId;
|
req.params.siteProvisioningKeyId
|
||||||
|
);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -80,10 +88,7 @@ export async function verifySiteProvisioningKeyAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userOrgs.userId, userId),
|
eq(userOrgs.userId, userId),
|
||||||
eq(
|
eq(userOrgs.orgId, row.siteProvisioningKeyOrg.orgId)
|
||||||
userOrgs.orgId,
|
|
||||||
row.siteProvisioningKeyOrg.orgId
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyTargetAccess(
|
export async function verifyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,7 +15,8 @@ export async function verifyTargetAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const targetId = parseInt(req.params.targetId);
|
const targetIdRaw = getFirstString(req.params.targetId);
|
||||||
|
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { userOrgs } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyUserIsOrgOwner(
|
export async function verifyUserIsOrgOwner(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -11,7 +12,7 @@ export async function verifyUserIsOrgOwner(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
+145
-1
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { redisManager } from "@server/private/lib/redis";
|
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
@@ -298,3 +298,147 @@ class AdaptiveCache {
|
|||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regional adaptive cache backed by the in-cluster Redis instance.
|
||||||
|
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
||||||
|
* Use this for data that is regional in nature (e.g. status history) so
|
||||||
|
* reads are served from the same cluster the user is hitting.
|
||||||
|
*/
|
||||||
|
const regionalLocalCache = new NodeCache({
|
||||||
|
stdTTL: 3600,
|
||||||
|
checkperiod: 120,
|
||||||
|
maxKeys: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
class RegionalAdaptiveCache {
|
||||||
|
private useRedis(): boolean {
|
||||||
|
return (
|
||||||
|
regionalRedisManager.isRedisEnabled() &&
|
||||||
|
regionalRedisManager.getHealthStatus().isHealthy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
||||||
|
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
||||||
|
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
const success = await regionalRedisManager.set(
|
||||||
|
key,
|
||||||
|
serialized,
|
||||||
|
redisTtl
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis set error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
|
||||||
|
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T = any>(key: string): Promise<T | undefined> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
if (value !== null) {
|
||||||
|
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
}
|
||||||
|
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis get error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = regionalLocalCache.get<T>(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string | string[]): Promise<number> {
|
||||||
|
const keys = Array.isArray(key) ? key : [key];
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
for (const k of keys) {
|
||||||
|
const success = await regionalRedisManager.del(k);
|
||||||
|
if (success) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedCount === keys.length) return deletedCount;
|
||||||
|
deletedCount = 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis del error:`, error);
|
||||||
|
deletedCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
const count = regionalLocalCache.del(k);
|
||||||
|
if (count > 0) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string): Promise<boolean> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
return value !== null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis has error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns keys matching the given prefix from whichever backend is active.
|
||||||
|
* Redis uses a KEYS scan; local cache filters in-memory keys.
|
||||||
|
*/
|
||||||
|
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
return await regionalRedisManager.keys(`${prefix}*`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis keys error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentBackend(): "redis" | "local" {
|
||||||
|
return this.useRedis() ? "redis" : "local";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalCache = new RegionalAdaptiveCache();
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import { LogStreamingManager } from "./LogStreamingManager";
|
|||||||
*/
|
*/
|
||||||
export const logStreamingManager = new LogStreamingManager();
|
export const logStreamingManager = new LogStreamingManager();
|
||||||
|
|
||||||
if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here
|
if (build !== "saas") {
|
||||||
|
// this is handled separately in the saas build, so we don't want to start it here
|
||||||
logStreamingManager.start();
|
logStreamingManager.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ export const privateConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
rejectUnauthorized: z.boolean().optional().default(true)
|
rejectUnauthorized: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
|
.optional(),
|
||||||
|
regional_redis: z
|
||||||
|
.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: portSchema,
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
|
||||||
|
db: z.int().nonnegative().optional().default(0),
|
||||||
|
tls: z
|
||||||
|
.object({
|
||||||
|
rejectUnauthorized: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -855,3 +855,163 @@ class RedisManager {
|
|||||||
export const redisManager = new RedisManager();
|
export const redisManager = new RedisManager();
|
||||||
export const redis = redisManager.getClient();
|
export const redis = redisManager.getClient();
|
||||||
export default redisManager;
|
export default redisManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
|
||||||
|
* Connects only when `redis.regional_redis` is present in the private config
|
||||||
|
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
|
||||||
|
* caching of regionally-scoped data.
|
||||||
|
*/
|
||||||
|
class RegionalRedisManager {
|
||||||
|
private writeClient: Redis | null = null;
|
||||||
|
private readClient: Redis | null = null;
|
||||||
|
private isEnabled: boolean = false;
|
||||||
|
private isHealthy: boolean = false;
|
||||||
|
private connectionTimeout: number = 5000;
|
||||||
|
private commandTimeout: number = 5000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (build === "oss") return;
|
||||||
|
|
||||||
|
const cfg = privateConfig.getRawPrivateConfig();
|
||||||
|
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
|
||||||
|
|
||||||
|
this.isEnabled = true;
|
||||||
|
this.initializeClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfig(): RedisOptions {
|
||||||
|
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
|
||||||
|
const opts: RedisOptions = {
|
||||||
|
host: r.host,
|
||||||
|
port: r.port,
|
||||||
|
password: r.password,
|
||||||
|
db: r.db
|
||||||
|
};
|
||||||
|
if (r.tls) {
|
||||||
|
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeClients(): void {
|
||||||
|
const cfg = this.getConfig();
|
||||||
|
const baseOpts = {
|
||||||
|
...cfg,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
keepAlive: 10000,
|
||||||
|
connectTimeout: this.connectionTimeout,
|
||||||
|
commandTimeout: this.commandTimeout
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 client ready");
|
||||||
|
this.isHealthy = true;
|
||||||
|
});
|
||||||
|
this.writeClient.on("error", (err) => {
|
||||||
|
logger.error("Regional Redis client error:", err);
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
this.writeClient.on("reconnecting", () => {
|
||||||
|
logger.info("Regional Redis client reconnecting...");
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Regional Redis client initialized");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to initialize regional Redis client:", error);
|
||||||
|
this.isEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRedisEnabled(): boolean {
|
||||||
|
return this.isEnabled && this.writeClient !== null && this.isHealthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHealthStatus() {
|
||||||
|
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
ttl?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
if (ttl) {
|
||||||
|
await this.writeClient.setex(key, ttl, value);
|
||||||
|
} else {
|
||||||
|
await this.writeClient.set(key, value);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis SET error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(key: string): Promise<string | null> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return null;
|
||||||
|
try {
|
||||||
|
return await this.readClient.get(key);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis GET error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async del(key: string): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
await this.writeClient.del(key);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis DEL error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async keys(pattern: string): Promise<string[]> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return [];
|
||||||
|
try {
|
||||||
|
return await this.readClient.keys(pattern);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis KEYS error:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalRedisManager = new RegionalRedisManager();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyCertificateAccess(
|
export async function verifyCertificateAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -27,11 +28,43 @@ export async function verifyCertificateAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Assume user/org access is already verified
|
// Assume user/org access is already verified
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const certId =
|
|
||||||
req.params.certId || req.body?.certId || req.query?.certId;
|
const certIdFromParams = getFirstString(req.params?.certId);
|
||||||
let domainId =
|
const certIdFromBody = getFirstString(req.body?.certId);
|
||||||
req.params.domainId || req.body?.domainId || req.query?.domainId;
|
|
||||||
|
if (
|
||||||
|
certIdFromParams &&
|
||||||
|
certIdFromBody &&
|
||||||
|
certIdFromParams !== certIdFromBody
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Certificate ID provided in both URL and body with different values"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const certId = certIdFromParams || certIdFromBody;
|
||||||
|
|
||||||
|
const domainIdFromParams = getFirstString(req.params?.domainId);
|
||||||
|
const domainIdFromBody = getFirstString(req.body?.domainId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
domainIdFromParams &&
|
||||||
|
domainIdFromBody &&
|
||||||
|
domainIdFromParams !== domainIdFromBody
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Domain ID provided in both URL and body with different values"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let domainId = domainIdFromParams || domainIdFromBody;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -65,7 +98,7 @@ export async function verifyCertificateAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
domainId = cert.domainId;
|
domainId = cert.domainId ?? undefined;
|
||||||
if (!domainId) {
|
if (!domainId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyIdpAccess(
|
export async function verifyIdpAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -25,8 +26,12 @@ export async function verifyIdpAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
const idpIdRaw =
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.params.idpId) ||
|
||||||
|
getFirstString(req.body?.idpId) ||
|
||||||
|
getFirstString(req.query?.idpId);
|
||||||
|
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -40,7 +45,7 @@ export async function verifyIdpAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idpId) {
|
if (Number.isNaN(idpId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyRemoteExitNodeAccess(
|
export async function verifyRemoteExitNodeAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -25,11 +26,11 @@ export async function verifyRemoteExitNodeAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId; // Assuming you have user information in the request
|
const userId = req.user!.userId; // Assuming you have user information in the request
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const remoteExitNodeId =
|
const remoteExitNodeId =
|
||||||
req.params.remoteExitNodeId ||
|
getFirstString(req.params.remoteExitNodeId) ||
|
||||||
req.body.remoteExitNodeId ||
|
getFirstString(req.body?.remoteExitNodeId) ||
|
||||||
req.query.remoteExitNodeId;
|
getFirstString(req.query?.remoteExitNodeId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -37,6 +38,15 @@ export async function verifyRemoteExitNodeAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId || !remoteExitNodeId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid organization or remote exit node ID"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [remoteExitNode] = await db
|
const [remoteExitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
if (build != "saas") {
|
if (build !== "saas") {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
|
import * as labels from "#private/routers/labels";
|
||||||
|
import * as client from "@server/routers/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -732,6 +734,59 @@ authenticated.get(
|
|||||||
alertRule.getAlertRule
|
alertRule.getAlertRule
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/labels",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.listOrgLabels),
|
||||||
|
labels.listOrgLabels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/labels",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.createOrgLabel),
|
||||||
|
labels.createOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.patch(
|
||||||
|
"/org/:orgId/label/:labelId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.updateOrgLabel),
|
||||||
|
labels.updateOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/label/:labelId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
||||||
|
labels.deleteOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/label/:labelId/attach",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.attachLabelToItem),
|
||||||
|
labels.attachLabelToItem
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/label/:labelId/detach",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
|
||||||
|
labels.detachLabelFromItem
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/health-checks",
|
"/org/:orgId/health-checks",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
@@ -775,3 +830,15 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.getTarget),
|
verifyUserHasAction(ActionsEnum.getTarget),
|
||||||
healthChecks.getHealthCheckStatusHistory
|
healthChecks.getHealthCheckStatusHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/client/:clientId/verify-associations-cache",
|
||||||
|
verifyClientAccess,
|
||||||
|
client.verifyClientAssociationsCache
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/client/:clientId/rebuild-associations-cache",
|
||||||
|
verifyClientAccess,
|
||||||
|
client.rebuildClientAssociationsCacheRoute
|
||||||
|
);
|
||||||
|
|||||||
@@ -16,40 +16,44 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { response as sendResponse } from "@server/lib/response";
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||||
|
|
||||||
export interface CreateNewLicenseResponse {
|
export interface CreateNewLicenseResponse {
|
||||||
data: Data
|
data: Data;
|
||||||
success: boolean
|
success: boolean;
|
||||||
error: boolean
|
error: boolean;
|
||||||
message: string
|
message: string;
|
||||||
status: number
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Data {
|
export interface Data {
|
||||||
licenseKey: LicenseKey
|
licenseKey: LicenseKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LicenseKey {
|
export interface LicenseKey {
|
||||||
id: number
|
id: number;
|
||||||
instanceName: any
|
instanceName: any;
|
||||||
instanceId: string
|
instanceId: string;
|
||||||
licenseKey: string
|
licenseKey: string;
|
||||||
tier: string
|
tier: string;
|
||||||
type: string
|
type: string;
|
||||||
quantity: number
|
quantity: number;
|
||||||
quantity_2: number
|
quantity_2: number;
|
||||||
isValid: boolean
|
isValid: boolean;
|
||||||
updatedAt: string
|
updatedAt: string;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
expiresAt: string
|
expiresAt: string;
|
||||||
paidFor: boolean
|
paidFor: boolean;
|
||||||
orgId: string
|
orgId: string;
|
||||||
metadata: string
|
metadata: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
|
export async function createNewLicense(
|
||||||
|
orgId: string,
|
||||||
|
licenseData: any
|
||||||
|
): Promise<CreateNewLicenseResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
||||||
@@ -80,7 +84,7 @@ export async function generateNewLicense(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { response as sendResponse } from "@server/lib/response";
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import {
|
import {
|
||||||
GeneratedLicenseKey,
|
GeneratedLicenseKey,
|
||||||
@@ -55,7 +56,7 @@ export async function listSaasLicenseKeys(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
clients,
|
||||||
|
clientLabels,
|
||||||
|
db,
|
||||||
|
labels,
|
||||||
|
resourceLabels,
|
||||||
|
resources,
|
||||||
|
siteLabels,
|
||||||
|
siteResourceLabels,
|
||||||
|
siteResources,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
labelId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachLabelBodySchema = z.strictObject({
|
||||||
|
siteId: z.number().int().optional(),
|
||||||
|
resourceId: z.number().int().optional(),
|
||||||
|
siteResourceId: z.number().int().optional(),
|
||||||
|
clientId: z.number().int().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function attachLabelToItem(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, labelId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = attachLabelBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId, resourceId, siteResourceId, clientId } =
|
||||||
|
parsedBody.data;
|
||||||
|
|
||||||
|
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(labels)
|
||||||
|
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Label with Id ${labelId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteId) {
|
||||||
|
const siteCount = await db.$count(
|
||||||
|
sites,
|
||||||
|
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (siteCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site with Id ${siteId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||||
|
await db
|
||||||
|
.insert(siteLabels)
|
||||||
|
.values({
|
||||||
|
labelId,
|
||||||
|
siteId
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceId) {
|
||||||
|
const resourceCount = await db.$count(
|
||||||
|
resources,
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resourceCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with Id ${resourceId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||||
|
await db
|
||||||
|
.insert(resourceLabels)
|
||||||
|
.values({
|
||||||
|
labelId,
|
||||||
|
resourceId
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteResourceId) {
|
||||||
|
const resourceCount = await db.$count(
|
||||||
|
siteResources,
|
||||||
|
and(
|
||||||
|
eq(siteResources.siteResourceId, siteResourceId),
|
||||||
|
eq(siteResources.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resourceCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||||
|
await db
|
||||||
|
.insert(siteResourceLabels)
|
||||||
|
.values({
|
||||||
|
labelId,
|
||||||
|
siteResourceId
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientId) {
|
||||||
|
const clientCount = await db.$count(
|
||||||
|
clients,
|
||||||
|
and(
|
||||||
|
eq(clients.clientId, clientId),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
isNull(clients.userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Client with Id ${clientId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||||
|
await db
|
||||||
|
.insert(clientLabels)
|
||||||
|
.values({
|
||||||
|
labelId,
|
||||||
|
clientId
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Label attached successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
labels,
|
||||||
|
resourceLabels,
|
||||||
|
resources,
|
||||||
|
siteLabels,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
name: z.string().nonempty(),
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
||||||
|
.nonempty(),
|
||||||
|
siteId: z.number().int().optional(),
|
||||||
|
resourceId: z.number().int().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createOrgLabel(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, color, siteId, resourceId } = parsedBody.data;
|
||||||
|
|
||||||
|
if (siteId) {
|
||||||
|
const siteCount = await db.$count(
|
||||||
|
sites,
|
||||||
|
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (siteCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Site with Id ${siteId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceId) {
|
||||||
|
const resourceCount = await db.$count(
|
||||||
|
resources,
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resourceCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Resource with Id ${resourceId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = await db.transaction(async (tx) => {
|
||||||
|
const [label] = await tx
|
||||||
|
.insert(labels)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
orgId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (siteId) {
|
||||||
|
await tx.insert(siteLabels).values({
|
||||||
|
siteId,
|
||||||
|
labelId: label.labelId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceId) {
|
||||||
|
await tx.insert(resourceLabels).values({
|
||||||
|
resourceId,
|
||||||
|
labelId: label.labelId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response<CreateOrEditLabelResponse>(res, {
|
||||||
|
data: { label },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Org Label created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
import { db, labels } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
labelId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteOrgLabel(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, labelId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(labels)
|
||||||
|
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(labels)
|
||||||
|
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Label deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
clients,
|
||||||
|
clientLabels,
|
||||||
|
db,
|
||||||
|
labels,
|
||||||
|
resourceLabels,
|
||||||
|
resources,
|
||||||
|
siteLabels,
|
||||||
|
siteResourceLabels,
|
||||||
|
siteResources,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
labelId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const detachLabelBodySchema = z.strictObject({
|
||||||
|
siteId: z.number().int().optional(),
|
||||||
|
resourceId: z.number().int().optional(),
|
||||||
|
siteResourceId: z.number().int().optional(),
|
||||||
|
clientId: z.number().int().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function detachLabelFromItem(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, labelId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = detachLabelBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId, resourceId, siteResourceId, clientId } =
|
||||||
|
parsedBody.data;
|
||||||
|
|
||||||
|
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(labels)
|
||||||
|
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Label with Id ${labelId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteId) {
|
||||||
|
const siteCount = await db.$count(
|
||||||
|
sites,
|
||||||
|
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (siteCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site with Id ${siteId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(siteLabels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteLabels.labelId, labelId),
|
||||||
|
eq(siteLabels.siteId, siteId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceId) {
|
||||||
|
const resourceCount = await db.$count(
|
||||||
|
resources,
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resourceCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with Id ${resourceId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(resourceLabels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceLabels.labelId, labelId),
|
||||||
|
eq(resourceLabels.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteResourceId) {
|
||||||
|
const resourceCount = await db.$count(
|
||||||
|
siteResources,
|
||||||
|
and(
|
||||||
|
eq(siteResources.siteResourceId, siteResourceId),
|
||||||
|
eq(siteResources.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resourceCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(siteResourceLabels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResourceLabels.labelId, labelId),
|
||||||
|
eq(siteResourceLabels.siteResourceId, siteResourceId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientId) {
|
||||||
|
const clientCount = await db.$count(
|
||||||
|
clients,
|
||||||
|
and(
|
||||||
|
eq(clients.clientId, clientId),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
isNull(clients.userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientCount === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Client with Id ${clientId} doesn't exist.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(clientLabels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientLabels.labelId, labelId),
|
||||||
|
eq(clientLabels.clientId, clientId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Label detached successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./listOrgLabels";
|
||||||
|
export * from "./createOrgLabel";
|
||||||
|
export * from "./updateOrgLabel";
|
||||||
|
export * from "./attachLabelToItem";
|
||||||
|
export * from "./detachLabelFromItem";
|
||||||
|
export * from "./deleteOrgLabel";
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db, labels } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, asc, eq, like, sql } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const listLabelsSchema = z.object({
|
||||||
|
pageSize: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.catch(20)
|
||||||
|
.default(20)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.catch(1)
|
||||||
|
.default(1)
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryLabelsBase() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
labelId: labels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color
|
||||||
|
})
|
||||||
|
.from(labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOrgLabels(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listLabelsSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageSize, page, query } = parsedQuery.data;
|
||||||
|
|
||||||
|
const conditions = [and(eq(labels.orgId, orgId))];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${labels.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryLabelsBase().where(and(...conditions));
|
||||||
|
|
||||||
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
|
const countQuery = db.$count(
|
||||||
|
queryLabelsBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_labels")
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelListQuery = baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(asc(labels.name));
|
||||||
|
|
||||||
|
const [totalCount, rows] = await Promise.all([
|
||||||
|
countQuery,
|
||||||
|
labelListQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response<ListOrgLabelsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
labels: rows,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
pageSize,
|
||||||
|
page
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Labels retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db, labels } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
labelId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLabelBodySchema = z.strictObject({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
||||||
|
.nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateOrgLabel(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, labelId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = updateLabelBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(labels)
|
||||||
|
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, color } = parsedBody.data;
|
||||||
|
|
||||||
|
const [label] = await db
|
||||||
|
.update(labels)
|
||||||
|
.set({
|
||||||
|
name,
|
||||||
|
color
|
||||||
|
})
|
||||||
|
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response<CreateOrEditLabelResponse>(res, {
|
||||||
|
data: {
|
||||||
|
label
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Label updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
|
||||||
import config from "#private/lib/config";
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -35,78 +34,9 @@ const paramsSchema = z.strictObject({
|
|||||||
|
|
||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
logoUrl: z
|
logoUrl: z
|
||||||
.union([
|
.string()
|
||||||
z.literal(""),
|
.optional()
|
||||||
z
|
.transform((val) => (val === "" ? null : val)),
|
||||||
.string()
|
|
||||||
.superRefine(async (urlOrPath, ctx) => {
|
|
||||||
const parseResult = z.url().safeParse(urlOrPath);
|
|
||||||
if (!parseResult.success) {
|
|
||||||
if (build !== "enterprise") {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Must be a valid URL"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
validateLocalPath(urlOrPath);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(urlOrPath, {
|
|
||||||
method: "HEAD"
|
|
||||||
}).catch(() => {
|
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
|
||||||
return fetch(urlOrPath, { method: "GET" });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `Failed to load image. Please check that the URL is accessible.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType =
|
|
||||||
response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.startsWith("image/")) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage =
|
|
||||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
|
||||||
|
|
||||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
||||||
errorMessage =
|
|
||||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = `Error verifying URL: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: errorMessage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
])
|
|
||||||
.transform((val) => (val === "" ? null : val))
|
|
||||||
.nullish(),
|
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
resourceTitle: z.string(),
|
resourceTitle: z.string(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
logsDb,
|
logsDb,
|
||||||
newts,
|
newts,
|
||||||
roles,
|
roles,
|
||||||
|
roleSiteResources,
|
||||||
roundTripMessageTracker,
|
roundTripMessageTracker,
|
||||||
siteResources,
|
siteResources,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
@@ -361,9 +362,26 @@ export async function signSshKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roleRows = await db
|
const roleRows = await db
|
||||||
.select()
|
.select({
|
||||||
|
sshSudoCommands: roles.sshSudoCommands,
|
||||||
|
sshUnixGroups: roles.sshUnixGroups,
|
||||||
|
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||||
|
sshSudoMode: roles.sshSudoMode
|
||||||
|
})
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(inArray(roles.roleId, roleIds));
|
.innerJoin(
|
||||||
|
roleSiteResources,
|
||||||
|
eq(roleSiteResources.roleId, roles.roleId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(roles.roleId, roleIds),
|
||||||
|
eq(
|
||||||
|
roleSiteResources.siteResourceId,
|
||||||
|
resource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const parsedSudoCommands: string[] = [];
|
const parsedSudoCommands: string[] = [];
|
||||||
const parsedGroupsSet = new Set<string>();
|
const parsedGroupsSet = new Set<string>();
|
||||||
@@ -379,13 +397,17 @@ export async function signSshKey(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||||
if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g));
|
if (Array.isArray(grps))
|
||||||
|
grps.forEach((g: string) => parsedGroupsSet.add(g));
|
||||||
} catch {
|
} catch {
|
||||||
// skip
|
// skip
|
||||||
}
|
}
|
||||||
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
||||||
const m = roleRow?.sshSudoMode ?? "none";
|
const m = roleRow?.sshSudoMode ?? "none";
|
||||||
if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) {
|
if (
|
||||||
|
sudoModeOrder[m as keyof typeof sudoModeOrder] >
|
||||||
|
sudoModeOrder[sudoMode]
|
||||||
|
) {
|
||||||
sudoMode = m as "none" | "commands" | "full";
|
sudoMode = m as "none" | "commands" | "full";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { UserType } from "@server/types/UserTypes";
|
|||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
import { verifyTotpCode } from "@server/auth/totp";
|
import { verifyTotpCode } from "@server/auth/totp";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
// The RP ID is the domain name of your application
|
// The RP ID is the domain name of your application
|
||||||
const rpID = (() => {
|
const rpID = (() => {
|
||||||
@@ -406,7 +407,12 @@ export async function deleteSecurityKey(
|
|||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { credentialId: encodedCredentialId } = req.params;
|
const encodedCredentialId = getFirstString(req.params.credentialId);
|
||||||
|
if (!encodedCredentialId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid credential ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ export * from "./listUserDevices";
|
|||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
|
export * from "./verifyClientAssociationsCache";
|
||||||
|
export * from "./rebuildClientAssociationsCacheRoute";
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
|
clientLabels,
|
||||||
clients,
|
clients,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
currentFingerprint,
|
currentFingerprint,
|
||||||
db,
|
db,
|
||||||
|
labels,
|
||||||
olms,
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
roleClients,
|
roleClients,
|
||||||
sites,
|
sites,
|
||||||
userClients,
|
userClients,
|
||||||
users
|
users,
|
||||||
|
type Label
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -113,7 +118,27 @@ const listClientsSchema = z.object({
|
|||||||
description:
|
description:
|
||||||
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
||||||
})
|
})
|
||||||
)
|
),
|
||||||
|
labels: z
|
||||||
|
.preprocess((val) => {
|
||||||
|
if (val === undefined || val === null || val === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
// the array is returned as this
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return val.split(",");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, z.array(z.string()))
|
||||||
|
.optional()
|
||||||
|
.catch([])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
description: "Filter by client labels"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
function queryClientsBase() {
|
function queryClientsBase() {
|
||||||
@@ -169,6 +194,7 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
|||||||
siteNiceId: string | null;
|
siteNiceId: string | null;
|
||||||
}>;
|
}>;
|
||||||
olmUpdateAvailable?: boolean;
|
olmUpdateAvailable?: boolean;
|
||||||
|
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OlmWithUpdateAvailable = ClientWithSites;
|
type OlmWithUpdateAvailable = ClientWithSites;
|
||||||
@@ -204,8 +230,16 @@ export async function listClients(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { page, pageSize, online, query, status, sort_by, order } =
|
const {
|
||||||
parsedQuery.data;
|
page,
|
||||||
|
pageSize,
|
||||||
|
online,
|
||||||
|
query,
|
||||||
|
status,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
labels: labelFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -255,6 +289,11 @@ export async function listClients(
|
|||||||
(client) => client.clientId
|
(client) => client.clientId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
// Get client count with filter
|
// Get client count with filter
|
||||||
const conditions = [
|
const conditions = [
|
||||||
and(
|
and(
|
||||||
@@ -287,21 +326,48 @@ export async function listClients(
|
|||||||
conditions.push(or(...filterAggregates));
|
conditions.push(or(...filterAggregates));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query) {
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
inArray(
|
||||||
like(
|
clients.clientId,
|
||||||
sql`LOWER(${clients.name})`,
|
db
|
||||||
"%" + query.toLowerCase() + "%"
|
.select({ id: clientLabels.clientId })
|
||||||
),
|
.from(clientLabels)
|
||||||
like(
|
.innerJoin(
|
||||||
sql`LOWER(${clients.niceId})`,
|
labels,
|
||||||
"%" + query.toLowerCase() + "%"
|
eq(labels.labelId, clientLabels.labelId)
|
||||||
)
|
)
|
||||||
|
.where(inArray(labels.name, labelFilter))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
|
const queryList = [
|
||||||
|
like(sql`LOWER(${clients.name})`, q),
|
||||||
|
like(sql`LOWER(${clients.niceId})`, q)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
clients.clientId,
|
||||||
|
db
|
||||||
|
.select({ id: clientLabels.clientId })
|
||||||
|
.from(clientLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, clientLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(or(...queryList));
|
||||||
|
}
|
||||||
|
|
||||||
const baseQuery = queryClientsBase().where(and(...conditions));
|
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||||
|
|
||||||
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||||
@@ -326,6 +392,30 @@ export async function listClients(
|
|||||||
const clientIds = clientsList.map((client) => client.clientId);
|
const clientIds = clientsList.map((client) => client.clientId);
|
||||||
const siteAssociations = await getSiteAssociations(clientIds);
|
const siteAssociations = await getSiteAssociations(clientIds);
|
||||||
|
|
||||||
|
let labelsForClients: Array<{
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
clientId: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled && clientIds.length > 0) {
|
||||||
|
labelsForClients = await db
|
||||||
|
.select({
|
||||||
|
labelId: labels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color,
|
||||||
|
clientId: clientLabels.clientId
|
||||||
|
})
|
||||||
|
.from(labels)
|
||||||
|
.innerJoin(
|
||||||
|
clientLabels,
|
||||||
|
eq(clientLabels.labelId, labels.labelId)
|
||||||
|
)
|
||||||
|
.where(inArray(clientLabels.clientId, clientIds))
|
||||||
|
.orderBy(asc(clientLabels.clientLabelId));
|
||||||
|
}
|
||||||
|
|
||||||
// Group site associations by client ID
|
// Group site associations by client ID
|
||||||
const sitesByClient = siteAssociations.reduce(
|
const sitesByClient = siteAssociations.reduce(
|
||||||
(acc, association) => {
|
(acc, association) => {
|
||||||
@@ -353,7 +443,10 @@ export async function listClients(
|
|||||||
const clientsWithSites = clientsList.map((client) => {
|
const clientsWithSites = clientsList.map((client) => {
|
||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
sites: sitesByClient[client.clientId] || []
|
sites: sitesByClient[client.clientId] || [],
|
||||||
|
labels: labelsForClients.filter(
|
||||||
|
(l) => l.clientId === client.clientId
|
||||||
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { clients } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/client/{clientId}/rebuild-associations-cache",
|
||||||
|
description:
|
||||||
|
"Rebuild the client's site/site-resource association cache based on current permissions.",
|
||||||
|
tags: [OpenAPITags.Client],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function rebuildClientAssociationsCacheRoute(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Client with ID ${clientId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromClient(client);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Client association cache rebuilt successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to rebuild client association cache"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { clients } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/client/{clientId}/verify-associations-cache",
|
||||||
|
description:
|
||||||
|
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
|
||||||
|
tags: [OpenAPITags.Client],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function verifyClientAssociationsCache(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Client with ID ${clientId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await verifyClientAssociationsCacheLib(client);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: report,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: report.consistent
|
||||||
|
? "Client association cache is consistent"
|
||||||
|
: "Client association cache is INCONSISTENT",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to verify client association cache"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { domain } from "zod/v4/core/regexes";
|
|
||||||
|
|
||||||
const getDomainSchema = z.strictObject({
|
const getDomainSchema = z.strictObject({
|
||||||
domainId: z.string().optional(),
|
domainId: z.string().optional(),
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Label } from "@server/db";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
|
||||||
|
export type ListOrgLabelsResponse = PaginatedResponse<{
|
||||||
|
labels: Omit<Label, "orgId">[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type CreateOrEditLabelResponse = {
|
||||||
|
label: Label;
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function getUserResources(
|
export async function getUserResources(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -26,7 +27,7 @@ export async function getUserResources(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -35,6 +36,12 @@ export async function getUserResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check user is in organization and get their role IDs
|
// Check user is in organization and get their role IDs
|
||||||
const [userOrg] = await db
|
const [userOrg] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
|
labels,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
|
resourceLabels,
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
resources,
|
resources,
|
||||||
@@ -9,8 +11,11 @@ import {
|
|||||||
sites,
|
sites,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
targets,
|
targets,
|
||||||
userResources
|
userResources,
|
||||||
|
type Label
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -66,7 +71,7 @@ const listResourcesSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
query: z.string().optional(),
|
query: z.string().optional(),
|
||||||
sort_by: z
|
sort_by: z
|
||||||
.enum(["name"])
|
.literal("name")
|
||||||
.optional()
|
.optional()
|
||||||
.catch(undefined)
|
.catch(undefined)
|
||||||
.openapi({
|
.openapi({
|
||||||
@@ -118,7 +123,27 @@ const listResourcesSchema = z.object({
|
|||||||
type: "integer",
|
type: "integer",
|
||||||
description:
|
description:
|
||||||
"When set, only resources that have at least one target on this site are returned"
|
"When set, only resources that have at least one target on this site are returned"
|
||||||
})
|
}),
|
||||||
|
labels: z
|
||||||
|
.preprocess((val) => {
|
||||||
|
if (val === undefined || val === null || val === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
// the array is returned as this
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return val.split(",");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, z.array(z.string()))
|
||||||
|
.optional()
|
||||||
|
.catch([])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
description: "Filter by resource labels"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// grouped by resource with targets[])
|
// grouped by resource with targets[])
|
||||||
@@ -154,6 +179,7 @@ export type ResourceWithTargets = {
|
|||||||
siteNiceId: string;
|
siteNiceId: string;
|
||||||
online?: boolean; // undefined for local sites
|
online?: boolean; // undefined for local sites
|
||||||
}>;
|
}>;
|
||||||
|
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function queryResourcesBase() {
|
function queryResourcesBase() {
|
||||||
@@ -255,7 +281,8 @@ export async function listResources(
|
|||||||
healthStatus,
|
healthStatus,
|
||||||
sort_by,
|
sort_by,
|
||||||
order,
|
order,
|
||||||
siteId
|
siteId,
|
||||||
|
labels: labelFilter
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||||
@@ -288,6 +315,11 @@ export async function listResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
let accessibleResources: Array<{ resourceId: number }>;
|
let accessibleResources: Array<{ resourceId: number }>;
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
accessibleResources = await db
|
accessibleResources = await db
|
||||||
@@ -325,24 +357,6 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (query) {
|
|
||||||
conditions.push(
|
|
||||||
or(
|
|
||||||
like(
|
|
||||||
sql`LOWER(${resources.name})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
),
|
|
||||||
like(
|
|
||||||
sql`LOWER(${resources.niceId})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
),
|
|
||||||
like(
|
|
||||||
sql`LOWER(${resources.fullDomain})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (typeof enabled !== "undefined") {
|
if (typeof enabled !== "undefined") {
|
||||||
conditions.push(eq(resources.enabled, enabled));
|
conditions.push(eq(resources.enabled, enabled));
|
||||||
}
|
}
|
||||||
@@ -387,6 +401,49 @@ export async function listResources(
|
|||||||
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
|
conditions.push(
|
||||||
|
inArray(
|
||||||
|
resources.resourceId,
|
||||||
|
db
|
||||||
|
.select({ id: resourceLabels.resourceId })
|
||||||
|
.from(resourceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, resourceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(inArray(labels.name, labelFilter))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
|
const queryList = [
|
||||||
|
like(sql`LOWER(${resources.name})`, q),
|
||||||
|
like(sql`LOWER(${resources.niceId})`, q),
|
||||||
|
like(sql`LOWER(${resources.fullDomain})`, q)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
resources.resourceId,
|
||||||
|
db
|
||||||
|
.select({ id: resourceLabels.resourceId })
|
||||||
|
.from(resourceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, resourceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(or(...queryList));
|
||||||
|
}
|
||||||
|
|
||||||
const baseQuery = queryResourcesBase().where(and(...conditions));
|
const baseQuery = queryResourcesBase().where(and(...conditions));
|
||||||
|
|
||||||
// we need to add `as` so that drizzle filters the result as a subquery
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
@@ -407,6 +464,36 @@ export async function listResources(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const resourceIdList = rows.map((row) => row.resourceId);
|
const resourceIdList = rows.map((row) => row.resourceId);
|
||||||
|
|
||||||
|
let labelsForResources: Array<{
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
resourceId: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
labelsForResources =
|
||||||
|
resourceIdList.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
labelId: labels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color,
|
||||||
|
resourceId: resourceLabels.resourceId
|
||||||
|
})
|
||||||
|
.from(labels)
|
||||||
|
.innerJoin(
|
||||||
|
resourceLabels,
|
||||||
|
eq(resourceLabels.labelId, labels.labelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
inArray(resourceLabels.resourceId, resourceIdList)
|
||||||
|
)
|
||||||
|
.orderBy(asc(resourceLabels.resourceLabelId));
|
||||||
|
}
|
||||||
|
|
||||||
const allResourceTargets =
|
const allResourceTargets =
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
? []
|
? []
|
||||||
@@ -458,7 +545,10 @@ export async function listResources(
|
|||||||
headerAuthId: row.headerAuthId,
|
headerAuthId: row.headerAuthId,
|
||||||
health: row.health ?? null,
|
health: row.health ?? null,
|
||||||
targets: [],
|
targets: [],
|
||||||
sites: []
|
sites: [],
|
||||||
|
labels: labelsForResources.filter(
|
||||||
|
(l) => l.resourceId === row.resourceId
|
||||||
|
)
|
||||||
};
|
};
|
||||||
map.set(row.resourceId, entry);
|
map.set(row.resourceId, entry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
siteResources,
|
siteResources,
|
||||||
targets,
|
targets,
|
||||||
sites,
|
sites,
|
||||||
userSites
|
userSites,
|
||||||
|
labels,
|
||||||
|
siteLabels,
|
||||||
|
type Label
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import cache from "#dynamic/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -23,6 +26,8 @@ import createHttpError from "http-errors";
|
|||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
||||||
// a transient network failure / timeout does not flip every site back to
|
// a transient network failure / timeout does not flip every site back to
|
||||||
@@ -182,12 +187,32 @@ const listSitesSchema = z.object({
|
|||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pending", "approved"],
|
enum: ["pending", "approved"],
|
||||||
description: "Filter by site status"
|
description: "Filter by site status"
|
||||||
|
}),
|
||||||
|
labels: z
|
||||||
|
.preprocess((val) => {
|
||||||
|
if (val === undefined || val === null || val === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
// the array is returned as this
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return val.split(",");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, z.array(z.string()))
|
||||||
|
.optional()
|
||||||
|
.catch([])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
description: "Filter by site labels"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
function querySitesBase() {
|
function querySitesBase() {
|
||||||
return db
|
return db
|
||||||
.select({
|
.selectDistinct({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
niceId: sites.niceId,
|
niceId: sites.niceId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
@@ -233,6 +258,7 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
|
|||||||
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
||||||
online?: SiteRowBase["online"]; // undefined for local sites
|
online?: SiteRowBase["online"]; // undefined for local sites
|
||||||
newtUpdateAvailable?: boolean;
|
newtUpdateAvailable?: boolean;
|
||||||
|
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListSitesResponse = PaginatedResponse<{
|
export type ListSitesResponse = PaginatedResponse<{
|
||||||
@@ -308,8 +334,21 @@ export async function listSites(
|
|||||||
.where(eq(sites.orgId, orgId));
|
.where(eq(sites.orgId, orgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { pageSize, page, query, sort_by, order, online, status } =
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
parsedQuery.data;
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
pageSize,
|
||||||
|
page,
|
||||||
|
query,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
online,
|
||||||
|
status,
|
||||||
|
labels: labelFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||||
|
|
||||||
@@ -319,26 +358,55 @@ export async function listSites(
|
|||||||
eq(sites.orgId, orgId)
|
eq(sites.orgId, orgId)
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
if (query) {
|
|
||||||
conditions.push(
|
|
||||||
or(
|
|
||||||
like(
|
|
||||||
sql`LOWER(${sites.name})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
),
|
|
||||||
like(
|
|
||||||
sql`LOWER(${sites.niceId})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (typeof online !== "undefined") {
|
if (typeof online !== "undefined") {
|
||||||
conditions.push(eq(sites.online, online));
|
conditions.push(eq(sites.online, online));
|
||||||
}
|
}
|
||||||
if (typeof status !== "undefined") {
|
if (typeof status !== "undefined") {
|
||||||
conditions.push(eq(sites.status, status));
|
conditions.push(eq(sites.status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
|
conditions.push(
|
||||||
|
inArray(
|
||||||
|
sites.siteId,
|
||||||
|
db
|
||||||
|
.select({ id: siteLabels.siteId })
|
||||||
|
.from(siteLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, siteLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(inArray(labels.name, labelFilter))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
|
const queryList = [
|
||||||
|
like(sql`LOWER(${sites.name})`, q),
|
||||||
|
like(sql`LOWER(${sites.niceId})`, q)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
sites.siteId,
|
||||||
|
db
|
||||||
|
.select({ id: siteLabels.siteId })
|
||||||
|
.from(siteLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, siteLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
conditions.push(or(...queryList));
|
||||||
|
}
|
||||||
|
|
||||||
const baseQuery = querySitesBase().where(and(...conditions));
|
const baseQuery = querySitesBase().where(and(...conditions));
|
||||||
|
|
||||||
// we need to add `as` so that drizzle filters the result as a subquery
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
@@ -367,11 +435,46 @@ export async function listSites(
|
|||||||
// Get latest version asynchronously without blocking the response
|
// Get latest version asynchronously without blocking the response
|
||||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||||
|
|
||||||
|
const siteIds = rows.map((site) => site.siteId);
|
||||||
|
|
||||||
|
let labelsForSites: Array<{
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
siteId: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
labelsForSites =
|
||||||
|
siteIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
labelId: labels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color,
|
||||||
|
siteId: siteLabels.siteId
|
||||||
|
})
|
||||||
|
.from(labels)
|
||||||
|
.innerJoin(
|
||||||
|
siteLabels,
|
||||||
|
eq(siteLabels.labelId, labels.labelId)
|
||||||
|
)
|
||||||
|
.where(inArray(siteLabels.siteId, siteIds))
|
||||||
|
.orderBy(asc(siteLabels.siteLabelId));
|
||||||
|
}
|
||||||
|
|
||||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||||
// Initially set to false, will be updated if version check succeeds
|
// Initially set to false, will be updated if version check succeeds
|
||||||
siteWithUpdate.newtUpdateAvailable = false;
|
siteWithUpdate.newtUpdateAvailable = false;
|
||||||
return siteWithUpdate;
|
|
||||||
|
// associate labels
|
||||||
|
const labelsForSite = labelsForSites.filter(
|
||||||
|
(label) => label.siteId === site.siteId
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...siteWithUpdate, labels: labelsForSite };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to get the latest version, but don't block if it fails
|
// Try to get the latest version, but don't block if it fails
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
DB_TYPE,
|
||||||
|
Label,
|
||||||
|
SiteResource,
|
||||||
|
siteNetworks,
|
||||||
|
siteResourceLabels,
|
||||||
|
siteResources,
|
||||||
|
sites,
|
||||||
|
labels
|
||||||
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -9,6 +19,8 @@ import { NextFunction, Request, Response } from "express";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -69,15 +81,30 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
default: "asc",
|
default: "asc",
|
||||||
description: "Sort order"
|
description: "Sort order"
|
||||||
}),
|
}),
|
||||||
siteId: z.coerce
|
siteId: z.coerce.number<string>().int().positive().optional().openapi({
|
||||||
.number<string>()
|
type: "integer",
|
||||||
.int()
|
description:
|
||||||
.positive()
|
"When set, only site resources associated with this site (via network) are returned"
|
||||||
|
}),
|
||||||
|
labels: z
|
||||||
|
.preprocess((val) => {
|
||||||
|
if (val === undefined || val === null || val === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
// the array is returned as this
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return val.split(",");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, z.array(z.string()))
|
||||||
.optional()
|
.optional()
|
||||||
|
.catch([])
|
||||||
.openapi({
|
.openapi({
|
||||||
type: "integer",
|
type: "array",
|
||||||
description:
|
description: "Filter by resource labels"
|
||||||
"When set, only site resources associated with this site (via network) are returned"
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +115,7 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
|||||||
siteNames: string[];
|
siteNames: string[];
|
||||||
siteNiceIds: string[];
|
siteNiceIds: string[];
|
||||||
siteAddresses: (string | null)[];
|
siteAddresses: (string | null)[];
|
||||||
|
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
||||||
})[];
|
})[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -231,8 +259,21 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
const {
|
||||||
parsedQuery.data;
|
page,
|
||||||
|
pageSize,
|
||||||
|
query,
|
||||||
|
mode,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
siteId,
|
||||||
|
labels: labelFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
|
|
||||||
@@ -258,39 +299,55 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
inArray(siteResources.siteResourceId, resourcesForSite)
|
inArray(siteResources.siteResourceId, resourcesForSite)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (query) {
|
|
||||||
|
if (mode) {
|
||||||
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
inArray(
|
||||||
like(
|
siteResources.siteResourceId,
|
||||||
sql`LOWER(${siteResources.name})`,
|
db
|
||||||
"%" + query.toLowerCase() + "%"
|
.select({ id: siteResourceLabels.siteResourceId })
|
||||||
),
|
.from(siteResourceLabels)
|
||||||
like(
|
.innerJoin(
|
||||||
sql`LOWER(${siteResources.niceId})`,
|
labels,
|
||||||
"%" + query.toLowerCase() + "%"
|
eq(labels.labelId, siteResourceLabels.labelId)
|
||||||
),
|
)
|
||||||
like(
|
.where(inArray(labels.name, labelFilter))
|
||||||
sql`LOWER(${siteResources.destination})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
),
|
|
||||||
like(
|
|
||||||
sql`LOWER(${siteResources.alias})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
),
|
|
||||||
like(
|
|
||||||
sql`LOWER(${siteResources.aliasAddress})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
),
|
|
||||||
like(
|
|
||||||
sql`LOWER(${sites.name})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode) {
|
if (query) {
|
||||||
conditions.push(eq(siteResources.mode, mode));
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
|
const queryList = [
|
||||||
|
like(sql`LOWER(${siteResources.name})`, q),
|
||||||
|
like(sql`LOWER(${siteResources.niceId})`, q),
|
||||||
|
like(sql`LOWER(${siteResources.destination})`, q),
|
||||||
|
like(sql`LOWER(${siteResources.alias})`, q),
|
||||||
|
like(sql`LOWER(${siteResources.aliasAddress})`, q),
|
||||||
|
like(sql`LOWER(${sites.name})`, q)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
db
|
||||||
|
.select({ id: siteResourceLabels.siteResourceId })
|
||||||
|
.from(siteResourceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, siteResourceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||||
@@ -315,11 +372,51 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
countQuery
|
countQuery
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
|
const siteResourcesList = siteResourcesRaw.map(
|
||||||
|
transformSiteResourceRow
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteResourceIdList = siteResourcesList.map(
|
||||||
|
(r) => r.siteResourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
let labelsForSiteResources: Array<{
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
siteResourceId: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
||||||
|
labelsForSiteResources = await db
|
||||||
|
.select({
|
||||||
|
labelId: labels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color,
|
||||||
|
siteResourceId: siteResourceLabels.siteResourceId
|
||||||
|
})
|
||||||
|
.from(labels)
|
||||||
|
.innerJoin(
|
||||||
|
siteResourceLabels,
|
||||||
|
eq(siteResourceLabels.labelId, labels.labelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
siteResourceLabels.siteResourceId,
|
||||||
|
siteResourceIdList
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
|
||||||
|
}
|
||||||
|
|
||||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
siteResources: siteResourcesList,
|
siteResources: siteResourcesList.map((r) => ({
|
||||||
|
...r,
|
||||||
|
labels: labelsForSiteResources.filter(
|
||||||
|
(l) => l.siteResourceId === r.siteResourceId
|
||||||
|
)
|
||||||
|
})),
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Labels"
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function LabelsPage({ params, searchParams }: Props) {
|
||||||
|
const { orgId } = await params;
|
||||||
|
|
||||||
|
const searchParamsObj = new URLSearchParams(await searchParams);
|
||||||
|
|
||||||
|
let labels: ListOrgLabelsResponse["labels"] = [];
|
||||||
|
let pagination: ListOrgLabelsResponse["pagination"] = {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<ListOrgLabelsResponse>>(
|
||||||
|
`/org/${orgId}/labels?${searchParamsObj.toString()}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
const responseData = res.data.data;
|
||||||
|
labels = responseData.labels;
|
||||||
|
pagination = responseData.pagination;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("labels")}
|
||||||
|
description={t("orgLabelsDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrgLabelsTable
|
||||||
|
labels={labels}
|
||||||
|
orgId={orgId}
|
||||||
|
rowCount={pagination.total}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,7 +76,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
agent: client.agent,
|
agent: client.agent,
|
||||||
archived: client.archived || false,
|
archived: client.archived || false,
|
||||||
blocked: client.blocked || false,
|
blocked: client.blocked || false,
|
||||||
approvalState: client.approvalState ?? "approved"
|
approvalState: client.approvalState ?? "approved",
|
||||||
|
labels: client.labels ?? []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,65 @@ export default function GeneralPage() {
|
|||||||
const [approvalId, setApprovalId] = useState<number | null>(null);
|
const [approvalId, setApprovalId] = useState<number | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const [cacheCheck, setCacheCheck] = useState<null | {
|
||||||
|
consistent: boolean;
|
||||||
|
missingSiteResourceIds: number[];
|
||||||
|
extraSiteResourceIds: number[];
|
||||||
|
missingSiteIds: number[];
|
||||||
|
extraSiteIds: number[];
|
||||||
|
expectedSiteResourceIds: number[];
|
||||||
|
actualSiteResourceIds: number[];
|
||||||
|
expectedSiteIds: number[];
|
||||||
|
actualSiteIds: number[];
|
||||||
|
}>(null);
|
||||||
|
const [isCheckingCache, setIsCheckingCache] = useState(false);
|
||||||
|
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
|
||||||
|
|
||||||
|
const handleRebuildCache = async () => {
|
||||||
|
if (!client.clientId) return;
|
||||||
|
setIsRebuildingCache(true);
|
||||||
|
try {
|
||||||
|
await api.post(
|
||||||
|
`/client/${client.clientId}/rebuild-associations-cache`
|
||||||
|
);
|
||||||
|
// Re-verify after rebuild so the result refreshes
|
||||||
|
const res = await api.get(
|
||||||
|
`/client/${client.clientId}/verify-associations-cache`
|
||||||
|
);
|
||||||
|
setCacheCheck(res.data.data);
|
||||||
|
toast({
|
||||||
|
title: "Cache rebuilt",
|
||||||
|
description: "Association cache rebuilt successfully."
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Rebuild failed",
|
||||||
|
description: formatAxiosError(e, "Failed to rebuild cache")
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRebuildingCache(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyCache = async () => {
|
||||||
|
if (!client.clientId) return;
|
||||||
|
setIsCheckingCache(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(
|
||||||
|
`/client/${client.clientId}/verify-associations-cache`
|
||||||
|
);
|
||||||
|
setCacheCheck(res.data.data);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Cache check failed",
|
||||||
|
description: formatAxiosError(e, "Failed to verify cache")
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingCache(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const showApprovalFeatures =
|
const showApprovalFeatures =
|
||||||
@@ -844,6 +903,75 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
|
||||||
|
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleVerifyCache}
|
||||||
|
disabled={isCheckingCache}
|
||||||
|
className="text-xs text-muted-foreground underline disabled:opacity-50"
|
||||||
|
title="Verify the client's site association cache against current permissions (read-only)"
|
||||||
|
>
|
||||||
|
{isCheckingCache
|
||||||
|
? "Checking cache…"
|
||||||
|
: "Verify association cache"}
|
||||||
|
</button>
|
||||||
|
{cacheCheck && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"text-xs rounded border px-2 py-1 " +
|
||||||
|
(cacheCheck.consistent
|
||||||
|
? "border-green-600 text-green-700"
|
||||||
|
: "border-red-600 text-red-700")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cacheCheck.consistent ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
Cache is consistent
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1 font-semibold">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Cache is INCONSISTENT
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Missing site resources: [
|
||||||
|
{cacheCheck.missingSiteResourceIds.join(
|
||||||
|
", "
|
||||||
|
)}
|
||||||
|
]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Extra site resources: [
|
||||||
|
{cacheCheck.extraSiteResourceIds.join(", ")}
|
||||||
|
]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Missing sites: [
|
||||||
|
{cacheCheck.missingSiteIds.join(", ")}]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Extra sites: [
|
||||||
|
{cacheCheck.extraSiteIds.join(", ")}]
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRebuildCache}
|
||||||
|
disabled={isRebuildingCache}
|
||||||
|
className="mt-1 text-xs underline font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRebuildingCache
|
||||||
|
? "Rebuilding…"
|
||||||
|
: "Rebuild cache now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useState, useRef, useEffect, useTransition } from "react";
|
import { useState, useTransition, useMemo } from "react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
@@ -20,6 +20,9 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,23 +33,8 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
const [filterAttributes, setFilterAttributes] = useState<{
|
|
||||||
actors: string[];
|
|
||||||
resources: {
|
|
||||||
id: number;
|
|
||||||
name: string | null;
|
|
||||||
}[];
|
|
||||||
locations: string[];
|
|
||||||
}>({
|
|
||||||
actors: [],
|
|
||||||
resources: [],
|
|
||||||
locations: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states - unified object for all filters
|
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -61,40 +49,21 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Initialize page size from storage or default
|
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,75 +72,95 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
const defaultRange = getDefaultDateRange();
|
let timeEnd: string | undefined;
|
||||||
queryDateTime(
|
|
||||||
defaultRange.startDate,
|
if (dateRange.startDate?.date) {
|
||||||
defaultRange.endDate,
|
const dt = new Date(dateRange.startDate.date);
|
||||||
0,
|
if (dateRange.startDate.time) {
|
||||||
pageSize
|
const [h, m, s] = dateRange.startDate.time
|
||||||
);
|
.split(":")
|
||||||
}, [orgId]); // Re-run if orgId changes
|
.map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
}
|
||||||
|
timeStart = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateRange.endDate?.date) {
|
||||||
|
const dt = new Date(dateRange.endDate.date);
|
||||||
|
if (dateRange.endDate.time) {
|
||||||
|
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
dt.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timeEnd = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...filters,
|
||||||
|
resourceId: filters.resourceId
|
||||||
|
? Number(filters.resourceId)
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}, [dateRange, currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||||
|
...logQueries.access({
|
||||||
|
orgId: orgId as string,
|
||||||
|
filters: queryFilters
|
||||||
|
}),
|
||||||
|
enabled: isPaidUser(tierMatrix.accessLogs) && build !== "oss"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = isLoading ? generateSampleAccessLogs() : (data?.log ?? []);
|
||||||
|
const totalCount = data?.pagination?.total ?? 0;
|
||||||
|
const filterAttributes = data?.filterAttributes ?? {
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: []
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
// Create new filters object with updated value
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
0,
|
|
||||||
pageSize,
|
|
||||||
newFilters
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -193,114 +182,8 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
|
||||||
startDate: DateTimeValue,
|
|
||||||
endDate: DateTimeValue,
|
|
||||||
page: number = currentPage,
|
|
||||||
size: number = pageSize,
|
|
||||||
filtersParam?: {
|
|
||||||
action?: string;
|
|
||||||
type?: string;
|
|
||||||
resourceId?: string;
|
|
||||||
location?: string;
|
|
||||||
actor?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
|
||||||
if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") {
|
|
||||||
console.log(
|
|
||||||
"Access denied: subscription inactive or license locked"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the provided filters or fall back to current state
|
|
||||||
const activeFilters = filtersParam || filters;
|
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
|
||||||
const params: any = {
|
|
||||||
limit: size,
|
|
||||||
offset: page * size,
|
|
||||||
...activeFilters
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate?.date) {
|
|
||||||
const startDateTime = new Date(startDate.date);
|
|
||||||
if (startDate.time) {
|
|
||||||
const [hours, minutes, seconds] = startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
}
|
|
||||||
params.timeStart = startDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate?.date) {
|
|
||||||
const endDateTime = new Date(endDate.date);
|
|
||||||
if (endDate.time) {
|
|
||||||
const [hours, minutes, seconds] = endDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
} else {
|
|
||||||
// If no time is specified, set to NOW
|
|
||||||
const now = new Date();
|
|
||||||
endDateTime.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.timeEnd = endDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.get(`/org/${orgId}/logs/access`, { params });
|
|
||||||
if (res.status === 200) {
|
|
||||||
setRows(res.data.data.log || []);
|
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
|
||||||
setFilterAttributes(res.data.data.filterAttributes);
|
|
||||||
console.log("Fetched logs:", res.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("Failed to filter logs"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = async () => {
|
|
||||||
console.log("Data refreshed");
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
// Refresh data with current date range and pagination
|
|
||||||
await queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
currentPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -316,7 +199,6 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -334,7 +216,6 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
// Parse the Blob as JSON
|
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -351,7 +232,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -366,7 +247,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
@@ -379,7 +260,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -396,13 +276,11 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
header: ({ column }) => {
|
header: () => t("ip")
|
||||||
return t("ip");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "location",
|
accessorKey: "location",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("location")}</span>
|
<span>{t("location")}</span>
|
||||||
@@ -417,7 +295,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("location", value)
|
handleFilterChange("location", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -442,7 +319,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -455,7 +332,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("resourceId", value)
|
handleFilterChange("resourceId", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -481,7 +357,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("type")}</span>
|
<span>{t("type")}</span>
|
||||||
@@ -500,7 +376,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("type", value)
|
handleFilterChange("type", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -518,7 +393,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -531,7 +406,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -559,16 +433,12 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: ({ column }) => {
|
header: () => t("actorId"),
|
||||||
return t("actorId");
|
cell: ({ row }) => (
|
||||||
},
|
<span className="flex items-center gap-1">
|
||||||
cell: ({ row }) => {
|
{row.original.actorId || "-"}
|
||||||
return (
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
)
|
||||||
{row.original.actorId || "-"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -614,13 +484,10 @@ export default function GeneralPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
title={t("accessLogs")}
|
title={t("accessLogs")}
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
|
||||||
// !isPaidUser(tierMatrix.accessLogs) || build === "oss"
|
|
||||||
// }
|
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
start: dateRange.startDate,
|
start: dateRange.startDate,
|
||||||
@@ -630,14 +497,12 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
// Row expansion props
|
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
||||||
@@ -645,3 +510,41 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
||||||
|
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||||
|
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
|
||||||
|
const actors = [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@example.com",
|
||||||
|
null
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const action = Math.random() > 0.3;
|
||||||
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
),
|
||||||
|
action,
|
||||||
|
orgId: "sample-org",
|
||||||
|
actorType: actor ? "user" : null,
|
||||||
|
actor,
|
||||||
|
actorId: actor ? `user-${i}` : null,
|
||||||
|
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||||
|
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||||
|
resourceName: `Resource ${(i % 3) + 1}`,
|
||||||
|
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||||
|
location: locations[Math.floor(Math.random() * locations.length)],
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
metadata: null,
|
||||||
|
type: types[Math.floor(Math.random() * types.length)]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import type { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Key, User } from "lucide-react";
|
import { Key, User } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,18 +31,8 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
const [filterAttributes, setFilterAttributes] = useState<{
|
|
||||||
actors: string[];
|
|
||||||
actions: string[];
|
|
||||||
}>({
|
|
||||||
actors: [],
|
|
||||||
actions: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states - unified object for all filters
|
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
@@ -48,40 +41,21 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Initialize page size from storage or default
|
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,78 +64,90 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
if (build === "oss") {
|
let timeEnd: string | undefined;
|
||||||
return;
|
|
||||||
|
if (dateRange.startDate?.date) {
|
||||||
|
const dt = new Date(dateRange.startDate.date);
|
||||||
|
if (dateRange.startDate.time) {
|
||||||
|
const [h, m, s] = dateRange.startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
}
|
||||||
|
timeStart = dt.toISOString();
|
||||||
}
|
}
|
||||||
const defaultRange = getDefaultDateRange();
|
|
||||||
queryDateTime(
|
if (dateRange.endDate?.date) {
|
||||||
defaultRange.startDate,
|
const dt = new Date(dateRange.endDate.date);
|
||||||
defaultRange.endDate,
|
if (dateRange.endDate.time) {
|
||||||
0,
|
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||||
pageSize
|
dt.setHours(h, m, s || 0);
|
||||||
);
|
} else {
|
||||||
}, [orgId]); // Re-run if orgId changes
|
const now = new Date();
|
||||||
|
dt.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timeEnd = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...filters
|
||||||
|
};
|
||||||
|
}, [dateRange, currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||||
|
...logQueries.action({
|
||||||
|
orgId: orgId as string,
|
||||||
|
filters: queryFilters
|
||||||
|
}),
|
||||||
|
enabled: isPaidUser(tierMatrix.actionLogs) && build !== "oss"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = isLoading ? generateSampleActionLogs() : (data?.log ?? []);
|
||||||
|
const totalCount = data?.pagination?.total ?? 0;
|
||||||
|
const filterAttributes = {
|
||||||
|
actors: data?.filterAttributes?.actors ?? []
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
// Create new filters object with updated value
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
0,
|
|
||||||
pageSize,
|
|
||||||
newFilters
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -183,110 +169,8 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
|
||||||
startDate: DateTimeValue,
|
|
||||||
endDate: DateTimeValue,
|
|
||||||
page: number = currentPage,
|
|
||||||
size: number = pageSize,
|
|
||||||
filtersParam?: {
|
|
||||||
action?: string;
|
|
||||||
actor?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
|
||||||
if (!isPaidUser(tierMatrix.actionLogs)) {
|
|
||||||
console.log(
|
|
||||||
"Access denied: subscription inactive or license locked"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the provided filters or fall back to current state
|
|
||||||
const activeFilters = filtersParam || filters;
|
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
|
||||||
const params: any = {
|
|
||||||
limit: size,
|
|
||||||
offset: page * size,
|
|
||||||
...activeFilters
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate?.date) {
|
|
||||||
const startDateTime = new Date(startDate.date);
|
|
||||||
if (startDate.time) {
|
|
||||||
const [hours, minutes, seconds] = startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
}
|
|
||||||
params.timeStart = startDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate?.date) {
|
|
||||||
const endDateTime = new Date(endDate.date);
|
|
||||||
if (endDate.time) {
|
|
||||||
const [hours, minutes, seconds] = endDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
} else {
|
|
||||||
// If no time is specified, set to NOW
|
|
||||||
const now = new Date();
|
|
||||||
endDateTime.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.timeEnd = endDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.get(`/org/${orgId}/logs/action`, { params });
|
|
||||||
if (res.status === 200) {
|
|
||||||
setRows(res.data.data.log || []);
|
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
|
||||||
setFilterAttributes(res.data.data.filterAttributes);
|
|
||||||
console.log("Fetched logs:", res.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("Failed to filter logs"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = async () => {
|
|
||||||
console.log("Data refreshed");
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
// Refresh data with current date range and pagination
|
|
||||||
await queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
currentPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -302,7 +186,6 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -320,7 +203,6 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
// Parse the Blob as JSON
|
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -337,7 +219,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -352,22 +234,16 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
<ColumnFilter
|
<ColumnFilter
|
||||||
options={filterAttributes.actions.map((action) => ({
|
options={[]}
|
||||||
label:
|
|
||||||
action.charAt(0).toUpperCase() +
|
|
||||||
action.slice(1),
|
|
||||||
value: action
|
|
||||||
}))}
|
|
||||||
selectedValue={filters.action}
|
selectedValue={filters.action}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -385,7 +261,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -398,7 +274,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -420,7 +295,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("actorId");
|
return t("actorId");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -469,12 +344,9 @@ export default function GeneralPage() {
|
|||||||
title={t("actionLogs")}
|
title={t("actionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="action"
|
searchColumn="action"
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
|
||||||
// !isPaidUser(tierMatrix.logExport) || build === "oss"
|
|
||||||
// }
|
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
@@ -485,14 +357,12 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
// Row expansion props
|
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
||||||
@@ -500,3 +370,39 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleActionLogs(): QueryActionAuditLogResponse["log"] {
|
||||||
|
const actions = [
|
||||||
|
"createResource",
|
||||||
|
"deleteResource",
|
||||||
|
"updateResource",
|
||||||
|
"createSite",
|
||||||
|
"deleteSite",
|
||||||
|
"inviteUser",
|
||||||
|
"removeUser"
|
||||||
|
];
|
||||||
|
const actors = [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@example.com"
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
),
|
||||||
|
action: actions[Math.floor(Math.random() * actions.length)],
|
||||||
|
orgId: "sample-org",
|
||||||
|
actorType: "user",
|
||||||
|
actor,
|
||||||
|
actorId: `user-${i}`,
|
||||||
|
metadata: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,24 +11,18 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import type { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
|
||||||
function formatBytes(bytes: number | null): string {
|
|
||||||
if (bytes === null || bytes === undefined) return "-";
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
const value = bytes / Math.pow(1024, i);
|
|
||||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(startedAt: number, endedAt: number | null): string {
|
function formatDuration(startedAt: number, endedAt: number | null): string {
|
||||||
if (endedAt === null || endedAt === undefined) return "Active";
|
if (endedAt === null || endedAt === undefined) return "Active";
|
||||||
@@ -54,24 +48,8 @@ export default function ConnectionLogsPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
const [filterAttributes, setFilterAttributes] = useState<{
|
|
||||||
protocols: string[];
|
|
||||||
destAddrs: string[];
|
|
||||||
clients: { id: number; name: string }[];
|
|
||||||
resources: { id: number; name: string | null }[];
|
|
||||||
users: { id: string; email: string | null }[];
|
|
||||||
}>({
|
|
||||||
protocols: [],
|
|
||||||
destAddrs: [],
|
|
||||||
clients: [],
|
|
||||||
resources: [],
|
|
||||||
users: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states - unified object for all filters
|
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
destAddr?: string;
|
destAddr?: string;
|
||||||
@@ -86,43 +64,24 @@ export default function ConnectionLogsPage() {
|
|||||||
userId: searchParams.get("userId") || undefined
|
userId: searchParams.get("userId") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Initialize page size from storage or default
|
|
||||||
const [pageSize, setPageSize] = useStoredPageSize(
|
const [pageSize, setPageSize] = useStoredPageSize(
|
||||||
"connection-audit-logs",
|
"connection-audit-logs",
|
||||||
20
|
20
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set default date range to last 7 days
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,78 +90,100 @@ export default function ConnectionLogsPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
if (build === "oss") {
|
let timeEnd: string | undefined;
|
||||||
return;
|
|
||||||
|
if (dateRange.startDate?.date) {
|
||||||
|
const dt = new Date(dateRange.startDate.date);
|
||||||
|
if (dateRange.startDate.time) {
|
||||||
|
const [h, m, s] = dateRange.startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
}
|
||||||
|
timeStart = dt.toISOString();
|
||||||
}
|
}
|
||||||
const defaultRange = getDefaultDateRange();
|
|
||||||
queryDateTime(
|
if (dateRange.endDate?.date) {
|
||||||
defaultRange.startDate,
|
const dt = new Date(dateRange.endDate.date);
|
||||||
defaultRange.endDate,
|
if (dateRange.endDate.time) {
|
||||||
0,
|
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||||
pageSize
|
dt.setHours(h, m, s || 0);
|
||||||
);
|
} else {
|
||||||
}, [orgId]); // Re-run if orgId changes
|
const now = new Date();
|
||||||
|
dt.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timeEnd = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...filters,
|
||||||
|
clientId: filters.clientId ? Number(filters.clientId) : undefined,
|
||||||
|
siteResourceId: filters.siteResourceId
|
||||||
|
? Number(filters.siteResourceId)
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}, [dateRange, currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||||
|
...logQueries.connection({
|
||||||
|
orgId: orgId as string,
|
||||||
|
filters: queryFilters
|
||||||
|
}),
|
||||||
|
enabled: isPaidUser(tierMatrix.connectionLogs) && build !== "oss"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = isLoading
|
||||||
|
? generateSampleConnectionLogs()
|
||||||
|
: (data?.log ?? []);
|
||||||
|
const totalCount = data?.pagination?.total ?? 0;
|
||||||
|
const filterAttributes = data?.filterAttributes ?? {
|
||||||
|
protocols: [],
|
||||||
|
destAddrs: [],
|
||||||
|
clients: [],
|
||||||
|
resources: [],
|
||||||
|
users: []
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
// Create new filters object with updated value
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
0,
|
|
||||||
pageSize,
|
|
||||||
newFilters
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -224,109 +205,8 @@ export default function ConnectionLogsPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
|
||||||
startDate: DateTimeValue,
|
|
||||||
endDate: DateTimeValue,
|
|
||||||
page: number = currentPage,
|
|
||||||
size: number = pageSize,
|
|
||||||
filtersParam?: typeof filters
|
|
||||||
) => {
|
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
|
||||||
if (!isPaidUser(tierMatrix.connectionLogs)) {
|
|
||||||
console.log(
|
|
||||||
"Access denied: subscription inactive or license locked"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the provided filters or fall back to current state
|
|
||||||
const activeFilters = filtersParam || filters;
|
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
|
||||||
const params: any = {
|
|
||||||
limit: size,
|
|
||||||
offset: page * size,
|
|
||||||
...activeFilters
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate?.date) {
|
|
||||||
const startDateTime = new Date(startDate.date);
|
|
||||||
if (startDate.time) {
|
|
||||||
const [hours, minutes, seconds] = startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
}
|
|
||||||
params.timeStart = startDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate?.date) {
|
|
||||||
const endDateTime = new Date(endDate.date);
|
|
||||||
if (endDate.time) {
|
|
||||||
const [hours, minutes, seconds] = endDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
} else {
|
|
||||||
// If no time is specified, set to NOW
|
|
||||||
const now = new Date();
|
|
||||||
endDateTime.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.timeEnd = endDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.get(`/org/${orgId}/logs/connection`, {
|
|
||||||
params
|
|
||||||
});
|
|
||||||
if (res.status === 200) {
|
|
||||||
setRows(res.data.data.log || []);
|
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
|
||||||
setFilterAttributes(res.data.data.filterAttributes);
|
|
||||||
console.log("Fetched connection logs:", res.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("Failed to filter logs"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = async () => {
|
|
||||||
console.log("Data refreshed");
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
// Refresh data with current date range and pagination
|
|
||||||
await queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
currentPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -345,7 +225,6 @@ export default function ConnectionLogsPage() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -363,7 +242,6 @@ export default function ConnectionLogsPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
// Parse the Blob as JSON
|
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -380,7 +258,7 @@ export default function ConnectionLogsPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "startedAt",
|
accessorKey: "startedAt",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -395,7 +273,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "protocol",
|
accessorKey: "protocol",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("protocol")}</span>
|
<span>{t("protocol")}</span>
|
||||||
@@ -426,7 +304,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -467,7 +345,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "clientName",
|
accessorKey: "clientName",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("client")}</span>
|
<span>{t("client")}</span>
|
||||||
@@ -510,7 +388,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "userEmail",
|
accessorKey: "userEmail",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("user")}</span>
|
<span>{t("user")}</span>
|
||||||
@@ -543,7 +421,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "sourceAddr",
|
accessorKey: "sourceAddr",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("sourceAddress");
|
return t("sourceAddress");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -556,7 +434,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "destAddr",
|
accessorKey: "destAddr",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("destinationAddress")}</span>
|
<span>{t("destinationAddress")}</span>
|
||||||
@@ -585,7 +463,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "duration",
|
accessorKey: "duration",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("duration");
|
return t("duration");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -606,9 +484,6 @@ export default function ConnectionLogsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
|
||||||
Connection Details
|
|
||||||
</div>*/}
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Session ID:</strong>{" "}
|
<strong>Session ID:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -633,18 +508,6 @@ export default function ConnectionLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
|
||||||
Resource & Site
|
|
||||||
</div>*/}
|
|
||||||
{/*<div>
|
|
||||||
<strong>Resource:</strong>{" "}
|
|
||||||
{row.resourceName ?? "-"}
|
|
||||||
{row.resourceNiceId && (
|
|
||||||
<span className="text-muted-foreground ml-1">
|
|
||||||
({row.resourceNiceId})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>*/}
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Client Endpoint:</strong>{" "}
|
<strong>Client Endpoint:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -680,30 +543,8 @@ export default function ConnectionLogsPage() {
|
|||||||
<strong>Duration:</strong>{" "}
|
<strong>Duration:</strong>{" "}
|
||||||
{formatDuration(row.startedAt, row.endedAt)}
|
{formatDuration(row.startedAt, row.endedAt)}
|
||||||
</div>
|
</div>
|
||||||
{/*<div>
|
|
||||||
<strong>Resource ID:</strong>{" "}
|
|
||||||
{row.siteResourceId ?? "-"}
|
|
||||||
</div>*/}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
|
||||||
Client & Transfer
|
|
||||||
</div>*/}
|
|
||||||
{/*<div>
|
|
||||||
<strong>Bytes Sent (TX):</strong>{" "}
|
|
||||||
{formatBytes(row.bytesTx)}
|
|
||||||
</div>*/}
|
|
||||||
{/*<div>
|
|
||||||
<strong>Bytes Received (RX):</strong>{" "}
|
|
||||||
{formatBytes(row.bytesRx)}
|
|
||||||
</div>*/}
|
|
||||||
{/*<div>
|
|
||||||
<strong>Total Transfer:</strong>{" "}
|
|
||||||
{formatBytes(
|
|
||||||
(row.bytesTx ?? 0) + (row.bytesRx ?? 0)
|
|
||||||
)}
|
|
||||||
</div>*/}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -724,8 +565,8 @@ export default function ConnectionLogsPage() {
|
|||||||
title={t("connectionLogs")}
|
title={t("connectionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="protocol"
|
searchColumn="protocol"
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -737,14 +578,12 @@ export default function ConnectionLogsPage() {
|
|||||||
id: "startedAt",
|
id: "startedAt",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
// Row expansion props
|
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -754,3 +593,49 @@ export default function ConnectionLogsPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleConnectionLogs(): QueryConnectionAuditLogResponse["log"] {
|
||||||
|
const protocols = ["tcp", "udp", "icmp"];
|
||||||
|
const destAddrs = [
|
||||||
|
"10.0.0.1:22",
|
||||||
|
"10.0.0.2:80",
|
||||||
|
"10.0.0.3:443",
|
||||||
|
"192.168.1.10:3306"
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const startedAt = Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
);
|
||||||
|
const active = Math.random() > 0.3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
siteResourceId: (i % 3) + 1,
|
||||||
|
orgId: "sample-org",
|
||||||
|
siteId: 1,
|
||||||
|
clientId: (i % 4) + 1,
|
||||||
|
clientEndpoint: `10.0.0.${i + 1}:51820`,
|
||||||
|
userId: i % 2 === 0 ? `user-${i}` : null,
|
||||||
|
sourceAddr: `192.168.1.${i + 1}:${40000 + i}`,
|
||||||
|
destAddr: destAddrs[Math.floor(Math.random() * destAddrs.length)],
|
||||||
|
protocol:
|
||||||
|
protocols[Math.floor(Math.random() * protocols.length)],
|
||||||
|
startedAt,
|
||||||
|
endedAt: active ? null : startedAt + Math.floor(Math.random() * 3600),
|
||||||
|
bytesTx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
||||||
|
bytesRx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
||||||
|
resourceName: `Resource ${(i % 3) + 1}`,
|
||||||
|
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||||
|
siteName: "Sample Site",
|
||||||
|
siteNiceId: "sample-site",
|
||||||
|
clientName: `Client ${(i % 4) + 1}`,
|
||||||
|
clientNiceId: `client-${(i % 4) + 1}`,
|
||||||
|
clientType: i % 2 === 0 ? "user" : "machine",
|
||||||
|
userEmail: i % 2 === 0 ? `user${i}@example.com` : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -25,36 +28,11 @@ export default function GeneralPage() {
|
|||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Initialize page size from storage or default
|
|
||||||
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
|
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
|
||||||
|
|
||||||
const [filterAttributes, setFilterAttributes] = useState<{
|
|
||||||
actors: string[];
|
|
||||||
resources: {
|
|
||||||
id: number;
|
|
||||||
name: string | null;
|
|
||||||
}[];
|
|
||||||
locations: string[];
|
|
||||||
hosts: string[];
|
|
||||||
paths: string[];
|
|
||||||
}>({
|
|
||||||
actors: [],
|
|
||||||
resources: [],
|
|
||||||
locations: [],
|
|
||||||
hosts: [],
|
|
||||||
paths: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter states - unified object for all filters
|
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
@@ -75,32 +53,18 @@ export default function GeneralPage() {
|
|||||||
path: searchParams.get("path") || undefined
|
path: searchParams.get("path") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,80 +73,97 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
if (build === "oss") {
|
let timeEnd: string | undefined;
|
||||||
return;
|
|
||||||
|
if (dateRange.startDate?.date) {
|
||||||
|
const dt = new Date(dateRange.startDate.date);
|
||||||
|
if (dateRange.startDate.time) {
|
||||||
|
const [h, m, s] = dateRange.startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
}
|
||||||
|
timeStart = dt.toISOString();
|
||||||
}
|
}
|
||||||
const defaultRange = getDefaultDateRange();
|
|
||||||
queryDateTime(
|
if (dateRange.endDate?.date) {
|
||||||
defaultRange.startDate,
|
const dt = new Date(dateRange.endDate.date);
|
||||||
defaultRange.endDate,
|
if (dateRange.endDate.time) {
|
||||||
0,
|
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||||
pageSize
|
dt.setHours(h, m, s || 0);
|
||||||
);
|
} else {
|
||||||
}, [orgId]); // Re-run if orgId changes
|
const now = new Date();
|
||||||
|
dt.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timeEnd = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...filters,
|
||||||
|
resourceId: filters.resourceId
|
||||||
|
? Number(filters.resourceId)
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}, [dateRange, currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||||
|
...logQueries.requests({
|
||||||
|
orgId: orgId as string,
|
||||||
|
filters: queryFilters
|
||||||
|
}),
|
||||||
|
enabled: build !== "oss"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []);
|
||||||
|
const totalCount = data?.pagination?.total ?? 0;
|
||||||
|
const filterAttributes = data?.filterAttributes ?? {
|
||||||
|
actors: [],
|
||||||
|
resources: [],
|
||||||
|
locations: [],
|
||||||
|
hosts: [],
|
||||||
|
paths: []
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
console.log(`${filterType} filter changed:`, value);
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
|
|
||||||
// Create new filters object with updated value
|
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
|
||||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
0,
|
|
||||||
pageSize,
|
|
||||||
newFilters
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrlParamsForAllFilters = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -204,101 +185,6 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryDateTime = async (
|
|
||||||
startDate: DateTimeValue,
|
|
||||||
endDate: DateTimeValue,
|
|
||||||
page: number = currentPage,
|
|
||||||
size: number = pageSize,
|
|
||||||
filtersParam?: {
|
|
||||||
action?: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the provided filters or fall back to current state
|
|
||||||
const activeFilters = filtersParam || filters;
|
|
||||||
|
|
||||||
// Convert the date/time values to API parameters
|
|
||||||
const params: any = {
|
|
||||||
limit: size,
|
|
||||||
offset: page * size,
|
|
||||||
...activeFilters
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate?.date) {
|
|
||||||
const startDateTime = new Date(startDate.date);
|
|
||||||
if (startDate.time) {
|
|
||||||
const [hours, minutes, seconds] = startDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
}
|
|
||||||
params.timeStart = startDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate?.date) {
|
|
||||||
const endDateTime = new Date(endDate.date);
|
|
||||||
if (endDate.time) {
|
|
||||||
const [hours, minutes, seconds] = endDate.time
|
|
||||||
.split(":")
|
|
||||||
.map(Number);
|
|
||||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
|
||||||
} else {
|
|
||||||
// If no time is specified, set to NOW
|
|
||||||
const now = new Date();
|
|
||||||
endDateTime.setHours(
|
|
||||||
now.getHours(),
|
|
||||||
now.getMinutes(),
|
|
||||||
now.getSeconds(),
|
|
||||||
now.getMilliseconds()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
params.timeEnd = endDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.get(`/org/${orgId}/logs/request`, { params });
|
|
||||||
if (res.status === 200) {
|
|
||||||
setRows(res.data.data.log || []);
|
|
||||||
setTotalCount(res.data.data.pagination?.total || 0);
|
|
||||||
setFilterAttributes(res.data.data.filterAttributes);
|
|
||||||
console.log("Fetched logs:", res.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("Failed to filter logs"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = async () => {
|
|
||||||
console.log("Data refreshed");
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
// Refresh data with current date range and pagination
|
|
||||||
await queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
currentPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
// Prepare query params for export
|
||||||
@@ -781,8 +667,8 @@ export default function GeneralPage() {
|
|||||||
title={t("requestLogs")}
|
title={t("requestLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="host"
|
searchColumn="host"
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -794,7 +680,6 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
@@ -808,3 +693,63 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleRequestLogs(): QueryRequestAuditLogResponse["log"] {
|
||||||
|
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
||||||
|
const paths = [
|
||||||
|
"/api/v1/users",
|
||||||
|
"/dashboard",
|
||||||
|
"/settings",
|
||||||
|
"/health",
|
||||||
|
"/metrics"
|
||||||
|
];
|
||||||
|
const hosts = ["app.example.com", "api.example.com", "admin.example.com"];
|
||||||
|
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||||
|
const allowedReasons = [100, 101, 102, 103, 104, 105, 106, 107, 108];
|
||||||
|
const deniedReasons = [201, 202, 203, 204, 205, 299];
|
||||||
|
const actors = [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@example.com",
|
||||||
|
null
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const action = Math.random() > 0.3;
|
||||||
|
const reason = action
|
||||||
|
? allowedReasons[Math.floor(Math.random() * allowedReasons.length)]
|
||||||
|
: deniedReasons[Math.floor(Math.random() * deniedReasons.length)];
|
||||||
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
),
|
||||||
|
action,
|
||||||
|
reason,
|
||||||
|
orgId: "sample-org",
|
||||||
|
actorType: actor ? "user" : null,
|
||||||
|
actor,
|
||||||
|
actorId: actor ? `user-${i}` : null,
|
||||||
|
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||||
|
siteResourceId: null,
|
||||||
|
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||||
|
resourceName: `Resource ${(i % 3) + 1}`,
|
||||||
|
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||||
|
location: locations[Math.floor(Math.random() * locations.length)],
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
metadata: null,
|
||||||
|
headers: null,
|
||||||
|
query: null,
|
||||||
|
originalRequestURL: null,
|
||||||
|
scheme: "https",
|
||||||
|
host: hosts[Math.floor(Math.random() * hosts.length)],
|
||||||
|
path: paths[Math.floor(Math.random() * paths.length)],
|
||||||
|
method: methods[Math.floor(Math.random() * methods.length)],
|
||||||
|
tls: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ export default async function ClientResourcesPage(
|
|||||||
authDaemonPort: siteResource.authDaemonPort ?? null,
|
authDaemonPort: siteResource.authDaemonPort ?? null,
|
||||||
subdomain: siteResource.subdomain ?? null,
|
subdomain: siteResource.subdomain ?? null,
|
||||||
domainId: siteResource.domainId ?? null,
|
domainId: siteResource.domainId ?? null,
|
||||||
fullDomain: siteResource.fullDomain ?? null
|
fullDomain: siteResource.fullDomain ?? null,
|
||||||
|
labels: siteResource.labels ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { build } from "@server/build";
|
|||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
|
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
|
||||||
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export default async function ProxyResourcesPage(
|
|||||||
protocol: resource.protocol,
|
protocol: resource.protocol,
|
||||||
proxyPort: resource.proxyPort,
|
proxyPort: resource.proxyPort,
|
||||||
http: resource.http,
|
http: resource.http,
|
||||||
|
labels: resource.labels,
|
||||||
authState: !resource.http
|
authState: !resource.http
|
||||||
? "none"
|
? "none"
|
||||||
: resource.sso ||
|
: resource.sso ||
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
return {
|
return {
|
||||||
name: site.name,
|
name: site.name,
|
||||||
id: site.siteId,
|
id: site.siteId,
|
||||||
|
labels: site.labels,
|
||||||
nice: site.niceId.toString(),
|
nice: site.niceId.toString(),
|
||||||
address: site.address?.split("/")[0],
|
address: site.address?.split("/")[0],
|
||||||
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
||||||
|
|||||||
+13
-3
@@ -23,6 +23,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
SquareMousePointer,
|
SquareMousePointer,
|
||||||
|
TagIcon,
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
Unplug,
|
Unplug,
|
||||||
User,
|
User,
|
||||||
@@ -99,7 +100,7 @@ export const orgNavSections = (
|
|||||||
href: "/{orgId}/settings/domains",
|
href: "/{orgId}/settings/domains",
|
||||||
icon: <Globe className="size-4 flex-none" />
|
icon: <Globe className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build == "saas"
|
...(build === "saas"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarRemoteExitNodes",
|
title: "sidebarRemoteExitNodes",
|
||||||
@@ -237,10 +238,19 @@ export const orgNavSections = (
|
|||||||
title: "sidebarApiKeys",
|
title: "sidebarApiKeys",
|
||||||
href: "/{orgId}/settings/api-keys",
|
href: "/{orgId}/settings/api-keys",
|
||||||
icon: <KeyRound className="size-4 flex-none" />
|
icon: <KeyRound className="size-4 flex-none" />
|
||||||
}
|
},
|
||||||
|
...(build !== "oss"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "labels",
|
||||||
|
href: "/{orgId}/settings/labels",
|
||||||
|
icon: <TagIcon className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...(build == "saas" && options?.isPrimaryOrg
|
...(build === "saas" && options?.isPrimaryOrg
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarBillingAndLicenses",
|
title: "sidebarBillingAndLicenses",
|
||||||
|
|||||||
+3
-1
@@ -21,9 +21,11 @@ export default async function Page(props: {
|
|||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
redirect: string | undefined;
|
redirect: string | undefined;
|
||||||
t: string | undefined;
|
t: string | undefined;
|
||||||
|
orgs?: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.searchParams; // this is needed to prevent static optimization
|
const params = await props.searchParams; // this is needed to prevent static optimization
|
||||||
|
const showOrgPicker = params.orgs === "1";
|
||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ export default async function Page(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetOrgId) {
|
if (targetOrgId && !showOrgPicker) {
|
||||||
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,77 +44,11 @@ export type AuthPageCustomizationProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AuthPageFormSchema = z.object({
|
const AuthPageFormSchema = z.object({
|
||||||
logoUrl: z.union([
|
logoUrl: z
|
||||||
z.literal(""),
|
.string()
|
||||||
z.string().superRefine(async (urlOrPath, ctx) => {
|
.optional()
|
||||||
const parseResult = z.url().safeParse(urlOrPath);
|
.transform((val) => (val === "" ? undefined : val)),
|
||||||
if (!parseResult.success) {
|
|
||||||
if (build !== "enterprise") {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Must be a valid URL"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
validateLocalPath(urlOrPath);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message:
|
|
||||||
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(urlOrPath, {
|
|
||||||
method: "HEAD"
|
|
||||||
}).catch(() => {
|
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
|
||||||
return fetch(urlOrPath, { method: "GET" });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `Failed to load image. Please check that the URL is accessible.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.startsWith("image/")) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage =
|
|
||||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
|
||||||
|
|
||||||
if (
|
|
||||||
error instanceof TypeError &&
|
|
||||||
error.message.includes("fetch")
|
|
||||||
) {
|
|
||||||
errorMessage =
|
|
||||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = `Error verifying URL: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: errorMessage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
orgTitle: z.string().optional(),
|
orgTitle: z.string().optional(),
|
||||||
|
|||||||
@@ -27,20 +27,18 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={userDisplayName ? 3 : 2}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>{client.name}</InfoSectionContent>
|
<InfoSectionContent>{client.name}</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
{userDisplayName ? (
|
||||||
<InfoSectionTitle>
|
<InfoSection>
|
||||||
{userDisplayName ? t("user") : t("identifier")}
|
<InfoSectionTitle>{t("user")}</InfoSectionTitle>
|
||||||
</InfoSectionTitle>
|
<InfoSectionContent>
|
||||||
<InfoSectionContent>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<span>{userDisplayName}</span>
|
||||||
<span>{userDisplayName || client.niceId}</span>
|
{(client.userType ?? "internal") !==
|
||||||
{userDisplayName &&
|
|
||||||
(client.userType ?? "internal") !==
|
|
||||||
"internal" && (
|
"internal" && (
|
||||||
<IdpTypeBadge
|
<IdpTypeBadge
|
||||||
type={client.userType ?? "oidc"}
|
type={client.userType ?? "oidc"}
|
||||||
@@ -54,9 +52,10 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
) : null}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { DataTable } from "@app/components/ui/data-table";
|
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
@@ -36,7 +35,13 @@ import { useTranslations } from "next-intl";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import {
|
||||||
|
startTransition,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useTransition
|
||||||
|
} from "react";
|
||||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
@@ -53,6 +58,12 @@ import {
|
|||||||
} from "@app/components/ResourceSitesStatusCell";
|
} from "@app/components/ResourceSitesStatusCell";
|
||||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { type SelectedLabel } from "./labels-selector";
|
||||||
|
import { TableLabelsCell } from "./TableLabelsCell";
|
||||||
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
|
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||||
|
|
||||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||||
|
|
||||||
@@ -84,6 +95,11 @@ export type InternalResourceRow = {
|
|||||||
subdomain?: string | null;
|
subdomain?: string | null;
|
||||||
domainId?: string | null;
|
domainId?: string | null;
|
||||||
fullDomain?: string | null;
|
fullDomain?: string | null;
|
||||||
|
labels?: Array<{
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDestinationDisplay(row: InternalResourceRow): string {
|
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||||
@@ -141,14 +157,17 @@ export default function ClientResourcesTable({
|
|||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||||
|
|
||||||
useEffect(() => {
|
const { isPaidUser } = usePaidStatus();
|
||||||
const interval = setInterval(() => {
|
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||||
router.refresh();
|
|
||||||
}, 30_000);
|
// useEffect(() => {
|
||||||
return () => clearInterval(interval);
|
// const interval = setInterval(() => {
|
||||||
}, [router]);
|
// router.refresh();
|
||||||
|
// }, 30_000);
|
||||||
|
// return () => clearInterval(interval);
|
||||||
|
// }, [router]);
|
||||||
|
|
||||||
const siteIdQ = searchParams.get("siteId");
|
const siteIdQ = searchParams.get("siteId");
|
||||||
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||||
@@ -167,7 +186,7 @@ export default function ClientResourcesTable({
|
|||||||
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startTransition(() => {
|
startRefreshTransition(() => {
|
||||||
try {
|
try {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -185,8 +204,8 @@ export default function ClientResourcesTable({
|
|||||||
siteId: number
|
siteId: number
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/site-resource/${resourceId}`).then(() => {
|
startTransition(async () => {
|
||||||
startTransition(() => {
|
await api.delete(`/site-resource/${resourceId}`).then(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
});
|
});
|
||||||
@@ -201,359 +220,351 @@ export default function ClientResourcesTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
|
const internalColumns = useMemo<
|
||||||
const { siteNames, siteNiceIds, orgId } = resourceRow;
|
ExtendedColumnDef<InternalResourceRow>[]
|
||||||
|
>(() => {
|
||||||
|
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
enableHiding: false,
|
||||||
|
friendlyName: t("name"),
|
||||||
|
header: () => {
|
||||||
|
const nameOrder = getSortDirection("name", searchParams);
|
||||||
|
const Icon =
|
||||||
|
nameOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: nameOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
|
|
||||||
if (!siteNames || siteNames.length === 0) {
|
return (
|
||||||
return (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("noSites", { defaultValue: "No sites" })}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteNames.length === 1) {
|
|
||||||
return (
|
|
||||||
<Link href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}>
|
|
||||||
<Button variant="outline">
|
|
||||||
{siteNames[0]}
|
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{siteNames.length} {t("sites")}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
{siteNames.map((siteName, idx) => (
|
|
||||||
<DropdownMenuItem key={siteNiceIds[idx]} asChild>
|
|
||||||
<Link
|
|
||||||
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
|
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
|
||||||
>
|
|
||||||
{siteName}
|
|
||||||
<ArrowUpRight className="h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
enableHiding: false,
|
|
||||||
friendlyName: t("name"),
|
|
||||||
header: () => {
|
|
||||||
const nameOrder = getSortDirection("name", searchParams);
|
|
||||||
const Icon =
|
|
||||||
nameOrder === "asc"
|
|
||||||
? ArrowDown01Icon
|
|
||||||
: nameOrder === "desc"
|
|
||||||
? ArrowUp10Icon
|
|
||||||
: ChevronsUpDownIcon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="p-3"
|
|
||||||
onClick={() => toggleSort("name")}
|
|
||||||
>
|
|
||||||
{t("name")}
|
|
||||||
<Icon className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "niceId",
|
|
||||||
accessorKey: "niceId",
|
|
||||||
friendlyName: t("identifier"),
|
|
||||||
enableHiding: true,
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("identifier")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return <span>{row.original.niceId || "-"}</span>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sites",
|
|
||||||
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
|
||||||
friendlyName: t("sites"),
|
|
||||||
header: () => (
|
|
||||||
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
role="combobox"
|
className="p-3"
|
||||||
className={cn(
|
onClick={() => toggleSort("name")}
|
||||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
|
||||||
!selectedSite && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
{t("name")}
|
||||||
{t("sites")}
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
<Funnel className="size-4 flex-none" />
|
|
||||||
{selectedSite && (
|
|
||||||
<Badge
|
|
||||||
className="truncate max-w-[10rem]"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
{selectedSite.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
);
|
||||||
<PopoverContent
|
}
|
||||||
className={dataTableFilterPopoverContentClassName}
|
},
|
||||||
align="start"
|
{
|
||||||
|
id: "niceId",
|
||||||
|
accessorKey: "niceId",
|
||||||
|
friendlyName: t("identifier"),
|
||||||
|
enableHiding: true,
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(
|
||||||
|
column.getIsSorted() === "asc"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("identifier")}
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <span>{row.original.niceId || "-"}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sites",
|
||||||
|
accessorFn: (row) =>
|
||||||
|
row.sites.map((s) => s.siteName).join(", "),
|
||||||
|
friendlyName: t("sites"),
|
||||||
|
header: () => (
|
||||||
|
<Popover
|
||||||
|
open={siteFilterOpen}
|
||||||
|
onOpenChange={setSiteFilterOpen}
|
||||||
>
|
>
|
||||||
<div className="border-b p-1">
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
role="combobox"
|
||||||
className="h-8 w-full justify-start font-normal"
|
className={cn(
|
||||||
onClick={clearSiteFilter}
|
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||||
|
!selectedSite && "text-muted-foreground"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{t("standaloneHcFilterAnySite")}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{t("sites")}
|
||||||
|
<Funnel className="size-4 flex-none" />
|
||||||
|
{selectedSite && (
|
||||||
|
<Badge
|
||||||
|
className="truncate max-w-[10rem]"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{selectedSite.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</PopoverTrigger>
|
||||||
<SitesSelector
|
<PopoverContent
|
||||||
orgId={orgId}
|
className={dataTableFilterPopoverContentClassName}
|
||||||
selectedSite={selectedSite}
|
align="start"
|
||||||
onSelectSite={onPickSite}
|
>
|
||||||
|
<div className="border-b p-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-full justify-start font-normal"
|
||||||
|
onClick={clearSiteFilter}
|
||||||
|
>
|
||||||
|
{t("standaloneHcFilterAnySite")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SitesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSite={selectedSite}
|
||||||
|
onSelectSite={onPickSite}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceRow = row.original;
|
||||||
|
return (
|
||||||
|
<ResourceSitesStatusCell
|
||||||
|
orgId={resourceRow.orgId}
|
||||||
|
resourceSites={resourceRow.sites}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
);
|
||||||
</Popover>
|
}
|
||||||
),
|
},
|
||||||
cell: ({ row }) => {
|
{
|
||||||
const resourceRow = row.original;
|
accessorKey: "mode",
|
||||||
return (
|
friendlyName: t("editInternalResourceDialogMode"),
|
||||||
<ResourceSitesStatusCell
|
header: () => (
|
||||||
orgId={resourceRow.orgId}
|
<ColumnFilterButton
|
||||||
resourceSites={resourceRow.sites}
|
options={[
|
||||||
/>
|
{
|
||||||
);
|
value: "host",
|
||||||
}
|
label: t("editInternalResourceDialogModeHost")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "mode",
|
value: "cidr",
|
||||||
friendlyName: t("editInternalResourceDialogMode"),
|
label: t("editInternalResourceDialogModeCidr")
|
||||||
header: () => (
|
},
|
||||||
<ColumnFilterButton
|
{
|
||||||
options={[
|
value: "http",
|
||||||
{
|
label: t("editInternalResourceDialogModeHttp")
|
||||||
value: "host",
|
}
|
||||||
label: t("editInternalResourceDialogModeHost")
|
]}
|
||||||
},
|
selectedValue={searchParams.get("mode") ?? undefined}
|
||||||
{
|
onValueChange={(value) =>
|
||||||
value: "cidr",
|
handleFilterChange("mode", value)
|
||||||
label: t("editInternalResourceDialogModeCidr")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "http",
|
|
||||||
label: t("editInternalResourceDialogModeHttp")
|
|
||||||
}
|
}
|
||||||
]}
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
selectedValue={searchParams.get("mode") ?? undefined}
|
emptyMessage={t("emptySearchOptions")}
|
||||||
onValueChange={(value) => handleFilterChange("mode", value)}
|
label={t("editInternalResourceDialogMode")}
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
className="p-3"
|
||||||
emptyMessage={t("emptySearchOptions")}
|
|
||||||
label={t("editInternalResourceDialogMode")}
|
|
||||||
className="p-3"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const resourceRow = row.original;
|
|
||||||
const modeLabels: Record<
|
|
||||||
"host" | "cidr" | "port" | "http",
|
|
||||||
string
|
|
||||||
> = {
|
|
||||||
host: t("editInternalResourceDialogModeHost"),
|
|
||||||
cidr: t("editInternalResourceDialogModeCidr"),
|
|
||||||
port: t("editInternalResourceDialogModePort"),
|
|
||||||
http: t("editInternalResourceDialogModeHttp")
|
|
||||||
};
|
|
||||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "destination",
|
|
||||||
friendlyName: t("resourcesTableDestination"),
|
|
||||||
header: () => (
|
|
||||||
<span className="p-3">{t("resourcesTableDestination")}</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const resourceRow = row.original;
|
|
||||||
const display = formatDestinationDisplay(resourceRow);
|
|
||||||
return (
|
|
||||||
<CopyToClipboard
|
|
||||||
text={display}
|
|
||||||
isLink={false}
|
|
||||||
displayText={display}
|
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
}
|
cell: ({ row }) => {
|
||||||
},
|
const resourceRow = row.original;
|
||||||
{
|
const modeLabels: Record<
|
||||||
accessorKey: "alias",
|
"host" | "cidr" | "port" | "http",
|
||||||
friendlyName: t("resourcesTableAlias"),
|
string
|
||||||
header: () => (
|
> = {
|
||||||
<span className="p-3">{t("resourcesTableAlias")}</span>
|
host: t("editInternalResourceDialogModeHost"),
|
||||||
),
|
cidr: t("editInternalResourceDialogModeCidr"),
|
||||||
cell: ({ row }) => {
|
port: t("editInternalResourceDialogModePort"),
|
||||||
const resourceRow = row.original;
|
http: t("editInternalResourceDialogModeHttp")
|
||||||
if (resourceRow.mode === "host" && resourceRow.alias) {
|
};
|
||||||
|
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "destination",
|
||||||
|
friendlyName: t("resourcesTableDestination"),
|
||||||
|
header: () => (
|
||||||
|
<span className="p-3">
|
||||||
|
{t("resourcesTableDestination")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceRow = row.original;
|
||||||
|
const display = formatDestinationDisplay(resourceRow);
|
||||||
return (
|
return (
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={resourceRow.alias}
|
text={display}
|
||||||
isLink={false}
|
isLink={false}
|
||||||
displayText={resourceRow.alias}
|
displayText={display}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (resourceRow.mode === "http") {
|
},
|
||||||
const domainId = resourceRow.domainId;
|
{
|
||||||
const fullDomain = resourceRow.fullDomain;
|
accessorKey: "alias",
|
||||||
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
|
friendlyName: t("resourcesTableAlias"),
|
||||||
const did =
|
header: () => (
|
||||||
build !== "oss" &&
|
<span className="p-3">{t("resourcesTableAlias")}</span>
|
||||||
resourceRow.ssl &&
|
),
|
||||||
domainId != null &&
|
cell: ({ row }) => {
|
||||||
domainId !== "" &&
|
const resourceRow = row.original;
|
||||||
fullDomain != null &&
|
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||||
fullDomain !== "";
|
return (
|
||||||
|
<CopyToClipboard
|
||||||
|
text={resourceRow.alias}
|
||||||
|
isLink={false}
|
||||||
|
displayText={resourceRow.alias}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resourceRow.mode === "http") {
|
||||||
|
const domainId = resourceRow.domainId;
|
||||||
|
const fullDomain = resourceRow.fullDomain;
|
||||||
|
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
|
||||||
|
const did =
|
||||||
|
build !== "oss" &&
|
||||||
|
resourceRow.ssl &&
|
||||||
|
domainId != null &&
|
||||||
|
domainId !== "" &&
|
||||||
|
fullDomain != null &&
|
||||||
|
fullDomain !== "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{did ? (
|
{did ? (
|
||||||
<ResourceAccessCertIndicator
|
<ResourceAccessCertIndicator
|
||||||
orgId={resourceRow.orgId}
|
orgId={resourceRow.orgId}
|
||||||
domainId={domainId}
|
domainId={domainId}
|
||||||
fullDomain={fullDomain}
|
fullDomain={fullDomain}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="">
|
<div className="">
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={url}
|
text={url}
|
||||||
isLink={isSafeUrlForLink(url)}
|
isLink={isSafeUrlForLink(url)}
|
||||||
displayText={url}
|
displayText={url}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "aliasAddress",
|
||||||
|
friendlyName: t("resourcesTableAliasAddress"),
|
||||||
|
enableHiding: true,
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
<span>{t("resourcesTableAliasAddress")}</span>
|
||||||
|
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceRow = row.original;
|
||||||
|
return resourceRow.aliasAddress ? (
|
||||||
|
<CopyToClipboard
|
||||||
|
text={resourceRow.aliasAddress}
|
||||||
|
isLink={false}
|
||||||
|
displayText={resourceRow.aliasAddress}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>-</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => <span className="p-3"></span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceRow = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("openMenu")}
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedInternalResource(
|
||||||
|
resourceRow
|
||||||
|
);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">
|
||||||
|
{t("delete")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingResource(resourceRow);
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span>-</span>;
|
|
||||||
}
|
}
|
||||||
},
|
];
|
||||||
{
|
|
||||||
accessorKey: "aliasAddress",
|
if (isLabelFeatureEnabled) {
|
||||||
friendlyName: t("resourcesTableAliasAddress"),
|
cols.splice(cols.length - 1, 0, {
|
||||||
enableHiding: true,
|
id: "labels",
|
||||||
header: () => (
|
accessorKey: "labels",
|
||||||
<div className="flex items-center gap-2 p-3">
|
header: () => (
|
||||||
<span>{t("resourcesTableAliasAddress")}</span>
|
<LabelColumnFilterButton
|
||||||
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
|
orgId={orgId}
|
||||||
</div>
|
selectedValues={searchParams.getAll("labels")}
|
||||||
),
|
onSelectedValuesChange={(value) =>
|
||||||
cell: ({ row }) => {
|
handleFilterChange("labels", value)
|
||||||
const resourceRow = row.original;
|
}
|
||||||
return resourceRow.aliasAddress ? (
|
label={t("labels")}
|
||||||
<CopyToClipboard
|
className="p-3"
|
||||||
text={resourceRow.aliasAddress}
|
|
||||||
isLink={false}
|
|
||||||
displayText={resourceRow.aliasAddress}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
),
|
||||||
<span>-</span>
|
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
||||||
);
|
<ClientResourceLabelCell
|
||||||
}
|
resource={row.original}
|
||||||
},
|
orgId={orgId}
|
||||||
{
|
/>
|
||||||
id: "actions",
|
)
|
||||||
enableHiding: false,
|
});
|
||||||
header: () => <span className="p-3"></span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const resourceRow = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">
|
|
||||||
{t("openMenu")}
|
|
||||||
</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedInternalResource(
|
|
||||||
resourceRow
|
|
||||||
);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-red-500">
|
|
||||||
{t("delete")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingResource(resourceRow);
|
|
||||||
setIsEditDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("edit")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
return cols;
|
||||||
|
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
value: string | undefined | null
|
value: string | undefined | null | string[]
|
||||||
) {
|
) {
|
||||||
searchParams.delete(column);
|
searchParams.delete(column);
|
||||||
searchParams.delete("page");
|
searchParams.delete("page");
|
||||||
|
|
||||||
if (value) {
|
if (typeof value === "string") {
|
||||||
searchParams.set(column, value);
|
searchParams.set(column, value);
|
||||||
|
} else if (value) {
|
||||||
|
value.forEach((val) => searchParams.append(column, val));
|
||||||
}
|
}
|
||||||
filter({
|
filter({
|
||||||
searchParams
|
searchParams
|
||||||
@@ -626,7 +637,7 @@ export default function ClientResourcesTable({
|
|||||||
rows={internalResources}
|
rows={internalResources}
|
||||||
tableId="internal-resources"
|
tableId="internal-resources"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchQuery={searchParams.get("query") ?? ""}
|
searchQuery={searchParams.get("query")?.toString()}
|
||||||
onAdd={() => setIsCreateDialogOpen(true)}
|
onAdd={() => setIsCreateDialogOpen(true)}
|
||||||
addButtonText={t("resourceAdd")}
|
addButtonText={t("resourceAdd")}
|
||||||
onSearch={handleSearchChange}
|
onSearch={handleSearchChange}
|
||||||
@@ -638,7 +649,8 @@ export default function ClientResourcesTable({
|
|||||||
enableColumnVisibility
|
enableColumnVisibility
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
niceId: false,
|
niceId: false,
|
||||||
aliasAddress: false
|
aliasAddress: false,
|
||||||
|
labels: false
|
||||||
}}
|
}}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
@@ -674,3 +686,64 @@ export default function ClientResourcesTable({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientResourceLabelCellProps = {
|
||||||
|
resource: InternalResourceRow;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ClientResourceLabelCell({
|
||||||
|
resource,
|
||||||
|
orgId
|
||||||
|
}: ClientResourceLabelCellProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [localLabels, setLocalLabels] = useLocalLabels(
|
||||||
|
resource.labels,
|
||||||
|
resource.id
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleResourceLabel(
|
||||||
|
label: SelectedLabel,
|
||||||
|
action: "attach" | "detach"
|
||||||
|
) {
|
||||||
|
const previousLabels = localLabels;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (action === "attach") {
|
||||||
|
setLocalLabels([...previousLabels, label]);
|
||||||
|
await api.put(
|
||||||
|
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||||
|
{ siteResourceId: resource.id }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setLocalLabels(
|
||||||
|
previousLabels.filter(
|
||||||
|
(lb) => lb.labelId !== label.labelId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await api.put(
|
||||||
|
`/org/${orgId}/label/${label.labelId}/detach`,
|
||||||
|
{ siteResourceId: resource.id }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLocalLabels(previousLabels);
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableLabelsCell
|
||||||
|
orgId={orgId}
|
||||||
|
localLabels={localLabels}
|
||||||
|
toggleLabel={toggleResourceLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function CreateBlueprintForm({
|
|||||||
if (res && res.status === 201) {
|
if (res && res.status === 201) {
|
||||||
const createdBlueprint = res.data.data;
|
const createdBlueprint = res.data.data;
|
||||||
toast({
|
toast({
|
||||||
variant: "warning",
|
variant: createdBlueprint.succeeded ? "default" : "warning",
|
||||||
title: createdBlueprint.succeeded ? "Success" : "Warning",
|
title: createdBlueprint.succeeded ? "Success" : "Warning",
|
||||||
description: createdBlueprint.message
|
description: createdBlueprint.message
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import {
|
import {
|
||||||
cleanForFQDN,
|
cleanForFQDN,
|
||||||
InternalResourceForm,
|
InternalResourceForm,
|
||||||
@@ -39,30 +39,30 @@ export default function CreateInternalResourceDialog({
|
|||||||
}: CreateInternalResourceDialogProps) {
|
}: CreateInternalResourceDialogProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||||
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
|
||||||
async function handleSubmit(values: InternalResourceFormValues) {
|
function handleSubmit(values: InternalResourceFormValues) {
|
||||||
setIsSubmitting(true);
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (
|
if (
|
||||||
(data.mode === "host" || data.mode === "http") &&
|
(data.mode === "host" || data.mode === "http") &&
|
||||||
isHostname(data.destination)
|
isHostname(data.destination)
|
||||||
) {
|
) {
|
||||||
const currentAlias = data.alias?.trim() || "";
|
const currentAlias = data.alias?.trim() || "";
|
||||||
if (!currentAlias) {
|
if (!currentAlias) {
|
||||||
let aliasValue = data.destination;
|
let aliasValue = data.destination;
|
||||||
if (data.destination.toLowerCase() === "localhost") {
|
if (data.destination.toLowerCase() === "localhost") {
|
||||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||||
|
}
|
||||||
|
data = { ...data, alias: aliasValue };
|
||||||
}
|
}
|
||||||
data = { ...data, alias: aliasValue };
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await api.put<AxiosResponse<{ data: { siteResourceId: number } }>>(
|
await api.put<
|
||||||
`/org/${orgId}/site-resource`,
|
AxiosResponse<{ data: { siteResourceId: number } }>
|
||||||
{
|
>(`/org/${orgId}/site-resource`, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
siteIds: data.siteIds,
|
siteIds: data.siteIds,
|
||||||
mode: data.mode,
|
mode: data.mode,
|
||||||
@@ -106,32 +106,30 @@ export default function CreateInternalResourceDialog({
|
|||||||
clientIds: data.clients
|
clientIds: data.clients
|
||||||
? data.clients.map((c) => parseInt(c.id))
|
? data.clients.map((c) => parseInt(c.id))
|
||||||
: []
|
: []
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("createInternalResourceDialogSuccess"),
|
title: t("createInternalResourceDialogSuccess"),
|
||||||
description: t(
|
description: t(
|
||||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||||
),
|
),
|
||||||
variant: "default"
|
variant: "default"
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t("createInternalResourceDialogError"),
|
title: t("createInternalResourceDialogError"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
error,
|
error,
|
||||||
t(
|
t(
|
||||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
}
|
||||||
setIsSubmitting(false);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "./Credenza";
|
||||||
|
import { OrgLabelForm } from "./OrgLabelForm";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
|
export type CreateOrgLabelDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (val: boolean) => void;
|
||||||
|
orgId: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreateOrgLabelDialog({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
orgId,
|
||||||
|
onSuccess
|
||||||
|
}: CreateOrgLabelDialogProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
|
||||||
|
async function createOrgLabel(data: { name: string; color: string }) {
|
||||||
|
try {
|
||||||
|
const res = await api.post<
|
||||||
|
AxiosResponse<CreateOrEditLabelResponse>
|
||||||
|
>(`/org/${orgId}/labels`, data);
|
||||||
|
|
||||||
|
if (res.status === 201) {
|
||||||
|
setOpen(false);
|
||||||
|
onSuccess?.();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("labelCreateSuccessMessage")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza open={open} onOpenChange={setOpen}>
|
||||||
|
<CredenzaContent className="md:max-w-md">
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("createLabelDialogDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<OrgLabelForm
|
||||||
|
onSubmit={(data) => {
|
||||||
|
startTransition(async () => createOrgLabel(data));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="org-label-form"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("labelCreate")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -318,12 +318,28 @@ export default function DeviceLoginForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={9}
|
maxLength={8}
|
||||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value
|
value={field.value
|
||||||
.replace(/-/g, "")
|
.replace(/-/g, "")
|
||||||
.toUpperCase()}
|
.toUpperCase()}
|
||||||
|
onPaste={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const pastedText =
|
||||||
|
event.clipboardData.getData(
|
||||||
|
"text"
|
||||||
|
);
|
||||||
|
const cleanedValue =
|
||||||
|
pastedText
|
||||||
|
.replace(
|
||||||
|
/[^a-zA-Z0-9]/g,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 8);
|
||||||
|
field.onChange(cleanedValue);
|
||||||
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
// Strip hyphens and convert to uppercase
|
// Strip hyphens and convert to uppercase
|
||||||
const cleanedValue = value
|
const cleanedValue = value
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "./Credenza";
|
||||||
|
import { OrgLabelForm } from "./OrgLabelForm";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
|
export type EditOrgLabelDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (val: boolean) => void;
|
||||||
|
orgId: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
label: {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
labelId: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditOrgLabelDialog({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
orgId,
|
||||||
|
onSuccess,
|
||||||
|
label
|
||||||
|
}: EditOrgLabelDialogProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
|
||||||
|
async function editOrgLabel(data: { name: string; color: string }) {
|
||||||
|
try {
|
||||||
|
const res = await api.patch<
|
||||||
|
AxiosResponse<CreateOrEditLabelResponse>
|
||||||
|
>(`/org/${orgId}/label/${label.labelId}`, data);
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
setOpen(false);
|
||||||
|
onSuccess?.();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("labelEditSuccessMessage")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: formatAxiosError(e, t("errorOccurred")),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza open={open} onOpenChange={setOpen}>
|
||||||
|
<CredenzaContent className="md:max-w-md">
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>{t("editLabelDialogTitle")}</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("editLabelDialogDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<OrgLabelForm
|
||||||
|
defaultValue={label}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
startTransition(async () => editOrgLabel(data));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="org-label-form"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("labelEdit")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -122,7 +122,7 @@ export default function ExitNodesTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: t("online"),
|
friendlyName: t("status"),
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -131,7 +131,7 @@ export default function ExitNodesTable({
|
|||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("online")}
|
{t("status")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ export default function HealthChecksTable({
|
|||||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||||
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
|
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
|
||||||
|
|
||||||
const pageSize = pagination.pageSize;
|
|
||||||
const query = searchParams.get("query") ?? undefined;
|
const query = searchParams.get("query") ?? undefined;
|
||||||
|
|
||||||
const siteIdQ = searchParams.get("siteId");
|
const siteIdQ = searchParams.get("siteId");
|
||||||
@@ -164,12 +163,12 @@ export default function HealthChecksTable({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
router.refresh();
|
// router.refresh();
|
||||||
}, 30_000);
|
// }, 30_000);
|
||||||
return () => clearInterval(interval);
|
// return () => clearInterval(interval);
|
||||||
}, [router]);
|
// }, [router]);
|
||||||
|
|
||||||
const handlePaginationChange = (newState: PaginationState) => {
|
const handlePaginationChange = (newState: PaginationState) => {
|
||||||
searchParams.set("page", (newState.pageIndex + 1).toString());
|
searchParams.set("page", (newState.pageIndex + 1).toString());
|
||||||
@@ -586,7 +585,9 @@ export default function HealthChecksTable({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={r.hcEnabled}
|
checked={r.hcEnabled}
|
||||||
disabled={
|
disabled={
|
||||||
!isPaid || togglingId === r.targetHealthCheckId || !!r.resourceId
|
!isPaid ||
|
||||||
|
togglingId === r.targetHealthCheckId ||
|
||||||
|
!!r.resourceId
|
||||||
}
|
}
|
||||||
onCheckedChange={(v) => handleToggleEnabled(r, v)}
|
onCheckedChange={(v) => handleToggleEnabled(r, v)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user