diff --git a/.cursor/rules/Localization.mdc b/.cursor/rules/Localization.mdc new file mode 100644 index 000000000..3014c6177 --- /dev/null +++ b/.cursor/rules/Localization.mdc @@ -0,0 +1,5 @@ +--- +alwaysApply: true +--- + +Always localize strings and use the `t` function to convert keys to strings. Add the keys to the en-us.json file. Never edit the other language files, as en-us.json is the single source of truth. diff --git a/.cursor/rules/Nomenclature.mdc b/.cursor/rules/Nomenclature.mdc new file mode 100644 index 000000000..d290f212e --- /dev/null +++ b/.cursor/rules/Nomenclature.mdc @@ -0,0 +1,7 @@ +--- +description: +alwaysApply: true +--- + +Proxy resources = public resources +Private resources = client resources = site resources diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 54ae22194..7a9004ee8 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -77,7 +77,7 @@ jobs: fi - name: Log in to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -149,7 +149,7 @@ jobs: fi - name: Log in to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -204,7 +204,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Log in to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -264,7 +264,7 @@ jobs: shell: bash - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: 1.25 @@ -299,7 +299,7 @@ jobs: shell: bash - name: Upload artifacts from /install/bin - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: install-bin path: install/bin/ @@ -407,35 +407,25 @@ jobs: shell: bash - name: Login to GitHub Container Registry (for cosign) - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Install cosign - # cosign is used to sign and verify container images (key and keyless) + # cosign is used to sign container images using keyless (OIDC) signing uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - - name: Dual-sign and verify (GHCR & Docker Hub) - # Sign each image by digest using keyless (OIDC) and key-based signing, - # then verify both the public key signature and the keyless OIDC signature. + - name: Sign (GHCR, keyless) + # Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor. + # Signatures are stored in the registry alongside the image. env: TAG: ${{ env.TAG }} - COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} - COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} COSIGN_YES: "true" run: | set -euo pipefail - issuer="https://token.actions.githubusercontent.com" - id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs) - - # Track failures - FAILED_TAGS=() - SUCCESSFUL_TAGS=() - # Determine if this is an RC release IS_RC="false" if [[ "$TAG" == *"-rc."* ]]; then @@ -463,95 +453,47 @@ jobs: ) fi - # Sign each image variant for both registries - for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do - for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do - echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}" - TAG_FAILED=false + FAILED_TAGS=() + SUCCESSFUL_TAGS=() - # Wrap the entire tag processing in error handling - ( - set -e - DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')" - REF="${BASE_IMAGE}@${DIGEST}" - echo "Resolved digest: ${REF}" + for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do + echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}" + TAG_FAILED=false - echo "==> cosign sign (keyless) --recursive ${REF}" - cosign sign --recursive "${REF}" + ( + set -e + DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')" + REF="${GHCR_IMAGE}@${DIGEST}" + echo "Resolved digest: ${REF}" - echo "==> cosign sign (key) --recursive ${REF}" - cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + echo "==> cosign sign (keyless) --recursive ${REF}" + cosign sign --recursive "${REF}" + ) || TAG_FAILED=true - # Retry wrapper for verification to handle registry propagation delays - retry_verify() { - local cmd="$1" - local attempts=6 - local delay=5 - local i=1 - until eval "$cmd"; do - if [ $i -ge $attempts ]; then - echo "Verification failed after $attempts attempts" - return 1 - fi - echo "Verification not yet available. Retry $i/$attempts after ${delay}s..." - sleep $delay - i=$((i+1)) - delay=$((delay*2)) - # Cap the delay to avoid very long waits - if [ $delay -gt 60 ]; then delay=60; fi - done - return 0 - } - - echo "==> cosign verify (public key) ${REF}" - if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then - VERIFIED_INDEX=true - else - VERIFIED_INDEX=false - fi - - echo "==> cosign verify (keyless policy) ${REF}" - if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then - VERIFIED_INDEX_KEYLESS=true - else - VERIFIED_INDEX_KEYLESS=false - fi - - # Check if verification succeeded - if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then - echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}" - echo "This may be due to registry propagation delays. Continuing anyway." - fi - ) || TAG_FAILED=true - - if [ "$TAG_FAILED" = "true" ]; then - echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}" - FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") - else - echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}" - SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") - fi - done + if [ "$TAG_FAILED" = "true" ]; then + echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}" + FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}") + else + echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}" + SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}") + fi done - # Report summary echo "" echo "==========================================" - echo "Sign and Verify Summary" + echo "Sign Summary" echo "==========================================" echo "Successful: ${#SUCCESSFUL_TAGS[@]}" echo "Failed: ${#FAILED_TAGS[@]}" - echo "" if [ ${#FAILED_TAGS[@]} -gt 0 ]; then echo "Failed tags:" for tag in "${FAILED_TAGS[@]}"; do echo " - $tag" done - echo "" - echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway" + echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway" else - echo "✓ All images signed and verified successfully!" + echo "✓ All images signed successfully!" fi shell: bash diff --git a/README.md b/README.md index 28fc991c8..562b35d40 100644 --- a/README.md +++ b/README.md @@ -35,43 +35,53 @@ -

- - We're Hiring! - -

-

Get started with Pangolin at app.pangolin.net

-Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources, all with zero-trust security and granular access control. +Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls. ## Installation -- Check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to install and set up Pangolin. -- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer. +- Get started for free with [Pangolin Cloud](https://app.pangolin.net/). +- Or, check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to self-host Pangolin. + - Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer. - +Pangolin ## Deployment Options -| | Description | -|-----------------|--------------| -| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing - no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. | -| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. | -| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. | +- **Pangolin Cloud** - Fully managed service - no infrastructure required. +- **Self-Host: Community Edition** - Free, open source, and licensed under AGPL-3. +- **Self-Host: Enterprise Edition** - Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue. ## Key Features -| | | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------| -| **Connect remote networks with sites**

Pangolin's lightweight site connectors create secure tunnels from remote networks without requiring public IP addresses or open ports. Sites make any network anywhere available for authorized access. | | -| **Browser-based reverse proxy access**

Expose web applications through identity and context-aware tunneled reverse proxies. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Users access applications through any web browser with authentication and granular access control. | | -| **Client-based private resource access**

Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. | | -| **Zero-trust granular access**

Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications and services you explicitly define, reducing security risk and attack surface. | | +### Connect remote networks with sites and NAT traversal + +Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform. + +Sites + +### Browser-based reverse proxy access + +Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. + +Reverse proxy access + +### Client-based private resource access + +Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network. + +Private resources + +### Give users and roles access to resources + +Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. + +Users from identity provider with roles ## Download Clients @@ -87,7 +97,7 @@ Download the Pangolin client for your platform: ### Sign up now -Create an account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud. A generous free tier is available. +Create a free account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud. ### Check out the docs @@ -102,7 +112,3 @@ Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License ## Contributions Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices. - ---- - -WireGuard® is a registered trademark of Jason A. Donenfeld. diff --git a/bruno/API Keys/Create API Key.bru b/bruno/API Keys/Create API Key.bru deleted file mode 100644 index 009b4b049..000000000 --- a/bruno/API Keys/Create API Key.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Create API Key - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/api-key - body: json - auth: inherit -} - -body:json { - { - "isRoot": true - } -} diff --git a/bruno/API Keys/Delete API Key.bru b/bruno/API Keys/Delete API Key.bru deleted file mode 100644 index 9285f7889..000000000 --- a/bruno/API Keys/Delete API Key.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Delete API Key - type: http - seq: 2 -} - -delete { - url: http://localhost:3000/api/v1/api-key/dm47aacqxxn3ubj - body: none - auth: inherit -} diff --git a/bruno/API Keys/List API Key Actions.bru b/bruno/API Keys/List API Key Actions.bru deleted file mode 100644 index ae5b721e1..000000000 --- a/bruno/API Keys/List API Key Actions.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: List API Key Actions - type: http - seq: 6 -} - -get { - url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions - body: none - auth: inherit -} diff --git a/bruno/API Keys/List Org API Keys.bru b/bruno/API Keys/List Org API Keys.bru deleted file mode 100644 index 468e964b9..000000000 --- a/bruno/API Keys/List Org API Keys.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: List Org API Keys - type: http - seq: 4 -} - -get { - url: http://localhost:3000/api/v1/org/home-lab/api-keys - body: none - auth: inherit -} diff --git a/bruno/API Keys/List Root API Keys.bru b/bruno/API Keys/List Root API Keys.bru deleted file mode 100644 index 8ef31b68c..000000000 --- a/bruno/API Keys/List Root API Keys.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: List Root API Keys - type: http - seq: 3 -} - -get { - url: http://localhost:3000/api/v1/root/api-keys - body: none - auth: inherit -} diff --git a/bruno/API Keys/Set API Key Actions.bru b/bruno/API Keys/Set API Key Actions.bru deleted file mode 100644 index 54a35c438..000000000 --- a/bruno/API Keys/Set API Key Actions.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Set API Key Actions - type: http - seq: 5 -} - -post { - url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions - body: json - auth: inherit -} - -body:json { - { - "actionIds": ["listSites"] - } -} diff --git a/bruno/API Keys/Set API Key Orgs.bru b/bruno/API Keys/Set API Key Orgs.bru deleted file mode 100644 index 3f0676c5b..000000000 --- a/bruno/API Keys/Set API Key Orgs.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Set API Key Orgs - type: http - seq: 7 -} - -post { - url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/orgs - body: json - auth: inherit -} - -body:json { - { - "orgIds": ["home-lab"] - } -} diff --git a/bruno/API Keys/folder.bru b/bruno/API Keys/folder.bru deleted file mode 100644 index bb8cd5c73..000000000 --- a/bruno/API Keys/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: API Keys -} diff --git a/bruno/Auth/2fa-disable.bru b/bruno/Auth/2fa-disable.bru deleted file mode 100644 index c98539c73..000000000 --- a/bruno/Auth/2fa-disable.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: 2fa-disable - type: http - seq: 6 -} - -post { - url: http://localhost:3000/api/v1/auth/2fa/disable - body: json - auth: none -} - -body:json { - { - "password": "aaaaa-1A", - "code": "377289" - } -} diff --git a/bruno/Auth/2fa-enable.bru b/bruno/Auth/2fa-enable.bru deleted file mode 100644 index a3a01d177..000000000 --- a/bruno/Auth/2fa-enable.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: 2fa-enable - type: http - seq: 4 -} - -post { - url: http://localhost:3000/api/v1/auth/2fa/enable - body: json - auth: none -} - -body:json { - { - "code": "374138" - } -} diff --git a/bruno/Auth/2fa-request.bru b/bruno/Auth/2fa-request.bru deleted file mode 100644 index fcf0c9862..000000000 --- a/bruno/Auth/2fa-request.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: 2fa-request - type: http - seq: 5 -} - -post { - url: http://localhost:3000/api/v1/auth/2fa/request - body: json - auth: none -} - -body:json { - { - "password": "aaaaa-1A" - } -} diff --git a/bruno/Auth/change-password.bru b/bruno/Auth/change-password.bru deleted file mode 100644 index 7d1c707e5..000000000 --- a/bruno/Auth/change-password.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: change-password - type: http - seq: 9 -} - -post { - url: http://localhost:3000/api/v1/auth/change-password - body: json - auth: none -} - -body:json { - { - "oldPassword": "", - "newPassword": "" - } -} diff --git a/bruno/Auth/login.bru b/bruno/Auth/login.bru deleted file mode 100644 index 3825a2525..000000000 --- a/bruno/Auth/login.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: login - type: http - seq: 1 -} - -post { - url: http://localhost:3000/api/v1/auth/login - body: json - auth: none -} - -body:json { - { - "email": "admin@fosrl.io", - "password": "Password123!" - } -} diff --git a/bruno/Auth/logout.bru b/bruno/Auth/logout.bru deleted file mode 100644 index 623cd47fe..000000000 --- a/bruno/Auth/logout.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: logout - type: http - seq: 3 -} - -post { - url: http://localhost:4000/api/v1/auth/logout - body: none - auth: none -} diff --git a/bruno/Auth/reset-password-request.bru b/bruno/Auth/reset-password-request.bru deleted file mode 100644 index 29c3b89d1..000000000 --- a/bruno/Auth/reset-password-request.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: reset-password-request - type: http - seq: 10 -} - -post { - url: http://localhost:3000/api/v1/auth/reset-password/request - body: json - auth: none -} - -body:json { - { - "email": "milo@pangolin.net" - } -} diff --git a/bruno/Auth/reset-password.bru b/bruno/Auth/reset-password.bru deleted file mode 100644 index 8d567b164..000000000 --- a/bruno/Auth/reset-password.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: reset-password - type: http - seq: 11 -} - -post { - url: http://localhost:3000/api/v1/auth/reset-password - body: json - auth: none -} - -body:json { - { - "token": "3uhsbom72dwdhboctwrtntyd6jrlg4jtf5oaxy4k", - "newPassword": "aaaaa-1A", - "code": "6irqCGR3" - } -} diff --git a/bruno/Auth/signup.bru b/bruno/Auth/signup.bru deleted file mode 100644 index bec59235e..000000000 --- a/bruno/Auth/signup.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: signup - type: http - seq: 2 -} - -put { - url: http://localhost:3000/api/v1/auth/signup - body: json - auth: none -} - -body:json { - { - "email": "numbat@pangolin.net", - "password": "Password123!" - } -} diff --git a/bruno/Auth/verify-email-request.bru b/bruno/Auth/verify-email-request.bru deleted file mode 100644 index 72189d1b2..000000000 --- a/bruno/Auth/verify-email-request.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: verify-email-request - type: http - seq: 8 -} - -post { - url: http://localhost:3000/api/v1/auth/verify-email/request - body: none - auth: none -} diff --git a/bruno/Auth/verify-email.bru b/bruno/Auth/verify-email.bru deleted file mode 100644 index a06a7108c..000000000 --- a/bruno/Auth/verify-email.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: verify-email - type: http - seq: 7 -} - -post { - url: http://localhost:3000/api/v1/auth/verify-email - body: json - auth: none -} - -body:json { - { - "code": "50317187" - } -} diff --git a/bruno/Auth/verify-user.bru b/bruno/Auth/verify-user.bru deleted file mode 100644 index 38955449d..000000000 --- a/bruno/Auth/verify-user.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: verify-user - type: http - seq: 4 -} - -get { - url: http://localhost:3001/api/v1/badger/verify-user?sessionId=mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e - body: none - auth: none -} - -params:query { - sessionId: mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e -} diff --git a/bruno/Clients/createClient.bru b/bruno/Clients/createClient.bru deleted file mode 100644 index 7577bb280..000000000 --- a/bruno/Clients/createClient.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: createClient - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/site/1/client - body: json - auth: none -} - -body:json { - { - "siteId": 1, - "name": "test", - "type": "olm", - "subnet": "100.90.129.4/30", - "olmId": "029yzunhx6nh3y5", - "secret": "l0ymp075y3d4rccb25l6sqpgar52k09etunui970qq5gj7x6" - } -} diff --git a/bruno/Clients/pickClientDefaults.bru b/bruno/Clients/pickClientDefaults.bru deleted file mode 100644 index 61509c112..000000000 --- a/bruno/Clients/pickClientDefaults.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: pickClientDefaults - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/site/1/pick-client-defaults - body: none - auth: none -} diff --git a/bruno/IDP/Create OIDC Provider.bru b/bruno/IDP/Create OIDC Provider.bru deleted file mode 100644 index 23e807cf9..000000000 --- a/bruno/IDP/Create OIDC Provider.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: Create OIDC Provider - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/org/home-lab/idp/oidc - body: json - auth: inherit -} - -body:json { - { - "clientId": "JJoSvHCZcxnXT2sn6CObj6a21MuKNRXs3kN5wbys", - "clientSecret": "2SlGL2wOGgMEWLI9yUuMAeFxre7qSNJVnXMzyepdNzH1qlxYnC4lKhhQ6a157YQEkYH3vm40KK4RCqbYiF8QIweuPGagPX3oGxEj2exwutoXFfOhtq4hHybQKoFq01Z3", - "authUrl": "http://localhost:9000/application/o/authorize/", - "tokenUrl": "http://localhost:9000/application/o/token/", - "scopes": ["email", "openid", "profile"], - "userIdentifier": "email" - } -} diff --git a/bruno/IDP/Generate OIDC URL.bru b/bruno/IDP/Generate OIDC URL.bru deleted file mode 100644 index 90443096f..000000000 --- a/bruno/IDP/Generate OIDC URL.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Generate OIDC URL - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1 - body: none - auth: inherit -} diff --git a/bruno/IDP/folder.bru b/bruno/IDP/folder.bru deleted file mode 100644 index fc1369159..000000000 --- a/bruno/IDP/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: IDP -} diff --git a/bruno/Internal/Traefik Config.bru b/bruno/Internal/Traefik Config.bru deleted file mode 100644 index 9fc1c1dcb..000000000 --- a/bruno/Internal/Traefik Config.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Traefik Config - type: http - seq: 1 -} - -get { - url: http://localhost:3001/api/v1/traefik-config - body: none - auth: inherit -} diff --git a/bruno/Internal/folder.bru b/bruno/Internal/folder.bru deleted file mode 100644 index 702931ec4..000000000 --- a/bruno/Internal/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Internal -} diff --git a/bruno/Newt/Create Newt.bru b/bruno/Newt/Create Newt.bru deleted file mode 100644 index 56baf89bd..000000000 --- a/bruno/Newt/Create Newt.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Create Newt - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/newt - body: none - auth: none -} diff --git a/bruno/Newt/Get Token.bru b/bruno/Newt/Get Token.bru deleted file mode 100644 index 93d91cc5d..000000000 --- a/bruno/Newt/Get Token.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: Get Token - type: http - seq: 1 -} - -get { - url: http://localhost:3000/api/v1/auth/newt/get-token - body: json - auth: none -} - -body:json { - { - "newtId": "o0d4rdxq3stnz7b", - "secret": "sy7l09fnaesd03iwrfp9m3qf0ryn19g0zf3dqieaazb4k7vk" - } -} diff --git a/bruno/Olm/createOlm.bru b/bruno/Olm/createOlm.bru deleted file mode 100644 index ca755dea8..000000000 --- a/bruno/Olm/createOlm.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: createOlm - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/olm - body: none - auth: inherit -} - -settings { - encodeUrl: true -} diff --git a/bruno/Olm/folder.bru b/bruno/Olm/folder.bru deleted file mode 100644 index d245e6d1c..000000000 --- a/bruno/Olm/folder.bru +++ /dev/null @@ -1,8 +0,0 @@ -meta { - name: Olm - seq: 15 -} - -auth { - mode: inherit -} diff --git a/bruno/Orgs/Check Id.bru b/bruno/Orgs/Check Id.bru deleted file mode 100644 index 17b63953c..000000000 --- a/bruno/Orgs/Check Id.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Check Id - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/org/checkId - body: none - auth: none -} diff --git a/bruno/Orgs/listOrgs.bru b/bruno/Orgs/listOrgs.bru deleted file mode 100644 index 89c34d0cb..000000000 --- a/bruno/Orgs/listOrgs.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: listOrgs - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/Remote Exit Node/createRemoteExitNode.bru b/bruno/Remote Exit Node/createRemoteExitNode.bru deleted file mode 100644 index 1c749a311..000000000 --- a/bruno/Remote Exit Node/createRemoteExitNode.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: createRemoteExitNode - type: http - seq: 1 -} - -put { - url: http://localhost:4000/api/v1/org/org_i21aifypnlyxur2/remote-exit-node - body: none - auth: none -} diff --git a/bruno/Resources/listResourcesByOrg.bru b/bruno/Resources/listResourcesByOrg.bru deleted file mode 100644 index 6efce1b20..000000000 --- a/bruno/Resources/listResourcesByOrg.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: listResourcesByOrg - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/Resources/listResourcesBySite.bru b/bruno/Resources/listResourcesBySite.bru deleted file mode 100644 index 81c9cf99b..000000000 --- a/bruno/Resources/listResourcesBySite.bru +++ /dev/null @@ -1,16 +0,0 @@ -meta { - name: listResourcesBySite - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/site/1/resources?limit=10&offset=0 - body: none - auth: none -} - -params:query { - limit: 10 - offset: 0 -} diff --git a/bruno/Sites/Get Site.bru b/bruno/Sites/Get Site.bru deleted file mode 100644 index fc2f7e62b..000000000 --- a/bruno/Sites/Get Site.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Get Site - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/org/test/sites/mexican-mole-lizard-windy - body: none - auth: none -} diff --git a/bruno/Sites/listSites.bru b/bruno/Sites/listSites.bru deleted file mode 100644 index b7912330a..000000000 --- a/bruno/Sites/listSites.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: listSites - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/Targets/listTargets.bru b/bruno/Targets/listTargets.bru deleted file mode 100644 index 7981eb453..000000000 --- a/bruno/Targets/listTargets.bru +++ /dev/null @@ -1,16 +0,0 @@ -meta { - name: listTargets - type: http - seq: 1 -} - -get { - url: http://localhost:3000/api/v1/resource/web.main.localhost/targets?limit=10&offset=0 - body: none - auth: none -} - -params:query { - limit: 10 - offset: 0 -} diff --git a/bruno/Test.bru b/bruno/Test.bru deleted file mode 100644 index 16286ec8c..000000000 --- a/bruno/Test.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Test - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1 - body: none - auth: inherit -} diff --git a/bruno/Traefik/traefik-config.bru b/bruno/Traefik/traefik-config.bru deleted file mode 100644 index a50b7aa15..000000000 --- a/bruno/Traefik/traefik-config.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: traefik-config - type: http - seq: 1 -} - -get { - url: http://localhost:3001/api/v1/traefik-config - body: none - auth: none -} diff --git a/bruno/Users/adminListUsers.bru b/bruno/Users/adminListUsers.bru deleted file mode 100644 index cdc410956..000000000 --- a/bruno/Users/adminListUsers.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: adminListUsers - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/users - body: none - auth: none -} diff --git a/bruno/Users/adminRemoveUser.bru b/bruno/Users/adminRemoveUser.bru deleted file mode 100644 index 9e9f35079..000000000 --- a/bruno/Users/adminRemoveUser.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: adminRemoveUser - type: http - seq: 3 -} - -delete { - url: http://localhost:3000/api/v1/user/ky5r7ivqs8wc7u4 - body: none - auth: none -} diff --git a/bruno/Users/getUser.bru b/bruno/Users/getUser.bru deleted file mode 100644 index d86372527..000000000 --- a/bruno/Users/getUser.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: getUser - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/bruno.json b/bruno/bruno.json deleted file mode 100644 index f19d936a8..000000000 --- a/bruno/bruno.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "1", - "name": "Pangolin", - "type": "collection", - "ignore": [ - "node_modules", - ".git" - ], - "presets": { - "requestType": "http", - "requestUrl": "http://localhost:3000/api/v1" - } -} \ No newline at end of file diff --git a/cli/commands/rotateServerSecret.ts b/cli/commands/rotateServerSecret.ts index d3828f0e5..afac262b2 100644 --- a/cli/commands/rotateServerSecret.ts +++ b/cli/commands/rotateServerSecret.ts @@ -1,5 +1,5 @@ import { CommandModule } from "yargs"; -import { db, idpOidcConfig, licenseKey } from "@server/db"; +import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db"; import { encrypt, decrypt } from "@server/lib/crypto"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { eq } from "drizzle-orm"; @@ -129,9 +129,15 @@ export const rotateServerSecret: CommandModule< console.log("\nReading encrypted data from database..."); const idpConfigs = await db.select().from(idpOidcConfig); const licenseKeys = await db.select().from(licenseKey); + const certs = await db.select().from(certificates); + const streamingDestinations = await db.select().from(eventStreamingDestinations); + const webhookActions = await db.select().from(alertWebhookActions); console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); console.log(`Found ${licenseKeys.length} license key(s)`); + console.log(`Found ${certs.length} certificate(s)`); + console.log(`Found ${streamingDestinations.length} event streaming destination(s)`); + console.log(`Found ${webhookActions.length} alert webhook action(s)`); // Prepare all decrypted and re-encrypted values console.log("\nDecrypting and re-encrypting values..."); @@ -149,8 +155,27 @@ export const rotateServerSecret: CommandModule< encryptedInstanceId: string; }; + type CertUpdate = { + certId: number; + encryptedCertFile: string | null; + encryptedKeyFile: string | null; + }; + + type StreamingDestinationUpdate = { + destinationId: number; + encryptedConfig: string; + }; + + type WebhookActionUpdate = { + webhookActionId: number; + encryptedConfig: string; + }; + const idpUpdates: IdpUpdate[] = []; const licenseKeyUpdates: LicenseKeyUpdate[] = []; + const certUpdates: CertUpdate[] = []; + const streamingDestinationUpdates: StreamingDestinationUpdate[] = []; + const webhookActionUpdates: WebhookActionUpdate[] = []; // Process idpOidcConfig entries for (const idpConfig of idpConfigs) { @@ -217,6 +242,70 @@ export const rotateServerSecret: CommandModule< } } + // Process certificate entries + for (const cert of certs) { + try { + const encryptedCertFile = cert.certFile + ? encrypt(decrypt(cert.certFile, oldSecret), newSecret) + : null; + const encryptedKeyFile = cert.keyFile + ? encrypt(decrypt(cert.keyFile, oldSecret), newSecret) + : null; + + certUpdates.push({ + certId: cert.certId, + encryptedCertFile, + encryptedKeyFile + }); + } catch (error) { + console.error( + `Error processing certificate ${cert.certId} (${cert.domain}):`, + error + ); + throw error; + } + } + + // Process eventStreamingDestinations entries + for (const dest of streamingDestinations) { + try { + const decryptedConfig = decrypt(dest.config, oldSecret); + const encryptedConfig = encrypt(decryptedConfig, newSecret); + + streamingDestinationUpdates.push({ + destinationId: dest.destinationId, + encryptedConfig + }); + } catch (error) { + console.error( + `Error processing event streaming destination ${dest.destinationId}:`, + error + ); + throw error; + } + } + + // Process alertWebhookActions entries + for (const webhook of webhookActions) { + try { + if (webhook.config == null) continue; + + const decryptedConfig = decrypt(webhook.config, oldSecret); + const encryptedConfig = encrypt(decryptedConfig, newSecret); + + webhookActionUpdates.push({ + webhookActionId: webhook.webhookActionId, + encryptedConfig + }); + } catch (error) { + console.error( + `Error processing alert webhook action ${webhook.webhookActionId}:`, + error + ); + throw error; + } + } + // Perform all database updates in a single transaction console.log("\nUpdating database in transaction..."); await db.transaction(async (trx) => { @@ -250,10 +339,50 @@ export const rotateServerSecret: CommandModule< instanceId: update.encryptedInstanceId }); } + + // Update certificate entries + for (const update of certUpdates) { + await trx + .update(certificates) + .set({ + certFile: update.encryptedCertFile, + keyFile: update.encryptedKeyFile + }) + .where(eq(certificates.certId, update.certId)); + } + + // Update event streaming destination entries + for (const update of streamingDestinationUpdates) { + await trx + .update(eventStreamingDestinations) + .set({ config: update.encryptedConfig }) + .where( + eq( + eventStreamingDestinations.destinationId, + update.destinationId + ) + ); + } + + // Update alert webhook action entries + for (const update of webhookActionUpdates) { + await trx + .update(alertWebhookActions) + .set({ config: update.encryptedConfig }) + .where( + eq( + alertWebhookActions.webhookActionId, + update.webhookActionId + ) + ); + } }); console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`); + console.log(`Rotated ${certUpdates.length} certificate(s)`); + console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`); + console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`); // Update config file with new secret console.log("\nUpdating config file..."); @@ -270,6 +399,9 @@ export const rotateServerSecret: CommandModule< console.log(`\nSummary:`); console.log(` - OIDC IdP configurations: ${idpUpdates.length}`); console.log(` - License keys: ${licenseKeyUpdates.length}`); + console.log(` - Certificates: ${certUpdates.length}`); + console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`); + console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`); console.log( `\n IMPORTANT: Restart the server for the new secret to take effect.` ); diff --git a/config/db/.gitignore b/config/db/.gitignore new file mode 100644 index 000000000..9d4b1bb9c --- /dev/null +++ b/config/db/.gitignore @@ -0,0 +1 @@ +*-journal diff --git a/docker-compose.pgr.yml b/docker-compose.pgr.yml index 9e6b2c5af..764c09150 100644 --- a/docker-compose.pgr.yml +++ b/docker-compose.pgr.yml @@ -7,8 +7,8 @@ services: POSTGRES_DB: postgres # Default database name POSTGRES_USER: postgres # Default user POSTGRES_PASSWORD: password # Default password (change for production!) - # volumes: - # - ./config/postgres:/var/lib/postgresql/data + volumes: + - ./config/postgres:/var/lib/postgresql/data ports: - "5432:5432" # Map host port 5432 to container port 5432 restart: no diff --git a/install/crowdsec.go b/install/crowdsec.go index c75dccf32..8dff42d99 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -6,12 +6,13 @@ import ( "log" "os" "os/exec" + "path/filepath" "strings" "gopkg.in/yaml.v3" ) -func installCrowdsec(config Config) error { +func installCrowdsec(config Config, installDir string) error { if err := stopContainers(config.InstallationContainerType); err != nil { return fmt.Errorf("failed to stop containers: %v", err) @@ -40,6 +41,8 @@ func installCrowdsec(config Config) error { os.Exit(1) } + setupTraefikLogRotate(installDir) + if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { fmt.Printf("Error copying docker service: %v\n", err) os.Exit(1) @@ -208,3 +211,69 @@ func CheckAndAddCrowdsecDependency(composePath string) error { fmt.Println("Added dependency of crowdsec to traefik") return nil } + +// setupTraefikLogRotate writes a logrotate config for the Traefik access log +// that CrowdSec depends on. This is only needed when CrowdSec is installed +// because the default Pangolin install does not enable Traefik access logs. +// +// copytruncate is used so Traefik does not need to be restarted or sent a +// signal after rotation — it keeps writing to the same file descriptor while +// the rotated copy is made and the original is truncated in place. +func setupTraefikLogRotate(installDir string) { + const logrotateDir = "/etc/logrotate.d" + const logrotateFile = "/etc/logrotate.d/pangolin-traefik" + + logPath := filepath.Join(installDir, "config/traefik/logs/access.log") + + if os.Geteuid() != 0 { + fmt.Println("\n[logrotate] Skipping automatic logrotate setup: not running as root.") + fmt.Println("[logrotate] To prevent unbounded growth of the Traefik access log used by CrowdSec,") + fmt.Println("[logrotate] create the file /etc/logrotate.d/pangolin-traefik manually with:") + printLogrotateConfig(logPath) + return + } + + config := fmt.Sprintf(`# Logrotate config for Traefik access logs used by CrowdSec. +# Generated by the Pangolin installer. Safe to edit. +%s { + daily + rotate 7 + compress + delaycompress + missingok + notifempty + copytruncate +} +`, logPath) + + if err := os.MkdirAll(logrotateDir, 0755); err != nil { + fmt.Printf("[logrotate] Warning: could not create %s: %v\n", logrotateDir, err) + return + } + + if err := os.WriteFile(logrotateFile, []byte(config), 0644); err != nil { + fmt.Printf("[logrotate] Warning: could not write %s: %v\n", logrotateFile, err) + fmt.Println("[logrotate] Set it up manually:") + printLogrotateConfig(logPath) + return + } + + fmt.Printf("[logrotate] Wrote logrotate config to %s\n", logrotateFile) + fmt.Println("[logrotate] Traefik access logs will be rotated daily, keeping 7 compressed copies.") +} + +// printLogrotateConfig prints a logrotate config block to stdout so users can +// set it up manually when the installer cannot write to /etc. +func printLogrotateConfig(logPath string) { + fmt.Printf(` + %s { + daily + rotate 7 + compress + delaycompress + missingok + notifempty + copytruncate + } +`, logPath) +} diff --git a/install/go.mod b/install/go.mod index 005a079df..878ab39a2 100644 --- a/install/go.mod +++ b/install/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -33,6 +33,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.23.0 // indirect ) diff --git a/install/go.sum b/install/go.sum index b67ae57e5..8730d4268 100644 --- a/install/go.sum +++ b/install/go.sum @@ -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/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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/install/main.go b/install/main.go index a38d78fc6..13e506d06 100644 --- a/install/main.go +++ b/install/main.go @@ -259,7 +259,7 @@ func main() { } config.DoCrowdsecInstall = true - err := installCrowdsec(config) + err := installCrowdsec(config, installDir) if err != nil { fmt.Printf("Error installing CrowdSec: %v\n", err) return diff --git a/license.py b/license.py deleted file mode 100644 index 865dfad7a..000000000 --- a/license.py +++ /dev/null @@ -1,115 +0,0 @@ -import os -import sys - -# --- Configuration --- -# The header text to be added to the files. -HEADER_TEXT = """/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025 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. - */ -""" - -def should_add_header(file_path): - """ - Checks if a file should receive the commercial license header. - Returns True if 'private' is in the path or file content. - """ - # Check if 'private' is in the file path (case-insensitive) - if 'server/private' in file_path.lower(): - return True - - # Check if 'private' is in the file content (case-insensitive) - # try: - # with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: - # content = f.read() - # if 'private' in content.lower(): - # return True - # except Exception as e: - # print(f"Could not read file {file_path}: {e}") - - return False - -def process_directory(root_dir): - """ - Recursively scans a directory and adds headers to qualifying .ts or .tsx files, - skipping any 'node_modules' directories. - """ - print(f"Scanning directory: {root_dir}") - files_processed = 0 - headers_added = 0 - - for root, dirs, files in os.walk(root_dir): - # --- MODIFICATION --- - # Exclude 'node_modules' directories from the scan to improve performance. - if 'node_modules' in dirs: - dirs.remove('node_modules') - - for file in files: - if file.endswith('.ts') or file.endswith('.tsx'): - file_path = os.path.join(root, file) - files_processed += 1 - - try: - with open(file_path, 'r+', encoding='utf-8') as f: - original_content = f.read() - has_header = original_content.startswith(HEADER_TEXT.strip()) - - if should_add_header(file_path): - # Add header only if it's not already there - if not has_header: - f.seek(0, 0) # Go to the beginning of the file - f.write(HEADER_TEXT.strip() + '\n\n' + original_content) - print(f"Added header to: {file_path}") - headers_added += 1 - else: - print(f"Header already exists in: {file_path}") - else: - # Remove header if it exists but shouldn't be there - if has_header: - # Find the end of the header and remove it (including following newlines) - header_with_newlines = HEADER_TEXT.strip() + '\n\n' - if original_content.startswith(header_with_newlines): - content_without_header = original_content[len(header_with_newlines):] - else: - # Handle case where there might be different newline patterns - header_end = len(HEADER_TEXT.strip()) - # Skip any newlines after the header - while header_end < len(original_content) and original_content[header_end] in '\n\r': - header_end += 1 - content_without_header = original_content[header_end:] - - f.seek(0) - f.write(content_without_header) - f.truncate() - print(f"Removed header from: {file_path}") - headers_added += 1 # Reusing counter for modifications - - except Exception as e: - print(f"Error processing file {file_path}: {e}") - - print("\n--- Scan Complete ---") - print(f"Total .ts or .tsx files found: {files_processed}") - print(f"Files modified (headers added/removed): {headers_added}") - - -if __name__ == "__main__": - # Get the target directory from the command line arguments. - # If no directory is provided, it uses the current directory ('.'). - if len(sys.argv) > 1: - target_directory = sys.argv[1] - else: - target_directory = '.' # Default to current directory - - if not os.path.isdir(target_directory): - print(f"Error: Directory '{target_directory}' not found.") - sys.exit(1) - - process_directory(os.path.abspath(target_directory)) diff --git a/license_header_checker.py b/license_header_checker.py new file mode 100644 index 000000000..ab7ddf4d5 --- /dev/null +++ b/license_header_checker.py @@ -0,0 +1,137 @@ +import os +import sys + +# --- Configuration --- +# The header text to be added to the files. +HEADER_TEXT = """/* + * 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. + */ +""" + +HEADER_NORMALIZED = HEADER_TEXT.strip() + + +def extract_leading_block_comment(content): + """ + If the file content begins with a /* ... */ block comment, return the + full text of that comment (including the delimiters) and the index at + which the rest of the file starts (after any trailing newlines). + Returns (None, 0) when no such comment is found. + """ + stripped = content.lstrip() + if not stripped.startswith('/*'): + return None, 0 + + # Account for any leading whitespace before the comment + comment_start = content.index('/*') + end_marker = content.find('*/', comment_start + 2) + if end_marker == -1: + return None, 0 + + comment_end = end_marker + 2 # position just after '*/' + comment_text = content[comment_start:comment_end].strip() + + # Advance past any whitespace / newlines that follow the closing */ + rest_start = comment_end + while rest_start < len(content) and content[rest_start] in '\n\r': + rest_start += 1 + + return comment_text, rest_start + + +def should_add_header(file_path): + """ + Checks if a file should receive the commercial license header. + Returns True if 'server/private' is in the path. + """ + if 'server/private' in file_path.lower(): + return True + + return False + + +def process_directory(root_dir): + """ + Recursively scans a directory and adds/replaces/removes headers in + qualifying .ts or .tsx files, skipping any 'node_modules' directories. + """ + print(f"Scanning directory: {root_dir}") + files_processed = 0 + files_modified = 0 + + for root, dirs, files in os.walk(root_dir): + # Exclude 'node_modules' directories from the scan. + if 'node_modules' in dirs: + dirs.remove('node_modules') + + for file in files: + if not (file.endswith('.ts') or file.endswith('.tsx')): + continue + + file_path = os.path.join(root, file) + files_processed += 1 + + try: + with open(file_path, 'r', encoding='utf-8') as f: + original_content = f.read() + + existing_comment, body_start = extract_leading_block_comment( + original_content + ) + has_any_header = existing_comment is not None + has_correct_header = existing_comment == HEADER_NORMALIZED + + body = original_content[body_start:] if has_any_header else original_content + + if should_add_header(file_path): + if has_correct_header: + print(f"Header up-to-date: {file_path}") + else: + # Either no header exists or the header is outdated - write + # the correct one. + action = "Replaced header in" if has_any_header else "Added header to" + new_content = HEADER_NORMALIZED + '\n\n' + body + with open(file_path, 'w', encoding='utf-8') as f: + f.write(new_content) + print(f"{action}: {file_path}") + files_modified += 1 + else: + if has_any_header: + # Remove the header - it shouldn't be here. + with open(file_path, 'w', encoding='utf-8') as f: + f.write(body) + print(f"Removed header from: {file_path}") + files_modified += 1 + else: + print(f"No header needed: {file_path}") + + except Exception as e: + print(f"Error processing file {file_path}: {e}") + + print("\n--- Scan Complete ---") + print(f"Total .ts or .tsx files found: {files_processed}") + print(f"Files modified (added/replaced/removed): {files_modified}") + + +if __name__ == "__main__": + # Get the target directory from the command line arguments. + # If no directory is provided, it uses the current directory ('.'). + if len(sys.argv) > 1: + target_directory = sys.argv[1] + else: + target_directory = '.' # Default to current directory + + if not os.path.isdir(target_directory): + print(f"Error: Directory '{target_directory}' not found.") + sys.exit(1) + + process_directory(os.path.abspath(target_directory)) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 15a2d36db..c3dd75de2 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Свържете се с отдел продажби, за да активирате тази функция.", + "contactSalesBookDemo": "Резервирайте демонстрация", + "contactSalesOr": "или", + "contactSalesContactUs": "свържете се с нас", "setupCreate": "Създайте организацията, сайта и ресурсите", "headerAuthCompatibilityInfo": "Активирайте това, за да принудите отговор '401 Неупълномощено', когато липсва токен за автентификация. Това е необходимо за браузъри или специфични HTTP библиотеки, които не изпращат идентификационни данни без сървърно предизвикателство.", "headerAuthCompatibility": "Разширена съвместимост.", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Засечен е невалиден или изтекъл лиценз. Проверете лицензионните условия, за да се възползвате от всички функционалности.", "dismiss": "Отхвърляне", "subscriptionViolationMessage": "Превишихте ограничението на текущия си план. Коригирайте проблема, като премахнете сайтове, потребители или други ресурси, за да оставате в рамките на плана си.", + "trialBannerMessage": "Пробният Ви период изтича след {countdown}. Актуализирайте за запазване на достъпа.", + "trialBannerExpired": "Пробният Ви период е изтекъл. Актуализирайте сега, за да възстановите достъпа.", + "trialActive": "Активен пробен период", + "trialExpired": "Пробният период е изтекъл", + "trialHasEnded": "Пробният Ви период е приключил.", + "trialDaysRemaining": "{count, plural, one {# ден остава} other {# дни остават}}", + "trialDaysLeftShort": "{days}д остават до края на пробния период", + "trialGoToBilling": "Отидете на страницата за фактуриране", "subscriptionViolationViewBilling": "Преглед на фактурирането", "componentsLicenseViolation": "Нарушение на лиценза: Сървърът използва {usedSites} сайта, което надвишава лицензионния лимит от {maxSites} сайта. Проверете лицензионните условия, за да се възползвате от всички функционалности.", "componentsSupporterMessage": "Благодарим ви, че подкрепяте Pangolin като {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "Копирах конфигурацията", "searchSitesProgress": "Търсене на сайтове...", "siteAdd": "Добавете сайт", + "sitesTableViewPublicResources": "Вижте публични ресурси", + "sitesTableViewPrivateResources": "Вижте частни ресурси", "siteInstallNewt": "Инсталирайте Newt", "siteInstallNewtDescription": "Пуснете Newt на вашата система", "WgConfiguration": "WireGuard конфигурация", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "Сайтът е актуализиран.", "siteGeneralDescription": "Конфигурирайте общи настройки за този сайт", "siteSettingDescription": "Конфигурирайте настройките на сайта", + "siteResourcesTab": "Ресурси", + "siteResourcesNoneOnSite": "Този сайт все още няма публични или частни ресурси.", + "siteResourcesSectionPublic": "Публични ресурси", + "siteResourcesSectionPrivate": "Частни ресурси", + "siteResourcesSectionPublicDescription": "Ресурси, които са изложени външно чрез домейни или портове.", + "siteResourcesSectionPrivateDescription": "Ресурси, които са достъпни в частната ви мрежа през сайта.", + "siteResourcesViewAllPublic": "Виж всички ресурси", + "siteResourcesViewAllPrivate": "Виж всички ресурси", + "siteResourcesDialogDescription": "Преглед на публични и частни ресурси, свързани с този сайт.", + "siteResourcesShowMore": "Покажи повече", + "siteResourcesPermissionDenied": "Нямате разрешение да изброите тези ресурси.", + "siteResourcesEmptyPublic": "Няма публични ресурси, насочени към този сайт все още.", + "siteResourcesEmptyPrivate": "Няма частни ресурси, свързани с този сайт още.", + "siteResourcesHowToAccess": "Как да получите достъп", + "siteResourcesTargetsOnSite": "Цели на този сайт", "siteSetting": "Настройки на {siteName}", "siteNewtTunnel": "Нов Сайт (Препоръчително)", "siteNewtTunnelDescription": "Най-лесният начин да създадете точка за достъп до всяка мрежа. Няма нужда от допълнителни настройки.", @@ -267,8 +296,11 @@ "orgMissing": "Липсва идентификатор на организация", "orgMissingMessage": "Невъзможност за регенериране на покана без идентификатор на организация.", "accessUsersManage": "Управление на потребители", + "accessUserManage": "Управление на потребител", "accessUsersDescription": "Канете и управлявайте потребители с достъп до тази организация", "accessUsersSearch": "Търсене на потребители...", + "accessUsersRoleFilterCount": "{count, plural, one {# роля} other {# роли}}", + "accessUsersRoleFilterClear": "Изчистване на филтрите за роли", "accessUserCreate": "Създайте потребител", "accessUserRemove": "Премахнете потребител", "username": "Потребителско име", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Неуспешно активиране на лицензионния ключ", "licenseErrorKeyActivateDescription": "Възникна грешка при активирането на лицензионния ключ.", "licenseAbout": "Относно лицензите", + "licenseBannerTitle": "Активирайте своята корпоративна лицензия", + "licenseBannerDescription": "Отключете корпоративните функции за вашият хостинг на Pangolin. Закупете лицензионен ключ, за да активирате премиум възможности, след това го добавете по-долу.", + "licenseBannerGetLicense": "Вземете лиценз", + "licenseBannerViewDocs": "Преглед на документацията", "communityEdition": "Комюнити издание", "licenseAboutDescription": "Това е за бизнес и корпоративни потребители, които използват Pangolin в търговска среда. Ако използвате Pangolin за лична употреба, можете да игнорирате този раздел.", "licenseKeyActivated": "Лицензионният ключ е активиран", @@ -727,6 +763,7 @@ "newtEndpoint": "Крайна точка", "newtId": "Идентификационен номер", "newtSecretKey": "Секретен ключ", + "newtVersion": "Версия", "architecture": "Архитектура", "sites": "Сайтове", "siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.", @@ -894,6 +931,7 @@ "idpDisplayName": "Име за показване за този доставчик на идентичност", "idpAutoProvisionUsers": "Автоматично потребителско създаване", "idpAutoProvisionUsersDescription": "Когато е активирано, потребителите ще бъдат автоматично създадени в системата при първо влизане с възможност за свързване на потребителите с роли и организации.", + "idpAutoProvisionConfigureAfterCreate": "Можете да конфигурирате настройките за автоматично предоставяне, след като дистрибуторът на самоличност бъде създаден.", "licenseBadge": "ЕЕ", "idpType": "Тип доставчик", "idpTypeDescription": "Изберете типа доставчик на идентичност, който искате да конфигурирате", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Карта на роля по подразбиране", "defaultMappingsRoleDescription": "Резултатът от този израз трябва да върне името на ролята, както е дефинирано в организацията, като стринг.", "defaultMappingsOrg": "Карта на организация по подразбиране", - "defaultMappingsOrgDescription": "Този израз трябва да върне ID на организацията или 'true', за да бъде разрешен достъпът на потребителя до организацията.", + "defaultMappingsOrgDescription": "При задаване, този израз трябва да върне идентификационния номер на организацията или true, за да се даде достъп на потребителя до тази организация. Ако не е зададено, дефинирането на роля е достатъчно: потребителят има право на достъп, стига валидно картографиране на роля да бъде разрешено за него в рамките на организацията.", "defaultMappingsSubmit": "Запазване на файловете по подразбиране", "orgPoliciesEdit": "Редактиране на Организационна Политика", "org": "Организация", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Преглед на дневници", "noneSelected": "Нищо не е избрано", "orgNotFound2": "Няма намерени организации.", + "search": "Търси…", "searchPlaceholder": "Търсене...", "emptySearchOptions": "Няма намерени опции", "create": "Създаване", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Управление.", "sidebarLogAndAnalytics": "Лог & Анализи", "sidebarBluePrints": "Чертежи", + "sidebarAlerting": "Извеждане на предупреждения", + "sidebarHealthChecks": "Проверки на състоянието", "sidebarOrganization": "Организация", "sidebarManagement": "Управление", "sidebarBillingAndLicenses": "Фактуриране & Лицензи", "sidebarLogsAnalytics": "Анализи", + "alertingTitle": "Извеждане на предупреждения", + "alertingDescription": "Определете източници, тригери и действия за уведомления", + "alertingRules": "Правила за предупреждение", + "alertingSearchRules": "Търсене на правила…", + "alertingAddRule": "Създаване на правило", + "alertingColumnSource": "Източник", + "alertingColumnTrigger": "Тригер", + "alertingColumnActions": "Действия", + "alertingColumnEnabled": "Активирано", + "alertingDeleteQuestion": "Моля, потвърдете, че искате да изтриете това правило за предупреждение.", + "alertingDeleteRule": "Изтриване на правило за предупреждение", + "alertingRuleDeleted": "Правилото за предупреждение е изтрито", + "alertingRuleSaved": "Правилото за предупреждение е запазено", + "alertingRuleSavedCreatedDescription": "Вашето ново правило за предупреждение беше създадено. Все още можете да го редактирате на тази страница.", + "alertingRuleSavedUpdatedDescription": "Промените, направени по това правило за предупреждение, бяха запазени.", + "alertingEditRule": "Редактиране на правило за предупреждение", + "alertingCreateRule": "Създаване на правило за предупреждение", + "alertingRuleCredenzaDescription": "Изберете какво да наблюдавате, кога да се активира и как да уведомите", + "alertingRuleNamePlaceholder": "Сайтът на производство е недостъпен", + "alertingRuleEnabled": "Правилото е активирано", + "alertingSectionSource": "Източник", + "alertingSourceType": "Тип на източника", + "alertingSourceSite": "Сайт", + "alertingSourceHealthCheck": "Проверка на състоянието", + "alertingPickSites": "Сайтове", + "alertingPickHealthChecks": "Проверки на състоянието", + "alertingPickResources": "Ресурси", + "alertingAllSites": "Всички сайтове", + "alertingAllSitesDescription": "Предупреждението се активира за всеки сайт", + "alertingSpecificSites": "Специфични сайтове", + "alertingSpecificSitesDescription": "Изберете специфични сайтове за наблюдение", + "alertingAllHealthChecks": "Всички проверки на състоянието", + "alertingAllHealthChecksDescription": "Предупреждението се активира за всяка проверка на състоянието", + "alertingSpecificHealthChecks": "Специфични проверки на състоянието", + "alertingSpecificHealthChecksDescription": "Изберете специфични проверки на състоянието за наблюдение", + "alertingAllResources": "Всички ресурси", + "alertingAllResourcesDescription": "Предупреждението се активира за всеки ресурс", + "alertingSpecificResources": "Специфични ресурси", + "alertingSpecificResourcesDescription": "Изберете специфични ресурси за наблюдение", + "alertingSelectResources": "Изберете ресурси…", + "alertingResourcesSelected": "Избрани {count} ресурса", + "alertingResourcesEmpty": "Няма ресурси с целите в първите 10 резултата.", + "alertingSectionTrigger": "Тригер", + "alertingTrigger": "Кога да се активира", + "alertingTriggerSiteOnline": "Сайтът е онлайн", + "alertingTriggerSiteOffline": "Сайтът е офлайн", + "alertingTriggerSiteToggle": "Състоянието на сайта се променя", + "alertingTriggerHcHealthy": "Проверка на състоянието е здрава", + "alertingTriggerHcUnhealthy": "Проверка на състоянието не е здрава", + "alertingTriggerHcToggle": "Състоянието на проверката се променя", + "alertingTriggerResourceHealthy": "Ресурсът е здрав", + "alertingTriggerResourceUnhealthy": "Ресурсът не е здрав", + "alertingTriggerResourceDegraded": "Деградирал ресурс", + "alertingSearchHealthChecks": "Търсене на проверки на състоянието…", + "alertingHealthChecksEmpty": "Няма налични проверки на състоянието.", + "alertingTriggerResourceToggle": "Състоянието на ресурса се променя", + "alertingSourceResource": "Ресурс", + "alertingSectionActions": "Действия", + "alertingAddAction": "Добавяне на действие", + "alertingActionNotify": "Имейл", + "alertingActionNotifyDescription": "Изпращане на имейл известия на потребители или роли", + "alertingActionWebhook": "Уеб кука", + "alertingActionWebhookDescription": "Изпращане на HTTP заявка към персонализирана крайна точка", + "alertingExternalIntegration": "Външна интеграция", + "alertingExternalPagerDutyDescription": "Изпратете предупреждения към PagerDuty за управление на инциденти", + "alertingExternalOpsgenieDescription": "Пренасочете предупрежденията към Opsgenie за управление на дежурните отчети", + "alertingExternalServiceNowDescription": "Създавайте инциденти в ServiceNow от събития на предупреждения", + "alertingExternalIncidentIoDescription": "Активирайте работни потоци в Incident.io от събития на предупреждения", + "alertingActionType": "Тип на действието", + "alertingNotifyUsers": "Потребители", + "alertingNotifyRoles": "Роли", + "alertingNotifyEmails": "Имейл адреси", + "alertingEmailPlaceholder": "Добавете имейл и натиснете Enter", + "alertingWebhookMethod": "HTTP метод", + "alertingWebhookSecret": "Секрет за подписване (по избор)", + "alertingWebhookSecretPlaceholder": "HMAC секрет", + "alertingWebhookHeaders": "Заглавия", + "alertingAddHeader": "Добавете заглавие", + "alertingSelectSites": "Изберете сайтове…", + "alertingSitesSelected": "Избрани {count} сайта", + "alertingSelectHealthChecks": "Изберете проверки на състоянието…", + "alertingHealthChecksSelected": "Избрани {count} проверки на състоянието", + "alertingNoHealthChecks": "Няма цели с активирани проверки на състоянието", + "alertingHealthCheckStub": "Изборът на източник за проверки на състоянието все още не е свързан - все още можете да конфигурирате тригери и действия.", + "alertingSelectUsers": "Изберете потребители…", + "alertingUsersSelected": "Избрани {count} потребителя", + "alertingSelectRoles": "Изберете роли…", + "alertingRolesSelected": "Избрани {count} роли", + "alertingSummarySites": "Сайтове ({count})", + "alertingSummaryAllSites": "Всички сайтове", + "alertingSummaryHealthChecks": "Проверки на състоянието ({count})", + "alertingSummaryAllHealthChecks": "Всички проверки на състоянието", + "alertingSummaryResources": "Ресурси ({count})", + "alertingSummaryAllResources": "Всички ресурси", + "alertingErrorNameRequired": "Въведете име", + "alertingErrorActionsMin": "Добавете поне едно действие", + "alertingErrorPickSites": "Изберете поне един сайт", + "alertingErrorPickHealthChecks": "Изберете поне една проверка на състоянието", + "alertingErrorPickResources": "Изберете поне един ресурс", + "alertingErrorTriggerSite": "Изберете тригер за сайт", + "alertingErrorTriggerHealth": "Изберете тригер за проверка на състоянието", + "alertingErrorTriggerResource": "Изберете тригер за ресурс", + "alertingErrorNotifyRecipients": "Изберете потребители, роли или поне един имейл", + "alertingConfigureSource": "Конфигуриране на източник", + "alertingConfigureTrigger": "Конфигуриране на тригер", + "alertingConfigureActions": "Конфигуриране на действия", + "alertingBackToRules": "Назад към правилата", + "alertingRuleCooldown": "Време за изчакване (секунди)", + "alertingRuleCooldownDescription": "Минимално време между повторни предупреждения за същото правило. Задайте на 0, за да се задейства всеки път.", + "alertingDraftBadge": "Чернова - запазете, за да съхраните правилото", + "alertingSidebarHint": "Кликнете върху стъпка на платното, за да я редактирате тук.", + "alertingGraphCanvasTitle": "Последователност на правилото", + "alertingGraphCanvasDescription": "Визуален преглед на източник, тригер и действия. Изберете елемент за редакция в панела.", + "alertingNodeNotConfigured": "Още не е конфигурирано", + "alertingNodeActionsCount": "{count, plural, one {# действие} other {# действия}}", + "alertingNodeRoleSource": "Източник", + "alertingNodeRoleTrigger": "Тригер", + "alertingNodeRoleAction": "Действие", + "alertingTabRules": "Правила за предупреждение", + "alertingTabHealthChecks": "Проверки на състоянието", + "alertingRulesBannerTitle": "Получавайте известия", + "alertingRulesBannerDescription": "Всяко правило свързва това, което да се наблюдава (сайт, проверка на състоянието или ресурс), кога да се активира (например офлайн или нездраве) и как да уведомите екипа чрез имейл, уеб куки или интеграции. Използвайте този списък, за да създавате, активирате и управлявате тези правила.", + "alertingHealthChecksBannerTitle": "Наблюдавайте здравето и ресурсите", + "alertingHealthChecksBannerDescription": "Проверките на състоянието са HTTP или TCP монитори, които определяте веднъж. След това можете да ги използвате като източници в правила за предупреждения, така че да бъдете уведомени, когато целта стане здраве или нездраве. Проверките на състоянието на ресурсите също се появяват тук.", + "standaloneHcTableTitle": "Проверки на състоянието", + "standaloneHcSearchPlaceholder": "Търсене на проверки на състоянието…", + "standaloneHcAddButton": "Създаване на проверка на състоянието", + "standaloneHcCreateTitle": "Създаване на проверка на състоянието", + "standaloneHcEditTitle": "Редактиране на проверка на състоянието", + "standaloneHcDescription": "Конфигурирайте HTTP или TCP проверка на състоянието за използване в правилата за предупреждения.", + "standaloneHcNameLabel": "Име", + "standaloneHcNamePlaceholder": "Моят HTTP монитор", + "standaloneHcDeleteTitle": "Изтриване на проверка на състоянието", + "standaloneHcDeleteQuestion": "Моля, потвърдете, че искате да изтриете тази проверка на състоянието.", + "standaloneHcDeleted": "Проверката на състоянието е изтрита", + "standaloneHcSaved": "Проверката на състоянието е запазена", + "standaloneHcColumnHealth": "Здраве", + "standaloneHcColumnMode": "Режим", + "standaloneHcColumnTarget": "Цел", + "standaloneHcHealthStateHealthy": "Здраве", + "standaloneHcHealthStateUnhealthy": "Нездраве", + "standaloneHcHealthStateUnknown": "Неизвестно", + "standaloneHcFilterAnySite": "Всички сайтове", + "standaloneHcFilterAnyResource": "Всички ресурси", + "standaloneHcFilterMode": "Режим", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Пинг", + "standaloneHcFilterHealth": "Здраве", + "standaloneHcFilterEnabled": "Активирано", + "standaloneHcFilterEnabledOn": "Активирано", + "standaloneHcFilterEnabledOff": "Деактивирано", + "standaloneHcFilterSiteIdFallback": "Сайт {id}", + "standaloneHcFilterResourceIdFallback": "Ресурс {id}", "blueprints": "Чертежи", "blueprintsDescription": "Прилагайте декларативни конфигурации и преглеждайте предишни изпълнения", "blueprintAdd": "Добави Чертеж", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Създайте администраторски акаунт на сървъра. Може да съществува само един администраторски акаунт. Винаги можете да промените тези данни по-късно.", "createAdminAccount": "Създаване на админ акаунт", "setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.", - "certificateStatus": "Статус на сертификата", + "certificateStatus": "Сертификат", + "certificateStatusAutoRefreshHint": "Състоянието се опреснява автоматично.", "loading": "Зареждане", "loadingAnalytics": "Зареждане на анализи", "restart": "Рестарт", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието", "newtUpdateAvailable": "Ново обновление", "newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.", + "pangolinNodeUpdateAvailableInfo": "Налична е нова версия на Pangolin Node. Моля, актуализирайте до последната версия за най-добро изживяване.", "domainPickerEnterDomain": "Домейн", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Конфигуриране на проверка на здравето", "configureHealthCheckDescription": "Настройте мониторинг на здравето за {target}", "enableHealthChecks": "Разрешаване на проверки на здравето", + "healthCheckDisabledStateDescription": "Когато е деактивиран, сайтът не изпълнява проверки и състоянието се счита за неизвестно.", "enableHealthChecksDescription": "Мониторинг на здравето на тази цел. Можете да наблюдавате различен краен пункт от целта, ако е необходимо.", "healthScheme": "Метод", "healthSelectScheme": "Избор на метод", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "Интервалът за проверка трябва да е поне 5 секунди", "healthCheckTimeoutMin": "Времето за изчакване трябва да е поне 1 секунда", "healthCheckRetryMin": "Опитите за повторение трябва да са поне 1", + "healthCheckMode": "Режим на проверка", + "healthCheckStrategy": "Стратегия", + "healthCheckModeDescription": "Режимът TCP проверява само свързаността. Режимът HTTP валидира HTTP отговора.", + "healthyThreshold": "Праг за здраве", + "healthyThresholdDescription": "Поредица от успехи, необходими за отбелязване като здраве.", + "unhealthyThreshold": "Праг за нездраве", + "unhealthyThresholdDescription": "Поредица от провали, необходими за отбелязване като нездраве.", + "healthCheckHealthyThresholdMin": "Прагът за здраве трябва да бъде поне 1", + "healthCheckUnhealthyThresholdMin": "Прагът за нездраве трябва да бъде поне 1", "httpMethod": "HTTP Метод", "selectHttpMethod": "Изберете HTTP метод", "domainPickerSubdomainLabel": "Поддомен", + "domainPickerWildcard": "Уайлдкард", + "domainPickerWildcardPaidOnly": "Уайлдкард подсайтовете са платена функция. Моля, надстройте за достъп до тази функция.", "domainPickerBaseDomainLabel": "Основен домейн", "domainPickerSearchDomains": "Търсене на домейни...", "domainPickerNoDomainsFound": "Не са намерени домейни", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Този адрес е част от подсистемата на организацията. Използва се за разрешаване на псевдонимни записи чрез вътрешно DNS разрешаване.", "resourcesTableClients": "Клиенти", "resourcesTableAndOnlyAccessibleInternally": "и са достъпни само вътрешно при свързване с клиент.", - "resourcesTableNoTargets": "Без цели", "resourcesTableHealthy": "Здрав", "resourcesTableDegraded": "Влошен", - "resourcesTableOffline": "Извън линия", + "resourcesTableUnhealthy": "Нездравословно", "resourcesTableUnknown": "Неизвестно", "resourcesTableNotMonitored": "Не е наблюдавано", + "resourcesTableNoTargets": "Няма цели", "editInternalResourceDialogEditClientResource": "Редактиране на частен ресурс", "editInternalResourceDialogUpdateResourceProperties": "Актуализирайте конфигурацията на ресурса и контрола на достъпа за {resourceName}", "editInternalResourceDialogResourceProperties": "Свойствата на ресурса", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Порт", "editInternalResourceDialogModeHost": "Хост", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Метод", + "editInternalResourceDialogEnableSsl": "Активирайте SSL", + "editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.", "editInternalResourceDialogDestination": "Дестинация", "editInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.", "editInternalResourceDialogDestinationIPDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Име", "createInternalResourceDialogSite": "Сайт", "selectSite": "Изберете сайт...", + "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} other {# сайтове}}", "noSitesFound": "Не са намерени сайтове.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Порт", "createInternalResourceDialogModeHost": "Хост", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Метод", + "createInternalResourceDialogScheme": "Метод", + "createInternalResourceDialogEnableSsl": "Активирайте SSL", + "createInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.", "createInternalResourceDialogDestination": "Дестинация", "createInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.", "createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.", "createInternalResourceDialogAlias": "Псевдоним", "createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.", + "internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси", + "internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси", "siteConfiguration": "Конфигурация", "siteAcceptClientConnections": "Приемане на клиентски връзки", "siteAcceptClientConnectionsDescription": "Позволете на потребителските устройства и клиенти да получават достъп до ресурси на този сайт. Това може да бъде променено по-късно.", @@ -1989,7 +2213,7 @@ "description": "По-надежден и по-нисък поддръжка на Самостоятелно-хостван Панголиин сървър с допълнителни екстри", "introTitle": "Управлявано Самостоятелно-хостван Панголиин", "introDescription": "е опция за внедряване, предназначена за хора, които искат простота и допълнителна надеждност, като същевременно запазят данните си частни и самостоятелно-хоствани.", - "introDetail": "С тази опция все още управлявате свой собствен Панголиин възел — вашите тунели, SSL терминатора и трафик остават на вашия сървър. Разликата е, че управлението и мониторингът се обработват чрез нашия облачен панел за контрол, който отключва редица предимства:", + "introDetail": "С тази опция все още управлявате свой собствен Панголиин възел - вашите тунели, SSL терминатора и трафик остават на вашия сървър. Разликата е, че управлението и мониторингът се обработват чрез нашия облачен панел за контрол, който отключва редица предимства:", "benefitSimplerOperations": { "title": "По-прости операции", "description": "Няма нужда да управлявате свой собствен имейл сървър или да настройвате сложни аларми. Ще получите проверки и предупреждения при прекъсване от самото начало." @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Открит международен домейн", "willbestoredas": "Ще бъде съхранено като:", - "roleMappingDescription": "Определете как се разпределят ролите на потребителите при вписване, когато е активирано автоматично предоставяне.", + "roleMappingDescription": "Определете как ролите се присвояват на потребителите, когато се вписват с този доставчик на самоличност.", "selectRole": "Избор на роля", "roleMappingExpression": "Израз", "selectRolePlaceholder": "Избор на роля", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Изберете домейн за страницата за удостоверяване на организацията", "domainPickerProvidedDomain": "Предоставен домейн", "domainPickerFreeProvidedDomain": "Безплатен предоставен домейн", + "domainPickerFreeDomainsPaidFeature": "Предоставените домейни са платена функция. Абонирайте се, за да получите домейн, включен във вашия план - няма нужда да използвате вашия собствен.", "domainPickerVerified": "Проверено", "domainPickerUnverified": "Непроверено", - "domainPickerInvalidSubdomainStructure": "Този поддомен съдържа невалидни знаци или структура. Ще бъде автоматично пречистен при запазване.", + "domainPickerManual": "Ръчно", + "domainPickerInvalidSubdomainStructure": "Невалидните символи ще бъдат почистени при записване.", "domainPickerError": "Грешка", "domainPickerErrorLoadDomains": "Неуспешно зареждане на домейни на организацията", "domainPickerErrorCheckAvailability": "Неуспешна проверка на наличността на домейни", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите", "orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.", "orgAuthSignInWithPangolin": "Впишете се с Pangolin", - "orgAuthSignInToOrg": "Влезте в организация", + "orgAuthSignInToOrg": "Идентификационен доставчик на организация (SSO)", "orgAuthSelectOrgTitle": "Вход в организация.", "orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.", "orgAuthOrgIdPlaceholder": "вашата-организация", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Разкриване на употреба", - "description": "Изберете лицензионен клас, който точно отразява вашата целена употреба. Персоналният лиценз позволява безплатно ползване на софтуера за индивидуална, некомерсиална или маломащабна комерсиална дейност с годишен брутен приход под 100,000 USD. Всяко ползване извън тези граници — включително ползване във фирма, организация или друга доходоносна среда — изисква валиден корпоративен лиценз и плащане на съответната лицензионна такса. Всички потребители, независимо дали са лични или корпоративни, трябва да спазват Условията на Fossorial Commercial License." + "description": "Изберете лицензионен клас, който точно отразява вашата целена употреба. Персоналният лиценз позволява безплатно ползване на софтуера за индивидуална, некомерсиална или маломащабна комерсиална дейност с годишен брутен приход под 100,000 USD. Всяко ползване извън тези граници - включително ползване във фирма, организация или друга доходоносна среда - изисква валиден корпоративен лиценз и плащане на съответната лицензионна такса. Всички потребители, независимо дали са лични или корпоративни, трябва да спазват Условията на Fossorial Commercial License." }, "trialPeriodInformation": { "title": "Информация за пробен период", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Скала", - "description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка." + "description": "Функции за корпоративни клиенти, 50 потребители, 100 сайта и приоритетна поддръжка." } }, "personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)", @@ -2422,6 +2648,7 @@ "validPassword": "Валидна парола", "validEmail": "Валиден имейл", "validSSO": "Валидно SSO", + "connectedClient": "Свързан клиент", "resourceBlocked": "Блокирани ресурси", "droppedByRule": "Прекратено от правило", "noSessions": "Няма сесии", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Добавяне на клиенти.", "editInternalResourceDialogDestinationLabel": "Дестинация.", "editInternalResourceDialogDestinationDescription": "Посочете адреса дестинация за вътрешния ресурс. Това може да бъде име на хост, IP адрес или CIDR обхват в зависимост от избрания режим. По избор настройте вътрешен DNS алиас за по-лесно идентифициране.", + "internalResourceFormMultiSiteRoutingHelp": "Избирайки няколко сайта, се осигурява сигурен път и пренасочване при висока достъпност.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Научете повече", "editInternalResourceDialogPortRestrictionsDescription": "Ограничете достъпа до конкретни TCP/UDP портове или позволете/блокирайте всички портове.", + "createInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Изберете домейна, който клиентите ще използват, за да достигнат този ресурс чрез HTTP или HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Изберете домейна, който клиентите ще използват, за да достигнат този ресурс чрез HTTP или HTTPS.", "editInternalResourceDialogTcp": "TCP.", "editInternalResourceDialogUdp": "UDP.", "editInternalResourceDialogIcmp": "ICMP.", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Ще се върнем скоро! Нашият сайт понастоящем е в процес на планирана поддръжка.", "maintenancePageMessageDescription": "Подробно съобщение, обясняващо поддръжката.", "maintenancePageTimeTitle": "Очаквано време за завършване (по избор).", + "privateMaintenanceScreenTitle": "Екран за поддръжка", + "privateMaintenanceScreenMessage": "Този домейн се използва при частен ресурс. Моля, свържете се с клиента на Pangolin, за да получите достъп до този ресурс.", + "privateMaintenanceScreenSteps": "След свързване, ако все още виждате това съобщение, кешът на DNS на вашия браузър все още може да сочи към стария адрес. За да коригирате това: напълно затворете и отворете отново този раздел, или браузъра си, след това се върнете на тази страница.", "maintenanceTime": "например, 2 часа, 1 ноември в 17:00.", "maintenanceEstimatedTimeDescription": "Кога очаквате поддръжката да бъде завършена?", "editDomain": "Редактиране на домейна.", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Добавяне на HTTP дестинация", "httpDestEditDescription": "Актуализирайте конфигурацията за този HTTP събитий.", "httpDestAddDescription": "Конфигурирайте нов HTTP крайна точка, за да получавате събития на вашата организация.", + "S3DestEditTitle": "Редактиране на дестинацията", + "S3DestAddTitle": "Добавете S3 дестинация", + "S3DestEditDescription": "Актуализирайте конфигурацията за тази S3 дестинация за предаване на събития.", + "S3DestAddDescription": "Конфигурирайте нов крайна точка на S3, за да получавате събития на вашата организация.", + "datadogDestEditTitle": "Редактиране на дестинация", + "datadogDestAddTitle": "Добавяне на Datadog дестинация", + "datadogDestEditDescription": "Актуализирайте конфигурацията за тази Datadog дестинация за предаване на събития.", + "datadogDestAddDescription": "Конфигурирайте нова крайна точка на Datadog, за да получавате събития на вашата организация.", "httpDestTabSettings": "Настройки", "httpDestTabHeaders": "Заглавки", "httpDestTabBody": "Тяло", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "JSON масив", "httpDestFormatJsonArrayDescription": "Една заявка на партида, тялото е JSON масив. Съвместим с повечето общи уеб куки и Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Една заявка на партида, тялото е ново линии отделени JSON — един обект на ред, няма външен масив. Изисквано от Splunk HEC, Elastic / OpenSearch и Grafana.", + "httpDestFormatNdjsonDescription": "Една заявка на партида, тялото е ново линии отделени JSON - един обект на ред, няма външен масив. Изисквано от Splunk HEC, Elastic / OpenSearch и Grafana.", "httpDestFormatSingleTitle": "Едно събитие на заявка", "httpDestFormatSingleDescription": "Изпращат се отделни HTTP POST за всяко индивидуално събитие. Използвайте само за крайни точки, които не могат да обработват партиди.", "httpDestLogTypesTitle": "Видове логове", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Дестинацията беше актуализирана успешно", "httpDestCreatedSuccess": "Дестинацията беше създадена успешно", "httpDestUpdateFailed": "Неуспешно актуализиране на дестинацията", - "httpDestCreateFailed": "Неуспешно създаване на дестинацията" + "httpDestCreateFailed": "Неуспешно създаване на дестинацията", + "followRedirects": "Следвайте пренасочвания", + "followRedirectsDescription": "Автоматично следвайте HTTP пренасочвания за заявки.", + "alertingErrorWebhookUrl": "Моля, въведете валид URL адрес за уеб куката.", + "healthCheckStrategyHttp": "Проверява свързаността и проверява статуса на HTTP отговора.", + "healthCheckStrategyTcp": "Проверява само TCP свързаност, без да изследва отговора.", + "healthCheckStrategySnmp": "Прави SNMP get заявка, за да провери здравето на мрежовите устройства и инфраструктура.", + "healthCheckStrategyIcmp": "Използва ICMP echo заявки (пинг), за да провери дали ресурсът е достъпен и отговаря.", + "healthCheckTabStrategy": "Стратегия", + "healthCheckTabConnection": "Връзка", + "healthCheckTabAdvanced": "Разширени", + "healthCheckStrategyNotAvailable": "Тази стратегия не е достъпна. Моля, свържете се с отдел продажби, за да активирате тази функция.", + "uptime30d": "Работно време (30д)", + "idpAddActionCreateNew": "Създайте нов доставчик на самоличност", + "idpAddActionImportFromOrg": "Импортиране от друга организация", + "idpImportDialogTitle": "Импортиране на доставчик на самоличност", + "idpImportDialogDescription": "Изберете доставчик на самоличност от организация, в която сте администратор. Той ще бъде свързан с тази организация.", + "idpImportSearchPlaceholder": "Търсене по име на организация или доставчик...", + "idpImportEmpty": "Няма намерени доставчици на самоличност.", + "idpImportedDescription": "Доставчикът на самоличност беше импортиран успешно.", + "idpDeleteGlobalQuestion": "Сигурни ли сте, че искате да изтриете този доставчик на самоличност завинаги?", + "idpDeleteGlobalDescription": "Това ще изтрие доставичка на самоличност завинаги от всички организации, с които е свързан.", + "idpUnassociateTitle": "Отвързване на доставчик на самоличност", + "idpUnassociateQuestion": "Сигурни ли сте, че искате да отвържете този доставчик на самоличност от тази организация?", + "idpUnassociateDescription": "Всички потребители, свързани с този доставчик на самоличност, ще бъдат премахнати от тази организация, но доставчика на самоличност ще продължи да съществува за други свързани организации.", + "idpUnassociateConfirm": "Потвърдете отвързване на доставчика на самоличност", + "idpUnassociateWarning": "Това не може да бъде отменено за тази организация.", + "idpUnassociatedDescription": "Доставчика на самоличност е успешно отвързан от тази организация", + "idpUnassociateMenu": "Отвързване", + "idpDeleteAllOrgsMenu": "Изтриване", + "publicIpEndpoint": "Крайна точка", + "lastTriggeredAt": "Последен тригер", + "reject": "Отхвърляне", + "uptimeDaysAgo": "{count} days ago", + "uptimeToday": "Днес", + "uptimeNoDataAvailable": "Няма налични данни", + "uptimeSuffix": "време без прекъсване", + "uptimeDowntimeSuffix": "време на прекъсване", + "uptimeTooltipUptimeLabel": "Време без прекъсване", + "uptimeTooltipDowntimeLabel": "Време на прекъсване", + "uptimeOngoing": "текущо", + "uptimeNoMonitoringData": "Няма данни за наблюдение", + "uptimeNoData": "Няма данни", + "uptimeMiniBarDown": "Прекъсване", + "uptimeSectionTitle": "Време без прекъсване", + "uptimeSectionDescription": "Наличност през последните {days} дни", + "uptimeAddAlert": "Добавяне на известие", + "uptimeViewAlerts": "Преглед на известията", + "uptimeCreateEmailAlert": "Създаване на електронна известие", + "uptimeAlertDescriptionSite": "Получавайте известия по електронна поща, когато този сайт се изключи или отново стане онлайн.", + "uptimeAlertDescriptionResource": "Получавайте известия по електронна поща, когато този ресурс се изключи или отново стане онлайн.", + "uptimeAlertNamePlaceholder": "Име на известието", + "uptimeAdditionalEmails": "Допълнителни имейли", + "uptimeCreateAlert": "Създаване на известие", + "uptimeAlertNoRecipients": "Няма получатели", + "uptimeAlertNoRecipientsDescription": "Моля, добавете поне един потребител, рол, или имейл за известяване.", + "uptimeAlertCreated": "Известието е създадено", + "uptimeAlertCreatedDescription": "Ще бъдете известени, когато това промени статуса си.", + "uptimeAlertCreateFailed": "Неуспешно създаване на известие", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Ключ", + "webhookHeaderValuePlaceholder": "Стойност", + "alertLabel": "Известие", + "domainPickerWildcardSubdomainNotAllowed": "Уайлдкард подсайтове не са позволени.", + "domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.", + "domainPickerWildcardCertWarningLink": "Научете повече", + "health": "Здраве", + "domainPendingErrorTitle": "Проблем при проверка" } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index aa564ae5b..00ee73906 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Obraťte se na prodejce, aby tuto funkci povolil.", + "contactSalesBookDemo": "Zarezervovat demo", + "contactSalesOr": "nebo", + "contactSalesContactUs": "kontaktujte nás", "setupCreate": "Vytvořte organizaci, stránku a zdroje", "headerAuthCompatibilityInfo": "Povolte toto, aby vyvolalo odpověď 401 Neoprávněné, když chybí autentizační token. Toto je potřeba pro prohlížeče nebo specifické HTTP knihovny, které neposílají přihlašovací údaje bez výzvy serveru.", "headerAuthCompatibility": "Rozšířená kompatibilita", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Byly nalezeny neplatné nebo propadlé licenční klíče. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", "dismiss": "Zavřít", "subscriptionViolationMessage": "Jste za hranicemi vašeho aktuálního plánu. Opravte problém odstraněním webů, uživatelů nebo jiných zdrojů, abyste zůstali ve vašem tarifu.", + "trialBannerMessage": "Vaše zkušební verze vyprší za {countdown}. Pro udržení přístupu upgraduje.", + "trialBannerExpired": "Vaše zkušební verze vypršela. Upgradujte nyní pro obnovu přístupu.", + "trialActive": "Zkušební verze je aktivní", + "trialExpired": "Zkušební verze vypršela", + "trialHasEnded": "Vaše zkušební verze skončila.", + "trialDaysRemaining": "{count, plural, one {# den zbývá} few {# dny zbývají} many {# dní zbývá} other {# dny zbývají}}", + "trialDaysLeftShort": "Zbývá {days} d ve zkušební verzi", + "trialGoToBilling": "Přejděte na fakturační stránku", "subscriptionViolationViewBilling": "Zobrazit fakturaci", "componentsLicenseViolation": "Porušení licenčních podmínek: Tento server používá {usedSites} stránek, což překračuje limit {maxSites} licencovaných stránek. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", "componentsSupporterMessage": "Děkujeme, že podporujete Pangolin jako {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "Konfiguraci jsem zkopíroval", "searchSitesProgress": "Hledat lokality...", "siteAdd": "Přidat lokalitu", + "sitesTableViewPublicResources": "Zobrazit veřejné zdroje", + "sitesTableViewPrivateResources": "Zobrazit soukromé zdroje", "siteInstallNewt": "Nainstalovat Newt", "siteInstallNewtDescription": "Spustit Newt na vašem systému", "WgConfiguration": "Konfigurace WireGuard", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "Lokalita byla upravena.", "siteGeneralDescription": "Upravte obecná nastavení pro tuto lokalitu", "siteSettingDescription": "Konfigurace nastavení na webu", + "siteResourcesTab": "Zdroje", + "siteResourcesNoneOnSite": "Tento web zatím nemá veřejné ani soukromé zdroje.", + "siteResourcesSectionPublic": "Veřejné zdroje", + "siteResourcesSectionPrivate": "Soukromé zdroje", + "siteResourcesSectionPublicDescription": "Zdroje zpřístupněné externě prostřednictvím domén nebo portů.", + "siteResourcesSectionPrivateDescription": "Zdroje dostupné ve vaší soukromé síti prostřednictvím webu.", + "siteResourcesViewAllPublic": "Zobrazit všechny zdroje", + "siteResourcesViewAllPrivate": "Zobrazit všechny zdroje", + "siteResourcesDialogDescription": "Přehled veřejných a soukromých zdrojů spojených s tímto webem.", + "siteResourcesShowMore": "Ukázat více", + "siteResourcesPermissionDenied": "Nemáte oprávnění k vypsání těchto zdrojů.", + "siteResourcesEmptyPublic": "Žádné veřejné zdroje ještě necílí na tento web.", + "siteResourcesEmptyPrivate": "Žádné soukromé zdroje ještě nejsou spojené s tímto webem.", + "siteResourcesHowToAccess": "Jak získat přístup", + "siteResourcesTargetsOnSite": "Cíle na tomto webu", "siteSetting": "Nastavení {siteName}", "siteNewtTunnel": "Novinka (doporučeno)", "siteNewtTunnelDescription": "Nejjednodušší způsob, jak vytvořit vstupní bod do jakékoli sítě. Žádné další nastavení.", @@ -267,8 +296,11 @@ "orgMissing": "Chybí ID organizace", "orgMissingMessage": "Nelze obnovit pozvánku bez ID organizace.", "accessUsersManage": "Spravovat uživatele", + "accessUserManage": "Spravovat uživatele", "accessUsersDescription": "Pozvat a spravovat uživatele s přístupem k této organizaci", "accessUsersSearch": "Hledat uživatele...", + "accessUsersRoleFilterCount": "{count, plural, one {# role} few {# role} many {# rolí} other {# roli}}", + "accessUsersRoleFilterClear": "Vymazat filtry rolí", "accessUserCreate": "Vytvořit uživatele", "accessUserRemove": "Odstranit uživatele", "username": "Uživatelské jméno", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Nepodařilo se aktivovat licenční klíč", "licenseErrorKeyActivateDescription": "Došlo k chybě při aktivaci licenčního klíče.", "licenseAbout": "O licencích", + "licenseBannerTitle": "Aktivovat vaši firemní licenci", + "licenseBannerDescription": "Odemkněte firemní funkce pro vaši samohostovanou instanci Pangolin. Zakupte si licenční klíč pro aktivaci prémiových možností a poté jej přidejte níže.", + "licenseBannerGetLicense": "Zakoupit licenci", + "licenseBannerViewDocs": "Zobrazit dokumentaci", "communityEdition": "Komunitní edice", "licenseAboutDescription": "To je pro obchodní a podnikové uživatele, kteří používají Pangolin v komerčním prostředí. Pokud používáte Pangolin pro osobní použití, můžete tuto sekci ignorovat.", "licenseKeyActivated": "Licenční klíč aktivován", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Tajný klíč", + "newtVersion": "Verze", "architecture": "Architektura", "sites": "Stránky", "siteWgAnyClients": "K připojení použijte jakéhokoli klienta WireGuard. Budete muset řešit interní zdroje pomocí klientské IP adresy.", @@ -894,6 +931,7 @@ "idpDisplayName": "Zobrazované jméno tohoto poskytovatele identity", "idpAutoProvisionUsers": "Automatická úprava uživatelů", "idpAutoProvisionUsersDescription": "Pokud je povoleno, uživatelé budou automaticky vytvářeni v systému při prvním přihlášení, s možností namapovat uživatele na role a organizace.", + "idpAutoProvisionConfigureAfterCreate": "Nastavení automatického poskytování lze nakonfigurovat, jakmile je vytvořen poskytovatel identity.", "licenseBadge": "PE", "idpType": "Typ poskytovatele", "idpTypeDescription": "Vyberte typ poskytovatele identity, který chcete nakonfigurovat", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Výchozí mapování rolí", "defaultMappingsRoleDescription": "Výsledek tohoto výrazu musí vrátit název role definovaný v organizaci jako řetězec.", "defaultMappingsOrg": "Výchozí mapování organizace", - "defaultMappingsOrgDescription": "Tento výraz musí vrátit org ID nebo pravdu, aby měl uživatel přístup k organizaci.", + "defaultMappingsOrgDescription": "Pokud je nastaven, musí tento výraz vracet ID organizace nebo pravda, aby k této organizaci měl uživatel přístup. Pokud není nastaveno, je dostačující definice mapování rolí: uživateli je umožněn přístup, pokud pro něj lze v rámci organizace vyřešit platné mapování rolí.", "defaultMappingsSubmit": "Uložit výchozí mapování", "orgPoliciesEdit": "Upravit zásady organizace", "org": "Organizace", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Zobrazit logy", "noneSelected": "Není vybráno", "orgNotFound2": "Nebyly nalezeny žádné organizace.", + "search": "Vyhledávání…", "searchPlaceholder": "Hledat...", "emptySearchOptions": "Nebyly nalezeny žádné možnosti", "create": "Vytvořit", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Spravovat", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Plány", + "sidebarAlerting": "Upozornění", + "sidebarHealthChecks": "Kontroly stavu", "sidebarOrganization": "Organizace", "sidebarManagement": "Správa", "sidebarBillingAndLicenses": "Fakturace a licence", "sidebarLogsAnalytics": "Analytici", + "alertingTitle": "Upozornění", + "alertingDescription": "Definujte zdroje, spouštěče a akce pro oznámení", + "alertingRules": "Pravidla upozornění", + "alertingSearchRules": "Hledat pravidla…", + "alertingAddRule": "Vytvořit pravidlo", + "alertingColumnSource": "Zdroj", + "alertingColumnTrigger": "Spouštěč", + "alertingColumnActions": "Akce", + "alertingColumnEnabled": "Povoleno", + "alertingDeleteQuestion": "Potvrďte, prosím, zda chcete toto pravidlo upozornění smazat.", + "alertingDeleteRule": "Smazat pravidlo upozornění", + "alertingRuleDeleted": "Pravidlo upozornění bylo smazáno", + "alertingRuleSaved": "Pravidlo upozornění bylo uloženo", + "alertingRuleSavedCreatedDescription": "Vaše nové pravidlo upozornění bylo vytvořeno. Můžete ho dál upravovat na této stránce.", + "alertingRuleSavedUpdatedDescription": "Vaše změny pro toto pravidlo upozornění byly uloženy.", + "alertingEditRule": "Upravit pravidlo upozornění", + "alertingCreateRule": "Vytvořit pravidlo upozornění", + "alertingRuleCredenzaDescription": "Vyberte, co sledovat, kdy ho spustit a jak oznamovat", + "alertingRuleNamePlaceholder": "Produkční stránka je dolů", + "alertingRuleEnabled": "Pravidlo povoleno", + "alertingSectionSource": "Zdroj", + "alertingSourceType": "Typ zdroje", + "alertingSourceSite": "Lokalita", + "alertingSourceHealthCheck": "Kontrola stavu", + "alertingPickSites": "Lokality", + "alertingPickHealthChecks": "Kontroly stavu", + "alertingPickResources": "Zdroje", + "alertingAllSites": "Všechny lokality", + "alertingAllSitesDescription": "Upozornění pro jakoukoli lokalitu", + "alertingSpecificSites": "Specifické lokality", + "alertingSpecificSitesDescription": "Vyberte specifické lokality k sledování", + "alertingAllHealthChecks": "Všechny kontroly stavu", + "alertingAllHealthChecksDescription": "Upozornění pro jakoukoli kontrolu stavu", + "alertingSpecificHealthChecks": "Specifické kontroly stavu", + "alertingSpecificHealthChecksDescription": "Vyberte specifické kontroly stavu k sledování", + "alertingAllResources": "Všechny zdroje", + "alertingAllResourcesDescription": "Upozornění pro jakýkoli zdroj", + "alertingSpecificResources": "Specifické zdroje", + "alertingSpecificResourcesDescription": "Vyberte specifické zdroje k sledování", + "alertingSelectResources": "Vyberte zdroje…", + "alertingResourcesSelected": "{count} zdrojů vybráno", + "alertingResourcesEmpty": "Žádné zdroje s cíly v prvních 10 výsledcích.", + "alertingSectionTrigger": "Spouštěč", + "alertingTrigger": "Kdy upozornit", + "alertingTriggerSiteOnline": "Stránky online", + "alertingTriggerSiteOffline": "Stránky offline", + "alertingTriggerSiteToggle": "Změny stavu stránek", + "alertingTriggerHcHealthy": "Kontrola stavu je zdravá", + "alertingTriggerHcUnhealthy": "Kontrola stavu je nezdravá", + "alertingTriggerHcToggle": "Změny stavu kontroly stavu", + "alertingTriggerResourceHealthy": "Zdroj je zdravý", + "alertingTriggerResourceUnhealthy": "Zdroj je nezdravý", + "alertingTriggerResourceDegraded": "Zhoršený zdroj", + "alertingSearchHealthChecks": "Hledat kontroly stavu…", + "alertingHealthChecksEmpty": "Nejsou dostupné kontroly stavu.", + "alertingTriggerResourceToggle": "Změny stavu zdroje", + "alertingSourceResource": "Zdroj", + "alertingSectionActions": "Akce", + "alertingAddAction": "Přidat akci", + "alertingActionNotify": "Email", + "alertingActionNotifyDescription": "Odesílat emailová upozornění uživatelům nebo rolím", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Odeslání HTTP požadavku na vlastní koncový bod", + "alertingExternalIntegration": "Externí integrace", + "alertingExternalPagerDutyDescription": "Odesílat upozornění do PagerDuty pro řízení incidentů", + "alertingExternalOpsgenieDescription": "Směrujte upozornění do Opsgenie pro řízení, když je někdo na telefonu", + "alertingExternalServiceNowDescription": "Vytvářet incidenty ServiceNow z událostí upozornění", + "alertingExternalIncidentIoDescription": "Spouštět Incident.io workflowy z událostí upozornění", + "alertingActionType": "Typ akce", + "alertingNotifyUsers": "Uživatelé", + "alertingNotifyRoles": "Role", + "alertingNotifyEmails": "Emailové adresy", + "alertingEmailPlaceholder": "Přidejte e-mail a stiskněte Enter", + "alertingWebhookMethod": "HTTP metoda", + "alertingWebhookSecret": "Přihlašovací tajemství (volitelné)", + "alertingWebhookSecretPlaceholder": "HMAC tajemství", + "alertingWebhookHeaders": "Hlavičky", + "alertingAddHeader": "Přidat hlavičku", + "alertingSelectSites": "Vybrat lokality…", + "alertingSitesSelected": "{count} lokalit vybráno", + "alertingSelectHealthChecks": "Vybrat kontroly stavu…", + "alertingHealthChecksSelected": "{count} kontrol stavu vybráno", + "alertingNoHealthChecks": "Žádné cíle s povolenými kontrolami stavu", + "alertingHealthCheckStub": "Výběr zdrojů kontrol stavu ještě není propojen – můžete stále konfigurovat spouštěče a akce.", + "alertingSelectUsers": "Vybrat uživatele…", + "alertingUsersSelected": "{count} uživatelů vybráno", + "alertingSelectRoles": "Vybrat role…", + "alertingRolesSelected": "{count} rolí vybráno", + "alertingSummarySites": "Lokality ({count})", + "alertingSummaryAllSites": "Všechny lokality", + "alertingSummaryHealthChecks": "Kontroly stavu ({count})", + "alertingSummaryAllHealthChecks": "Všechny kontroly stavu", + "alertingSummaryResources": "Zdroje ({count})", + "alertingSummaryAllResources": "Všechny zdroje", + "alertingErrorNameRequired": "Zadejte jméno", + "alertingErrorActionsMin": "Přidat alespoň jednu akci", + "alertingErrorPickSites": "Vyberte alespoň jednu lokalitu", + "alertingErrorPickHealthChecks": "Vyberte alespoň jednu kontrolu stavu", + "alertingErrorPickResources": "Vyberte alespoň jeden zdroj", + "alertingErrorTriggerSite": "Vyberte spouštěč lokality", + "alertingErrorTriggerHealth": "Vyberte spouštěč kontroly stavu", + "alertingErrorTriggerResource": "Vyberte spouštěč zdroje", + "alertingErrorNotifyRecipients": "Vyberte uživatele, role nebo alespoň jeden email", + "alertingConfigureSource": "Konfigurace zdroje", + "alertingConfigureTrigger": "Konfigurace spouštěče", + "alertingConfigureActions": "Konfigurace akcí", + "alertingBackToRules": "Zpět na pravidla", + "alertingRuleCooldown": "Odpočinek (sekundy)", + "alertingRuleCooldownDescription": "Minimální doba mezi opakovanými upozorněními pro stejné pravidlo. Nastavte na 0 pro spuštění pokaždé.", + "alertingDraftBadge": "Koncept - uložit pro uložení tohoto pravidla", + "alertingSidebarHint": "Kliknutím na krok na plátno ho zde upravte.", + "alertingGraphCanvasTitle": "Průběh pravidla", + "alertingGraphCanvasDescription": "Vizuální přehled o zdroji, spouštěči a akcích. Vyberte uzel k jeho editaci v panelu.", + "alertingNodeNotConfigured": "Ještě není nakonfigurováno", + "alertingNodeActionsCount": "{count, plural, one {# akce} few {# akce} many {# akcí} other {# akce}}", + "alertingNodeRoleSource": "Zdroj", + "alertingNodeRoleTrigger": "Spouštěč", + "alertingNodeRoleAction": "Akce", + "alertingTabRules": "Pravidla upozornění", + "alertingTabHealthChecks": "Kontroly stavu", + "alertingRulesBannerTitle": "Dostávat upozornění", + "alertingRulesBannerDescription": "Každé pravidlo spojuje, co sledovat (lokalita, kontrola stavu nebo zdroj), kdy ho spustit (například offline nebo nezdravé), a jak informovat váš tým emailem, webhookem nebo integracemi. Použijte tento seznam k vytvoření, povolení a správě těchto pravidel.", + "alertingHealthChecksBannerTitle": "Monitorujte zdraví a zdroje", + "alertingHealthChecksBannerDescription": "Kontroly stavu jsou HTTP nebo TCP monitory, které nastavíte jednou. Poté je můžete použít jako zdroje v pravidlech upozornění, takže budete informováni, když se cíl stane zdravým nebo nezdravým. Kontroly stavu také zde se objeví.", + "standaloneHcTableTitle": "Kontroly stavu", + "standaloneHcSearchPlaceholder": "Hledat kontroly stavu…", + "standaloneHcAddButton": "Vytvořit kontrolu stavu", + "standaloneHcCreateTitle": "Vytvořit kontrolu stavu", + "standaloneHcEditTitle": "Upravit kontrolu stavu", + "standaloneHcDescription": "Nakonfigurujte HTTP nebo TCP kontrolu stavu pro použití v pravidlech upozornění.", + "standaloneHcNameLabel": "Jméno", + "standaloneHcNamePlaceholder": "Můj HTTP Monitor", + "standaloneHcDeleteTitle": "Smazat kontrolu stavu", + "standaloneHcDeleteQuestion": "Potvrďte, prosím, zda chcete tuto kontrolu stavu smazat.", + "standaloneHcDeleted": "Kontrola stavu byla smazána", + "standaloneHcSaved": "Kontrola stavu byla uložena", + "standaloneHcColumnHealth": "Zdraví", + "standaloneHcColumnMode": "Režim", + "standaloneHcColumnTarget": "Cíl", + "standaloneHcHealthStateHealthy": "Zdravé", + "standaloneHcHealthStateUnhealthy": "Nezdravé", + "standaloneHcHealthStateUnknown": "Neznámý", + "standaloneHcFilterAnySite": "Všechny lokality", + "standaloneHcFilterAnyResource": "Všechny zdroje", + "standaloneHcFilterMode": "Režim", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Zdraví", + "standaloneHcFilterEnabled": "Povoleno", + "standaloneHcFilterEnabledOn": "Povoleno", + "standaloneHcFilterEnabledOff": "Zakázáno", + "standaloneHcFilterSiteIdFallback": "Stránka {id}", + "standaloneHcFilterResourceIdFallback": "Zdroj {id}", "blueprints": "Plány", "blueprintsDescription": "Použít deklarativní konfigurace a zobrazit předchozí běhy", "blueprintAdd": "Přidat plán", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Vytvořte účet správce intial serveru. Pouze jeden správce serveru může existovat. Tyto přihlašovací údaje můžete kdykoliv změnit.", "createAdminAccount": "Vytvořit účet správce", "setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.", - "certificateStatus": "Stav certifikátu", + "certificateStatus": "Certifikát", + "certificateStatusAutoRefreshHint": "Stav se automaticky obnovuje.", "loading": "Načítání", "loadingAnalytics": "Načítání analytiky", "restart": "Restartovat", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Zobrazit poznámky k vydání", "newtUpdateAvailable": "Dostupná aktualizace", "newtUpdateAvailableInfo": "Je k dispozici nová verze Newt. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.", + "pangolinNodeUpdateAvailableInfo": "Je k dispozici nová verze uzlu Pangolin. Pro nejlepší zážitek aktualizujte na nejnovější verzi.", "domainPickerEnterDomain": "Doména", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Zadejte úplnou doménu zdroje pro zobrazení dostupných možností.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Konfigurace kontroly stavu", "configureHealthCheckDescription": "Nastavit sledování zdravotního stavu pro {target}", "enableHealthChecks": "Povolit kontrolu stavu", + "healthCheckDisabledStateDescription": "Pokud je zakázáno, web nebude provádět zdravotní kontroly a stav bude považován za neznámý.", "enableHealthChecksDescription": "Sledujte zdraví tohoto cíle. V případě potřeby můžete sledovat jiný cílový bod, než je cíl.", "healthScheme": "Způsob", "healthSelectScheme": "Vybrat metodu", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "Interval kontroly musí být nejméně 5 sekund", "healthCheckTimeoutMin": "Časový limit musí být nejméně 1 sekunda", "healthCheckRetryMin": "Pokusy opakovat musí být alespoň 1", + "healthCheckMode": "Režim kontroly", + "healthCheckStrategy": "Strategie", + "healthCheckModeDescription": "Režim TCP ověřuje pouze připojení. Režim HTTP ověřuje HTTP odezvu.", + "healthyThreshold": "Zdravý práh", + "healthyThresholdDescription": "Počet po sobě jdoucích úspěchů vyžadovaných před označením jako zdravý.", + "unhealthyThreshold": "Nezdravý práh", + "unhealthyThresholdDescription": "Počet po sobě jdoucích selhání vyžadovaných před označením jako nezdravý.", + "healthCheckHealthyThresholdMin": "Zdravý práh musí být alespoň 1", + "healthCheckUnhealthyThresholdMin": "Nezdravý práh musí být alespoň 1", "httpMethod": "HTTP metoda", "selectHttpMethod": "Vyberte HTTP metodu", "domainPickerSubdomainLabel": "Subdoména", + "domainPickerWildcard": "Zástupný znak", + "domainPickerWildcardPaidOnly": "Zástupné poddomény jsou placenou funkcí. Upgradujte, prosím, pro přístup k této funkci.", "domainPickerBaseDomainLabel": "Základní doména", "domainPickerSearchDomains": "Hledat domény...", "domainPickerNoDomainsFound": "Nebyly nalezeny žádné domény", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Tato adresa je součástí subsítě veřejných služeb organizace. Používá se k řešení záznamů aliasů pomocí interního rozlišení DNS.", "resourcesTableClients": "Klienti", "resourcesTableAndOnlyAccessibleInternally": "a jsou interně přístupné pouze v případě, že jsou propojeni s klientem.", - "resourcesTableNoTargets": "Žádné cíle", "resourcesTableHealthy": "Zdravé", "resourcesTableDegraded": "Rozklad", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Nezdravý", "resourcesTableUnknown": "Neznámý", "resourcesTableNotMonitored": "Není sledováno", + "resourcesTableNoTargets": "Žádné cíle", "editInternalResourceDialogEditClientResource": "Upravit soukromý dokument", "editInternalResourceDialogUpdateResourceProperties": "Aktualizovat konfiguraci zdroje a ovládací prvky přístupu pro {resourceName}", "editInternalResourceDialogResourceProperties": "Vlastnosti zdroje", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Přístav", "editInternalResourceDialogModeHost": "Hostitel", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Schéma", + "editInternalResourceDialogEnableSsl": "Povolit SSL", + "editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.", "editInternalResourceDialogDestination": "Místo určení", "editInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.", "editInternalResourceDialogDestinationIPDescription": "IP nebo název hostitele zdroje v síti webu.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Jméno", "createInternalResourceDialogSite": "Lokalita", "selectSite": "Vybrat lokalitu...", + "multiSitesSelectorSitesCount": "{count, plural, one {# web} few {# weby} many {# webů} other {# weby}}", "noSitesFound": "Nebyly nalezeny žádné lokality.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Přístav", "createInternalResourceDialogModeHost": "Hostitel", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Schéma", + "createInternalResourceDialogScheme": "Schéma", + "createInternalResourceDialogEnableSsl": "Povolit SSL", + "createInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.", "createInternalResourceDialogDestination": "Místo určení", "createInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.", "createInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.", + "internalResourceDownstreamSchemeRequired": "HTTP metoda je vyžadována pro HTTP zdroje", + "internalResourceHttpPortRequired": "Přípoječný port je nutný pro HTTP zdroj", "siteConfiguration": "Konfigurace", "siteAcceptClientConnections": "Přijmout připojení klienta", "siteAcceptClientConnectionsDescription": "Povolit uživatelským zařízením a klientům přístup ke zdrojům na tomto webu. To lze později změnit.", @@ -1989,7 +2213,7 @@ "description": "Spolehlivější a nízko udržovaný Pangolinův server s dalšími zvony a bičkami", "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.", - "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:", + "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": { "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." @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Zjištěna mezinárodní doména", "willbestoredas": "Bude uloženo jako:", - "roleMappingDescription": "Určete, jak jsou role přiřazeny uživatelům, když se přihlásí, když je povoleno automatické poskytnutí služby.", + "roleMappingDescription": "Určete, jak jsou role přiřazeny uživatelům, když se přihlásí s tímto poskytovatelem identity.", "selectRole": "Vyberte roli", "roleMappingExpression": "Výraz", "selectRolePlaceholder": "Vyberte roli", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Vyberte doménu pro ověřovací stránku organizace", "domainPickerProvidedDomain": "Poskytnutá doména", "domainPickerFreeProvidedDomain": "Zdarma poskytnutá doména", + "domainPickerFreeDomainsPaidFeature": "Poskytnuté domény jsou placenou funkcí. Předplaťte si plán, abyste získali doménu zahrnutou v plánu – nemusíte si přinést vlastní.", "domainPickerVerified": "Ověřeno", "domainPickerUnverified": "Neověřeno", - "domainPickerInvalidSubdomainStructure": "Tato subdoména obsahuje neplatné znaky nebo strukturu. Bude automaticky sanitována při uložení.", + "domainPickerManual": "Ruční nastavení", + "domainPickerInvalidSubdomainStructure": "Neplatné znaky budou při ukládání vyčištěny.", "domainPickerError": "Chyba", "domainPickerErrorLoadDomains": "Nepodařilo se načíst domény organizace", "domainPickerErrorCheckAvailability": "Kontrola dostupnosti domény se nezdařila", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Chcete-li pokračovat, vyberte svého poskytovatele identity", "orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.", "orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu", - "orgAuthSignInToOrg": "Přihlásit se do organizace", + "orgAuthSignInToOrg": "Poskytovatel identity organizace (SSO)", "orgAuthSelectOrgTitle": "Přihlášení do organizace", "orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování", "orgAuthOrgIdPlaceholder": "vaše-organizace", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Měřítko", - "description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory." + "description": "Podnikové funkce, 50 uživatelů, 100 stránek a prioritní podpora." } }, "personalUseOnly": "Pouze pro osobní použití (zdarma licence - bez ověření)", @@ -2422,6 +2648,7 @@ "validPassword": "Platné heslo", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Připojený klient", "resourceBlocked": "Zablokované zdroje", "droppedByRule": "Zrušeno pravidlem", "noSessions": "Žádné relace", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Přidat klienty", "editInternalResourceDialogDestinationLabel": "Cíl", "editInternalResourceDialogDestinationDescription": "Určete cílovou adresu pro interní prostředek. Může se jednat o hostname, IP adresu, nebo rozsah CIDR v závislosti na vybraném režimu. Volitelně nastavte interní DNS alias pro snazší identifikaci.", + "internalResourceFormMultiSiteRoutingHelp": "Výběrem více webů se povolí odolné směrování a přepojení pro vysokou dostupnost.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Zjistit více", "editInternalResourceDialogPortRestrictionsDescription": "Omezte přístup na specifické TCP/UDP porty nebo povolte/blokujte všechny porty.", + "createInternalResourceDialogHttpConfiguration": "Konfigurace HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Zvolte doménu, kterou klienti použijí k dosažení tohoto zdroje přes HTTP nebo HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Konfigurace HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Zvolte doménu, kterou klienti použijí k dosažení tohoto zdroje přes HTTP nebo HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Vrátíme se brzy! Naše stránka právě prochází plánovanou údrbou.", "maintenancePageMessageDescription": "Podrobná zpráva vysvětlující údržbu", "maintenancePageTimeTitle": "Odhadovaný čas dokončení (volitelný)", + "privateMaintenanceScreenTitle": "Soukromá obrazovka údržby", + "privateMaintenanceScreenMessage": "Tato doména je používána na soukromém zdroji. Prosím, připojte se přes klienta Pangolin pro přístup k tomuto zdroji.", + "privateMaintenanceScreenSteps": "Jakmile se připojíte, pokud stále vidíte tuto zprávu, možná je mezipaměť DNS vašeho prohlížeče stále nasměrována na starou adresu. Abyste to opravili: úplně zavřete a znovu otevřete tuto záložku nebo prohlížeč, a poté se vraťte na tuto stránku.", "maintenanceTime": "např. 2 hodiny, 1. listopadu v 17:00", "maintenanceEstimatedTimeDescription": "Kdy očekáváte, že údržba bude dokončena", "editDomain": "Upravit doménu", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Přidat cíl HTTP", "httpDestEditDescription": "Aktualizovat konfiguraci pro tuto destinaci HTTP události", "httpDestAddDescription": "Konfigurace nového koncového bodu HTTP pro příjem událostí vaší organizace.", + "S3DestEditTitle": "Upravit cíl", + "S3DestAddTitle": "Přidat S3 cíl", + "S3DestEditDescription": "Aktualizujte konfiguraci tohoto S3 cíle pro streamování událostí.", + "S3DestAddDescription": "Konfigurujte nový S3 koncový bod pro přijímání událostí vaší organizace.", + "datadogDestEditTitle": "Upravit cíl", + "datadogDestAddTitle": "Přidat Datadog cíl", + "datadogDestEditDescription": "Aktualizujte konfiguraci tohoto Datadog cíle pro streamování událostí.", + "datadogDestAddDescription": "Konfigurujte nový Datadog koncový bod pro přijímání událostí vaší organizace.", "httpDestTabSettings": "Nastavení", "httpDestTabHeaders": "Záhlaví", "httpDestTabBody": "Tělo", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Cíl byl úspěšně aktualizován", "httpDestCreatedSuccess": "Cíl byl úspěšně vytvořen", "httpDestUpdateFailed": "Nepodařilo se aktualizovat cíl", - "httpDestCreateFailed": "Nepodařilo se vytvořit cíl" + "httpDestCreateFailed": "Nepodařilo se vytvořit cíl", + "followRedirects": "Následovat přesměrování", + "followRedirectsDescription": "Automaticky sledovat přesměrování HTTP pro požadavky.", + "alertingErrorWebhookUrl": "Zadejte platnou URL pro webhook.", + "healthCheckStrategyHttp": "Ověření připojení a kontrola stavu HTTP odpovědi.", + "healthCheckStrategyTcp": "Ověření TCP připojení, bez inspekce odpovědi.", + "healthCheckStrategySnmp": "Vytváří SNMP požadavek pro kontrolu stavu síťových zařízení a infrastruktury.", + "healthCheckStrategyIcmp": "Používá se ICMP echo požadavky (pingy) ke kontrole, zda je zdroj dosažitelný a reaguje.", + "healthCheckTabStrategy": "Strategie", + "healthCheckTabConnection": "Připojení", + "healthCheckTabAdvanced": "Pokročilé", + "healthCheckStrategyNotAvailable": "Tato strategie není dostupná. Kontaktujte prodejce pro povolení této funkce.", + "uptime30d": "Doba provozu (30d)", + "idpAddActionCreateNew": "Vytvořit nového poskytovatele identity", + "idpAddActionImportFromOrg": "Importovat z jiné organizace", + "idpImportDialogTitle": "Importovat poskytovatele identity", + "idpImportDialogDescription": "Vyberte poskytovatele identity z organizace, v níž jste administrátor. Tento poskytovatel bude propojen s touto organizací.", + "idpImportSearchPlaceholder": "Hledat podle názvu organizace nebo poskytovatele...", + "idpImportEmpty": "Nebyli nalezeni žádní poskytovatelé identity.", + "idpImportedDescription": "Poskytovatel identity byl úspěšně importován.", + "idpDeleteGlobalQuestion": "Opravdu chcete trvale smazat tohoto poskytovatele identity?", + "idpDeleteGlobalDescription": "Tímto bude poskytovatel identity trvale odstraněn ze všech organizací, se kterými je spojen.", + "idpUnassociateTitle": "Odpojit poskytovatele identity", + "idpUnassociateQuestion": "Opravdu chcete odpojit tohoto poskytovatele identity od této organizace?", + "idpUnassociateDescription": "Všichni uživatelé spojení s tímto poskytovatelem identity budou odstraněni z této organizace, ale poskytovatel identity zůstane nadále existovat pro ostatní přidružené organizace.", + "idpUnassociateConfirm": "Potvrdit odpojení poskytovatele identity", + "idpUnassociateWarning": "Toto nelze pro tuto organizaci vrátit.", + "idpUnassociatedDescription": "Poskytovatel identity byl úspěšně odpojen od této organizace", + "idpUnassociateMenu": "Odpojit", + "idpDeleteAllOrgsMenu": "Odstranit", + "publicIpEndpoint": "Koncový bod", + "lastTriggeredAt": "Poslední spouštěč", + "reject": "Odmítnout", + "uptimeDaysAgo": "Před {count} dny", + "uptimeToday": "Dnes", + "uptimeNoDataAvailable": "Dostupná žádná data", + "uptimeSuffix": "doba dostupnosti", + "uptimeDowntimeSuffix": "doba nedostupnosti", + "uptimeTooltipUptimeLabel": "Doba dostupnosti", + "uptimeTooltipDowntimeLabel": "Doba nedostupnosti", + "uptimeOngoing": "probíhá", + "uptimeNoMonitoringData": "Žádné monitorovací údaje", + "uptimeNoData": "Žádná data", + "uptimeMiniBarDown": "Nedostupný", + "uptimeSectionTitle": "Doba dostupnosti", + "uptimeSectionDescription": "Dostupnost za posledních {days} dní", + "uptimeAddAlert": "Přidat upozornění", + "uptimeViewAlerts": "Zobrazit upozornění", + "uptimeCreateEmailAlert": "Vytvořit e-mailové upozornění", + "uptimeAlertDescriptionSite": "Pošleme vám upozornění e-mailem, když bude tento web offline nebo se vrátí online.", + "uptimeAlertDescriptionResource": "Pošleme vám upozornění e-mailem, když bude tento zdroj offline nebo se vrátí online.", + "uptimeAlertNamePlaceholder": "Název upozornění", + "uptimeAdditionalEmails": "Další e-maily", + "uptimeCreateAlert": "Vytvořit upozornění", + "uptimeAlertNoRecipients": "Žádní příjemci", + "uptimeAlertNoRecipientsDescription": "Přidejte prosím alespoň jednoho uživatele, roli nebo e-mailovou adresu pro upozornění.", + "uptimeAlertCreated": "Upozornění vytvořeno", + "uptimeAlertCreatedDescription": "Budete upozorněni, když se tento stav změní.", + "uptimeAlertCreateFailed": "Nepodařilo se vytvořit upozornění", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Klíč", + "webhookHeaderValuePlaceholder": "Hodnota", + "alertLabel": "Upozornění", + "domainPickerWildcardSubdomainNotAllowed": "Zástupné poddomény nejsou povoleny.", + "domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.", + "domainPickerWildcardCertWarningLink": "Zjistit více", + "health": "Zdraví", + "domainPendingErrorTitle": "Problém s ověřením" } diff --git a/messages/de-DE.json b/messages/de-DE.json index 7518ba5ff..b4411fac4 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Vertrieb kontaktieren, um diese Funktion zu aktivieren.", + "contactSalesBookDemo": "Demo vereinbaren", + "contactSalesOr": "oder", + "contactSalesContactUs": "kontaktieren Sie uns", "setupCreate": "Organisation, Standort und Ressourcen erstellen", "headerAuthCompatibilityInfo": "Aktivieren Sie dies, um eine 401 Nicht autorisierte Antwort zu erzwingen, wenn ein Authentifizierungs-Token fehlt. Dies ist erforderlich für Browser oder bestimmte HTTP-Bibliotheken, die keine Anmeldedaten ohne Server-Challenge senden.", "headerAuthCompatibility": "Erweiterte Kompatibilität", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Ungültige oder abgelaufene Lizenzschlüssel erkannt. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "dismiss": "Verwerfen", "subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.", + "trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.", + "trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.", + "trialActive": "Kostenlose Testversion aktiv", + "trialExpired": "Testversion abgelaufen", + "trialHasEnded": "Ihre Testversion ist beendet.", + "trialDaysRemaining": "{count, plural, one {# Tag übrig} other {# Tage übrig}}", + "trialDaysLeftShort": "Noch {days}d in der Testversion", + "trialGoToBilling": "Zur Rechnungsseite gehen", "subscriptionViolationViewBilling": "Rechnung anzeigen", "componentsLicenseViolation": "Lizenzverstoß: Dieser Server benutzt {usedSites} Standorte, was das Lizenzlimit von {maxSites} Standorten überschreitet. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "componentsSupporterMessage": "Vielen Dank für die Unterstützung von Pangolin als {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "Ich habe die Konfiguration kopiert", "searchSitesProgress": "Standorte durchsuchen...", "siteAdd": "Standort hinzufügen", + "sitesTableViewPublicResources": "Öffentliche Ressourcen anzeigen", + "sitesTableViewPrivateResources": "Private Ressourcen anzeigen", "siteInstallNewt": "Newt installieren", "siteInstallNewtDescription": "Installiere Newt auf deinem System.", "WgConfiguration": "WireGuard Konfiguration", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "Der Standort wurde aktualisiert.", "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", "siteSettingDescription": "Standorteinstellungen konfigurieren", + "siteResourcesTab": "Ressourcen", + "siteResourcesNoneOnSite": "Diese Seite hat noch keine öffentlichen oder privaten Ressourcen.", + "siteResourcesSectionPublic": "Öffentliche Ressourcen", + "siteResourcesSectionPrivate": "Private Ressourcen", + "siteResourcesSectionPublicDescription": "Ressourcen, die extern über Domains oder Ports bereitgestellt werden.", + "siteResourcesSectionPrivateDescription": "Ressourcen, die in Ihrem privaten Netzwerk über die Seite verfügbar sind.", + "siteResourcesViewAllPublic": "Alle Ressourcen anzeigen", + "siteResourcesViewAllPrivate": "Alle Ressourcen anzeigen", + "siteResourcesDialogDescription": "Überblick über öffentliche und private Ressourcen, die mit dieser Seite verbunden sind.", + "siteResourcesShowMore": "Mehr anzeigen", + "siteResourcesPermissionDenied": "Sie haben keine Berechtigung, diese Ressourcen aufzulisten.", + "siteResourcesEmptyPublic": "Noch sind keine öffentlichen Ressourcen für diese Seite vorhanden.", + "siteResourcesEmptyPrivate": "Noch sind keine privaten Ressourcen mit dieser Seite verbunden.", + "siteResourcesHowToAccess": "Zugriffsmöglichkeiten", + "siteResourcesTargetsOnSite": "Ziele auf dieser Seite", "siteSetting": "{siteName} Einstellungen", "siteNewtTunnel": "Newt Standort (empfohlen)", "siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.", @@ -267,8 +296,11 @@ "orgMissing": "Organisations-ID fehlt", "orgMissingMessage": "Einladung kann ohne Organisations-ID nicht neu generiert werden.", "accessUsersManage": "Benutzer verwalten", + "accessUserManage": "Benutzer verwalten", "accessUsersDescription": "Benutzer mit Zugriff auf diese Organisation einladen und verwalten", "accessUsersSearch": "Benutzer suchen...", + "accessUsersRoleFilterCount": "{count, plural, one {# Rolle} other {# Rollen}}", + "accessUsersRoleFilterClear": "Rollenfilter löschen", "accessUserCreate": "Benutzer erstellen", "accessUserRemove": "Benutzer entfernen", "username": "Benutzername", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Fehler beim Aktivieren des Lizenzschlüssels", "licenseErrorKeyActivateDescription": "Beim Aktivieren des Lizenzschlüssels ist ein Fehler aufgetreten.", "licenseAbout": "Über Lizenzierung", + "licenseBannerTitle": "Aktivieren Sie Ihre Enterprise-Lizenz", + "licenseBannerDescription": "Schalten Sie Unternehmensfunktionen für Ihre selbstgehostete Pangolin-Instanz frei. Kaufen Sie einen Lizenzschlüssel, um Premium-Funktionen zu aktivieren, und fügen Sie ihn dann unten hinzu.", + "licenseBannerGetLicense": "Lizenz erhalten", + "licenseBannerViewDocs": "Dokumentation anzeigen", "communityEdition": "Community-Edition", "licenseAboutDescription": "Dies ist für Geschäfts- und Unternehmensanwender, die Pangolin in einem kommerziellen Umfeld einsetzen. Wenn Sie Pangolin für den persönlichen Gebrauch verwenden, können Sie diesen Abschnitt ignorieren.", "licenseKeyActivated": "Lizenzschlüssel aktiviert", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpunkt", "newtId": "ID", "newtSecretKey": "Geheimnis", + "newtVersion": "Version", "architecture": "Architektur", "sites": "Standorte", "siteWgAnyClients": "Verwenden Sie jeden WireGuard-Client um sich zu verbinden. Sie müssen interne Ressourcen über die Peer-IP ansprechen.", @@ -894,6 +931,7 @@ "idpDisplayName": "Ein Anzeigename für diesen Identitätsanbieter", "idpAutoProvisionUsers": "Automatische Benutzerbereitstellung", "idpAutoProvisionUsersDescription": "Wenn aktiviert, werden Benutzer beim ersten Login automatisch im System erstellt, mit der Möglichkeit, Benutzer Rollen und Organisationen zuzuordnen.", + "idpAutoProvisionConfigureAfterCreate": "Sie können die automatische Bereitstellung einstellen, sobald der Identitätsanbieter erstellt ist.", "licenseBadge": "EE", "idpType": "Anbietertyp", "idpTypeDescription": "Wählen Sie den Typ des Identitätsanbieters, den Sie konfigurieren möchten", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Standard-Rollenzuordnung", "defaultMappingsRoleDescription": "JMESPath zur Extraktion von Rolleninformationen aus dem ID-Token. Das Ergebnis dieses Ausdrucks muss den Rollennamen als String zurückgeben, wie er in der Organisation definiert ist.", "defaultMappingsOrg": "Standard-Organisationszuordnung", - "defaultMappingsOrgDescription": "JMESPath zur Extraktion von Organisationsinformationen aus dem ID-Token. Dieser Ausdruck muss die Organisations-ID oder true zurückgeben, damit der Benutzer Zugriff auf die Organisation erhält.", + "defaultMappingsOrgDescription": "Wenn diese Einstellung festgelegt ist, muss dieser Ausdruck die Organisations-ID oder wahr zurückgeben, damit der Benutzer diese Organisation betreten kann. Ist sie nicht festgelegt, reicht die Definition einer Rollenzuordnung aus: Der Benutzer darf eintreten, solange eine gültige Rollenzuordnung innerhalb der Organisation für ihn aufgelöst werden kann.", "defaultMappingsSubmit": "Standardzuordnungen speichern", "orgPoliciesEdit": "Organisationsrichtlinie bearbeiten", "org": "Organisation", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Logs anzeigen", "noneSelected": "Keine ausgewählt", "orgNotFound2": "Keine Organisationen gefunden.", + "search": "Suche…", "searchPlaceholder": "Suche...", "emptySearchOptions": "Keine Optionen gefunden", "create": "Erstellen", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Verwalten", "sidebarLogAndAnalytics": "Log & Analytik", "sidebarBluePrints": "Blaupausen", + "sidebarAlerting": "Benachrichtigung", + "sidebarHealthChecks": "Gesundheits-Checks", "sidebarOrganization": "Organisation", "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Abrechnung & Lizenzen", "sidebarLogsAnalytics": "Analytik", + "alertingTitle": "Benachrichtigung", + "alertingDescription": "Quellen, Auslöser und Aktionen für Benachrichtigungen festlegen", + "alertingRules": "Benachrichtigungsregeln", + "alertingSearchRules": "Suchregeln…", + "alertingAddRule": "Regel erstellen", + "alertingColumnSource": "Quelle", + "alertingColumnTrigger": "Auslöser", + "alertingColumnActions": "Aktionen", + "alertingColumnEnabled": "Aktiviert", + "alertingDeleteQuestion": "Bitte bestätigen Sie, dass Sie diese Benachrichtigungsregel löschen möchten.", + "alertingDeleteRule": "Benachrichtigungsregel löschen", + "alertingRuleDeleted": "Benachrichtigungsregel gelöscht", + "alertingRuleSaved": "Benachrichtigungsregel gespeichert", + "alertingRuleSavedCreatedDescription": "Ihre neue Benachrichtigungsregel wurde erstellt. Sie können sie auf dieser Seite weiter bearbeiten.", + "alertingRuleSavedUpdatedDescription": "Ihre Änderungen an dieser Benachrichtigungsregel wurden gespeichert.", + "alertingEditRule": "Benachrichtigungsregel bearbeiten", + "alertingCreateRule": "Benachrichtigungsregel erstellen", + "alertingRuleCredenzaDescription": "Wählen Sie aus, was beobachtet, wann ausgelöst und wie benachrichtigt werden soll", + "alertingRuleNamePlaceholder": "Produktionsseite ausgefallen", + "alertingRuleEnabled": "Regel aktiviert", + "alertingSectionSource": "Quelle", + "alertingSourceType": "Quellentyp", + "alertingSourceSite": "Standort", + "alertingSourceHealthCheck": "Gesundheits-Check", + "alertingPickSites": "Standorte", + "alertingPickHealthChecks": "Gesundheits-Checks", + "alertingPickResources": "Ressourcen", + "alertingAllSites": "Alle Standorte", + "alertingAllSitesDescription": "Benachrichtigung für jeden Standort", + "alertingSpecificSites": "Bestimmte Standorte", + "alertingSpecificSitesDescription": "Wählen Sie spezifische Standorte zur Beobachtung aus", + "alertingAllHealthChecks": "Alle Gesundheits-Checks", + "alertingAllHealthChecksDescription": "Benachrichtigung für jeden Gesundheits-Check", + "alertingSpecificHealthChecks": "Bestimmte Gesundheits-Checks", + "alertingSpecificHealthChecksDescription": "Wählen Sie spezifische Gesundheits-Checks zur Beobachtung aus", + "alertingAllResources": "Alle Ressourcen", + "alertingAllResourcesDescription": "Benachrichtigung für jede Ressource", + "alertingSpecificResources": "Spezifische Ressourcen", + "alertingSpecificResourcesDescription": "Wählen Sie spezifische Ressourcen zur Beobachtung aus", + "alertingSelectResources": "Ressourcen auswählen…", + "alertingResourcesSelected": "{count} Ressourcen ausgewählt", + "alertingResourcesEmpty": "Keine Ressourcen mit Zielen in den ersten 10 Ergebnissen.", + "alertingSectionTrigger": "Auslöser", + "alertingTrigger": "Wann benachrichtigen", + "alertingTriggerSiteOnline": "Seite online", + "alertingTriggerSiteOffline": "Seite offline", + "alertingTriggerSiteToggle": "Seitenstatus ändern", + "alertingTriggerHcHealthy": "Gesundheits-Check gesund", + "alertingTriggerHcUnhealthy": "Gesundheits-Check ungesund", + "alertingTriggerHcToggle": "Gesundheits-Check-Status ändern", + "alertingTriggerResourceHealthy": "Ressource gesund", + "alertingTriggerResourceUnhealthy": "Ressource ungesund", + "alertingTriggerResourceDegraded": "Ressource verschlechtert", + "alertingSearchHealthChecks": "Gesundheits-Checks suchen…", + "alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.", + "alertingTriggerResourceToggle": "Ressourcenstatus ändern", + "alertingSourceResource": "Ressource", + "alertingSectionActions": "Aktionen", + "alertingAddAction": "Aktion hinzufügen", + "alertingActionNotify": "E-Mail", + "alertingActionNotifyDescription": "Versenden Sie E-Mail-Benachrichtigungen an Benutzer oder Rollen", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Senden Sie eine HTTP-Anfrage an einen benutzerdefinierten Endpunkt", + "alertingExternalIntegration": "Externe Integration", + "alertingExternalPagerDutyDescription": "Senden Sie Benachrichtigungen an PagerDuty für Incident Management", + "alertingExternalOpsgenieDescription": "Leiten Sie Benachrichtigungen an Opsgenie für On-Call Management", + "alertingExternalServiceNowDescription": "Erstellen Sie ServiceNow-Incidents aus Benachrichtigungsereignissen", + "alertingExternalIncidentIoDescription": "Starten Sie Incident.io-Workflows aus Benachrichtigungsereignissen", + "alertingActionType": "Aktionstyp", + "alertingNotifyUsers": "Benutzer", + "alertingNotifyRoles": "Rollen", + "alertingNotifyEmails": "E-Mail-Adressen", + "alertingEmailPlaceholder": "E-Mail hinzufügen und Enter drücken", + "alertingWebhookMethod": "HTTP-Methode", + "alertingWebhookSecret": "Signatur geheim (optional)", + "alertingWebhookSecretPlaceholder": "HMAC-Geheimnis", + "alertingWebhookHeaders": "Header", + "alertingAddHeader": "Header hinzufügen", + "alertingSelectSites": "Standorte auswählen…", + "alertingSitesSelected": "{count} Standorte ausgewählt", + "alertingSelectHealthChecks": "Gesundheits-Checks auswählen…", + "alertingHealthChecksSelected": "{count} Gesundheits-Checks ausgewählt", + "alertingNoHealthChecks": "Keine Ziele mit aktivierten Gesundheits-Checks", + "alertingHealthCheckStub": "Gesundheits-Quellenauswahl ist noch nicht verdrahtet – Sie können trotzdem Auslöser und Aktionen konfigurieren.", + "alertingSelectUsers": "Benutzer auswählen…", + "alertingUsersSelected": "{count} Benutzer ausgewählt", + "alertingSelectRoles": "Rollen auswählen…", + "alertingRolesSelected": "{count} Rollen ausgewählt", + "alertingSummarySites": "Standorte ({count})", + "alertingSummaryAllSites": "Alle Standorte", + "alertingSummaryHealthChecks": "Gesundheits-Checks ({count})", + "alertingSummaryAllHealthChecks": "Alle Gesundheits-Checks", + "alertingSummaryResources": "Ressourcen ({count})", + "alertingSummaryAllResources": "Alle Ressourcen", + "alertingErrorNameRequired": "Einen Namen eingeben", + "alertingErrorActionsMin": "Mindestens eine Aktion hinzufügen", + "alertingErrorPickSites": "Wählen Sie mindestens einen Standort aus", + "alertingErrorPickHealthChecks": "Wählen Sie mindestens einen Gesundheits-Check aus", + "alertingErrorPickResources": "Wählen Sie mindestens eine Ressource aus", + "alertingErrorTriggerSite": "Wählen Sie einen Auslöser für den Standort", + "alertingErrorTriggerHealth": "Wählen Sie einen Auslöser für den Gesundheits-Check", + "alertingErrorTriggerResource": "Wählen Sie einen Auslöser für die Ressource", + "alertingErrorNotifyRecipients": "Wählen Sie Benutzer, Rollen oder mindestens eine E-Mail aus", + "alertingConfigureSource": "Quelle konfigurieren", + "alertingConfigureTrigger": "Auslöser konfigurieren", + "alertingConfigureActions": "Aktionen konfigurieren", + "alertingBackToRules": "Zurück zu den Regeln", + "alertingRuleCooldown": "Cooldown (Sekunden)", + "alertingRuleCooldownDescription": "Mindest-Zeit zwischen wiederholten Benachrichtigungen für dieselbe Regel. Auf 0 setzen, um jedes Mal auszulösen.", + "alertingDraftBadge": "Entwurf - speichern, um diese Regel zu sichern", + "alertingSidebarHint": "Klicken Sie auf einen Schritt auf der Leinwand, um ihn hier zu bearbeiten.", + "alertingGraphCanvasTitle": "Regelfluss", + "alertingGraphCanvasDescription": "Visuelle Übersicht über Quelle, Auslöser und Aktionen. Wählen Sie einen Knoten aus, um ihn im Panel zu bearbeiten.", + "alertingNodeNotConfigured": "Noch nicht konfiguriert", + "alertingNodeActionsCount": "{count, plural, one {# Aktion} other {# Aktionen}}", + "alertingNodeRoleSource": "Quelle", + "alertingNodeRoleTrigger": "Auslöser", + "alertingNodeRoleAction": "Aktion", + "alertingTabRules": "Benachrichtigungsregeln", + "alertingTabHealthChecks": "Gesundheits-Checks", + "alertingRulesBannerTitle": "Benachrichtigt werden", + "alertingRulesBannerDescription": "Jede Regel verknüpft, was beobachtet werden soll (eine Seite, ein Gesundheits-Check oder eine Ressource), wann es ausgelöst werden soll (zum Beispiel offline oder ungesund), und wie Ihr Team benachrichtigt wird, z. B. per E-Mail, Webhooks oder Integrationen. Verwenden Sie diese Liste, um diese Regeln zu erstellen, zu aktivieren und zu verwalten.", + "alertingHealthChecksBannerTitle": "Gesundheit & Ressourcen überwachen", + "alertingHealthChecksBannerDescription": "Gesundheits-Checks sind HTTP- oder TCP-Monitore, die Sie einmal definieren. Sie können sie dann als Quellen in Benachrichtigungsregeln verwenden, so dass Sie benachrichtigt werden, wenn ein Ziel gesund oder ungesund wird. Gesundheits-Checks für Ressourcen erscheinen ebenfalls hier.", + "standaloneHcTableTitle": "Gesundheits-Checks", + "standaloneHcSearchPlaceholder": "Gesundheits-Checks suchen…", + "standaloneHcAddButton": "Gesundheits-Check erstellen", + "standaloneHcCreateTitle": "Gesundheits-Check erstellen", + "standaloneHcEditTitle": "Gesundheits-Check bearbeiten", + "standaloneHcDescription": "Konfigurieren Sie einen HTTP- oder TCP-Gesundheits-Check zur Verwendung in Benachrichtigungsregeln.", + "standaloneHcNameLabel": "Name", + "standaloneHcNamePlaceholder": "Mein HTTP-Monitor", + "standaloneHcDeleteTitle": "Gesundheits-Check löschen", + "standaloneHcDeleteQuestion": "Bitte bestätigen Sie, dass Sie diesen Gesundheits-Check löschen möchten.", + "standaloneHcDeleted": "Gesundheits-Check gelöscht", + "standaloneHcSaved": "Gesundheits-Check gespeichert", + "standaloneHcColumnHealth": "Gesundheit", + "standaloneHcColumnMode": "Modus", + "standaloneHcColumnTarget": "Ziel", + "standaloneHcHealthStateHealthy": "Gesund", + "standaloneHcHealthStateUnhealthy": "Ungesund", + "standaloneHcHealthStateUnknown": "Unbekannt", + "standaloneHcFilterAnySite": "Alle Standorte", + "standaloneHcFilterAnyResource": "Alle Ressourcen", + "standaloneHcFilterMode": "Modus", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Gesundheit", + "standaloneHcFilterEnabled": "Aktiviert", + "standaloneHcFilterEnabledOn": "Aktiviert", + "standaloneHcFilterEnabledOff": "Deaktiviert", + "standaloneHcFilterSiteIdFallback": "Standort {id}", + "standaloneHcFilterResourceIdFallback": "Ressource {id}", "blueprints": "Blaupausen", "blueprintsDescription": "Deklarative Konfigurationen anwenden und vorherige Abläufe anzeigen", "blueprintAdd": "Blueprint hinzufügen", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Erstellen Sie das initiale Server-Admin-Konto. Es kann nur einen Server-Admin geben. Sie können diese Anmeldedaten später immer ändern.", "createAdminAccount": "Admin-Konto erstellen", "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.", - "certificateStatus": "Zertifikatsstatus", + "certificateStatus": "Zertifikat", + "certificateStatusAutoRefreshHint": "Der Status wird automatisch aktualisiert.", "loading": "Laden", "loadingAnalytics": "Analytik wird geladen", "restart": "Neustart", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Versionshinweise anzeigen", "newtUpdateAvailable": "Update verfügbar", "newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", + "pangolinNodeUpdateAvailableInfo": "Eine neue Version von Pangolin Node ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "domainPickerEnterDomain": "Domäne", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Geben Sie die vollständige Domain der Ressource ein, um verfügbare Optionen zu sehen.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Gesundheits-Check konfigurieren", "configureHealthCheckDescription": "Richten Sie die Gesundheitsüberwachung für {target} ein", "enableHealthChecks": "Gesundheits-Checks aktivieren", + "healthCheckDisabledStateDescription": "Wenn deaktiviert, führt die Seite keine Gesundheitsprüfungen durch und der Zustand wird als unbekannt betrachtet.", "enableHealthChecksDescription": "Überwachen Sie die Gesundheit dieses Ziels. Bei Bedarf können Sie einen anderen Endpunkt als das Ziel überwachen.", "healthScheme": "Methode", "healthSelectScheme": "Methode auswählen", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "Prüfintervall muss mindestens 5 Sekunden betragen", "healthCheckTimeoutMin": "Zeitüberschreitung muss mindestens 1 Sekunde betragen", "healthCheckRetryMin": "Wiederholungsversuche müssen mindestens 1 betragen", + "healthCheckMode": "Überprüfungsmodus", + "healthCheckStrategy": "Strategie", + "healthCheckModeDescription": "TCP-Modus überprüft nur die Konnektivität. HTTP-Modus validiert die HTTP-Antwort.", + "healthyThreshold": "Gesundheitsschwelle", + "healthyThresholdDescription": "Erforderliche aufeinanderfolgende Erfolge, bevor als gesund markiert wird.", + "unhealthyThreshold": "Ungesunde Schwelle", + "unhealthyThresholdDescription": "Erforderliche aufeinanderfolgende Fehlschläge, bevor als ungesund markiert wird.", + "healthCheckHealthyThresholdMin": "Gesundheitsschwelle muss mindestens 1 betragen", + "healthCheckUnhealthyThresholdMin": "Ungesunde Schwelle muss mindestens 1 betragen", "httpMethod": "HTTP-Methode", "selectHttpMethod": "HTTP-Methode auswählen", "domainPickerSubdomainLabel": "Subdomain", + "domainPickerWildcard": "Platzhalter", + "domainPickerWildcardPaidOnly": "Wildcard-Subdomains sind ein kostenpflichtiges Feature. Bitte upgraden Sie, um auf dieses Feature zuzugreifen.", "domainPickerBaseDomainLabel": "Basisdomain", "domainPickerSearchDomains": "Domains suchen...", "domainPickerNoDomainsFound": "Keine Domains gefunden", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Diese Adresse ist Teil des Utility-Subnetzes der Organisation. Sie wird verwendet, um Alias-Einträge mit interner DNS-Auflösung aufzulösen.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "und sind nur intern zugänglich, wenn mit einem Client verbunden.", - "resourcesTableNoTargets": "Keine Ziele", "resourcesTableHealthy": "Gesund", "resourcesTableDegraded": "Degradiert", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Ungesund", "resourcesTableUnknown": "Unbekannt", "resourcesTableNotMonitored": "Nicht überwacht", + "resourcesTableNoTargets": "Keine Ziele", "editInternalResourceDialogEditClientResource": "Private Ressource bearbeiten", "editInternalResourceDialogUpdateResourceProperties": "Ressourcen-Konfiguration und Zugriffssteuerung für {resourceName} aktualisieren", "editInternalResourceDialogResourceProperties": "Ressourceneigenschaften", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Schema", + "editInternalResourceDialogEnableSsl": "SSL aktivieren", + "editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.", "editInternalResourceDialogDestination": "Ziel", "editInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.", "editInternalResourceDialogDestinationIPDescription": "Die IP-Adresse oder Hostname Adresse der Ressource im Netzwerk der Website.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Standort", "selectSite": "Standort auswählen...", + "multiSitesSelectorSitesCount": "{count, plural, one {# Standort} other {# Standorte}}", "noSitesFound": "Keine Standorte gefunden.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Schema", + "createInternalResourceDialogScheme": "Schema", + "createInternalResourceDialogEnableSsl": "SSL aktivieren", + "createInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.", "createInternalResourceDialogDestination": "Ziel", "createInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.", "createInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", + "internalResourceDownstreamSchemeRequired": "Schema ist für HTTP-Ressourcen erforderlich", + "internalResourceHttpPortRequired": "Zielport ist für HTTP-Ressourcen erforderlich", "siteConfiguration": "Konfiguration", "siteAcceptClientConnections": "Clientverbindungen akzeptieren", "siteAcceptClientConnectionsDescription": "Erlaube Benutzer-Geräten und Clients Zugriff auf Ressourcen auf diesem Standort. Dies kann später geändert werden.", @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Internationale Domain erkannt", "willbestoredas": "Wird gespeichert als:", - "roleMappingDescription": "Legen Sie fest, wie den Benutzern Rollen zugewiesen werden, wenn sie sich anmelden, wenn Auto Provision aktiviert ist.", + "roleMappingDescription": "Bestimmen Sie, wie Rollen zugewiesen werden, wenn sich Benutzer mit diesem Identitätsanbieter anmelden.", "selectRole": "Wählen Sie eine Rolle", "roleMappingExpression": "Ausdruck", "selectRolePlaceholder": "Rolle auswählen", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Wählen Sie eine Domain für die Authentifizierungsseite der Organisation", "domainPickerProvidedDomain": "Angegebene Domain", "domainPickerFreeProvidedDomain": "Kostenlose Domain", + "domainPickerFreeDomainsPaidFeature": "Bereitgestellte Domains sind ein kostenpflichtiges Feature. Abonnieren Sie, um eine Domain in Ihrem Tarif zu erhalten – keine Notwendigkeit, Ihre eigene mitzubringen.", "domainPickerVerified": "Verifiziert", "domainPickerUnverified": "Nicht verifiziert", - "domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.", + "domainPickerManual": "Manuell", + "domainPickerInvalidSubdomainStructure": "Ungültige Zeichen werden beim Speichern bereinigt.", "domainPickerError": "Fehler", "domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domains", "domainPickerErrorCheckAvailability": "Fehler beim Prüfen der Domain-Verfügbarkeit", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Wähle deinen Identitätsanbieter um fortzufahren", "orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.", "orgAuthSignInWithPangolin": "Mit Pangolin anmelden", - "orgAuthSignInToOrg": "Bei einer Organisation anmelden", + "orgAuthSignInToOrg": "Organisations-Identitätsanbieter (SSO)", "orgAuthSelectOrgTitle": "Organisations-Anmeldung", "orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren", "orgAuthOrgIdPlaceholder": "Ihre Organisation", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Verwendungsanzeige", - "description": "Wählen Sie die Lizenz-Ebene, die Ihre beabsichtigte Nutzung genau widerspiegelt. Die Persönliche Lizenz erlaubt die freie Nutzung der Software für individuelle, nicht-kommerzielle oder kleine kommerzielle Aktivitäten mit jährlichen Brutto-Einnahmen von 100.000 USD. Über diese Grenzen hinausgehende Verwendungszwecke – einschließlich der Verwendung innerhalb eines Unternehmens, einer Organisation, oder eine andere umsatzgenerierende Umgebung — erfordert eine gültige Enterprise-Lizenz und die Zahlung der Lizenzgebühr. Alle Benutzer, ob Personal oder Enterprise, müssen die Fossorial Commercial License Bedingungen einhalten." + "description": "Wählen Sie die Lizenz-Ebene, die Ihre beabsichtigte Nutzung genau widerspiegelt. Die Persönliche Lizenz erlaubt die freie Nutzung der Software für individuelle, nicht-kommerzielle oder kleine kommerzielle Aktivitäten mit jährlichen Brutto-Einnahmen von 100.000 USD. Über diese Grenzen hinausgehende Verwendungszwecke – einschließlich der Verwendung innerhalb eines Unternehmens, einer Organisation, oder eine andere umsatzgenerierende Umgebung - erfordert eine gültige Enterprise-Lizenz und die Zahlung der Lizenzgebühr. Alle Benutzer, ob Personal oder Enterprise, müssen die Fossorial Commercial License Bedingungen einhalten." }, "trialPeriodInformation": { "title": "Testperiode Information", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Maßstab", - "description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung." + "description": "Unternehmensmerkmale, 50 Benutzer, 100 Standorte und prioritärer Support." } }, "personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz - kein Checkout)", @@ -2422,6 +2648,7 @@ "validPassword": "Gültiges Passwort", "validEmail": "Gültige E-Mail-Adresse", "validSSO": "Gültige SSO-Anmeldung", + "connectedClient": "Verbundenes Gerät", "resourceBlocked": "Ressource blockiert", "droppedByRule": "Abgelegt durch Regel", "noSessions": "Keine Sitzungen", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Clients hinzufügen", "editInternalResourceDialogDestinationLabel": "Ziel", "editInternalResourceDialogDestinationDescription": "Geben Sie die Zieladresse für die interne Ressource an. Dies kann ein Hostname, eine IP-Adresse oder ein CIDR-Bereich sein, abhängig vom gewählten Modus. Legen Sie optional einen internen DNS-Alias für eine vereinfachte Identifizierung fest.", + "internalResourceFormMultiSiteRoutingHelp": "Durch die Auswahl mehrerer Seiten wird ein ausfallsicheres Routing und Failover für hohe Verfügbarkeit ermöglicht.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Mehr erfahren", "editInternalResourceDialogPortRestrictionsDescription": "Den Zugriff auf bestimmte TCP/UDP-Ports beschränken oder alle Ports erlauben/blockieren.", + "createInternalResourceDialogHttpConfiguration": "HTTP-Konfiguration", + "createInternalResourceDialogHttpConfigurationDescription": "Wählen Sie die Domain, die Clients verwenden, um über HTTP oder HTTPS auf diese Ressource zuzugreifen.", + "editInternalResourceDialogHttpConfiguration": "HTTP-Konfiguration", + "editInternalResourceDialogHttpConfigurationDescription": "Wählen Sie die Domain, die Clients verwenden, um über HTTP oder HTTPS auf diese Ressource zuzugreifen.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Wir sind bald wieder da! Unsere Seite wird derzeit planmäßig gewartet.", "maintenancePageMessageDescription": "Detaillierte Meldung zur Erklärung der Wartung", "maintenancePageTimeTitle": "Geschätzte Abschlusszeit (Optional)", + "privateMaintenanceScreenTitle": "Privater Platzhalterschirm", + "privateMaintenanceScreenMessage": "Diese Domain wird auf einer privaten Ressource verwendet. Bitte verbinden Sie sich mit dem Pangolin-Client, um auf diese Ressource zuzugreifen.", + "privateMaintenanceScreenSteps": "Sobald verbunden, wenn Sie diese Nachricht weiterhin sehen, zeigt der DNS-Cache Ihres Browsers möglicherweise noch auf die alte Adresse. Um dies zu beheben: Schließen Sie diesen Tab vollständig und öffnen Sie ihn erneut oder starten Sie Ihren Browser neu und rufen Sie dann diese Seite erneut auf.", "maintenanceTime": "z.B.: 2 Stunden, Nov 1 um 17:00 Uhr", "maintenanceEstimatedTimeDescription": "Wann Sie den Abschluss der Wartung erwarten", "editDomain": "Domain bearbeiten", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "HTTP-Ziel hinzufügen", "httpDestEditDescription": "Aktualisiere die Konfiguration für dieses HTTP-Streaming-Ziel.", "httpDestAddDescription": "Konfigurieren Sie einen neuen HTTP-Endpunkt, um die Ereignisse Ihrer Organisation zu empfangen.", + "S3DestEditTitle": "Ziel bearbeiten", + "S3DestAddTitle": "S3-Ziel hinzufügen", + "S3DestEditDescription": "Konfiguration für dieses S3-Ereignis-Streamingziel aktualisieren.", + "S3DestAddDescription": "Neuen S3-Endpunkt konfigurieren, um die Ereignisse Ihrer Organisation zu erhalten.", + "datadogDestEditTitle": "Ziel bearbeiten", + "datadogDestAddTitle": "Datadog-Ziel hinzufügen", + "datadogDestEditDescription": "Konfiguration für dieses Datadog-Ereignis-Streamingziel aktualisieren.", + "datadogDestAddDescription": "Neuen Datadog-Endpunkt konfigurieren, um die Ereignisse Ihrer Organisation zu erhalten.", "httpDestTabSettings": "Einstellungen", "httpDestTabHeaders": "Kopfzeilen", "httpDestTabBody": "Körper", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "JSON Array", "httpDestFormatJsonArrayDescription": "Eine Anfrage pro Stapel ist ein JSON-Array. Kompatibel mit den meisten generischen Webhooks und Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Eine Anfrage pro Batch, der Körper ist newline-getrenntes JSON — ein Objekt pro Zeile, kein äußeres Array. Benötigt von Splunk HEC, Elastic / OpenSearch, und Grafana Loki.", + "httpDestFormatNdjsonDescription": "Eine Anfrage pro Batch, der Körper ist newline-getrenntes JSON - ein Objekt pro Zeile, kein äußeres Array. Benötigt von Splunk HEC, Elastic / OpenSearch, und Grafana Loki.", "httpDestFormatSingleTitle": "Ein Ereignis pro Anfrage", "httpDestFormatSingleDescription": "Sendet eine separate HTTP-POST für jedes einzelne Ereignis. Nur für Endpunkte, die Batches nicht handhaben können.", "httpDestLogTypesTitle": "Log-Typen", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Ziel erfolgreich aktualisiert", "httpDestCreatedSuccess": "Ziel erfolgreich erstellt", "httpDestUpdateFailed": "Fehler beim Aktualisieren des Ziels", - "httpDestCreateFailed": "Fehler beim Erstellen des Ziels" + "httpDestCreateFailed": "Fehler beim Erstellen des Ziels", + "followRedirects": "Weiterleitungen folgen", + "followRedirectsDescription": "HTTP-Weiterleitungen für Anfragen automatisch folgen.", + "alertingErrorWebhookUrl": "Bitte geben Sie eine gültige URL für das Webhook ein.", + "healthCheckStrategyHttp": "Prüft die Konnektivität und den HTTP-Antwort-Status.", + "healthCheckStrategyTcp": "Verifiziert nur die TCP-Konnektivität, ohne die Antwort zu überprüfen.", + "healthCheckStrategySnmp": "Stellt eine SNMP-Get-Anfrage, um die Gesundheit von Netzwerkgeräten und Infrastruktur zu überprüfen.", + "healthCheckStrategyIcmp": "Verwendet ICMP-Echo-Anfragen (Pings), um zu überprüfen, ob eine Ressource erreichbar und reaktionsfähig ist.", + "healthCheckTabStrategy": "Strategie", + "healthCheckTabConnection": "Verbindung", + "healthCheckTabAdvanced": "Fortgeschritten", + "healthCheckStrategyNotAvailable": "Diese Strategie ist nicht verfügbar. Bitte kontaktieren Sie den Vertrieb, um diese Funktion zu aktivieren.", + "uptime30d": "Betriebszeit (30 Tage)", + "idpAddActionCreateNew": "Neuen Identitätsanbieter erstellen", + "idpAddActionImportFromOrg": "Von einer anderen Organisation importieren", + "idpImportDialogTitle": "Identitätsanbieter importieren", + "idpImportDialogDescription": "Wählen Sie einen Identitätsanbieter aus einer Organisation, in der Sie Administrator sind. Er wird mit dieser Organisation verknüpft.", + "idpImportSearchPlaceholder": "Nach Organisation oder Anbieternamen suchen...", + "idpImportEmpty": "Keine Identitätsanbieter gefunden.", + "idpImportedDescription": "Identitätsanbieter erfolgreich importiert.", + "idpDeleteGlobalQuestion": "Sind Sie sicher, dass Sie diesen Identitätsanbieter dauerhaft löschen möchten?", + "idpDeleteGlobalDescription": "Dies wird den Identitätsanbieter dauerhaft von allen Organisationen löschen, mit denen er verbunden ist.", + "idpUnassociateTitle": "Verknüpfung mit Identitätsanbieter aufheben", + "idpUnassociateQuestion": "Sind Sie sicher, dass Sie die Verknüpfung dieses Identitätsanbieters mit dieser Organisation aufheben möchten?", + "idpUnassociateDescription": "Alle Benutzer, die mit diesem Identitätsanbieter verbunden sind, werden aus dieser Organisation entfernt, aber der Identitätsanbieter bleibt für andere verbundene Organisationen weiterhin bestehen.", + "idpUnassociateConfirm": "Verknüpfung des Identitätsanbieters aufheben bestätigen", + "idpUnassociateWarning": "Dies kann für diese Organisation nicht rückgängig gemacht werden.", + "idpUnassociatedDescription": "Identitätsanbieter erfolgreich von dieser Organisation gelöst", + "idpUnassociateMenu": "Verknüpfung aufheben", + "idpDeleteAllOrgsMenu": "Löschen", + "publicIpEndpoint": "Endpunkt", + "lastTriggeredAt": "Letzter Auslöser", + "reject": "Zurückweisen", + "uptimeDaysAgo": "vor {count} Tagen", + "uptimeToday": "Heute", + "uptimeNoDataAvailable": "Keine Daten verfügbar", + "uptimeSuffix": "Betriebzeit", + "uptimeDowntimeSuffix": "Ausfallzeit", + "uptimeTooltipUptimeLabel": "Betriebszeit", + "uptimeTooltipDowntimeLabel": "Ausfallzeit", + "uptimeOngoing": "im Gange", + "uptimeNoMonitoringData": "Keine Überwachungsdaten", + "uptimeNoData": "Keine Daten", + "uptimeMiniBarDown": "Unten", + "uptimeSectionTitle": "Betriebszeit", + "uptimeSectionDescription": "Verfügbarkeit in den letzten {days} Tagen", + "uptimeAddAlert": "Warnmeldung hinzufügen", + "uptimeViewAlerts": "Warnungen anzeigen", + "uptimeCreateEmailAlert": "E-Mail Alarm erstellen", + "uptimeAlertDescriptionSite": "Werde per E-Mail benachrichtigt, wenn diese Seite offline oder wieder online ist.", + "uptimeAlertDescriptionResource": "Werde per E-Mail benachrichtigt, wenn diese Ressource offline oder wieder online ist.", + "uptimeAlertNamePlaceholder": "Alarmname", + "uptimeAdditionalEmails": "Zusätzliche E-Mails", + "uptimeCreateAlert": "Alarm erstellen", + "uptimeAlertNoRecipients": "Kein Empfänger", + "uptimeAlertNoRecipientsDescription": "Bitte fügen Sie mindestens einen Benutzer, eine Rolle oder eine E-Mail zur Benachrichtigung hinzu.", + "uptimeAlertCreated": "Alarm erstellt", + "uptimeAlertCreatedDescription": "Sie werden benachrichtigt, wenn dieser Status sich ändert", + "uptimeAlertCreateFailed": "Fehler beim Erstellen der Benachrichtigung", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Schlüssel", + "webhookHeaderValuePlaceholder": "Wert", + "alertLabel": "Alarm", + "domainPickerWildcardSubdomainNotAllowed": "Wildcard-Subdomains sind nicht erlaubt.", + "domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.", + "domainPickerWildcardCertWarningLink": "Mehr erfahren", + "health": "Gesundheit", + "domainPendingErrorTitle": "Verifizierungsproblem" } diff --git a/messages/en-US.json b/messages/en-US.json index 5c86aabec..a7b045480 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contact sales to enable this feature.", + "contactSalesBookDemo": "Book a demo", + "contactSalesOr": "or", + "contactSalesContactUs": "contact us", "setupCreate": "Create the organization, site, and resources", "headerAuthCompatibilityInfo": "Enable this to force a 401 Unauthorized response when an authentication token is missing. This is required for browsers or specific HTTP libraries that do not send credentials without a server challenge.", "headerAuthCompatibility": "Extended compatibility", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", "dismiss": "Dismiss", "subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.", + "trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.", + "trialBannerExpired": "Your trial has expired. Upgrade now to restore access.", + "trialActive": "Free Trial Active", + "trialExpired": "Trial Expired", + "trialHasEnded": "Your trial has ended.", + "trialDaysRemaining": "{count, plural, one {# day remaining} other {# days remaining}}", + "trialDaysLeftShort": "{days}d left in trial", + "trialGoToBilling": "Go to billing page", "subscriptionViolationViewBilling": "View billing", "componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.", "componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "I have copied the config", "searchSitesProgress": "Search sites...", "siteAdd": "Add Site", + "sitesTableViewPublicResources": "View Public Resources", + "sitesTableViewPrivateResources": "View Private Resources", "siteInstallNewt": "Install Site", "siteInstallNewtDescription": "Install the site connector for your system", "WgConfiguration": "WireGuard Configuration", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "The site has been updated.", "siteGeneralDescription": "Configure the general settings for this site", "siteSettingDescription": "Configure the settings on the site", + "siteResourcesTab": "Resources", + "siteResourcesNoneOnSite": "This site has no public or private resources yet.", + "siteResourcesSectionPublic": "Public Resources", + "siteResourcesSectionPrivate": "Private Resources", + "siteResourcesSectionPublicDescription": "Resources exposed externally through domains or ports.", + "siteResourcesSectionPrivateDescription": "Resources available on your private network through the site.", + "siteResourcesViewAllPublic": "View all resources", + "siteResourcesViewAllPrivate": "View all resources", + "siteResourcesDialogDescription": "Overview of public and private resources associated with this site.", + "siteResourcesShowMore": "Show more", + "siteResourcesPermissionDenied": "You do not have permission to list these resources.", + "siteResourcesEmptyPublic": "No public resources target this site yet.", + "siteResourcesEmptyPrivate": "No private resources are associated with this site yet.", + "siteResourcesHowToAccess": "How to access", + "siteResourcesTargetsOnSite": "Targets on this site", "siteSetting": "{siteName} Settings", "siteNewtTunnel": "Newt Site (Recommended)", "siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.", @@ -163,7 +192,7 @@ "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "proxyResourcesBannerTitle": "Web-based Public Access", - "proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", + "proxyResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", @@ -267,8 +296,11 @@ "orgMissing": "Organization ID Missing", "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", "accessUsersManage": "Manage Users", + "accessUserManage": "Manage User", "accessUsersDescription": "Invite and manage users with access to this organization", "accessUsersSearch": "Search users...", + "accessUsersRoleFilterCount": "{count, plural, one {# role} other {# roles}}", + "accessUsersRoleFilterClear": "Clear role filters", "accessUserCreate": "Create User", "accessUserRemove": "Remove User", "username": "Username", @@ -380,7 +412,7 @@ "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", "userAbount": "About User Management", - "userAbountDescription": "This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.", + "userAbountDescription": "This table displays all base user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their base user object. They will remain in the system. To completely remove a user from the system, you must delete their base user object using the delete action in this table.", "userServer": "Server Users", "userSearch": "Search server users...", "userErrorDelete": "Error deleting user", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Failed to activate license key", "licenseErrorKeyActivateDescription": "An error occurred while activating the license key.", "licenseAbout": "About Licensing", + "licenseBannerTitle": "Enable Your Enterprise License", + "licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.", + "licenseBannerGetLicense": "Get a License", + "licenseBannerViewDocs": "View Documentation", "communityEdition": "Community Edition", "licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.", "licenseKeyActivated": "License key activated", @@ -519,7 +555,7 @@ "userSettings": "User Information", "userSettingsDescription": "Enter the details for the new user", "inviteEmailSent": "Send invite email to user", - "inviteValid": "Valid For", + "inviteValid": "Invite Valid For (days)", "selectDuration": "Select duration", "selectResource": "Select Resource", "filterByResource": "Filter By Resource", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secret", + "newtVersion": "Version", "architecture": "Architecture", "sites": "Sites", "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.", @@ -894,6 +931,7 @@ "idpDisplayName": "A display name for this identity provider", "idpAutoProvisionUsers": "Auto Provision Users", "idpAutoProvisionUsersDescription": "When enabled, users will be automatically created in the system upon first login with the ability to map users to roles and organizations.", + "idpAutoProvisionConfigureAfterCreate": "You can configure auto provision settings once the identity provider is created.", "licenseBadge": "EE", "idpType": "Provider Type", "idpTypeDescription": "Select the type of identity provider you want to configure", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Default Role Mapping", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Default Organization Mapping", - "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining an organization policy for that org is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", + "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining a role mapping is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", "defaultMappingsSubmit": "Save Default Mappings", "orgPoliciesEdit": "Edit Organization Policy", "org": "Organization", @@ -1252,6 +1290,7 @@ "actionViewLogs": "View Logs", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", + "search": "Search…", "searchPlaceholder": "Search...", "emptySearchOptions": "No options found", "create": "Create", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Manage", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", + "sidebarAlerting": "Alerting", + "sidebarHealthChecks": "Health checks", "sidebarOrganization": "Organization", "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", + "alertingTitle": "Alerting", + "alertingDescription": "Define sources, triggers, and actions for notifications", + "alertingRules": "Alert rules", + "alertingSearchRules": "Search rules…", + "alertingAddRule": "Create Rule", + "alertingColumnSource": "Source", + "alertingColumnTrigger": "Trigger", + "alertingColumnActions": "Actions", + "alertingColumnEnabled": "Enabled", + "alertingDeleteQuestion": "Please confirm you want to delete this alert rule.", + "alertingDeleteRule": "Delete alert rule", + "alertingRuleDeleted": "Alert rule deleted", + "alertingRuleSaved": "Alert rule saved", + "alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.", + "alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.", + "alertingEditRule": "Edit Alert Rule", + "alertingCreateRule": "Create Alert Rule", + "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify", + "alertingRuleNamePlaceholder": "Production site down", + "alertingRuleEnabled": "Rule enabled", + "alertingSectionSource": "Source", + "alertingSourceType": "Source type", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Health check", + "alertingPickSites": "Sites", + "alertingPickHealthChecks": "Health checks", + "alertingPickResources": "Resources", + "alertingAllSites": "All Sites", + "alertingAllSitesDescription": "Alert fires for any site", + "alertingSpecificSites": "Specific Sites", + "alertingSpecificSitesDescription": "Choose specific sites to watch", + "alertingAllHealthChecks": "All Health Checks", + "alertingAllHealthChecksDescription": "Alert fires for any health check", + "alertingSpecificHealthChecks": "Specific Health Checks", + "alertingSpecificHealthChecksDescription": "Choose specific health checks to watch", + "alertingAllResources": "All Resources", + "alertingAllResourcesDescription": "Alert fires for any resource", + "alertingSpecificResources": "Specific Resources", + "alertingSpecificResourcesDescription": "Choose specific resources to watch", + "alertingSelectResources": "Select resources…", + "alertingResourcesSelected": "{count} resources selected", + "alertingResourcesEmpty": "No resources with targets in the first 10 results.", + "alertingSectionTrigger": "Trigger", + "alertingTrigger": "When to alert", + "alertingTriggerSiteOnline": "Site online", + "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerSiteToggle": "Site status changes", + "alertingTriggerHcHealthy": "Health check healthy", + "alertingTriggerHcUnhealthy": "Health check unhealthy", + "alertingTriggerHcToggle": "Health check status changes", + "alertingTriggerResourceHealthy": "Resource healthy", + "alertingTriggerResourceUnhealthy": "Resource unhealthy", + "alertingTriggerResourceDegraded": "Resource degraded", + "alertingSearchHealthChecks": "Search health checks…", + "alertingHealthChecksEmpty": "No health checks available.", + "alertingTriggerResourceToggle": "Resource status changes", + "alertingSourceResource": "Resource", + "alertingSectionActions": "Actions", + "alertingAddAction": "Add Action", + "alertingActionNotify": "Email", + "alertingActionNotifyDescription": "Send email notifications to users or roles", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Send an HTTP request to a custom endpoint", + "alertingExternalIntegration": "External Integration", + "alertingExternalPagerDutyDescription": "Send alerts to PagerDuty for incident management", + "alertingExternalOpsgenieDescription": "Route alerts to Opsgenie for on-call management", + "alertingExternalServiceNowDescription": "Create ServiceNow incidents from alert events", + "alertingExternalIncidentIoDescription": "Trigger Incident.io workflows from alert events", + "alertingActionType": "Action type", + "alertingNotifyUsers": "Users", + "alertingNotifyRoles": "Roles", + "alertingNotifyEmails": "Email addresses", + "alertingEmailPlaceholder": "Add email and press Enter", + "alertingWebhookMethod": "HTTP method", + "alertingWebhookSecret": "Signing secret (optional)", + "alertingWebhookSecretPlaceholder": "HMAC secret", + "alertingWebhookHeaders": "Headers", + "alertingAddHeader": "Add header", + "alertingSelectSites": "Select sites…", + "alertingSitesSelected": "{count} sites selected", + "alertingSelectHealthChecks": "Select health checks…", + "alertingHealthChecksSelected": "{count} health checks selected", + "alertingNoHealthChecks": "No targets with health checks enabled", + "alertingHealthCheckStub": "Health check source selection is not wired up yet - you can still configure triggers and actions.", + "alertingSelectUsers": "Select users…", + "alertingUsersSelected": "{count} users selected", + "alertingSelectRoles": "Select roles…", + "alertingRolesSelected": "{count} roles selected", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryAllSites": "All sites", + "alertingSummaryHealthChecks": "Health checks ({count})", + "alertingSummaryAllHealthChecks": "All health checks", + "alertingSummaryResources": "Resources ({count})", + "alertingSummaryAllResources": "All resources", + "alertingErrorNameRequired": "Enter a name", + "alertingErrorActionsMin": "Add at least one action", + "alertingErrorPickSites": "Select at least one site", + "alertingErrorPickHealthChecks": "Select at least one health check", + "alertingErrorPickResources": "Select at least one resource", + "alertingErrorTriggerSite": "Choose a site trigger", + "alertingErrorTriggerHealth": "Choose a health check trigger", + "alertingErrorTriggerResource": "Choose a resource trigger", + "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", + "alertingConfigureSource": "Configure Source", + "alertingConfigureTrigger": "Configure Trigger", + "alertingConfigureActions": "Configure Actions", + "alertingBackToRules": "Back to Rules", + "alertingRuleCooldown": "Cooldown (seconds)", + "alertingRuleCooldownDescription": "Minimum time between repeated alerts for the same rule. Set to 0 to fire every time.", + "alertingDraftBadge": "Draft - save to store this rule", + "alertingSidebarHint": "Click a step on the canvas to edit it here.", + "alertingGraphCanvasTitle": "Rule Flow", + "alertingGraphCanvasDescription": "Visual overview of source, trigger, and actions. Select a node to edit it in the panel.", + "alertingNodeNotConfigured": "Not configured yet", + "alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}", + "alertingNodeRoleSource": "Source", + "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleAction": "Action", + "alertingTabRules": "Alert Rules", + "alertingTabHealthChecks": "Health Checks", + "alertingRulesBannerTitle": "Get Notified", + "alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.", + "alertingHealthChecksBannerTitle": "Monitor Health & Resources", + "alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.", + "standaloneHcTableTitle": "Health Checks", + "standaloneHcSearchPlaceholder": "Search health checks…", + "standaloneHcAddButton": "Create Health Check", + "standaloneHcCreateTitle": "Create Health Check", + "standaloneHcEditTitle": "Edit Health Check", + "standaloneHcDescription": "Configure a HTTP or TCP health check for use in alert rules.", + "standaloneHcNameLabel": "Name", + "standaloneHcNamePlaceholder": "My HTTP Monitor", + "standaloneHcDeleteTitle": "Delete health check", + "standaloneHcDeleteQuestion": "Please confirm you want to delete this health check.", + "standaloneHcDeleted": "Health check deleted", + "standaloneHcSaved": "Health check saved", + "standaloneHcColumnHealth": "Health", + "standaloneHcColumnMode": "Mode", + "standaloneHcColumnTarget": "Target", + "standaloneHcHealthStateHealthy": "Healthy", + "standaloneHcHealthStateUnhealthy": "Unhealthy", + "standaloneHcHealthStateUnknown": "Unknown", + "standaloneHcFilterAnySite": "All sites", + "standaloneHcFilterAnyResource": "All resources", + "standaloneHcFilterMode": "Mode", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Health", + "standaloneHcFilterEnabled": "Enabled", + "standaloneHcFilterEnabledOn": "Enabled", + "standaloneHcFilterEnabledOff": "Disabled", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Resource {id}", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", "createAdminAccount": "Create Admin Account", "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", - "certificateStatus": "Certificate Status", + "certificateStatus": "Certificate", + "certificateStatusAutoRefreshHint": "Status refreshes automatically.", "loading": "Loading", "loadingAnalytics": "Loading Analytics", "restart": "Restart", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "View Release Notes", "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", + "pangolinNodeUpdateAvailableInfo": "A new version of Pangolin Node is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Configure Health Check", "configureHealthCheckDescription": "Set up health monitoring for {target}", "enableHealthChecks": "Enable Health Checks", + "healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.", "enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.", "healthScheme": "Method", "healthSelectScheme": "Select Method", @@ -1745,8 +1944,8 @@ "retryAttempts": "Retry Attempts", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", - "customHeaders": "Custom Headers", - "customHeadersDescription": "Headers new line separated: Header-Name: value", + "customHeaders": "Custom Request Headers", + "customHeadersDescription": "Request headers sent to the downstream targets. Headers new line separated: Header-Name: value", "headersValidationError": "Headers must be in the format: Header-Name: value", "saveHealthCheck": "Save Health Check", "healthCheckSaved": "Health Check Saved", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "Check interval must be at least 5 seconds", "healthCheckTimeoutMin": "Timeout must be at least 1 second", "healthCheckRetryMin": "Retry attempts must be at least 1", - "httpMethod": "HTTP Method", - "selectHttpMethod": "Select HTTP method", + "healthCheckMode": "Check Mode", + "healthCheckStrategy": "Strategy", + "healthCheckModeDescription": "TCP mode verifies connectivity only. HTTP mode validates the HTTP response.", + "healthyThreshold": "Healthy Threshold", + "healthyThresholdDescription": "Consecutive successes required before marking as healthy.", + "unhealthyThreshold": "Unhealthy Threshold", + "unhealthyThresholdDescription": "Consecutive failures required before marking as unhealthy.", + "healthCheckHealthyThresholdMin": "Healthy threshold must be at least 1", + "healthCheckUnhealthyThresholdMin": "Unhealthy threshold must be at least 1", + "httpMethod": "Scheme", + "selectHttpMethod": "Select scheme", "domainPickerSubdomainLabel": "Subdomain", + "domainPickerWildcard": "Wildcard", + "domainPickerWildcardPaidOnly": "Wildcard subdomains are a paid feature. Please upgrade to access this feature.", "domainPickerBaseDomainLabel": "Base Domain", "domainPickerSearchDomains": "Search domains...", "domainPickerNoDomainsFound": "No domains found", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "This address is part of the organization's utility subnet. It's used to resolve alias records using internal DNS resolution.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.", - "resourcesTableNoTargets": "No targets", "resourcesTableHealthy": "Healthy", "resourcesTableDegraded": "Degraded", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Unhealthy", "resourcesTableUnknown": "Unknown", "resourcesTableNotMonitored": "Not monitored", + "resourcesTableNoTargets": "No targets", "editInternalResourceDialogEditClientResource": "Edit Private Resource", "editInternalResourceDialogUpdateResourceProperties": "Update the resource configuration and access controls for {resourceName}", "editInternalResourceDialogResourceProperties": "Resource Properties", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Scheme", + "editInternalResourceDialogEnableSsl": "Enable SSL", + "editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.", "editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Site", "selectSite": "Select site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "No sites found.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Scheme", + "createInternalResourceDialogScheme": "Scheme", + "createInternalResourceDialogEnableSsl": "Enable SSL", + "createInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.", "createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", + "internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources", + "internalResourceHttpPortRequired": "Destination port is required for HTTP resources", "siteConfiguration": "Configuration", "siteAcceptClientConnections": "Accept Client Connections", "siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.", @@ -1989,7 +2213,7 @@ "description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles", "introTitle": "Managed Self-Hosted Pangolin", "introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.", - "introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL 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": "With this option, you still run your own Pangolin node - your tunnels, SSL 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:", "benefitSimplerOperations": { "title": "Simpler operations", "description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box." @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "International Domain Detected", "willbestoredas": "Will be stored as:", - "roleMappingDescription": "Determine how roles are assigned to users when they sign in when Auto Provision is enabled.", + "roleMappingDescription": "Determine how roles are assigned to users when they sign in with this identity provider.", "selectRole": "Select a Role", "roleMappingExpression": "Expression", "selectRolePlaceholder": "Choose a role", @@ -2114,11 +2338,11 @@ "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page", "domainPickerProvidedDomain": "Provided Domain", "domainPickerFreeProvidedDomain": "Provided Domain", - "domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.", + "domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan - no need to bring your own.", "domainPickerVerified": "Verified", "domainPickerUnverified": "Unverified", "domainPickerManual": "Manual", - "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", + "domainPickerInvalidSubdomainStructure": "Invalid characters will be sanitized when saved.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Failed to load organization domains", "domainPickerErrorCheckAvailability": "Failed to check domain availability", @@ -2131,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Choose your identity provider to continue", "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", "orgAuthSignInWithPangolin": "Sign in with Pangolin", - "orgAuthSignInToOrg": "Sign in to an organization", + "orgAuthSignInToOrg": "Organization Identity Provider (SSO)", "orgAuthSelectOrgTitle": "Organization Sign In", "orgAuthSelectOrgDescription": "Enter your organization ID to continue", "orgAuthOrgIdPlaceholder": "your-organization", @@ -2292,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Usage Disclosure", - "description": "Select the license tier that accurately reflects your intended use. The Personal License permits free use of the Software for individual, non-commercial or small-scale commercial activities with annual gross revenue under $100,000 USD. Any use beyond these limits — including use within a business, organization, or other revenue-generating environment — requires a valid Enterprise License and payment of the applicable licensing fee. All users, whether Personal or Enterprise, must comply with the Fossorial Commercial License Terms." + "description": "Select the license tier that accurately reflects your intended use. The Personal License permits free use of the Software for individual, non-commercial or small-scale commercial activities with annual gross revenue under $100,000 USD. Any use beyond these limits - including use within a business, organization, or other revenue-generating environment - requires a valid Enterprise License and payment of the applicable licensing fee. All users, whether Personal or Enterprise, must comply with the Fossorial Commercial License Terms." }, "trialPeriodInformation": { "title": "Trial Period Information", @@ -2347,7 +2571,7 @@ }, "scale": { "title": "Scale", - "description": "Enterprise features, 50 users, 50 sites, and priority support." + "description": "Enterprise features, 50 users, 100 sites, and priority support." } }, "personalUseOnly": "Personal use only (free license - no checkout)", @@ -2411,7 +2635,7 @@ "action": "Action", "actor": "Actor", "timestamp": "Timestamp", - "accessLogs": "Access Logs", + "accessLogs": "Authentication Logs", "exportCsv": "Export CSV", "exportError": "Unknown error when exporting CSV", "exportCsvTooltip": "Within Time Range", @@ -2424,6 +2648,7 @@ "validPassword": "Valid Password", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Connected Client", "resourceBlocked": "Resource Blocked", "droppedByRule": "Dropped by Rule", "noSessions": "No Sessions", @@ -2431,25 +2656,25 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Reason", - "requestLogs": "Request Logs", + "requestLogs": "HTTPS Request Logs", "requestAnalytics": "Request Analytics", "host": "Host", "location": "Location", - "actionLogs": "Action Logs", - "sidebarLogsRequest": "Request Logs", - "sidebarLogsAccess": "Access Logs", - "sidebarLogsAction": "Action Logs", + "actionLogs": "Admin Action Logs", + "sidebarLogsRequest": "HTTPS Request Logs", + "sidebarLogsAccess": "Authentication Logs", + "sidebarLogsAction": "Admin Action Logs", "logRetention": "Log Retention", "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", - "requestLogsDescription": "View detailed request logs for resources in this organization", + "requestLogsDescription": "View detailed request logs for HTTPS resources in this organization", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization", - "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestLabel": "HTTPS Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", - "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessLabel": "Authentication Log Retention", "logRetentionAccessDescription": "How long to retain access logs", - "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionLabel": "Admin Action Log Retention", "logRetentionActionDescription": "How long to retain action logs", - "logRetentionConnectionLabel": "Connection Log Retention", + "logRetentionConnectionLabel": "Network Log Retention", "logRetentionConnectionDescription": "How long to retain connection logs", "logRetentionDisabled": "Disabled", "logRetention3Days": "3 days", @@ -2461,10 +2686,10 @@ "logRetentionEndOfFollowingYear": "End of following year", "actionLogsDescription": "View a history of actions performed in this organization", "accessLogsDescription": "View access auth requests for resources in this organization", - "connectionLogs": "Connection Logs", - "connectionLogsDescription": "View connection logs for tunnels in this organization", - "sidebarLogsConnection": "Connection Logs", - "sidebarLogsStreaming": "Streaming", + "connectionLogs": "Network Logs", + "connectionLogsDescription": "View network session logs handled by sites in this organization", + "sidebarLogsConnection": "Network Logs", + "sidebarLogsStreaming": "Event Streaming", "sourceAddress": "Source Address", "destinationAddress": "Destination Address", "duration": "Duration", @@ -2661,8 +2886,14 @@ "editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogDestinationLabel": "Destination", - "editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", + "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.", + "internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", + "createInternalResourceDialogHttpConfiguration": "HTTP configuration", + "createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", + "editInternalResourceDialogHttpConfiguration": "HTTP configuration", + "editInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2701,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.", "maintenancePageMessageDescription": "Detailed message explaining the maintenance", "maintenancePageTimeTitle": "Estimated Completion Time (Optional)", + "privateMaintenanceScreenTitle": "Private Placeholder Screen", + "privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.", + "privateMaintenanceScreenSteps": "Once connected, if you are still seeing this message your browser's DNS cache may still point to the old address. To fix this: fully close and reopen this tab, or your browser, then navigate back to this page.", "maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM", "maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed", "editDomain": "Edit Domain", @@ -2820,9 +3054,9 @@ "streamingHttpWebhookTitle": "HTTP Webhook", "streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.", "streamingS3Title": "Amazon S3", - "streamingS3Description": "Stream events to an S3-compatible object storage bucket. Coming soon.", + "streamingS3Description": "Stream events to an S3-compatible object storage bucket.", "streamingDatadogTitle": "Datadog", - "streamingDatadogDescription": "Forward events directly to your Datadog account. Coming soon.", + "streamingDatadogDescription": "Forward events directly to your Datadog account.", "streamingTypePickerDescription": "Choose a destination type to get started.", "streamingFailedToLoad": "Failed to load destinations", "streamingUnexpectedError": "An unexpected error occurred.", @@ -2838,6 +3072,14 @@ "httpDestAddTitle": "Add HTTP Destination", "httpDestEditDescription": "Update the configuration for this HTTP event streaming destination.", "httpDestAddDescription": "Configure a new HTTP endpoint to receive your organization's events.", + "S3DestEditTitle": "Edit Destination", + "S3DestAddTitle": "Add S3 Destination", + "S3DestEditDescription": "Update the configuration for this S3 event streaming destination.", + "S3DestAddDescription": "Configure a new S3 endpoint to receive your organization's events.", + "datadogDestEditTitle": "Edit Destination", + "datadogDestAddTitle": "Add Datadog Destination", + "datadogDestEditDescription": "Update the configuration for this Datadog event streaming destination.", + "datadogDestAddDescription": "Configure a new Datadog endpoint to receive your organization's events.", "httpDestTabSettings": "Settings", "httpDestTabHeaders": "Headers", "httpDestTabBody": "Body", @@ -2845,7 +3087,7 @@ "httpDestNamePlaceholder": "My HTTP destination", "httpDestUrlLabel": "Destination URL", "httpDestUrlErrorHttpRequired": "URL must use http or https", - "httpDestUrlErrorHttpsRequired": "HTTPS is required on cloud deployments", + "httpDestUrlErrorHttpsRequired": "HTTPS is required", "httpDestUrlErrorInvalid": "Enter a valid URL (e.g. https://example.com/webhook)", "httpDestAuthTitle": "Authentication", "httpDestAuthDescription": "Choose how requests to your endpoint are authenticated.", @@ -2877,23 +3119,90 @@ "httpDestFormatJsonArrayTitle": "JSON Array", "httpDestFormatJsonArrayDescription": "One request per batch, body is a JSON array. Compatible with most generic webhooks and Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "One request per batch, body is newline-delimited JSON — one object per line, no outer array. Required by Splunk HEC, Elastic / OpenSearch, and Grafana Loki.", + "httpDestFormatNdjsonDescription": "One request per batch, body is newline-delimited JSON - one object per line, no outer array. Required by Splunk HEC, Elastic / OpenSearch, and Grafana Loki.", "httpDestFormatSingleTitle": "One Event Per Request", "httpDestFormatSingleDescription": "Sends a separate HTTP POST for each individual event. Use only for endpoints that cannot handle batches.", "httpDestLogTypesTitle": "Log Types", "httpDestLogTypesDescription": "Choose which log types are forwarded to this destination. Only enabled log types will be streamed.", - "httpDestAccessLogsTitle": "Access Logs", + "httpDestAccessLogsTitle": "Authentication Logs", "httpDestAccessLogsDescription": "Resource access attempts, including authenticated and denied requests.", - "httpDestActionLogsTitle": "Action Logs", + "httpDestActionLogsTitle": "Admin Action Logs", "httpDestActionLogsDescription": "Administrative actions performed by users within the organization.", - "httpDestConnectionLogsTitle": "Connection Logs", + "httpDestConnectionLogsTitle": "Network Logs", "httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.", - "httpDestRequestLogsTitle": "Request Logs", + "httpDestRequestLogsTitle": "HTTPS Request Logs", "httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.", "httpDestSaveChanges": "Save Changes", "httpDestCreateDestination": "Create Destination", "httpDestUpdatedSuccess": "Destination updated successfully", "httpDestCreatedSuccess": "Destination created successfully", "httpDestUpdateFailed": "Failed to update destination", - "httpDestCreateFailed": "Failed to create destination" + "httpDestCreateFailed": "Failed to create destination", + "followRedirects": "Follow Redirects", + "followRedirectsDescription": "Automatically follow HTTP redirects for requests.", + "alertingErrorWebhookUrl": "Please enter a valid URL for the webhook.", + "healthCheckStrategyHttp": "Validates connectivity and checks the HTTP response status.", + "healthCheckStrategyTcp": "Verifies TCP connectivity only, without inspecting the response.", + "healthCheckStrategySnmp": "Makes an SNMP get request to check the health of network devices and infrastructure.", + "healthCheckStrategyIcmp": "Uses ICMP echo requests (pings) to check if a resource is reachable and responsive.", + "healthCheckTabStrategy": "Strategy", + "healthCheckTabConnection": "Connection", + "healthCheckTabAdvanced": "Advanced", + "healthCheckStrategyNotAvailable": "This strategy is not available. Please contact sales to enable this feature.", + "uptime30d": "Uptime (30d)", + "idpAddActionCreateNew": "Create new identity provider", + "idpAddActionImportFromOrg": "Import from another organization", + "idpImportDialogTitle": "Import Identity Provider", + "idpImportDialogDescription": "Choose an identity provider from an organization where you are an admin. It will be linked to this organization.", + "idpImportSearchPlaceholder": "Search by organization or provider name...", + "idpImportEmpty": "No identity providers found.", + "idpImportedDescription": "Identity provider imported successfully.", + "idpDeleteGlobalQuestion": "Are you sure you want to permanently delete this identity provider?", + "idpDeleteGlobalDescription": "This will permanently delete the identity provider from all organizations it is associated with.", + "idpUnassociateTitle": "Unassociate Identity Provider", + "idpUnassociateQuestion": "Are you sure you want to unassociate this identity provider from this organization?", + "idpUnassociateDescription": "All users associated with this identity provider will be removed from this organization, but the identity provider will still continue to exist for other associated organizations.", + "idpUnassociateConfirm": "Confirm Unassociate Identity Provider", + "idpUnassociateWarning": "This cannot be undone for this organization.", + "idpUnassociatedDescription": "Identity provider unassociated from this organization successfully", + "idpUnassociateMenu": "Unassociate", + "idpDeleteAllOrgsMenu": "Delete", + "publicIpEndpoint": "Endpoint", + "lastTriggeredAt": "Last Trigger", + "reject": "Reject", + "uptimeDaysAgo": "{count} days ago", + "uptimeToday": "Today", + "uptimeNoDataAvailable": "No data available", + "uptimeSuffix": "uptime", + "uptimeDowntimeSuffix": "downtime", + "uptimeTooltipUptimeLabel": "Uptime", + "uptimeTooltipDowntimeLabel": "Downtime", + "uptimeOngoing": "ongoing", + "uptimeNoMonitoringData": "No monitoring data", + "uptimeNoData": "No data", + "uptimeMiniBarDown": "Down", + "uptimeSectionTitle": "Uptime", + "uptimeSectionDescription": "Availability over the last {days} days", + "uptimeAddAlert": "Add Alert", + "uptimeViewAlerts": "View Alerts", + "uptimeCreateEmailAlert": "Create Email Alert", + "uptimeAlertDescriptionSite": "Get notified by email when this site goes offline or comes back online.", + "uptimeAlertDescriptionResource": "Get notified by email when this resource goes offline or comes back online.", + "uptimeAlertNamePlaceholder": "Alert name", + "uptimeAdditionalEmails": "Additional Emails", + "uptimeCreateAlert": "Create Alert", + "uptimeAlertNoRecipients": "No recipients", + "uptimeAlertNoRecipientsDescription": "Please add at least one user, role, or email to notify.", + "uptimeAlertCreated": "Alert created", + "uptimeAlertCreatedDescription": "You will be notified when this changes status.", + "uptimeAlertCreateFailed": "Failed to create alert", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Key", + "webhookHeaderValuePlaceholder": "Value", + "alertLabel": "Alert", + "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.", + "domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.", + "domainPickerWildcardCertWarningLink": "Learn more", + "health": "Health", + "domainPendingErrorTitle": "Verification Issue" } diff --git a/messages/es-ES.json b/messages/es-ES.json index e2e9bdf51..63219984f 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contacta ventas para habilitar esta función.", + "contactSalesBookDemo": "Reservar una demostración", + "contactSalesOr": "o", + "contactSalesContactUs": "contáctenos", "setupCreate": "Crear la organización, el sitio y los recursos", "headerAuthCompatibilityInfo": "Habilite esto para forzar una respuesta 401 no autorizada cuando falte un token de autenticación. Esto es necesario para navegadores o bibliotecas HTTP específicas que no envían credenciales sin un desafío del servidor.", "headerAuthCompatibility": "Compatibilidad extendida", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Se han detectado claves de licencia inválidas o caducadas. Siga los términos de licencia para seguir usando todas las características.", "dismiss": "Descartar", "subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.", + "trialBannerMessage": "Su prueba expira en {countdown}. Actualice para mantener el acceso.", + "trialBannerExpired": "Su prueba ha expirado. Actualice ahora para restaurar el acceso.", + "trialActive": "Prueba gratuita activa", + "trialExpired": "Prueba expirada", + "trialHasEnded": "Su prueba ha terminado.", + "trialDaysRemaining": "{count, plural, one {# día restante} other {# días restantes}}", + "trialDaysLeftShort": "Quedan {days}d en la prueba", + "trialGoToBilling": "Ir a la página de facturación", "subscriptionViolationViewBilling": "Ver facturación", "componentsLicenseViolation": "Violación de la Licencia: Este servidor está usando sitios {usedSites} que exceden su límite de licencias de sitios {maxSites} . Siga los términos de licencia para seguir usando todas las características.", "componentsSupporterMessage": "¡Gracias por apoyar a Pangolin como {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "He copiado la configuración", "searchSitesProgress": "Buscar sitios...", "siteAdd": "Añadir sitio", + "sitesTableViewPublicResources": "Ver Recursos Públicos", + "sitesTableViewPrivateResources": "Ver Recursos Privados", "siteInstallNewt": "Instalar Newt", "siteInstallNewtDescription": "Recibe Newt corriendo en tu sistema", "WgConfiguration": "Configuración de Wirex Guard", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "El sitio ha sido actualizado.", "siteGeneralDescription": "Configurar la configuración general de este sitio", "siteSettingDescription": "Configurar los ajustes en el sitio", + "siteResourcesTab": "Recursos", + "siteResourcesNoneOnSite": "Este sitio aún no tiene recursos públicos o privados.", + "siteResourcesSectionPublic": "Recursos Públicos", + "siteResourcesSectionPrivate": "Recursos Privados", + "siteResourcesSectionPublicDescription": "Recursos expuestos externamente a través de dominios o puertos.", + "siteResourcesSectionPrivateDescription": "Recursos disponibles en tu red privada a través del sitio.", + "siteResourcesViewAllPublic": "Ver todos los recursos", + "siteResourcesViewAllPrivate": "Ver todos los recursos", + "siteResourcesDialogDescription": "Descripción general de los recursos públicos y privados asociados con este sitio.", + "siteResourcesShowMore": "Mostrar más", + "siteResourcesPermissionDenied": "No tienes permiso para listar estos recursos.", + "siteResourcesEmptyPublic": "Aún no hay recursos públicos apuntando a este sitio.", + "siteResourcesEmptyPrivate": "Aún no hay recursos privados asociados con este sitio.", + "siteResourcesHowToAccess": "Cómo acceder", + "siteResourcesTargetsOnSite": "Objetivos en este sitio", "siteSetting": "Ajustes {siteName}", "siteNewtTunnel": "Sitio nuevo (recomendado)", "siteNewtTunnelDescription": "La forma más fácil de crear un punto de entrada en cualquier red. Sin configuración extra.", @@ -267,8 +296,11 @@ "orgMissing": "Falta el ID de la organización", "orgMissingMessage": "No se puede regenerar la invitación sin el ID de la organización.", "accessUsersManage": "Administrar usuarios", + "accessUserManage": "Administrar usuario", "accessUsersDescription": "Invitar y administrar usuarios con acceso a esta organización", "accessUsersSearch": "Buscar usuarios...", + "accessUsersRoleFilterCount": "{count, plural, one {# rol} other {# roles}}", + "accessUsersRoleFilterClear": "Borrar filtros de rol", "accessUserCreate": "Crear usuario", "accessUserRemove": "Eliminar usuario", "username": "Usuario", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Error al activar la clave de licencia", "licenseErrorKeyActivateDescription": "Se ha producido un error al activar la clave de licencia.", "licenseAbout": "Acerca de la licencia", + "licenseBannerTitle": "Habilitar su Licencia Enterprise", + "licenseBannerDescription": "Desbloquea funciones empresariales para tu instancia autohospedada de Pangolin. Compra una clave de licencia para activar capacidades premium, luego agréguela a continuación.", + "licenseBannerGetLicense": "Obtener una Licencia", + "licenseBannerViewDocs": "Ver Documentación", "communityEdition": "Edición comunitaria", "licenseAboutDescription": "Esto es para usuarios empresariales y empresariales que utilizan Pangolin en un entorno comercial. Si estás usando Pangolin para uso personal, puedes ignorar esta sección.", "licenseKeyActivated": "Clave de licencia activada", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secreto", + "newtVersion": "Versión", "architecture": "Arquitectura", "sites": "Sitios", "siteWgAnyClients": "Usa cualquier cliente de Wirex para conectarte. Tendrás que dirigirte a los recursos internos usando la IP de compañeros.", @@ -894,6 +931,7 @@ "idpDisplayName": "Un nombre mostrado para este proveedor de identidad", "idpAutoProvisionUsers": "Auto-Provisión de Usuarios", "idpAutoProvisionUsersDescription": "Cuando está habilitado, los usuarios serán creados automáticamente en el sistema al iniciar sesión con la capacidad de asignar a los usuarios a roles y organizaciones.", + "idpAutoProvisionConfigureAfterCreate": "Puede configurar las configuraciones de provisión automática una vez que se haya creado el proveedor de identidad.", "licenseBadge": "EE", "idpType": "Tipo de proveedor", "idpTypeDescription": "Seleccione el tipo de proveedor de identidad que desea configurar", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Mapeo de Rol por defecto", "defaultMappingsRoleDescription": "El resultado de esta expresión debe devolver el nombre del rol tal y como se define en la organización como una cadena.", "defaultMappingsOrg": "Mapeo de organización por defecto", - "defaultMappingsOrgDescription": "Esta expresión debe devolver el ID de org o verdadero para que el usuario pueda acceder a la organización.", + "defaultMappingsOrgDescription": "Cuando se establece, esta expresión debe devolver el ID de la organización o verdadero para que el usuario acceda a esa organización. Cuando no se establece, definir un mapeo de roles es suficiente: se permite la entrada del usuario siempre que se pueda resolver un mapeo de roles válido para él dentro de la organización.", "defaultMappingsSubmit": "Guardar asignaciones por defecto", "orgPoliciesEdit": "Editar Política de Organización", "org": "Organización", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Ver registros", "noneSelected": "Ninguno seleccionado", "orgNotFound2": "No se encontraron organizaciones.", + "search": "Buscar…", "searchPlaceholder": "Buscar...", "emptySearchOptions": "No se encontraron opciones", "create": "Crear", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Gestionar", "sidebarLogAndAnalytics": "Registro y análisis", "sidebarBluePrints": "Planos", + "sidebarAlerting": "Alertas", + "sidebarHealthChecks": "Chequeos de salud", "sidebarOrganization": "Organización", "sidebarManagement": "Gestión", "sidebarBillingAndLicenses": "Facturación y licencias", "sidebarLogsAnalytics": "Analíticas", + "alertingTitle": "Alertas", + "alertingDescription": "Definir fuentes, disparadores y acciones para notificaciones", + "alertingRules": "Reglas de alerta", + "alertingSearchRules": "Buscar reglas…", + "alertingAddRule": "Crear regla", + "alertingColumnSource": "Fuente", + "alertingColumnTrigger": "Disparador", + "alertingColumnActions": "Acciones", + "alertingColumnEnabled": "Activado", + "alertingDeleteQuestion": "Por favor, confirme que desea eliminar esta regla de alerta.", + "alertingDeleteRule": "Eliminar regla de alerta", + "alertingRuleDeleted": "Regla de alerta eliminada", + "alertingRuleSaved": "Regla de alerta guardada", + "alertingRuleSavedCreatedDescription": "Tu nueva regla de alerta fue creada. Puedes seguir editándola en esta página.", + "alertingRuleSavedUpdatedDescription": "Tus cambios a esta regla de alerta fueron guardados.", + "alertingEditRule": "Editar regla de alerta", + "alertingCreateRule": "Crear regla de alerta", + "alertingRuleCredenzaDescription": "Elija qué observar, cuándo disparar y cómo notificar", + "alertingRuleNamePlaceholder": "Sitio de producción caído", + "alertingRuleEnabled": "Regla habilitada", + "alertingSectionSource": "Fuente", + "alertingSourceType": "Tipo de fuente", + "alertingSourceSite": "Sitio", + "alertingSourceHealthCheck": "Chequeo de salud", + "alertingPickSites": "Sitios", + "alertingPickHealthChecks": "Chequeos de salud", + "alertingPickResources": "Recursos", + "alertingAllSites": "Todos los sitios", + "alertingAllSitesDescription": "Las alertas se activan para cualquier sitio", + "alertingSpecificSites": "Sitios específicos", + "alertingSpecificSitesDescription": "Escoja sitios específicos para observar", + "alertingAllHealthChecks": "Todos los chequeos de salud", + "alertingAllHealthChecksDescription": "Las alertas se activan para cualquier chequeo de salud", + "alertingSpecificHealthChecks": "Chequeos de salud específicos", + "alertingSpecificHealthChecksDescription": "Elija chequeos de salud específicos para observar", + "alertingAllResources": "Todos los recursos", + "alertingAllResourcesDescription": "Las alertas se activan para cualquier recurso", + "alertingSpecificResources": "Recursos específicos", + "alertingSpecificResourcesDescription": "Elija recursos específicos para observar", + "alertingSelectResources": "Seleccionar recursos…", + "alertingResourcesSelected": "{count} recursos seleccionados", + "alertingResourcesEmpty": "No hay recursos con objetivos en los primeros 10 resultados.", + "alertingSectionTrigger": "Disparador", + "alertingTrigger": "Cuándo alertar", + "alertingTriggerSiteOnline": "Sitio en línea", + "alertingTriggerSiteOffline": "Sitio fuera de línea", + "alertingTriggerSiteToggle": "El estado del sitio cambia", + "alertingTriggerHcHealthy": "Chequeo de salud saludable", + "alertingTriggerHcUnhealthy": "Chequeo de salud no saludable", + "alertingTriggerHcToggle": "El estado del chequeo de salud cambia", + "alertingTriggerResourceHealthy": "Recurso saludable", + "alertingTriggerResourceUnhealthy": "Recurso no saludable", + "alertingTriggerResourceDegraded": "Recurso degradado", + "alertingSearchHealthChecks": "Buscar chequeos de salud…", + "alertingHealthChecksEmpty": "No hay chequeos de salud disponibles.", + "alertingTriggerResourceToggle": "El estado del recurso cambia", + "alertingSourceResource": "Recurso", + "alertingSectionActions": "Acciones", + "alertingAddAction": "Añadir acción", + "alertingActionNotify": "E-mail", + "alertingActionNotifyDescription": "Enviar notificaciones por correo electrónico a usuarios o roles", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Enviar una solicitud HTTP a un punto final personalizado", + "alertingExternalIntegration": "Integración externa", + "alertingExternalPagerDutyDescription": "Enviar alertas a PagerDuty para gestión de incidentes", + "alertingExternalOpsgenieDescription": "Dirigir alertas a Opsgenie para gestión de llamadas", + "alertingExternalServiceNowDescription": "Crear incidentes de ServiceNow a partir de eventos de alerta", + "alertingExternalIncidentIoDescription": "Activar flujos de trabajo de Incident.io a partir de eventos de alerta", + "alertingActionType": "Tipo de acción", + "alertingNotifyUsers": "Usuarios", + "alertingNotifyRoles": "Roles", + "alertingNotifyEmails": "Direcciones de correo electrónico", + "alertingEmailPlaceholder": "Añadir email y presionar Enter", + "alertingWebhookMethod": "Método HTTP", + "alertingWebhookSecret": "Firma secreta (opcional)", + "alertingWebhookSecretPlaceholder": "Secreto HMAC", + "alertingWebhookHeaders": "Encabezados", + "alertingAddHeader": "Añadir encabezado", + "alertingSelectSites": "Seleccionar sitios…", + "alertingSitesSelected": "{count} sitios seleccionados", + "alertingSelectHealthChecks": "Seleccionar chequeos de salud…", + "alertingHealthChecksSelected": "{count} chequeos de salud seleccionados", + "alertingNoHealthChecks": "No hay objetivos con chequeos de salud habilitados", + "alertingHealthCheckStub": "La selección de chequeo de salud no está conectada aún - todavía puede configurar disparadores y acciones.", + "alertingSelectUsers": "Seleccionar usuarios…", + "alertingUsersSelected": "{count} usuarios seleccionados", + "alertingSelectRoles": "Seleccionar roles…", + "alertingRolesSelected": "{count} roles seleccionados", + "alertingSummarySites": "Sitios ({count})", + "alertingSummaryAllSites": "Todos los sitios", + "alertingSummaryHealthChecks": "Chequeos de salud ({count})", + "alertingSummaryAllHealthChecks": "Todos los chequeos de salud", + "alertingSummaryResources": "Recursos ({count})", + "alertingSummaryAllResources": "Todos los recursos", + "alertingErrorNameRequired": "Introduce un nombre", + "alertingErrorActionsMin": "Añada al menos una acción", + "alertingErrorPickSites": "Seleccione al menos un sitio", + "alertingErrorPickHealthChecks": "Seleccione al menos un chequeo de salud", + "alertingErrorPickResources": "Seleccione al menos un recurso", + "alertingErrorTriggerSite": "Elija un disparador de sitio", + "alertingErrorTriggerHealth": "Elija un disparador de chequeo de salud", + "alertingErrorTriggerResource": "Elija un disparador de recurso", + "alertingErrorNotifyRecipients": "Elija usuarios, roles o al menos un correo electrónico", + "alertingConfigureSource": "Configurar fuente", + "alertingConfigureTrigger": "Configurar disparador", + "alertingConfigureActions": "Configurar acciones", + "alertingBackToRules": "Volver a las reglas", + "alertingRuleCooldown": "Tiempo de espera (segundos)", + "alertingRuleCooldownDescription": "Tiempo mínimo entre alertas repetidas para la misma regla. Establezca en 0 para disparar cada vez.", + "alertingDraftBadge": "Borrador - guardarlo para almacenar esta regla", + "alertingSidebarHint": "Haga clic en un paso en el lienzo para editarlo aquí.", + "alertingGraphCanvasTitle": "Flujo de regla", + "alertingGraphCanvasDescription": "Visión general visual de fuente, disparador y acciones. Selecciona un nodo para editarlo en el panel.", + "alertingNodeNotConfigured": "Aún no configurado", + "alertingNodeActionsCount": "{count, plural, one {# acción} other {# acciones}}", + "alertingNodeRoleSource": "Fuente", + "alertingNodeRoleTrigger": "Disparador", + "alertingNodeRoleAction": "Acción", + "alertingTabRules": "Reglas de Alerta", + "alertingTabHealthChecks": "Chequeos de salud", + "alertingRulesBannerTitle": "Obtenga notificaciones", + "alertingRulesBannerDescription": "Cada regla vincula lo que se debe observar (un sitio, chequeo de salud o recurso), cuándo disparar (por ejemplo, fuera de línea o no saludable), y cómo notificar a su equipo vía email, webhooks o integraciones. Use esta lista para crear, habilitar y administrar esas reglas.", + "alertingHealthChecksBannerTitle": "Monitorear Salud y Recursos", + "alertingHealthChecksBannerDescription": "Los chequeos de salud son monitores HTTP o TCP que define una vez. Luego puede usarlos como fuentes en reglas de alerta para que se le notifique cuando un objetivo se vuelva saludable o no saludable. Los chequeos de salud en recursos también aparecen aquí.", + "standaloneHcTableTitle": "Chequeos de salud", + "standaloneHcSearchPlaceholder": "Buscar chequeos de salud…", + "standaloneHcAddButton": "Crear chequeo de salud", + "standaloneHcCreateTitle": "Crear chequeo de salud", + "standaloneHcEditTitle": "Editar chequeo de salud", + "standaloneHcDescription": "Configurar un chequeo de salud HTTP o TCP para usar en reglas de alerta.", + "standaloneHcNameLabel": "Nombre", + "standaloneHcNamePlaceholder": "Mi monitor HTTP", + "standaloneHcDeleteTitle": "Eliminar chequeo de salud", + "standaloneHcDeleteQuestion": "Por favor, confirme que desea eliminar este chequeo de salud.", + "standaloneHcDeleted": "Chequeo de salud eliminado", + "standaloneHcSaved": "Chequeo de salud guardado", + "standaloneHcColumnHealth": "Salud", + "standaloneHcColumnMode": "Modo", + "standaloneHcColumnTarget": "Destino", + "standaloneHcHealthStateHealthy": "Saludable", + "standaloneHcHealthStateUnhealthy": "No saludable", + "standaloneHcHealthStateUnknown": "Desconocido", + "standaloneHcFilterAnySite": "Todos los sitios", + "standaloneHcFilterAnyResource": "Todos los recursos", + "standaloneHcFilterMode": "Modo", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Salud", + "standaloneHcFilterEnabled": "Activado", + "standaloneHcFilterEnabledOn": "Activado", + "standaloneHcFilterEnabledOff": "Deshabilitado", + "standaloneHcFilterSiteIdFallback": "Sitio {id}", + "standaloneHcFilterResourceIdFallback": "Recurso {id}", "blueprints": "Planos", "blueprintsDescription": "Aplicar configuraciones declarativas y ver ejecuciones anteriores", "blueprintAdd": "Añadir plano", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Cree la cuenta de administrador del servidor inicial. Solo puede existir un administrador del servidor. Siempre puede cambiar estas credenciales más tarde.", "createAdminAccount": "Crear cuenta de administrador", "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.", - "certificateStatus": "Estado del certificado", + "certificateStatus": "Certificado", + "certificateStatusAutoRefreshHint": "El estado se actualiza automáticamente.", "loading": "Cargando", "loadingAnalytics": "Cargando analíticas", "restart": "Reiniciar", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Ver notas de lanzamiento", "newtUpdateAvailable": "Nueva actualización disponible", "newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.", + "pangolinNodeUpdateAvailableInfo": "Hay una nueva versión de Pangolin Node disponible. Actualice a la última versión para la mejor experiencia.", "domainPickerEnterDomain": "Dominio", "domainPickerPlaceholder": "miapp.ejemplo.com", "domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Configurar Chequeo de Salud", "configureHealthCheckDescription": "Configura la monitorización de salud para {target}", "enableHealthChecks": "Activar Chequeos de Salud", + "healthCheckDisabledStateDescription": "Cuando está deshabilitado, el sitio no realizará comprobaciones de salud y el estado se considerará desconocido.", "enableHealthChecksDescription": "Controlar la salud de este objetivo. Puedes supervisar un punto final diferente al objetivo si es necesario.", "healthScheme": "Método", "healthSelectScheme": "Seleccionar método", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "El intervalo de comprobación debe ser de al menos 5 segundos", "healthCheckTimeoutMin": "El tiempo de espera debe ser de al menos 1 segundo", "healthCheckRetryMin": "Los intentos de reintento deben ser de al menos 1", + "healthCheckMode": "Modo de chequeo", + "healthCheckStrategy": "Estrategia", + "healthCheckModeDescription": "El modo TCP verifica solo la conectividad. El modo HTTP valida la respuesta HTTP.", + "healthyThreshold": "Umbral Saludable", + "healthyThresholdDescription": "Éxitos consecutivos requeridos antes de marcar como saludable.", + "unhealthyThreshold": "Umbral No Saludable", + "unhealthyThresholdDescription": "Fallos consecutivos requeridos antes de marcar como no saludable.", + "healthCheckHealthyThresholdMin": "El umbral saludable debe ser al menos 1", + "healthCheckUnhealthyThresholdMin": "El umbral no saludable debe ser al menos 1", "httpMethod": "Método HTTP", "selectHttpMethod": "Seleccionar método HTTP", "domainPickerSubdomainLabel": "Subdominio", + "domainPickerWildcard": "Comodín", + "domainPickerWildcardPaidOnly": "Los subdominios comodín son una característica paga. Por favor, mejora tu plan para acceder a esta característica.", "domainPickerBaseDomainLabel": "Dominio base", "domainPickerSearchDomains": "Buscar dominios...", "domainPickerNoDomainsFound": "No se encontraron dominios", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Esta dirección es parte de la subred de utilidad de la organización. Se utiliza para resolver registros de alias usando resolución DNS interna.", "resourcesTableClients": "Clientes", "resourcesTableAndOnlyAccessibleInternally": "y solo son accesibles internamente cuando se conectan con un cliente.", - "resourcesTableNoTargets": "Sin objetivos", "resourcesTableHealthy": "Saludable", "resourcesTableDegraded": "Degrado", - "resourcesTableOffline": "Desconectado", + "resourcesTableUnhealthy": "No saludable", "resourcesTableUnknown": "Desconocido", "resourcesTableNotMonitored": "No supervisado", + "resourcesTableNoTargets": "Sin objetivos", "editInternalResourceDialogEditClientResource": "Editar recurso privado", "editInternalResourceDialogUpdateResourceProperties": "Actualizar la configuración del recurso y los controles de acceso para {resourceName}", "editInternalResourceDialogResourceProperties": "Propiedades del recurso", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Puerto", "editInternalResourceDialogModeHost": "Anfitrión", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Esquema", + "editInternalResourceDialogEnableSsl": "Activar SSL", + "editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.", "editInternalResourceDialogDestination": "Destino", "editInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", "editInternalResourceDialogDestinationIPDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Nombre", "createInternalResourceDialogSite": "Sitio", "selectSite": "Seleccionar sitio...", + "multiSitesSelectorSitesCount": "{count, plural, one {# sitio} other {# sitios}}", "noSitesFound": "Sitios no encontrados.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Puerto", "createInternalResourceDialogModeHost": "Anfitrión", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Esquema", + "createInternalResourceDialogScheme": "Esquema", + "createInternalResourceDialogEnableSsl": "Activar SSL", + "createInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.", "createInternalResourceDialogDestination": "Destino", "createInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", "createInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.", + "internalResourceDownstreamSchemeRequired": "Se requiere el método para recursos HTTP", + "internalResourceHttpPortRequired": "Se requiere el puerto de destino para recursos HTTP", "siteConfiguration": "Configuración", "siteAcceptClientConnections": "Aceptar conexiones de clientes", "siteAcceptClientConnectionsDescription": "Permitir a los dispositivos de usuario y clientes acceder a los recursos de este sitio. Esto se puede cambiar más tarde.", @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Dominio Internacional detectado", "willbestoredas": "Se almacenará como:", - "roleMappingDescription": "Determinar cómo se asignan los roles a los usuarios cuando se registran cuando está habilitada la provisión automática.", + "roleMappingDescription": "Determine cómo se asignan los roles a los usuarios cuando inician sesión con este proveedor de identidad.", "selectRole": "Seleccione un rol", "roleMappingExpression": "Expresión", "selectRolePlaceholder": "Elija un rol", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización", "domainPickerProvidedDomain": "Dominio proporcionado", "domainPickerFreeProvidedDomain": "Dominio proporcionado gratis", + "domainPickerFreeDomainsPaidFeature": "Los dominios proporcionados son una función de pago. Suscríbete para obtener un dominio incluido con tu plan - no necesitas traer el tuyo propio.", "domainPickerVerified": "Verificado", "domainPickerUnverified": "Sin verificar", - "domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.", + "domainPickerManual": "Manual", + "domainPickerInvalidSubdomainStructure": "Los caracteres inválidos serán saneados al guardar.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización", "domainPickerErrorCheckAvailability": "No se pudo comprobar la disponibilidad del dominio", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar", "orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.", "orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin", - "orgAuthSignInToOrg": "Iniciar sesión en una organización", + "orgAuthSignInToOrg": "Proveedor de identidad de la organización (SSO)", "orgAuthSelectOrgTitle": "Inicio de sesión de organización", "orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar", "orgAuthOrgIdPlaceholder": "tu-organización", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Divulgación de uso", - "description": "Seleccione el nivel de licencia que refleje con precisión su uso previsto. La Licencia Personal permite el uso libre del Software para actividades comerciales individuales, no comerciales o de pequeña escala con ingresos brutos anuales inferiores a $100,000 USD. Cualquier uso más allá de estos límites — incluyendo el uso dentro de una empresa, organización, u otro entorno de generación de ingresos — requiere una Licencia Empresarial válida y el pago de la cuota de licencia aplicable. Todos los usuarios, ya sean personales o empresariales, deben cumplir con las Condiciones de Licencia Comercial Fossorial." + "description": "Seleccione el nivel de licencia que refleje con precisión su uso previsto. La Licencia Personal permite el uso libre del Software para actividades comerciales individuales, no comerciales o de pequeña escala con ingresos brutos anuales inferiores a $100,000 USD. Cualquier uso más allá de estos límites - incluyendo el uso dentro de una empresa, organización, u otro entorno de generación de ingresos - requiere una Licencia Empresarial válida y el pago de la cuota de licencia aplicable. Todos los usuarios, ya sean personales o empresariales, deben cumplir con las Condiciones de Licencia Comercial Fossorial." }, "trialPeriodInformation": { "title": "Información del período de prueba", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Escala", - "description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario." + "description": "Funcionalidades empresariales, 50 usuarios, 100 sitios y soporte prioritario." } }, "personalUseOnly": "Solo uso personal (licencia gratuita - sin salida)", @@ -2422,6 +2648,7 @@ "validPassword": "Contraseña válida", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Cliente conectado", "resourceBlocked": "Recurso bloqueado", "droppedByRule": "Soltado por regla", "noSessions": "No hay sesiones", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Agregar clientes", "editInternalResourceDialogDestinationLabel": "Destino", "editInternalResourceDialogDestinationDescription": "Especifique la dirección de destino para el recurso interno. Puede ser un nombre de host, dirección IP o rango CIDR dependiendo del modo seleccionado. Opcionalmente establezca un alias DNS interno para una identificación más fácil.", + "internalResourceFormMultiSiteRoutingHelp": "Seleccionar múltiples sitios habilita el enrutamiento resistente y la conmutación por error para alta disponibilidad.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Más información", "editInternalResourceDialogPortRestrictionsDescription": "Restringir el acceso a puertos TCP/UDP específicos o permitir/bloquear todos los puertos.", + "createInternalResourceDialogHttpConfiguration": "Configuración HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Elija el dominio que los clientes usarán para alcanzar este recurso a través de HTTP o HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Configuración HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Elija el dominio que los clientes usarán para alcanzar este recurso a través de HTTP o HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "¡Volveremos pronto! Nuestro sitio está actualmente en mantenimiento programado.", "maintenancePageMessageDescription": "Mensaje detallado explicando el mantenimiento", "maintenancePageTimeTitle": "Tiempo estimado de finalización (Opcional)", + "privateMaintenanceScreenTitle": "Pantalla de marcador de posición privada", + "privateMaintenanceScreenMessage": "Este dominio se está utilizando en un recurso privado. Conéctese usando el cliente Pangolin para acceder a este recurso.", + "privateMaintenanceScreenSteps": "Una vez conectado, si sigues viendo este mensaje, la caché de DNS de tu navegador puede seguir apuntando a la dirección antigua. Para solucionarlo: cierra por completo y vuelve a abrir esta pestaña o tu navegador, luego regresa a esta página.", "maintenanceTime": "Ej., 2 horas, 1 de noviembre a las 5:00 PM", "maintenanceEstimatedTimeDescription": "Cuando espera que el mantenimiento esté terminado", "editDomain": "Editar dominio", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Añadir destino HTTP", "httpDestEditDescription": "Actualizar la configuración para este destino de transmisión de eventos HTTP.", "httpDestAddDescription": "Configure un nuevo extremo HTTP para recibir los eventos de su organización.", + "S3DestEditTitle": "Editar destino", + "S3DestAddTitle": "Añadir destino S3", + "S3DestEditDescription": "Actualice la configuración para este destino de transmisión de eventos S3.", + "S3DestAddDescription": "Configure un nuevo punto final S3 para recibir los eventos de su organización.", + "datadogDestEditTitle": "Editar destino", + "datadogDestAddTitle": "Añadir destino Datadog", + "datadogDestEditDescription": "Actualice la configuración para este destino de transmisión de eventos Datadog.", + "datadogDestAddDescription": "Configure un nuevo punto final de Datadog para recibir los eventos de su organización.", "httpDestTabSettings": "Ajustes", "httpDestTabHeaders": "Encabezados", "httpDestTabBody": "Cuerpo", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "Matriz JSON", "httpDestFormatJsonArrayDescription": "Una petición por lote, cuerpo es una matriz JSON. Compatible con la mayoría de los webhooks y Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Una petición por lote, el cuerpo es JSON delimitado por línea — un objeto por línea, sin arrays externos. Requerido por Splunk HEC, Elastic / OpenSearch, y Grafana Loki.", + "httpDestFormatNdjsonDescription": "Una petición por lote, el cuerpo es JSON delimitado por línea - un objeto por línea, sin arrays externos. Requerido por Splunk HEC, Elastic / OpenSearch, y Grafana Loki.", "httpDestFormatSingleTitle": "Un evento por solicitud", "httpDestFormatSingleDescription": "Envía un HTTP POST separado para cada evento individual. Úsalo sólo para los extremos que no pueden manejar lotes.", "httpDestLogTypesTitle": "Tipos de Log", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Destino actualizado correctamente", "httpDestCreatedSuccess": "Destino creado correctamente", "httpDestUpdateFailed": "Error al actualizar destino", - "httpDestCreateFailed": "Error al crear el destino" + "httpDestCreateFailed": "Error al crear el destino", + "followRedirects": "Seguir redirecciones", + "followRedirectsDescription": "Seguir automáticamente las redirecciones HTTP para solicitudes.", + "alertingErrorWebhookUrl": "Por favor, introduzca una URL válida para el webhook.", + "healthCheckStrategyHttp": "Valida la conectividad y verifica el estado de respuesta HTTP.", + "healthCheckStrategyTcp": "Verifica la conectividad TCP solamente, sin inspeccionar la respuesta.", + "healthCheckStrategySnmp": "Realiza una solicitud SNMP get para verificar la salud de dispositivos y la infraestructura de red.", + "healthCheckStrategyIcmp": "Usa solicitudes de eco ICMP (pings) para verificar si un recurso es alcanzable y receptivo.", + "healthCheckTabStrategy": "Estrategia", + "healthCheckTabConnection": "Conexión", + "healthCheckTabAdvanced": "Avanzado", + "healthCheckStrategyNotAvailable": "Esta estrategia no está disponible. Contacte ventas para habilitar esta funcionalidad.", + "uptime30d": "Tiempo de actividad (30d)", + "idpAddActionCreateNew": "Crear nuevo proveedor de identidad", + "idpAddActionImportFromOrg": "Importar de otra organización", + "idpImportDialogTitle": "Importar Proveedor de Identidad", + "idpImportDialogDescription": "Elija un proveedor de identidad de una organización donde usted sea administrador. Se vinculará a esta organización.", + "idpImportSearchPlaceholder": "Buscar por nombre de organización o proveedor...", + "idpImportEmpty": "No se encontraron proveedores de identidad.", + "idpImportedDescription": "Proveedor de identidad importado con éxito.", + "idpDeleteGlobalQuestion": "¿Está seguro de que desea eliminar permanentemente este proveedor de identidad?", + "idpDeleteGlobalDescription": "Esto eliminará permanentemente el proveedor de identidad de todas las organizaciones con las que está asociado.", + "idpUnassociateTitle": "Desasociar Proveedor de Identidad", + "idpUnassociateQuestion": "¿Está seguro de que desea desasociar este proveedor de identidad de esta organización?", + "idpUnassociateDescription": "Todos los usuarios asociados con este proveedor de identidad serán eliminados de esta organización, pero el proveedor de identidad continuará existiendo para otras organizaciones asociadas.", + "idpUnassociateConfirm": "Confirme Desasociar Proveedor de Identidad", + "idpUnassociateWarning": "Esto no se puede deshacer para esta organización.", + "idpUnassociatedDescription": "Proveedor de identidad desasociado de esta organización con éxito", + "idpUnassociateMenu": "Desasociar", + "idpDeleteAllOrgsMenu": "Eliminar", + "publicIpEndpoint": "Punto final", + "lastTriggeredAt": "Último disparo", + "reject": "Rechazar", + "uptimeDaysAgo": "Hace {count} días", + "uptimeToday": "Hoy", + "uptimeNoDataAvailable": "No hay datos disponibles", + "uptimeSuffix": "disponibilidad", + "uptimeDowntimeSuffix": "tiempo de inactividad", + "uptimeTooltipUptimeLabel": "Disponibilidad", + "uptimeTooltipDowntimeLabel": "Tiempo de inactividad", + "uptimeOngoing": "en curso", + "uptimeNoMonitoringData": "No hay datos de monitoreo", + "uptimeNoData": "Sin datos", + "uptimeMiniBarDown": "Caído", + "uptimeSectionTitle": "Disponibilidad", + "uptimeSectionDescription": "Disponibilidad durante los últimos {days} días", + "uptimeAddAlert": "Agregar alerta", + "uptimeViewAlerts": "Ver alertas", + "uptimeCreateEmailAlert": "Crear alerta de correo electrónico", + "uptimeAlertDescriptionSite": "Recibe notificaciones por correo electrónico cuando este sitio esté fuera de línea o vuelva en línea.", + "uptimeAlertDescriptionResource": "Recibe notificaciones por correo electrónico cuando este recurso esté fuera de línea o vuelva en línea.", + "uptimeAlertNamePlaceholder": "Nombre de la alerta", + "uptimeAdditionalEmails": "Emails adicionales", + "uptimeCreateAlert": "Crear alerta", + "uptimeAlertNoRecipients": "Sin destinatarios", + "uptimeAlertNoRecipientsDescription": "Por favor, agrega al menos un usuario, rol o correo electrónico para notificación.", + "uptimeAlertCreated": "Alerta creada", + "uptimeAlertCreatedDescription": "Serás notificado cuando cambie de estado.", + "uptimeAlertCreateFailed": "Error al crear la alerta", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Clave", + "webhookHeaderValuePlaceholder": "Valor", + "alertLabel": "Alerta", + "domainPickerWildcardSubdomainNotAllowed": "No se permiten subdominios comodín.", + "domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.", + "domainPickerWildcardCertWarningLink": "Más información", + "health": "Salud", + "domainPendingErrorTitle": "Problema de verificación" } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 20d764f75..edbd6839b 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contactez le service commercial pour activer cette fonctionnalité.", + "contactSalesBookDemo": "Réserver une démo", + "contactSalesOr": "ou", + "contactSalesContactUs": "contactez-nous", "setupCreate": "Créer l'organisation, le site et les ressources", "headerAuthCompatibilityInfo": "Activez ceci pour forcer une réponse 401 Unauthorized lorsque le jeton d'authentification est manquant. Cela est nécessaire pour les navigateurs ou les bibliothèques HTTP spécifiques qui n'envoient pas de credentials sans un challenge du serveur.", "headerAuthCompatibility": "Compatibilité étendue", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Veuillez respecter les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "dismiss": "Rejeter", "subscriptionViolationMessage": "Vous dépassez vos limites pour votre forfait actuel. Corrigez le problème en supprimant des sites, des utilisateurs ou d'autres ressources pour rester dans votre forfait.", + "trialBannerMessage": "Votre essai expire dans {countdown}. Passez à l'abonnement pour garder l'accès.", + "trialBannerExpired": "Votre essai a expiré. Passez à l'abonnement maintenant pour restaurer l'accès.", + "trialActive": "Essai gratuit actif", + "trialExpired": "Essai expiré", + "trialHasEnded": "Votre essai est terminé.", + "trialDaysRemaining": "{count, plural, one {# jour restant} other {# jours restants}}", + "trialDaysLeftShort": "{days}j restants dans l'essai", + "trialGoToBilling": "Aller à la page de facturation", "subscriptionViolationViewBilling": "Voir la facturation", "componentsLicenseViolation": "Violation de licence : ce serveur utilise {usedSites} nœuds, ce qui dépasse la limite autorisée de {maxSites} nœuds. Respectez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "componentsSupporterMessage": "Merci de soutenir Pangolin en tant que {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "J'ai copié la configuration", "searchSitesProgress": "Rechercher des nœuds...", "siteAdd": "Ajouter un nœud", + "sitesTableViewPublicResources": "Voir les ressources publiques", + "sitesTableViewPrivateResources": "Voir les ressources privées", "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Faites fonctionner Newt sur votre système", "WgConfiguration": "Configuration WireGuard", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "Le nœud a été mis à jour.", "siteGeneralDescription": "Configurer les paramètres par défaut de ce nœud", "siteSettingDescription": "Configurer les paramètres du site", + "siteResourcesTab": "Ressources", + "siteResourcesNoneOnSite": "Ce site n'a pas encore de ressources publiques ou privées.", + "siteResourcesSectionPublic": "Ressources publiques", + "siteResourcesSectionPrivate": "Ressources privées", + "siteResourcesSectionPublicDescription": "Ressources exposées à l'extérieur via des domaines ou des ports.", + "siteResourcesSectionPrivateDescription": "Ressources disponibles sur votre réseau privé via le site.", + "siteResourcesViewAllPublic": "Voir toutes les ressources", + "siteResourcesViewAllPrivate": "Voir toutes les ressources", + "siteResourcesDialogDescription": "Aperçu des ressources publiques et privées associées à ce site.", + "siteResourcesShowMore": "Afficher plus", + "siteResourcesPermissionDenied": "Vous n'avez pas la permission de lister ces ressources.", + "siteResourcesEmptyPublic": "Aucune ressource publique ne cible encore ce site.", + "siteResourcesEmptyPrivate": "Aucune ressource privée n'est encore associée à ce site.", + "siteResourcesHowToAccess": "Comment accéder", + "siteResourcesTargetsOnSite": "Cibles sur ce site", "siteSetting": "Paramètres de {siteName}", "siteNewtTunnel": "Site Newt (Recommandé)", "siteNewtTunnelDescription": "La façon la plus simple de créer un point d'entrée dans n'importe quel réseau. Pas de configuration supplémentaire.", @@ -267,8 +296,11 @@ "orgMissing": "ID d'organisation manquant", "orgMissingMessage": "Impossible de régénérer l'invitation sans un ID d'organisation.", "accessUsersManage": "Gérer les utilisateurs", + "accessUserManage": "Gérer l'utilisateur", "accessUsersDescription": "Inviter et gérer les utilisateurs ayant accès à cette organisation", "accessUsersSearch": "Chercher des utilisateurs...", + "accessUsersRoleFilterCount": "{count, plural, one {# rôle} other {# rôles}}", + "accessUsersRoleFilterClear": "Effacer les filtres de rôle", "accessUserCreate": "Créer un utilisateur", "accessUserRemove": "Supprimer un utilisateur", "username": "Nom d'utilisateur", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Échec de l'activation de la clé de licence", "licenseErrorKeyActivateDescription": "Une erreur s'est produite lors de l'activation de la clé de licence.", "licenseAbout": "À propos de la licence", + "licenseBannerTitle": "Activer Votre Licence Entreprise", + "licenseBannerDescription": "Débloquez les fonctionnalités d'entreprise pour votre instance autohébergée de Pangolin. Achetez une clé de licence pour activer les capacités premium, puis ajoutez-la ci-dessous.", + "licenseBannerGetLicense": "Obtenez une Licence", + "licenseBannerViewDocs": "Afficher la Documentation", "communityEdition": "Edition Communautaire", "licenseAboutDescription": "Ceci est destiné aux entreprises qui utilisent Pangolin dans un environnement commercial. Si vous utilisez Pangolin pour un usage personnel, vous pouvez ignorer cette section.", "licenseKeyActivated": "Clé de licence activée", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secrète", + "newtVersion": "Version", "architecture": "Architecture", "sites": "Nœuds", "siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser des ressources internes en utilisant l'adresse IP du pair.", @@ -894,6 +931,7 @@ "idpDisplayName": "Un nom d'affichage pour ce fournisseur d'identité", "idpAutoProvisionUsers": "Approvisionnement automatique des utilisateurs", "idpAutoProvisionUsersDescription": "Lorsque cette option est activée, les utilisateurs seront automatiquement créés dans le système lors de leur première connexion avec la possibilité de mapper les utilisateurs aux rôles et aux organisations.", + "idpAutoProvisionConfigureAfterCreate": "Vous pouvez configurer les paramètres de provisionnement automatique une fois le fournisseur d'identités créé.", "licenseBadge": "EE", "idpType": "Type de fournisseur", "idpTypeDescription": "Sélectionnez le type de fournisseur d'identité que vous souhaitez configurer", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Mappage de rôle par défaut", "defaultMappingsRoleDescription": "JMESPath pour extraire les informations de rôle du jeton ID. Le résultat de cette expression doit renvoyer le nom du rôle tel que défini dans l'organisation sous forme de chaîne.", "defaultMappingsOrg": "Mappage d'organisation par défaut", - "defaultMappingsOrgDescription": "JMESPath pour extraire les informations d'organisation du jeton ID. Cette expression doit renvoyer l'ID de l'organisation ou true pour que l'utilisateur soit autorisé à accéder à l'organisation.", + "defaultMappingsOrgDescription": "Lorsque défini, cette expression doit renvoyer l'identifiant de l'organisation ou vrai pour que l'utilisateur accède à cette organisation. Lorsqu'indéfini, définir un mappage de rôle est suffisant : l'utilisateur est autorisé tant qu'un mappage de rôle valide peut être résolu pour lui au sein de l'organisation.", "defaultMappingsSubmit": "Enregistrer les mappages par défaut", "orgPoliciesEdit": "Modifier la politique d'organisation", "org": "Organisation", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Voir les logs", "noneSelected": "Aucune sélection", "orgNotFound2": "Aucune organisation trouvée.", + "search": "Rechercher…", "searchPlaceholder": "Recherche...", "emptySearchOptions": "Aucune option trouvée", "create": "Créer", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Gérer", "sidebarLogAndAnalytics": "Journaux & Analytiques", "sidebarBluePrints": "Configs", + "sidebarAlerting": "Alertes", + "sidebarHealthChecks": "Vérifications de l'état de santé", "sidebarOrganization": "Organisation", "sidebarManagement": "Gestion", "sidebarBillingAndLicenses": "Facturation & Licences", "sidebarLogsAnalytics": "Analyses", + "alertingTitle": "Alertes", + "alertingDescription": "Définissez des sources, des déclencheurs et des actions pour les notifications", + "alertingRules": "Règles d'alerte", + "alertingSearchRules": "Rechercher des règles…", + "alertingAddRule": "Créer une règle", + "alertingColumnSource": "Source", + "alertingColumnTrigger": "Déclencheur", + "alertingColumnActions": "Actions", + "alertingColumnEnabled": "Activé", + "alertingDeleteQuestion": "Veuillez confirmer que vous souhaitez supprimer cette règle d'alerte.", + "alertingDeleteRule": "Supprimer la règle d'alerte", + "alertingRuleDeleted": "Règle d'alerte supprimée", + "alertingRuleSaved": "Règle d'alerte enregistrée", + "alertingRuleSavedCreatedDescription": "Votre nouvelle règle d'alerte a été créée. Vous pouvez continuer à la modifier sur cette page.", + "alertingRuleSavedUpdatedDescription": "Vos modifications apportées à cette règle d'alerte ont été enregistrées.", + "alertingEditRule": "Modifier la règle d'alerte", + "alertingCreateRule": "Créer une règle d'alerte", + "alertingRuleCredenzaDescription": "Choisissez ce qu'il faut surveiller, quand la déclencher et comment notifier", + "alertingRuleNamePlaceholder": "Site de production hors ligne", + "alertingRuleEnabled": "Règle activée", + "alertingSectionSource": "Source", + "alertingSourceType": "Type de source", + "alertingSourceSite": "Nœud", + "alertingSourceHealthCheck": "Vérification de l'état de santé", + "alertingPickSites": "Nœuds", + "alertingPickHealthChecks": "Vérifications de l'état de santé", + "alertingPickResources": "Ressources", + "alertingAllSites": "Tous les nœuds", + "alertingAllSitesDescription": "Les alertes se déclenchent pour n'importe quel nœud", + "alertingSpecificSites": "Nœuds spécifiques", + "alertingSpecificSitesDescription": "Choisissez des nœuds spécifiques à surveiller", + "alertingAllHealthChecks": "Toutes les vérifications de l'état de santé", + "alertingAllHealthChecksDescription": "Les alertes se déclenchent pour n'importe quelle vérification de l'état de santé", + "alertingSpecificHealthChecks": "Vérifications de l'état de santé spécifiques", + "alertingSpecificHealthChecksDescription": "Choisissez des vérifications de l'état de santé spécifiques à surveiller", + "alertingAllResources": "Toutes les ressources", + "alertingAllResourcesDescription": "Les alertes se déclenchent pour n'importe quelle ressource", + "alertingSpecificResources": "Ressources spécifiques", + "alertingSpecificResourcesDescription": "Choisissez des ressources spécifiques à surveiller", + "alertingSelectResources": "Sélectionner des ressources…", + "alertingResourcesSelected": "{count} ressources sélectionnées", + "alertingResourcesEmpty": "Aucune ressource avec des cibles dans les 10 premiers résultats.", + "alertingSectionTrigger": "Déclencheur", + "alertingTrigger": "Quand alerter", + "alertingTriggerSiteOnline": "Site en ligne", + "alertingTriggerSiteOffline": "Site hors ligne", + "alertingTriggerSiteToggle": "Les changements d'état du site", + "alertingTriggerHcHealthy": "Vérification de l'état de santé sain", + "alertingTriggerHcUnhealthy": "Vérification de l'état de santé non sain", + "alertingTriggerHcToggle": "Les changements d'état de la vérification de l'état de santé", + "alertingTriggerResourceHealthy": "Ressource saine", + "alertingTriggerResourceUnhealthy": "Ressource non saine", + "alertingTriggerResourceDegraded": "Ressource dégradée", + "alertingSearchHealthChecks": "Rechercher des vérifications de l'état de santé…", + "alertingHealthChecksEmpty": "Aucune vérification de l'état de santé disponible.", + "alertingTriggerResourceToggle": "Les changements d'état de la ressource", + "alertingSourceResource": "Ressource", + "alertingSectionActions": "Actions", + "alertingAddAction": "Ajouter une action", + "alertingActionNotify": "Adresse mail", + "alertingActionNotifyDescription": "Envoyez des notifications par e-mail aux utilisateurs ou aux rôles", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Envoyez une requête HTTP à un point de terminaison personnalisé", + "alertingExternalIntegration": "Intégration externe", + "alertingExternalPagerDutyDescription": "Envoyer des alertes à PagerDuty pour la gestion des incidents", + "alertingExternalOpsgenieDescription": "Diriger les alertes vers Opsgenie pour la gestion des appels", + "alertingExternalServiceNowDescription": "Créer des incidents ServiceNow à partir des événements d'alerte", + "alertingExternalIncidentIoDescription": "Déclencher des flux de travail Incident.io à partir d'événements d'alerte", + "alertingActionType": "Type d'action", + "alertingNotifyUsers": "Utilisateurs", + "alertingNotifyRoles": "Rôles", + "alertingNotifyEmails": "Adresses e-mail", + "alertingEmailPlaceholder": "Ajoutez un e-mail et appuyez sur Entrée", + "alertingWebhookMethod": "Méthode HTTP", + "alertingWebhookSecret": "Secret de signature (facultatif)", + "alertingWebhookSecretPlaceholder": "Secret HMAC", + "alertingWebhookHeaders": "En-têtes", + "alertingAddHeader": "Ajouter un en-tête", + "alertingSelectSites": "Sélectionner des sites…", + "alertingSitesSelected": "{count} sites sélectionnés", + "alertingSelectHealthChecks": "Sélectionner des vérifications de l'état de santé…", + "alertingHealthChecksSelected": "{count} vérifications de santé sélectionnées", + "alertingNoHealthChecks": "Aucune cible avec des vérifications de l'état de santé activées", + "alertingHealthCheckStub": "La sélection de la source de vérification de l'état de santé n'est pas encore câblée - vous pouvez toujours configurer les déclencheurs et les actions.", + "alertingSelectUsers": "Sélectionner des utilisateurs…", + "alertingUsersSelected": "{count} utilisateurs sélectionnés", + "alertingSelectRoles": "Sélectionner des rôles…", + "alertingRolesSelected": "{count} rôles sélectionnés", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryAllSites": "Tous les nœuds", + "alertingSummaryHealthChecks": "Vérifications de l'état de santé ({count})", + "alertingSummaryAllHealthChecks": "Toutes les vérifications de l'état de santé", + "alertingSummaryResources": "Ressources ({count})", + "alertingSummaryAllResources": "Toutes les ressources", + "alertingErrorNameRequired": "Entrer un nom", + "alertingErrorActionsMin": "Ajoutez au moins une action", + "alertingErrorPickSites": "Sélectionnez au moins un site", + "alertingErrorPickHealthChecks": "Sélectionnez au moins une vérification de l'état de santé", + "alertingErrorPickResources": "Sélectionnez au moins une ressource", + "alertingErrorTriggerSite": "Choisissez un déclencheur de site", + "alertingErrorTriggerHealth": "Choisissez un déclencheur de vérification de l'état de santé", + "alertingErrorTriggerResource": "Choisissez un déclencheur de ressource", + "alertingErrorNotifyRecipients": "Choisissez des utilisateurs, des rôles ou au moins un e-mail", + "alertingConfigureSource": "Configurer la source", + "alertingConfigureTrigger": "Configurer le déclencheur", + "alertingConfigureActions": "Configurer les actions", + "alertingBackToRules": "Retour aux règles", + "alertingRuleCooldown": "Temps de repos (secondes)", + "alertingRuleCooldownDescription": "Temps minimum entre les alertes répétées pour la même règle. Réglez sur 0 pour déclencher à chaque fois.", + "alertingDraftBadge": "Brouillon - enregistrez pour stocker cette règle", + "alertingSidebarHint": "Cliquez sur une étape dans la vue d'ensemble pour la modifier ici.", + "alertingGraphCanvasTitle": "Flux de règle", + "alertingGraphCanvasDescription": "Vue d'ensemble visuelle de la source, du déclencheur et des actions. Sélectionnez un nœud pour le modifier dans le panneau.", + "alertingNodeNotConfigured": "Pas encore configuré", + "alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}", + "alertingNodeRoleSource": "Source", + "alertingNodeRoleTrigger": "Déclencheur", + "alertingNodeRoleAction": "Action", + "alertingTabRules": "Règles d'alerte", + "alertingTabHealthChecks": "Vérifications de l'état de santé", + "alertingRulesBannerTitle": "Soyez averti", + "alertingRulesBannerDescription": "Chaque règle associe ce qu'il faut surveiller (un site, une vérification de l'état de santé ou une ressource), quand l'exécuter (par exemple, hors ligne ou non saine), et comment notifier votre équipe par e-mail, webhooks ou intégrations. Utilisez cette liste pour créer, activer et gérer ces règles.", + "alertingHealthChecksBannerTitle": "Surveiller la santé et les ressources", + "alertingHealthChecksBannerDescription": "Les vérifications de l'état de santé sont des moniteurs HTTP ou TCP que vous définissez une fois. Vous pouvez ensuite les utiliser comme sources dans les règles d'alerte pour être averti lorsqu'une cible devient saine ou non saine. Les vérifications de l'état de santé sur les ressources apparaissent également ici.", + "standaloneHcTableTitle": "Vérifications de l'état de santé", + "standaloneHcSearchPlaceholder": "Rechercher des vérifications de l'état de santé…", + "standaloneHcAddButton": "Créer une vérification de l'état de santé", + "standaloneHcCreateTitle": "Créer une vérification de l'état de santé", + "standaloneHcEditTitle": "Modifier la vérification de l'état de santé", + "standaloneHcDescription": "Configurez une vérification HTTP ou TCP de l'état de santé pour une utilisation dans les règles d'alerte.", + "standaloneHcNameLabel": "Nom", + "standaloneHcNamePlaceholder": "Mon moniteur HTTP", + "standaloneHcDeleteTitle": "Supprimer la vérification de l'état de santé", + "standaloneHcDeleteQuestion": "Veuillez confirmer que você souhaitez supprimer cette vérification de l'état de santé.", + "standaloneHcDeleted": "Vérification de l'état de santé supprimée", + "standaloneHcSaved": "Vérification de l'état de santé enregistrée", + "standaloneHcColumnHealth": "Santé", + "standaloneHcColumnMode": "Mode", + "standaloneHcColumnTarget": "Cible", + "standaloneHcHealthStateHealthy": "Sain", + "standaloneHcHealthStateUnhealthy": "En mauvaise santé", + "standaloneHcHealthStateUnknown": "Inconnu", + "standaloneHcFilterAnySite": "Tous les nœuds", + "standaloneHcFilterAnyResource": "Toutes les ressources", + "standaloneHcFilterMode": "Mode", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Santé", + "standaloneHcFilterEnabled": "Activé", + "standaloneHcFilterEnabledOn": "Activé", + "standaloneHcFilterEnabledOff": "Désactivé", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Ressource {id}", "blueprints": "Configs", "blueprintsDescription": "Appliquer les configurations déclaratives et afficher les exécutions précédentes", "blueprintAdd": "Ajouter une Config", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Créer le compte administrateur du serveur initial. Un seul administrateur serveur peut exister. Vous pouvez toujours changer ces informations d'identification plus tard.", "createAdminAccount": "Créer un compte administrateur", "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.", - "certificateStatus": "Statut du certificat", + "certificateStatus": "Certificat", + "certificateStatusAutoRefreshHint": "L'état se rafraîchit automatiquement.", "loading": "Chargement", "loadingAnalytics": "Chargement de l'analyse", "restart": "Redémarrer", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Voir les notes de publication", "newtUpdateAvailable": "Mise à jour disponible", "newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", + "pangolinNodeUpdateAvailableInfo": "Une nouvelle version de Pangolin Node est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", "domainPickerEnterDomain": "Domaine", "domainPickerPlaceholder": "monapp.exemple.com", "domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Configurer la vérification de l'état de santé", "configureHealthCheckDescription": "Configurer la surveillance de la santé pour {target}", "enableHealthChecks": "Activer les vérifications de santé", + "healthCheckDisabledStateDescription": "Lorsqu'il est désactivé, le site ne procédera pas aux vérifications de santé et l'état sera considéré comme inconnu.", "enableHealthChecksDescription": "Surveiller la vie de cette cible. Vous pouvez surveiller un point de terminaison différent de la cible si nécessaire.", "healthScheme": "Méthode", "healthSelectScheme": "Sélectionnez la méthode", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "L'intervalle de vérification doit être d'au moins 5 secondes", "healthCheckTimeoutMin": "Le délai doit être d'au moins 1 seconde", "healthCheckRetryMin": "Les tentatives de réessai doivent être d'au moins 1", + "healthCheckMode": "Mode de vérification", + "healthCheckStrategy": "Stratégie", + "healthCheckModeDescription": "Le mode TCP vérifie uniquement la connectivité. Le mode HTTP valide la réponse HTTP.", + "healthyThreshold": "Seuil de santé", + "healthyThresholdDescription": "Succès consécutifs requis avant de marquer comme sain.", + "unhealthyThreshold": "Seuil de non-santé", + "unhealthyThresholdDescription": "Echecs consécutifs requis avant de signaler comme non sain.", + "healthCheckHealthyThresholdMin": "Le seuil de santé doit être d'au moins 1", + "healthCheckUnhealthyThresholdMin": "Le seuil de non-santé doit être d'au moins 1", "httpMethod": "Méthode HTTP", "selectHttpMethod": "Sélectionnez la méthode HTTP", "domainPickerSubdomainLabel": "Sous-domaine", + "domainPickerWildcard": "Joker", + "domainPickerWildcardPaidOnly": "Les sous-domaines Joker sont une fonctionnalité payante. Veuillez mettre à niveau pour accéder à cette fonctionnalité.", "domainPickerBaseDomainLabel": "Domaine de base", "domainPickerSearchDomains": "Rechercher des domaines...", "domainPickerNoDomainsFound": "Aucun domaine trouvé", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Cette adresse fait partie du sous-réseau utilitaire de l'organisation. Elle est utilisée pour résoudre les enregistrements d'alias en utilisant une résolution DNS interne.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "et sont uniquement accessibles en interne lorsqu'elles sont connectées avec un client.", - "resourcesTableNoTargets": "Aucune cible", "resourcesTableHealthy": "Sain", "resourcesTableDegraded": "Dégradé", - "resourcesTableOffline": "Hors ligne", + "resourcesTableUnhealthy": "En mauvaise santé", "resourcesTableUnknown": "Inconnu", "resourcesTableNotMonitored": "Non-monitoré", + "resourcesTableNoTargets": "Aucune cible", "editInternalResourceDialogEditClientResource": "Modifier une ressource privée", "editInternalResourceDialogUpdateResourceProperties": "Mettre à jour la configuration de la ressource et les contrôles d'accès pour {resourceName}", "editInternalResourceDialogResourceProperties": "Propriétés de la ressource", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Hôte", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Méthode HTTP", + "editInternalResourceDialogEnableSsl": "Activer SSL", + "editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.", "editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", "editInternalResourceDialogDestinationIPDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Nom", "createInternalResourceDialogSite": "Site", "selectSite": "Sélectionner un site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "Aucun site trouvé.", "createInternalResourceDialogProtocol": "Protocole", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Hôte", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Méthode HTTP", + "createInternalResourceDialogScheme": "Méthode HTTP", + "createInternalResourceDialogEnableSsl": "Activer SSL", + "createInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.", "createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", "createInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.", + "internalResourceDownstreamSchemeRequired": "Un schéma est requis pour les ressources HTTP", + "internalResourceHttpPortRequired": "Le port de destination est requis pour les ressources HTTP", "siteConfiguration": "Configuration", "siteAcceptClientConnections": "Accepter les connexions client", "siteAcceptClientConnectionsDescription": "Autoriser les utilisateurs et les clients à accéder aux ressources de ce site. Cela peut être modifié plus tard.", @@ -1989,7 +2213,7 @@ "description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires", "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.", - "introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin — vos tunnels, la terminaison SSL 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 :", + "introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin - vos tunnels, la terminaison SSL 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": { "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." @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Domaine international détecté", "willbestoredas": "Sera stocké comme :", - "roleMappingDescription": "Détermine comment les rôles sont assignés aux utilisateurs lorsqu'ils se connectent lorsque la fourniture automatique est activée.", + "roleMappingDescription": "Déterminez comment les rôles sont attribués aux utilisateurs lorsqu'ils se connectent avec ce fournisseur d'identité.", "selectRole": "Sélectionnez un rôle", "roleMappingExpression": "Expression", "selectRolePlaceholder": "Choisir un rôle", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Sélectionnez un domaine pour la page d'authentification de l'organisation", "domainPickerProvidedDomain": "Domaine fourni", "domainPickerFreeProvidedDomain": "Domaine fourni gratuitement", + "domainPickerFreeDomainsPaidFeature": "Les domaines fournis sont une fonctionnalité payante. Abonnez-vous pour obtenir un domaine inclus avec votre plan - plus besoin de fournir le vôtre.", "domainPickerVerified": "Vérifié", "domainPickerUnverified": "Non vérifié", - "domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.", + "domainPickerManual": "Manuel", + "domainPickerInvalidSubdomainStructure": "Les caractères invalides seront nettoyés lors de l'enregistrement.", "domainPickerError": "Erreur", "domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation", "domainPickerErrorCheckAvailability": "Impossible de vérifier la disponibilité du domaine", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Choisissez votre fournisseur d'identité pour continuer", "orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.", "orgAuthSignInWithPangolin": "Se connecter avec Pangolin", - "orgAuthSignInToOrg": "Se connecter à une organisation", + "orgAuthSignInToOrg": "Fournisseur d'identité d'organisation (SSO)", "orgAuthSelectOrgTitle": "Connexion à l'organisation", "orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer", "orgAuthOrgIdPlaceholder": "votre-organisation", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Divulgation d'utilisation", - "description": "Sélectionnez le niveau de licence qui correspond exactement à votre utilisation prévue. La Licence Personnelle autorise l'utilisation libre du Logiciel pour des activités commerciales individuelles, non commerciales ou à petite échelle avec un revenu annuel brut inférieur à 100 000 USD. Toute utilisation au-delà de ces limites — y compris l'utilisation au sein d'une entreprise, d'une organisation, ou tout autre environnement générateur de revenus — nécessite une licence d’entreprise valide et le paiement des droits de licence applicables. Tous les utilisateurs, qu'ils soient personnels ou d'entreprise, doivent se conformer aux conditions de licence commerciale Fossorial." + "description": "Sélectionnez le niveau de licence qui correspond exactement à votre utilisation prévue. La Licence Personnelle autorise l'utilisation libre du Logiciel pour des activités commerciales individuelles, non commerciales ou à petite échelle avec un revenu annuel brut inférieur à 100 000 USD. Toute utilisation au-delà de ces limites - y compris l'utilisation au sein d'une entreprise, d'une organisation, ou tout autre environnement générateur de revenus - nécessite une licence d’entreprise valide et le paiement des droits de licence applicables. Tous les utilisateurs, qu'ils soient personnels ou d'entreprise, doivent se conformer aux conditions de licence commerciale Fossorial." }, "trialPeriodInformation": { "title": "Informations sur la période d'essai", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Échelle", - "description": "Fonctionnalités d'entreprise, 50 utilisateurs, 50 sites et une prise en charge prioritaire." + "description": "Fonctionnalités d'entreprise, 50 utilisateurs, 100 sites et support prioritaire." } }, "personalUseOnly": "Usage personnel uniquement (licence gratuite - pas de validation)", @@ -2422,6 +2648,7 @@ "validPassword": "Mot de passe valide", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Client connecté", "resourceBlocked": "Ressource bloquée", "droppedByRule": "Abandonné par la règle", "noSessions": "Aucune session", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Ajouter des clients", "editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationDescription": "Indiquez l'adresse de destination pour la ressource interne. Cela peut être un nom d'hôte, une adresse IP ou une plage CIDR selon le mode sélectionné. Définissez éventuellement un alias DNS interne pour une identification plus facile.", + "internalResourceFormMultiSiteRoutingHelp": "La sélection de plusieurs sites permet un routage résilient et un basculement pour une haute disponibilité.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "En savoir plus", "editInternalResourceDialogPortRestrictionsDescription": "Restreindre l'accès à des ports TCP/UDP spécifiques ou autoriser/bloquer tous les ports.", + "createInternalResourceDialogHttpConfiguration": "Configuration HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Choisissez le domaine que les clients utiliseront pour atteindre cette ressource via HTTP ou HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Configuration HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Choisissez le domaine que les clients utiliseront pour atteindre cette ressource via HTTP ou HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Nous serons bientôt de retour ! Notre site est actuellement en maintenance planifiée.", "maintenancePageMessageDescription": "Message détaillé expliquant la maintenance", "maintenancePageTimeTitle": "Temps d'achèvement estimé (facultatif)", + "privateMaintenanceScreenTitle": "Écran de maintien de service privé", + "privateMaintenanceScreenMessage": "Ce domaine est utilisé sur une ressource privée. Veuillez vous connecter à l'aide du client Pangolin pour accéder à cette ressource.", + "privateMaintenanceScreenSteps": "Une fois connecté, si vous voyez toujours ce message, le cache DNS de votre navigateur peut toujours pointer vers l'ancienne adresse. Pour résoudre cela : fermez complètement et rouvrez cet onglet, ou votre navigateur, puis retournez sur cette page.", "maintenanceTime": "par exemple, 2 heures, le 1er nov. à 17:00", "maintenanceEstimatedTimeDescription": "Quand vous attendez que la maintenance soit terminée", "editDomain": "Modifier le domaine", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Ajouter une destination HTTP", "httpDestEditDescription": "Mettre à jour la configuration pour cette destination de streaming d'événements HTTP.", "httpDestAddDescription": "Configurez un nouveau point de terminaison HTTP pour recevoir les événements de votre organisation.", + "S3DestEditTitle": "Modifier la destination", + "S3DestAddTitle": "Ajouter une destination S3", + "S3DestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements S3.", + "S3DestAddDescription": "Configurer un nouveau point de terminaison S3 pour recevoir les événements de votre organisation.", + "datadogDestEditTitle": "Modifier la destination", + "datadogDestAddTitle": "Ajouter une destination Datadog", + "datadogDestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements Datadog.", + "datadogDestAddDescription": "Configurer un nouveau point de terminaison Datadog pour recevoir les événements de votre organisation.", "httpDestTabSettings": "Réglages", "httpDestTabHeaders": "En-têtes", "httpDestTabBody": "Corps", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "Tableau JSON", "httpDestFormatJsonArrayDescription": "Une requête par lot, le corps est un tableau JSON. Compatible avec la plupart des webhooks génériques et des datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Une requête par lot, body est un JSON délimité par une nouvelle ligne — un objet par ligne, pas de tableau extérieur. Requis par Splunk HEC, Elastic / OpenSearch, et Grafana Loki.", + "httpDestFormatNdjsonDescription": "Une requête par lot, body est un JSON délimité par une nouvelle ligne - un objet par ligne, pas de tableau extérieur. Requis par Splunk HEC, Elastic / OpenSearch, et Grafana Loki.", "httpDestFormatSingleTitle": "Un événement par demande", "httpDestFormatSingleDescription": "Envoie un POST HTTP séparé pour chaque événement individuel. Utilisé uniquement pour les terminaux qui ne peuvent pas gérer des lots.", "httpDestLogTypesTitle": "Types de logs", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Destination mise à jour avec succès", "httpDestCreatedSuccess": "Destination créée avec succès", "httpDestUpdateFailed": "Impossible de mettre à jour la destination", - "httpDestCreateFailed": "Impossible de créer la destination" + "httpDestCreateFailed": "Impossible de créer la destination", + "followRedirects": "Suivre les redirections", + "followRedirectsDescription": "Suivre automatiquement les redirections HTTP pour les requêtes.", + "alertingErrorWebhookUrl": "Veuillez entrer une URL valide pour le webhook.", + "healthCheckStrategyHttp": "Valide la connectivité et vérifie le statut de la réponse HTTP.", + "healthCheckStrategyTcp": "Vérifie uniquement la connectivité TCP, sans inspecter la réponse.", + "healthCheckStrategySnmp": "Effectue une requête SNMP pour vérifier la santé des dispositifs et de l'infrastructure réseau.", + "healthCheckStrategyIcmp": "Utilise des requêtes écho ICMP (pings) pour vérifier si une ressource est accessible et réactive.", + "healthCheckTabStrategy": "Stratégie", + "healthCheckTabConnection": "Connexion", + "healthCheckTabAdvanced": "Avancé", + "healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.", + "uptime30d": "Disponibilité (30j)", + "idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité", + "idpAddActionImportFromOrg": "Importer d'une autre organisation", + "idpImportDialogTitle": "Importer le fournisseur d'identité", + "idpImportDialogDescription": "Choisissez un fournisseur d'identités d'une organisation où vous êtes administrateur. Il sera lié à cette organisation.", + "idpImportSearchPlaceholder": "Recherche par nom d'organisation ou de fournisseur...", + "idpImportEmpty": "Aucun fournisseur d'identités trouvé.", + "idpImportedDescription": "Fournisseur d'identités importé avec succès.", + "idpDeleteGlobalQuestion": "Êtes-vous sûr de vouloir supprimer définitivement ce fournisseur d'identités?", + "idpDeleteGlobalDescription": "Cela supprimera définitivement le fournisseur d'identités de toutes les organisations auxquelles il est associé.", + "idpUnassociateTitle": "Dissocier le fournisseur d'identité", + "idpUnassociateQuestion": "Êtes-vous sûr de vouloir dissocier ce fournisseur d'identités de cette organisation?", + "idpUnassociateDescription": "Tous les utilisateurs associés à ce fournisseur d'identités seront retirés de cette organisation, mais le fournisseur d'identités continuera d'exister pour d'autres organisations associées.", + "idpUnassociateConfirm": "Confirmer la dissociation du fournisseur d'identités", + "idpUnassociateWarning": "Cela ne peut pas être annulé pour cette organisation.", + "idpUnassociatedDescription": "Fournisseur d'identités dissocié de cette organisation avec succès", + "idpUnassociateMenu": "Dissocier", + "idpDeleteAllOrgsMenu": "Supprimer", + "publicIpEndpoint": "Point de terminaison", + "lastTriggeredAt": "Dernier déclenchement", + "reject": "Rejeter", + "uptimeDaysAgo": "Il y a {count} jours", + "uptimeToday": "Aujourd'hui", + "uptimeNoDataAvailable": "Aucune donnée disponible", + "uptimeSuffix": "disponibilité", + "uptimeDowntimeSuffix": "indisponibilité", + "uptimeTooltipUptimeLabel": "Disponibilité", + "uptimeTooltipDowntimeLabel": "Indisponibilité", + "uptimeOngoing": "en cours", + "uptimeNoMonitoringData": "Pas de données de surveillance", + "uptimeNoData": "Aucune donnée", + "uptimeMiniBarDown": "Non disponible", + "uptimeSectionTitle": "Disponibilité", + "uptimeSectionDescription": "Disponibilité sur les {days} derniers jours", + "uptimeAddAlert": "Ajouter une alerte", + "uptimeViewAlerts": "Voir les alertes", + "uptimeCreateEmailAlert": "Créer une alerte par e-mail", + "uptimeAlertDescriptionSite": "Recevez un e-mail lorsque ce site est hors ligne ou revient en ligne.", + "uptimeAlertDescriptionResource": "Recevez un e-mail lorsque cette ressource est hors ligne ou revient en ligne.", + "uptimeAlertNamePlaceholder": "Nom de l'alerte", + "uptimeAdditionalEmails": "E-mails supplémentaires", + "uptimeCreateAlert": "Créer une alerte", + "uptimeAlertNoRecipients": "Aucun destinataire", + "uptimeAlertNoRecipientsDescription": "Veuillez ajouter au moins un utilisateur, rôle ou e-mail à notifier.", + "uptimeAlertCreated": "Alerte créé", + "uptimeAlertCreatedDescription": "Vous serez notifié lorsque ce statut changera.", + "uptimeAlertCreateFailed": "Échec de la création de l'alerte", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Clé", + "webhookHeaderValuePlaceholder": "Valeur", + "alertLabel": "Alerte", + "domainPickerWildcardSubdomainNotAllowed": "Les sous-domaines Joker ne sont pas autorisés.", + "domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.", + "domainPickerWildcardCertWarningLink": "En savoir plus", + "health": "Santé", + "domainPendingErrorTitle": "Problème de vérification" } diff --git a/messages/it-IT.json b/messages/it-IT.json index 7aa39bb74..f51bd5845 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1,24 +1,36 @@ { + "contactSalesEnable": "Contatta le vendite per abilitare questa funzionalità.", + "contactSalesBookDemo": "Prenota una demo", + "contactSalesOr": "o", + "contactSalesContactUs": "contattaci", "setupCreate": "Creare l'organizzazione, il sito e le risorse", - "headerAuthCompatibilityInfo": "Abilita questo per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.", + "headerAuthCompatibilityInfo": "Abilita questa funzionalità per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.", "headerAuthCompatibility": "Compatibilità estesa", "setupNewOrg": "Nuova Organizzazione", "setupCreateOrg": "Crea Organizzazione", "setupCreateResources": "Crea Risorse", - "setupOrgName": "Nome Dell'Organizzazione", + "setupOrgName": "Nome dell'Organizzazione", "orgDisplayName": "Questo è il nome visualizzato dell'organizzazione.", "orgId": "Id Organizzazione", "setupIdentifierMessage": "Questo è l'identificatore univoco per l'organizzazione.", "setupErrorIdentifier": "L'ID dell'organizzazione è già utilizzato. Si prega di sceglierne uno diverso.", "componentsErrorNoMemberCreate": "Al momento non sei un membro di nessuna organizzazione. Crea un'organizzazione per iniziare.", "componentsErrorNoMember": "Attualmente non sei membro di nessuna organizzazione.", - "welcome": "Benvenuti a Pangolin", - "welcomeTo": "Benvenuto a", + "welcome": "Benvenuto su Pangolin!", + "welcomeTo": "Benvenuto su Pangolin!", "componentsCreateOrg": "Crea un'organizzazione", "componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.", "componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.", "dismiss": "Ignora", "subscriptionViolationMessage": "Hai superato i tuoi limiti per il tuo piano attuale. Correggi il problema rimuovendo siti, utenti o altre risorse per rimanere all'interno del tuo piano.", + "trialBannerMessage": "Il tuo periodo di prova scade tra {countdown}. Aggiorna per mantenere l'accesso.", + "trialBannerExpired": "Il tuo periodo di prova è scaduto. Aggiorna ora per ripristinare l'accesso.", + "trialActive": "Prova Gratuita Attiva", + "trialExpired": "Prova scaduta", + "trialHasEnded": "La tua prova è terminata.", + "trialDaysRemaining": "{count, plural, one {# giorno rimanente} other {# giorni rimanenti}}", + "trialDaysLeftShort": "{days}g rimasti nella prova", + "trialGoToBilling": "Vai alla pagina di fatturazione", "subscriptionViolationViewBilling": "Visualizza fatturazione", "componentsLicenseViolation": "Violazione della licenza: Questo server sta usando i siti {usedSites} che superano il suo limite concesso in licenza per i siti {maxSites} . Segui i termini di licenza per continuare a usare tutte le funzionalità.", "componentsSupporterMessage": "Grazie per aver supportato Pangolin come {tier}!", @@ -27,7 +39,7 @@ "inviteLoginUser": "Assicurati di aver effettuato l'accesso come utente corretto.", "inviteErrorNoUser": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia per un utente che esiste.", "inviteCreateUser": "Si prega di creare un account prima.", - "goHome": "Vai A Home", + "goHome": "Vai alla Home", "inviteLogInOtherUser": "Accedi come utente diverso", "createAnAccount": "Crea un account", "inviteNotAccepted": "Invito Non Accettato", @@ -51,7 +63,7 @@ "edit": "Modifica", "siteConfirmDelete": "Conferma Eliminazione Sito", "siteDelete": "Elimina Sito", - "siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli obiettivi associati al sito verranno rimossi.", + "siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.", "siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?", "siteManageSites": "Gestisci Siti", "siteDescription": "Creare e gestire siti per abilitare la connettività a reti private", @@ -75,12 +87,14 @@ "siteLoadWGConfig": "Caricamento configurazione WireGuard...", "siteDocker": "Espandi per i dettagli di distribuzione Docker", "toggle": "Attiva/disattiva", - "dockerCompose": "Composizione Docker", + "dockerCompose": "Docker Compose", "dockerRun": "Corsa Docker", - "siteLearnLocal": "I siti locali non tunnel, saperne di più", + "siteLearnLocal": "I siti locali non effettuano il tunnel, per saperne di più", "siteConfirmCopy": "Ho copiato la configurazione", "searchSitesProgress": "Cerca siti...", "siteAdd": "Aggiungi Sito", + "sitesTableViewPublicResources": "Visualizza Risorse Pubbliche", + "sitesTableViewPrivateResources": "Visualizza Risorse Private", "siteInstallNewt": "Installa Newt", "siteInstallNewtDescription": "Esegui Newt sul tuo sistema", "WgConfiguration": "Configurazione WireGuard", @@ -88,29 +102,44 @@ "operatingSystem": "Sistema Operativo", "commands": "Comandi", "recommended": "Consigliato", - "siteNewtDescription": "Per la migliore esperienza utente, utilizzare Newt. Utilizza WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.", + "siteNewtDescription": "Per la migliore esperienza utente utilizzare Newt, che usa WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.", "siteRunsInDocker": "Esegue nel Docker", "siteRunsInShell": "Esegue in shell su macOS, Linux e Windows", - "siteErrorDelete": "Errore nell'eliminare il sito", + "siteErrorDelete": "Errore nella eliminazione del sito", "siteErrorUpdate": "Impossibile aggiornare il sito", "siteErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento del sito.", "siteUpdated": "Sito aggiornato", "siteUpdatedDescription": "Il sito è stato aggiornato.", "siteGeneralDescription": "Configura le impostazioni generali per questo sito", "siteSettingDescription": "Configura le impostazioni del sito", - "siteSetting": "Impostazioni {siteName}", + "siteResourcesTab": "Risorse", + "siteResourcesNoneOnSite": "Questo sito non ha ancora risorse pubbliche o private.", + "siteResourcesSectionPublic": "Risorse Pubbliche", + "siteResourcesSectionPrivate": "Risorse Private", + "siteResourcesSectionPublicDescription": "Risorse esposte esternamente attraverso domini o porte.", + "siteResourcesSectionPrivateDescription": "Risorse disponibili sulla tua rete privata tramite il sito.", + "siteResourcesViewAllPublic": "Visualizza tutte le risorse", + "siteResourcesViewAllPrivate": "Visualizza tutte le risorse", + "siteResourcesDialogDescription": "Panoramica delle risorse pubbliche e private associate a questo sito.", + "siteResourcesShowMore": "Mostra Altro", + "siteResourcesPermissionDenied": "Non hai il permesso di elencare queste risorse.", + "siteResourcesEmptyPublic": "Ancora nessuna risorsa pubblica punta a questo sito.", + "siteResourcesEmptyPrivate": "Ancora nessuna risorsa privata è associata a questo sito.", + "siteResourcesHowToAccess": "Come accedere", + "siteResourcesTargetsOnSite": "Obiettivi su questo sito", + "siteSetting": "Impostazioni del sito {siteName}", "siteNewtTunnel": "Nuovo Sito (Consigliato)", "siteNewtTunnelDescription": "Modo più semplice per creare un entrypoint in qualsiasi rete. Nessuna configurazione aggiuntiva.", "siteWg": "WireGuard Base", - "siteWgDescription": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.", - "siteWgDescriptionSaas": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta. FUNZIONA SOLO SU NODI AUTO-OSPITATI", + "siteWgDescription": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.", + "siteWgDescriptionSaas": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.", "siteLocalDescription": "Solo risorse locali. Nessun tunneling.", "siteLocalDescriptionSaas": "Solo risorse locali. Nessun tunneling. Disponibile solo su nodi remoti.", "siteSeeAll": "Vedi Tutti I Siti", - "siteTunnelDescription": "Determinare come si desidera connettersi al sito", + "siteTunnelDescription": "Selezionare la modalità con la quale si desidera connettersi al sito", "siteNewtCredentials": "Credenziali", - "siteNewtCredentialsDescription": "Questo è come il sito si autenticerà con il server", - "remoteNodeCredentialsDescription": "Questo è come il nodo remoto si autenticherà con il server", + "siteNewtCredentialsDescription": "Questo è come il sito si autenticherà con il server", + "remoteNodeCredentialsDescription": "Questo è il modo in cui il nodo remoto si autenticherà con il server", "siteCredentialsSave": "Salva le credenziali", "siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.", "siteInfo": "Informazioni Sito", @@ -140,8 +169,8 @@ "shareCreateDescription": "Chiunque con questo link può accedere alla risorsa", "shareTitleOptional": "Titolo (facoltativo)", "expireIn": "Scadenza In", - "neverExpire": "Mai scadere", - "shareExpireDescription": "Il tempo di scadenza è per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.", + "neverExpire": "Nessuna scadenza", + "shareExpireDescription": "Il tempo di scadenza indica per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.", "shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.", "shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.", "shareTokenUsage": "Vedi Utilizzo Token Di Accesso", @@ -161,9 +190,9 @@ "never": "Mai", "shareErrorSelectResource": "Seleziona una risorsa", "proxyResourceTitle": "Gestisci Risorse Pubbliche", - "proxyResourceDescription": "Creare e gestire risorse accessibili al pubblico tramite un browser web", + "proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web", "proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web", - "proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili a chiunque su Internet tramite un browser web. A differenza delle risorse private, non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", + "proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", "clientResourceTitle": "Gestisci Risorse Private", "clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso", "privateResourcesBannerTitle": "Accesso Privato Zero-Trust", @@ -174,12 +203,12 @@ "authentication": "Autenticazione", "protected": "Protetto", "notProtected": "Non Protetto", - "resourceMessageRemove": "Una volta rimossa, la risorsa non sarà più accessibile. Tutti gli obiettivi associati alla risorsa saranno rimossi.", + "resourceMessageRemove": "Una volta rimossa la risorsa non sarà più accessibile. Tutti gli oggetti target associati alla risorsa saranno rimossi.", "resourceQuestionRemove": "Sei sicuro di voler rimuovere la risorsa dall'organizzazione?", "resourceHTTP": "Risorsa HTTPS", "resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.", "resourceRaw": "Risorsa Raw TCP/UDP", - "resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.", + "resourceRawDescription": "Richieste proxy su TCP/UDP raw utilizzando un numero di porta.", "resourceRawDescriptionCloud": "Richiesta proxy su TCP/UDP grezzo utilizzando un numero di porta. Richiede siti per connettersi a un nodo remoto.", "resourceCreate": "Crea Risorsa", "resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa", @@ -192,7 +221,7 @@ "selectCountry": "Seleziona paese", "searchCountries": "Cerca paesi...", "noCountryFound": "Nessun paese trovato.", - "siteSelectionDescription": "Questo sito fornirà connettività all'obiettivo.", + "siteSelectionDescription": "Questo sito fornirà connettività all'oggetto target.", "resourceType": "Tipo Di Risorsa", "resourceTypeDescription": "Determinare come accedere alla risorsa", "resourceHTTPSSettings": "Impostazioni HTTPS", @@ -206,13 +235,13 @@ "protocol": "Protocollo", "protocolSelect": "Seleziona un protocollo", "resourcePortNumber": "Numero Porta", - "resourcePortNumberDescription": "Il numero di porta esterna per le richieste di proxy.", + "resourcePortNumberDescription": "Il numero di porta esterna per le richieste proxy.", "back": "Indietro", "cancel": "Annulla", "resourceConfig": "Snippet Di Configurazione", "resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP", - "resourceAddEntrypoints": "Traefik: Aggiungi Ingresso", - "resourceExposePorts": "Gerbil: espone le porte in Docker componi", + "resourceAddEntrypoints": "Traefik: Aggiungi Entrypoint", + "resourceExposePorts": "Gerbil: espone le porte in Docker Compose", "resourceLearnRaw": "Scopri come configurare le risorse TCP/UDP", "resourceBack": "Torna alle risorse", "resourceGoTo": "Vai alla Risorsa", @@ -228,7 +257,7 @@ "rules": "Regole", "resourceSettingDescription": "Configura le impostazioni sulla risorsa", "resourceSetting": "Impostazioni {resourceName}", - "alwaysAllow": "Autenticazione Bypass", + "alwaysAllow": "Bypass Autenticazione", "alwaysDeny": "Blocca Accesso", "passToAuth": "Passa all'autenticazione", "orgSettingsDescription": "Configura le impostazioni dell'organizzazione", @@ -237,11 +266,11 @@ "saveGeneralSettings": "Salva Impostazioni Generali", "saveSettings": "Salva Impostazioni", "orgDangerZone": "Zona Pericolosa", - "orgDangerZoneDescription": "Una volta che si elimina questo org, non c'è ritorno. Si prega di essere certi.", + "orgDangerZoneDescription": "Una volta che si elimina questa org non sarà possibile tornare indietro, assicurarsi quindi di essere certi della decisione.", "orgDelete": "Elimina Organizzazione", "orgDeleteConfirm": "Conferma Elimina Organizzazione", "orgMessageRemove": "Questa azione è irreversibile e cancellerà tutti i dati associati.", - "orgMessageConfirm": "Per confermare, digita il nome dell'organizzazione qui sotto.", + "orgMessageConfirm": "Per confermare digita il nome dell'organizzazione qui sotto.", "orgQuestionRemove": "Sei sicuro di voler rimuovere l'organizzazione?", "orgUpdated": "Organizzazione aggiornata", "orgUpdatedDescription": "L'organizzazione è stata aggiornata.", @@ -254,10 +283,10 @@ "orgDeleted": "Organizzazione eliminata", "orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.", "deleteAccount": "Elimina Account", - "deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.", + "deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.", "deleteAccountButton": "Elimina Account", "deleteAccountConfirmTitle": "Elimina Account", - "deleteAccountConfirmMessage": "Questo cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.", + "deleteAccountConfirmMessage": "Questa operazione cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.", "deleteAccountConfirmString": "elimina account", "deleteAccountSuccess": "Account Eliminato", "deleteAccountSuccessMessage": "Il tuo account è stato eliminato.", @@ -267,12 +296,15 @@ "orgMissing": "ID Organizzazione Mancante", "orgMissingMessage": "Impossibile rigenerare l'invito senza un ID organizzazione.", "accessUsersManage": "Gestisci Utenti", + "accessUserManage": "Gestisci Utente", "accessUsersDescription": "Invita e gestisci gli utenti con accesso a questa organizzazione", "accessUsersSearch": "Cerca utenti...", + "accessUsersRoleFilterCount": "{count, plural, one {# ruolo} other {# ruoli}}", + "accessUsersRoleFilterClear": "Cancella filtri ruolo", "accessUserCreate": "Crea Utente", "accessUserRemove": "Rimuovi Utente", "username": "Nome utente", - "identityProvider": "Provider Di Identità", + "identityProvider": "Provider Identità", "role": "Ruolo", "nameRequired": "Il nome è obbligatorio", "accessRolesManage": "Gestisci Ruoli", @@ -328,8 +360,8 @@ "apiKeysDelete": "Elimina Chiave API", "apiKeysManage": "Gestisci Chiavi API", "apiKeysDescription": "Le chiavi API sono utilizzate per autenticarsi con l'API di integrazione", - "provisioningKeysTitle": "Chiave Di Provvedimento", - "provisioningKeysManage": "Gestisci Chiavi Di Provvedimento", + "provisioningKeysTitle": "Chiave di provisioning", + "provisioningKeysManage": "Gestisci Chiavi di provisioning", "provisioningKeysDescription": "Le chiavi di provisioning vengono utilizzate per autenticare il provisioning automatico del sito per la tua organizzazione.", "provisioningManage": "Accantonamento", "provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.", @@ -337,25 +369,25 @@ "siteApproveSuccess": "Sito approvato con successo", "siteApproveError": "Errore nell'approvazione del sito", "provisioningKeys": "Chiavi Di Provvedimento", - "searchProvisioningKeys": "Cerca i tasti di provisioning ...", - "provisioningKeysAdd": "Genera Chiave Di Provvedimento", - "provisioningKeysErrorDelete": "Errore nell'eliminare la chiave di provisioning", - "provisioningKeysErrorDeleteMessage": "Errore nell'eliminare la chiave di provisioning", + "searchProvisioningKeys": "Cerca le chiavi di provisioning...", + "provisioningKeysAdd": "Genera Chiave di provisioning", + "provisioningKeysErrorDelete": "Errore nell'eliminazione della chiave di provisioning", + "provisioningKeysErrorDeleteMessage": "Errore nell'eliminazione della chiave di provisioning", "provisioningKeysQuestionRemove": "Sei sicuro di voler rimuovere questa chiave di provisioning dall'organizzazione?", "provisioningKeysMessageRemove": "Una volta rimossa, la chiave non può più essere utilizzata per il provisioning.", - "provisioningKeysDeleteConfirm": "Conferma Elimina Chiave Provvisoria", + "provisioningKeysDeleteConfirm": "Conferma Eliminazione della chiave di provisioning", "provisioningKeysDelete": "Elimina chiave di provisioning", - "provisioningKeysCreate": "Genera Chiave Di Provvedimento", + "provisioningKeysCreate": "Genera Chiave di provisioning", "provisioningKeysCreateDescription": "Genera una nuova chiave di provisioning per l'organizzazione", "provisioningKeysSeeAll": "Vedi tutte le chiavi di provisioning", "provisioningKeysSave": "Salva la chiave di provisioning", "provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.", "provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning", "provisioningKeysList": "Nuova chiave di provisioning", - "provisioningKeysMaxBatchSize": "Dimensione massima lotto", - "provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del lotto (nessun limite)", + "provisioningKeysMaxBatchSize": "Dimensione massima batch", + "provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del batch (nessun limite)", "provisioningKeysMaxBatchUnlimited": "Illimitato", - "provisioningKeysMaxBatchSizeInvalid": "Inserisci un lotto massimo valido (1–1.000.000).", + "provisioningKeysMaxBatchSizeInvalid": "Inserisci una dimensione massima valida del batch (1–1.000.000).", "provisioningKeysValidUntil": "Valido fino al", "provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.", "provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.", @@ -363,14 +395,14 @@ "provisioningKeysLastUsed": "Ultimo utilizzo", "provisioningKeysNoExpiry": "Nessuna scadenza", "provisioningKeysNeverUsed": "Mai", - "provisioningKeysEdit": "Modifica Chiave Di Provvedimento", - "provisioningKeysEditDescription": "Aggiorna la dimensione massima del lotto e il tempo di scadenza per questa chiave.", + "provisioningKeysEdit": "Modifica Chiave di provisioning", + "provisioningKeysEditDescription": "Aggiorna la dimensione massima del batch e il tempo di scadenza per questa chiave.", "provisioningKeysApproveNewSites": "Approva nuovi siti", "provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.", "provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning", - "provisioningKeysUpdated": "Chiave di accantonamento aggiornata", + "provisioningKeysUpdated": "Chiave di provisioning aggiornata", "provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.", - "provisioningKeysBannerTitle": "Chiavi Di Provvedimento Sito", + "provisioningKeysBannerTitle": "Chiavi di provisioning del Sito", "provisioningKeysBannerDescription": "Genera una chiave di provisioning e usala con il connettore Newt per creare automaticamente i siti al primo avvio - non è necessario configurare credenziali separate per ogni sito.", "provisioningKeysBannerButtonText": "Scopri di più", "pendingSitesBannerTitle": "Siti In Attesa", @@ -386,7 +418,7 @@ "userErrorDelete": "Errore nell'eliminare l'utente", "userDeleteConfirm": "Conferma Eliminazione Utente", "userDeleteServer": "Elimina utente dal server", - "userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni ed essere completamente rimosso dal server.", + "userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni e verrà completamente rimosso dal server.", "userQuestionRemove": "Sei sicuro di voler eliminare definitivamente l'utente dal server?", "licenseKey": "Chiave Di Licenza", "valid": "Valido", @@ -404,9 +436,13 @@ "licenseKeyDeletedDescription": "La chiave di licenza è stata eliminata.", "licenseErrorKeyActivate": "Attivazione della chiave di licenza non riuscita", "licenseErrorKeyActivateDescription": "Si è verificato un errore nell'attivazione della chiave di licenza.", - "licenseAbout": "Informazioni Su Licenze", + "licenseAbout": "Informazioni sul Licensing", + "licenseBannerTitle": "Attiva la tua Licenza Enterprise", + "licenseBannerDescription": "Sblocca le funzionalità enterprise per la tua istanza Pangolin auto-ospitata. Acquista una chiave di licenza per attivare le capacità premium e poi aggiungila qui sotto.", + "licenseBannerGetLicense": "Ottieni una Licenza", + "licenseBannerViewDocs": "Visualizza Documentazione", "communityEdition": "Edizione Community", - "licenseAboutDescription": "Questo è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.", + "licenseAboutDescription": "Questa sezione è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.", "licenseKeyActivated": "Chiave di licenza attivata", "licenseKeyActivatedDescription": "La chiave di licenza è stata attivata correttamente.", "licenseErrorKeyRecheck": "Impossibile ricontrollare le chiavi di licenza", @@ -429,7 +465,7 @@ "licenseHostDescription": "Gestisci la chiave di licenza principale per l'host.", "licensedNot": "Non Licenziato", "hostId": "ID Host", - "licenseReckeckAll": "Ricontrolla Tutte Le Tasti", + "licenseReckeckAll": "Ricontrolla Tutte le chiavi", "licenseSiteUsage": "Utilizzo Siti", "licenseSiteUsageDecsription": "Visualizza il numero di siti che utilizzano questa licenza.", "licenseNoSiteLimit": "Non c'è alcun limite al numero di siti che utilizzano un host senza licenza.", @@ -480,7 +516,7 @@ "userOrgRemoved": "Utente rimosso", "userOrgRemovedDescription": "L'utente {email} è stato rimosso dall'organizzazione.", "userQuestionOrgRemove": "Sei sicuro di voler rimuovere questo utente dall'organizzazione?", - "userMessageOrgRemove": "Una volta rimosso, questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.", + "userMessageOrgRemove": "Una volta rimosso questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.", "userRemoveOrgConfirm": "Conferma Rimozione Utente", "userRemoveOrg": "Rimuovi Utente dall'Organizzazione", "users": "Utenti", @@ -532,13 +568,13 @@ "approve": "Approva", "approved": "Approvato", "denied": "Negato", - "deniedApproval": "Omologazione Negata", + "deniedApproval": "Approvazione Negata", "all": "Tutti", "deny": "Nega", "viewDetails": "Visualizza Dettagli", "requestingNewDeviceApproval": "ha richiesto un nuovo dispositivo", "resetFilters": "Ripristina Filtri", - "totalBlocked": "Richieste Bloccate Da Pangolino", + "totalBlocked": "Richieste Bloccate Da Pangolin", "totalRequests": "Totale Richieste", "requestsByCountry": "Richieste Per Paese", "requestsByDay": "Richieste Per Giorno", @@ -546,7 +582,7 @@ "allowed": "Consentito", "topCountries": "Paesi Principali", "accessRoleSelect": "Seleziona ruolo", - "inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. Devono accedere al link per accettare l'invito.", + "inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. L'utente deve accedere al link per accettare l'invito.", "inviteSentDescription": "L'utente è stato invitato. Deve accedere al link qui sotto per accettare l'invito.", "inviteExpiresIn": "L'invito scadrà tra {days, plural, one {# giorno} other {# giorni}}.", "idpTitle": "Informazioni Generali", @@ -562,7 +598,7 @@ "userSaved": "Utente salvato", "userSavedDescription": "L'utente è stato aggiornato.", "autoProvisioned": "Auto Provisioned", - "autoProvisionSettings": "Impostazioni Automatiche Di Fornitura", + "autoProvisionSettings": "Impostazioni Automatiche di provisioning", "autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità", "accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione", "accessControlsSubmit": "Salva Controlli di Accesso", @@ -576,9 +612,9 @@ "proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.", "proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.", "proxyEnableSSL": "Abilita SSL", - "proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure agli obiettivi.", + "proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alle risorse interne target.", "target": "Target", - "configureTarget": "Configura Obiettivi", + "configureTarget": "Configura Risorse Interne", "targetErrorFetch": "Impossibile recuperare i target", "targetErrorFetchDescription": "Si è verificato un errore durante il recupero dei target", "siteErrorFetch": "Impossibile recuperare la risorsa", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Segreto", + "newtVersion": "Versione", "architecture": "Architettura", "sites": "Siti", "siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse interne utilizzando l'IP del peer.", @@ -894,6 +931,7 @@ "idpDisplayName": "Un nome visualizzato per questo provider di identità", "idpAutoProvisionUsers": "Provisioning Automatico Utenti", "idpAutoProvisionUsersDescription": "Quando abilitato, gli utenti verranno creati automaticamente nel sistema al primo accesso con la possibilità di mappare gli utenti a ruoli e organizzazioni.", + "idpAutoProvisionConfigureAfterCreate": "Puoi configurare le impostazioni di auto fornitura una volta creato il provider di identità.", "licenseBadge": "EE", "idpType": "Tipo di Provider", "idpTypeDescription": "Seleziona il tipo di provider di identità che desideri configurare", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Mappatura Ruolo Predefinito", "defaultMappingsRoleDescription": "JMESPath per estrarre informazioni sul ruolo dal token ID. Il risultato di questa espressione deve restituire il nome del ruolo come definito nell'organizzazione come stringa.", "defaultMappingsOrg": "Mappatura Organizzazione Predefinita", - "defaultMappingsOrgDescription": "JMESPath per estrarre informazioni sull'organizzazione dal token ID. Questa espressione deve restituire l'ID dell'organizzazione o true affinché l'utente possa accedere all'organizzazione.", + "defaultMappingsOrgDescription": "Quando impostata, questa espressione deve restituire l'ID dell'organizzazione o true affinché l'utente possa accedere a quell'organizzazione. Quando non impostata, è sufficiente definire una mappatura di ruoli: l'utente è autorizzato se esiste una mappatura di ruolo valida per loro all'interno dell'organizzazione.", "defaultMappingsSubmit": "Salva Mappature Predefinite", "orgPoliciesEdit": "Modifica Politica Organizzazione", "org": "Organizzazione", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Visualizza Log", "noneSelected": "Nessuna selezione", "orgNotFound2": "Nessuna organizzazione trovata.", + "search": "Cerca…", "searchPlaceholder": "Cerca...", "emptySearchOptions": "Nessuna opzione trovata", "create": "Crea", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Gestisci", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Progetti", + "sidebarAlerting": "Allerta", + "sidebarHealthChecks": "Controlli di salute", "sidebarOrganization": "Organizzazione", "sidebarManagement": "Gestione", "sidebarBillingAndLicenses": "Fatturazione E Licenze", "sidebarLogsAnalytics": "Analisi", + "alertingTitle": "Allerta", + "alertingDescription": "Definisci fonti, trigger e azioni per le notifiche", + "alertingRules": "Regole di allerta", + "alertingSearchRules": "Cerca regole…", + "alertingAddRule": "Crea Regola", + "alertingColumnSource": "Fonte", + "alertingColumnTrigger": "Trigger", + "alertingColumnActions": "Azioni", + "alertingColumnEnabled": "Abilitato", + "alertingDeleteQuestion": "Si prega di confermare di voler eliminare questa regola di allerta.", + "alertingDeleteRule": "Elimina regola di allerta", + "alertingRuleDeleted": "Regola di allerta eliminata", + "alertingRuleSaved": "Regola di allerta salvata", + "alertingRuleSavedCreatedDescription": "La tua nuova regola di allerta è stata creata. Puoi continuare a modificarla su questa pagina.", + "alertingRuleSavedUpdatedDescription": "Le modifiche a questa regola di allerta sono state salvate.", + "alertingEditRule": "Modifica Regola di Allerta", + "alertingCreateRule": "Crea Regola di Allerta", + "alertingRuleCredenzaDescription": "Scegli cosa monitorare, quando attivare e come notificare", + "alertingRuleNamePlaceholder": "Sito di produzione giù", + "alertingRuleEnabled": "Regola abilitata", + "alertingSectionSource": "Fonte", + "alertingSourceType": "Tipo Di Fonte", + "alertingSourceSite": "Sito", + "alertingSourceHealthCheck": "Controllo di Salute", + "alertingPickSites": "Siti", + "alertingPickHealthChecks": "Controlli di Salute", + "alertingPickResources": "Risorse", + "alertingAllSites": "Tutti i Siti", + "alertingAllSitesDescription": "L'allerta scatta per qualsiasi sito", + "alertingSpecificSites": "Siti Specifici", + "alertingSpecificSitesDescription": "Scegli siti specifici da monitorare", + "alertingAllHealthChecks": "Tutti i Controlli di Salute", + "alertingAllHealthChecksDescription": "L'allerta scatta per qualsiasi controllo di salute", + "alertingSpecificHealthChecks": "Controlli di Salute Specifici", + "alertingSpecificHealthChecksDescription": "Scegli controlli di salute specifici da monitorare", + "alertingAllResources": "Tutte le Risorse", + "alertingAllResourcesDescription": "L'allerta scatta per qualsiasi risorsa", + "alertingSpecificResources": "Risorse Specifiche", + "alertingSpecificResourcesDescription": "Scegli risorse specifiche da monitorare", + "alertingSelectResources": "Seleziona risorse…", + "alertingResourcesSelected": "{count} risorse selezionate", + "alertingResourcesEmpty": "Nessuna risorsa con target nei primi 10 risultati.", + "alertingSectionTrigger": "Trigger", + "alertingTrigger": "Quando allertare", + "alertingTriggerSiteOnline": "Sito online", + "alertingTriggerSiteOffline": "Sito offline", + "alertingTriggerSiteToggle": "I cambiamenti di stato del sito", + "alertingTriggerHcHealthy": "Controllo di Salute Sano", + "alertingTriggerHcUnhealthy": "Controllo di Salute Non Sano", + "alertingTriggerHcToggle": "I cambiamenti di stato del controllo di salute", + "alertingTriggerResourceHealthy": "Risorsa in buona salute", + "alertingTriggerResourceUnhealthy": "Risorsa in cattiva salute", + "alertingTriggerResourceDegraded": "Risorsa degradata", + "alertingSearchHealthChecks": "Cerca controlli di salute…", + "alertingHealthChecksEmpty": "Nessun controllo di salute disponibile.", + "alertingTriggerResourceToggle": "Variazioni di stato della risorsa", + "alertingSourceResource": "Fonte", + "alertingSectionActions": "Azioni", + "alertingAddAction": "Aggiungi Azione", + "alertingActionNotify": "Email", + "alertingActionNotifyDescription": "Invia notifiche email agli utenti o ai ruoli", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Invia una richiesta HTTP a un endpoint personalizzato", + "alertingExternalIntegration": "Integrazione esterna", + "alertingExternalPagerDutyDescription": "Invia avvisi a PagerDuty per la gestione degli incidenti", + "alertingExternalOpsgenieDescription": "Indirizza avvisi a Opsgenie per la gestione delle chiamate", + "alertingExternalServiceNowDescription": "Crea incidenti ServiceNow dagli eventi di allerta", + "alertingExternalIncidentIoDescription": "Attiva i flussi di lavoro di Incident.io dagli eventi di allerta", + "alertingActionType": "Tipo di azione", + "alertingNotifyUsers": "Utenti", + "alertingNotifyRoles": "Ruoli", + "alertingNotifyEmails": "Indirizzi email", + "alertingEmailPlaceholder": "Aggiungi email e premi Invio", + "alertingWebhookMethod": "Metodo HTTP", + "alertingWebhookSecret": "Segreto di firma (opzionale)", + "alertingWebhookSecretPlaceholder": "Segreto HMAC", + "alertingWebhookHeaders": "Intestazioni", + "alertingAddHeader": "Aggiungi intestazione", + "alertingSelectSites": "Seleziona siti…", + "alertingSitesSelected": "{count} siti selezionati", + "alertingSelectHealthChecks": "Seleziona controlli di salute…", + "alertingHealthChecksSelected": "{count} controlli di salute selezionati", + "alertingNoHealthChecks": "Nessun obiettivo con controlli di salute abilitati", + "alertingHealthCheckStub": "Selezione fonte controllo di salute non ancora collegata - puoi comunque configurare trigger e azioni.", + "alertingSelectUsers": "Seleziona utenti…", + "alertingUsersSelected": "{count} utenti selezionati", + "alertingSelectRoles": "Seleziona ruoli…", + "alertingRolesSelected": "{count} ruoli selezionati", + "alertingSummarySites": "Siti ({count})", + "alertingSummaryAllSites": "Tutti i siti", + "alertingSummaryHealthChecks": "Controlli di Salute ({count})", + "alertingSummaryAllHealthChecks": "Tutti i controlli di salute", + "alertingSummaryResources": "Risorse ({count})", + "alertingSummaryAllResources": "Tutte le risorse", + "alertingErrorNameRequired": "Inserisci un nome", + "alertingErrorActionsMin": "Aggiungi almeno un'azione", + "alertingErrorPickSites": "Seleziona almeno un sito", + "alertingErrorPickHealthChecks": "Seleziona almeno un controllo di salute", + "alertingErrorPickResources": "Seleziona almeno una risorsa", + "alertingErrorTriggerSite": "Scegli un trigger sito", + "alertingErrorTriggerHealth": "Scegli un trigger controllo di salute", + "alertingErrorTriggerResource": "Scegli un trigger risorsa", + "alertingErrorNotifyRecipients": "Seleziona utenti, ruoli o almeno un indirizzo email", + "alertingConfigureSource": "Configura Fonte", + "alertingConfigureTrigger": "Configura Trigger", + "alertingConfigureActions": "Configura Azioni", + "alertingBackToRules": "Torna alle Regole", + "alertingRuleCooldown": "Tempo di riposo (secondi)", + "alertingRuleCooldownDescription": "Tempo minimo tra avvisi ripetuti per la stessa regola. Imposta a 0 per attivare ogni volta.", + "alertingDraftBadge": "Bozza - salva per memorizzare questa regola", + "alertingSidebarHint": "Clicca su un passaggio nella tela per modificarlo qui.", + "alertingGraphCanvasTitle": "Flusso della regola", + "alertingGraphCanvasDescription": "Panoramica visiva di fonte, trigger e azioni. Seleziona un nodo per modificarlo nel pannello.", + "alertingNodeNotConfigured": "Non ancora configurato", + "alertingNodeActionsCount": "{count, plural, one {# azione} other {# azioni}}", + "alertingNodeRoleSource": "Fonte", + "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleAction": "Azione", + "alertingTabRules": "Regole di Allerta", + "alertingTabHealthChecks": "Controlli di Salute", + "alertingRulesBannerTitle": "Ricevi Notifiche", + "alertingRulesBannerDescription": "Ogni regola collega ciò che monitorare (un sito, controllo di salute o risorsa), quando attivare (ad esempio offline o non sano) e come notificare il tuo team via email, webhook o integrazioni. Usa questo elenco per creare, abilitare e gestire queste regole.", + "alertingHealthChecksBannerTitle": "Monitora Salute & Risorse", + "alertingHealthChecksBannerDescription": "I controlli di salute sono monitor HTTP o TCP che definisci una volta. Puoi poi usarli come fonti nelle regole di allerta così ricevi avvisi quando un obiettivo diventa sano o non sano. I controlli di salute sulle risorse appaiono anche qui.", + "standaloneHcTableTitle": "Controlli di Salute", + "standaloneHcSearchPlaceholder": "Cerca controlli di salute…", + "standaloneHcAddButton": "Crea Controllo di Salute", + "standaloneHcCreateTitle": "Crea Controllo di Salute", + "standaloneHcEditTitle": "Modifica Controllo di Salute", + "standaloneHcDescription": "Configura un controllo di salute HTTP o TCP da utilizzare nelle regole di allerta.", + "standaloneHcNameLabel": "Nome", + "standaloneHcNamePlaceholder": "Il mio Monitor HTTP", + "standaloneHcDeleteTitle": "Elimina controllo di salute", + "standaloneHcDeleteQuestion": "Si prega di confermare di voler eliminare questo controllo di integrità.", + "standaloneHcDeleted": "Controllo di salute eliminato", + "standaloneHcSaved": "Controllo di salute salvato", + "standaloneHcColumnHealth": "Salute", + "standaloneHcColumnMode": "Modalità", + "standaloneHcColumnTarget": "Target", + "standaloneHcHealthStateHealthy": "Sano", + "standaloneHcHealthStateUnhealthy": "Non Sano", + "standaloneHcHealthStateUnknown": "Sconosciuto", + "standaloneHcFilterAnySite": "Tutti i siti", + "standaloneHcFilterAnyResource": "Tutte le risorse", + "standaloneHcFilterMode": "Modalità", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Salute", + "standaloneHcFilterEnabled": "Abilitato", + "standaloneHcFilterEnabledOn": "Abilitato", + "standaloneHcFilterEnabledOff": "Disabilitato", + "standaloneHcFilterSiteIdFallback": "Sito {id}", + "standaloneHcFilterResourceIdFallback": "Risorsa {id}", "blueprints": "Progetti", "blueprintsDescription": "Applica le configurazioni dichiarative e visualizza le partite precedenti", "blueprintAdd": "Aggiungi Progetto", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Crea l'account amministratore del server iniziale. Può esistere solo un amministratore del server. È sempre possibile modificare queste credenziali in seguito.", "createAdminAccount": "Crea Account Admin", "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.", - "certificateStatus": "Stato del Certificato", + "certificateStatus": "Certificato", + "certificateStatusAutoRefreshHint": "Lo stato si aggiorna automaticamente.", "loading": "Caricamento", "loadingAnalytics": "Caricamento Delle Analisi", "restart": "Riavvia", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Visualizza Note Di Rilascio", "newtUpdateAvailable": "Aggiornamento Disponibile", "newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.", + "pangolinNodeUpdateAvailableInfo": "È disponibile una nuova versione di Pangolin Node. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "domainPickerEnterDomain": "Dominio", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Configura Controllo Salute", "configureHealthCheckDescription": "Imposta il monitoraggio della salute per {target}", "enableHealthChecks": "Abilita i Controlli di Salute", + "healthCheckDisabledStateDescription": "Quando disabilitato, il sito non eseguirà controlli di integrità e lo stato sarà considerato sconosciuto.", "enableHealthChecksDescription": "Monitorare lo stato di salute di questo obiettivo. Se necessario, è possibile monitorare un endpoint diverso da quello del bersaglio.", "healthScheme": "Metodo", "healthSelectScheme": "Seleziona Metodo", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "L'intervallo del controllo deve essere almeno di 5 secondi", "healthCheckTimeoutMin": "Il timeout deve essere di almeno 1 secondo", "healthCheckRetryMin": "I tentativi di riprova devono essere almeno 1", + "healthCheckMode": "Verifica Modalità", + "healthCheckStrategy": "Strategia", + "healthCheckModeDescription": "La modalità TCP verifica solo la connettività. La modalità HTTP valida la risposta HTTP.", + "healthyThreshold": "Soglia di salute", + "healthyThresholdDescription": "Successi consecutivi necessari prima di contrassegnare come sano.", + "unhealthyThreshold": "Soglia non sana", + "unhealthyThresholdDescription": "Fallimenti consecutivi richiesti prima di contrassegnare come non sano.", + "healthCheckHealthyThresholdMin": "La soglia di salute deve essere almeno 1", + "healthCheckUnhealthyThresholdMin": "La soglia non sana deve essere almeno 1", "httpMethod": "Metodo HTTP", "selectHttpMethod": "Seleziona metodo HTTP", "domainPickerSubdomainLabel": "Sottodominio", + "domainPickerWildcard": "Jolly", + "domainPickerWildcardPaidOnly": "Sotto-domini wildcard sono una funzione a pagamento. Si prega di aggiornare per accedere a questa funzione.", "domainPickerBaseDomainLabel": "Dominio Base", "domainPickerSearchDomains": "Cerca domini...", "domainPickerNoDomainsFound": "Nessun dominio trovato", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Questo indirizzo fa parte della subnet di utilità dell'organizzazione. È usato per risolvere i record alias usando la risoluzione DNS interna.", "resourcesTableClients": "Client", "resourcesTableAndOnlyAccessibleInternally": "e sono accessibili solo internamente quando connessi con un client.", - "resourcesTableNoTargets": "Nessun obiettivo", "resourcesTableHealthy": "Sano", "resourcesTableDegraded": "Degraded", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Non Sano", "resourcesTableUnknown": "Sconosciuto", "resourcesTableNotMonitored": "Non monitorato", + "resourcesTableNoTargets": "Nessun obiettivo", "editInternalResourceDialogEditClientResource": "Modifica Risorse Private", "editInternalResourceDialogUpdateResourceProperties": "Aggiorna la configurazione delle risorse e i controlli di accesso per {resourceName}", "editInternalResourceDialogResourceProperties": "Proprietà della Risorsa", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Porta", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Metodo HTTP", + "editInternalResourceDialogEnableSsl": "Abilitare SSL", + "editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.", "editInternalResourceDialogDestination": "Destinazione", "editInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.", "editInternalResourceDialogDestinationIPDescription": "L'indirizzo IP o hostname della risorsa nella rete del sito.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Nome", "createInternalResourceDialogSite": "Sito", "selectSite": "Seleziona sito...", + "multiSitesSelectorSitesCount": "{count, plural, one {# sito} other {# siti}}", "noSitesFound": "Nessun sito trovato.", "createInternalResourceDialogProtocol": "Protocollo", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Porta", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Metodo HTTP", + "createInternalResourceDialogScheme": "Metodo HTTP", + "createInternalResourceDialogEnableSsl": "Abilitare SSL", + "createInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.", "createInternalResourceDialogDestination": "Destinazione", "createInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.", "createInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.", + "internalResourceDownstreamSchemeRequired": "Il metodo è richiesto per risorse HTTP", + "internalResourceHttpPortRequired": "Porta di destinazione richiesta per risorse HTTP", "siteConfiguration": "Configurazione", "siteAcceptClientConnections": "Accetta Connessioni Client", "siteAcceptClientConnectionsDescription": "Consenti ai dispositivi utente e ai client di accedere alle risorse di questo sito. Questo può essere modificato in seguito.", @@ -1989,7 +2213,7 @@ "description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra", "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.", - "introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin — i tunnel, la terminazione SSL 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:", + "introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin - i tunnel, la terminazione SSL 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": { "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." @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Dominio Internazionale Rilevato", "willbestoredas": "Verrà conservato come:", - "roleMappingDescription": "Determinare come i ruoli sono assegnati agli utenti quando accedono quando è abilitata la fornitura automatica.", + "roleMappingDescription": "Determina come i ruoli vengono assegnati agli utenti quando si accede con questo provider di identità.", "selectRole": "Seleziona un ruolo", "roleMappingExpression": "Espressione", "selectRolePlaceholder": "Scegli un ruolo", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Seleziona un dominio per la pagina di autenticazione dell'organizzazione", "domainPickerProvidedDomain": "Dominio Fornito", "domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito", + "domainPickerFreeDomainsPaidFeature": "I domini forniti sono una funzionalità a pagamento. Abbonati per ricevere un dominio incluso con il tuo piano - non è necessario portare il proprio.", "domainPickerVerified": "Verificato", "domainPickerUnverified": "Non Verificato", - "domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.", + "domainPickerManual": "Manuale", + "domainPickerInvalidSubdomainStructure": "I caratteri non validi saranno sanitizzati quando salvati.", "domainPickerError": "Errore", "domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione", "domainPickerErrorCheckAvailability": "Impossibile verificare la disponibilità del dominio", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Scegli il tuo provider di identità per continuare", "orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.", "orgAuthSignInWithPangolin": "Accedi con Pangolino", - "orgAuthSignInToOrg": "Accedi a un'organizzazione", + "orgAuthSignInToOrg": "Provider di identità dell'organizzazione (SSO)", "orgAuthSelectOrgTitle": "Accesso Organizzazione", "orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare", "orgAuthOrgIdPlaceholder": "la-tua-organizzazione", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Trasparenza Di Utilizzo", - "description": "Seleziona il livello di licenza che rispecchia accuratamente il tuo utilizzo previsto. La Licenza Personale consente l'uso gratuito del Software per le attività commerciali individuali, non commerciali o su piccola scala con entrate lorde annue inferiori a $100.000 USD. Qualsiasi uso oltre questi limiti — compreso l'uso all'interno di un'azienda, organizzazione, o altro ambiente generatore di entrate — richiede una licenza Enterprise valida e il pagamento della tassa di licenza applicabile. Tutti gli utenti, siano essi personali o aziendali, devono rispettare i termini di licenza commerciale Fossorial." + "description": "Seleziona il livello di licenza che rispecchia accuratamente il tuo utilizzo previsto. La Licenza Personale consente l'uso gratuito del Software per le attività commerciali individuali, non commerciali o su piccola scala con entrate lorde annue inferiori a $100.000 USD. Qualsiasi uso oltre questi limiti - compreso l'uso all'interno di un'azienda, organizzazione, o altro ambiente generatore di entrate - richiede una licenza Enterprise valida e il pagamento della tassa di licenza applicabile. Tutti gli utenti, siano essi personali o aziendali, devono rispettare i termini di licenza commerciale Fossorial." }, "trialPeriodInformation": { "title": "Informazioni Periodo Di Prova", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Scala", - "description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario." + "description": "Funzionalità aziendali, 50 utenti, 100 siti e supporto prioritario." } }, "personalUseOnly": "Uso personale esclusivo (licenza gratuita - nessun pagamento)", @@ -2422,6 +2648,7 @@ "validPassword": "Password Valida", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Cliente Connesso", "resourceBlocked": "Risorsa Bloccata", "droppedByRule": "Eliminato dalla regola", "noSessions": "Nessuna Sessione", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Aggiungi Clienti", "editInternalResourceDialogDestinationLabel": "Destinazione", "editInternalResourceDialogDestinationDescription": "Specifica l'indirizzo di destinazione per la risorsa interna. Può essere un hostname, indirizzo IP o un intervallo CIDR a seconda della modalità selezionata. Opzionalmente imposta un alias DNS interno per una più facile identificazione.", + "internalResourceFormMultiSiteRoutingHelp": "Selezionare più siti consente un routing resiliente e Failover per alta disponibilità.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Scopri di più", "editInternalResourceDialogPortRestrictionsDescription": "Limita l'accesso a porte TCP/UDP specifiche o consenti/blocca tutte le porte.", + "createInternalResourceDialogHttpConfiguration": "Configurazione HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Scegli il dominio che i clienti utilizzeranno per accedere a questa risorsa tramite HTTP o HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Configurazione HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Scegli il dominio che i clienti utilizzeranno per accedere a questa risorsa tramite HTTP o HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Torneremo presto! Il nostro sito è attualmente in manutenzione programmata.", "maintenancePageMessageDescription": "Messaggio dettagliato che spiega la manutenzione", "maintenancePageTimeTitle": "Tempo di Completamento Stimato (Opzionale)", + "privateMaintenanceScreenTitle": "Schermo segnaposto privato", + "privateMaintenanceScreenMessage": "Questo dominio è utilizzato su una risorsa privata. Connettiti usando il client Pangolin per accedere a questa risorsa.", + "privateMaintenanceScreenSteps": "Una volta connesso, se ancora visualizzi questo messaggio, la cache DNS del tuo browser potrebbe ancora puntare al vecchio indirizzo. Per risolvere: chiudi e riapri completamente questa scheda o il tuo browser, quindi torna su questa pagina.", "maintenanceTime": "es. 2 ore, 1 novembre alle 17:00", "maintenanceEstimatedTimeDescription": "Quando prevedi che la manutenzione sarà completata", "editDomain": "Modifica Dominio", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Aggiungi Destinazione HTTP", "httpDestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming di eventi HTTP.", "httpDestAddDescription": "Configura un nuovo endpoint HTTP per ricevere gli eventi della tua organizzazione.", + "S3DestEditTitle": "Modifica Destinazione", + "S3DestAddTitle": "Aggiungi Destinazione S3", + "S3DestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi S3.", + "S3DestAddDescription": "Configura un nuovo endpoint S3 per ricevere gli eventi della tua organizzazione.", + "datadogDestEditTitle": "Modifica Destinazione", + "datadogDestAddTitle": "Aggiungi Destinazione Datadog", + "datadogDestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi Datadog.", + "datadogDestAddDescription": "Configura un nuovo endpoint Datadog per ricevere gli eventi della tua organizzazione.", "httpDestTabSettings": "Impostazioni", "httpDestTabHeaders": "Intestazioni", "httpDestTabBody": "Corpo", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "JSON Array", "httpDestFormatJsonArrayDescription": "Una richiesta per lotto, corpo è un array JSON. Compatibile con la maggior parte dei webhooks generici e Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Una richiesta per lotto, corpo è newline-delimited JSON — un oggetto per linea, nessun array esterno. Richiesto da Splunk HEC, Elastic / OpenSearch, e Grafana Loki.", + "httpDestFormatNdjsonDescription": "Una richiesta per lotto, corpo è newline-delimited JSON - un oggetto per linea, nessun array esterno. Richiesto da Splunk HEC, Elastic / OpenSearch, e Grafana Loki.", "httpDestFormatSingleTitle": "Un Evento Per Richiesta", "httpDestFormatSingleDescription": "Invia un HTTP POST separato per ogni singolo evento. Usa solo per gli endpoint che non possono gestire i batch.", "httpDestLogTypesTitle": "Tipi Di Log", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Destinazione aggiornata con successo", "httpDestCreatedSuccess": "Destinazione creata con successo", "httpDestUpdateFailed": "Impossibile aggiornare la destinazione", - "httpDestCreateFailed": "Impossibile creare la destinazione" + "httpDestCreateFailed": "Impossibile creare la destinazione", + "followRedirects": "Segui i reindirizzamenti", + "followRedirectsDescription": "Segui automaticamente i reindirizzamenti HTTP per le richieste.", + "alertingErrorWebhookUrl": "Inserisci un URL valido per il webhook.", + "healthCheckStrategyHttp": "Convalida la connettività e controlla lo stato della risposta HTTP.", + "healthCheckStrategyTcp": "Verifica solo la connettività TCP, senza controllare la risposta.", + "healthCheckStrategySnmp": "Effettua una richiesta SNMP per controllare la salute di dispositivi di rete e infrastrutture.", + "healthCheckStrategyIcmp": "Utilizza richieste ICMP echo (ping) per verificare se una risorsa è raggiungibile e reattiva.", + "healthCheckTabStrategy": "Strategia", + "healthCheckTabConnection": "Connessione", + "healthCheckTabAdvanced": "Avanzato", + "healthCheckStrategyNotAvailable": "Questa strategia non è disponibile. Contatta le vendite per abilitare questa funzionalità.", + "uptime30d": "Uptime (30d)", + "idpAddActionCreateNew": "Crea nuovo provider di identità", + "idpAddActionImportFromOrg": "Importa da un'altra organizzazione", + "idpImportDialogTitle": "Importa Provider di Identità", + "idpImportDialogDescription": "Scegli un provider di identità da un'organizzazione di cui sei amministratore. Verrà collegato a questa organizzazione.", + "idpImportSearchPlaceholder": "Cerca per nome organizzazione o provider...", + "idpImportEmpty": "Nessun provider di identità trovato.", + "idpImportedDescription": "Provider di identità importato con successo.", + "idpDeleteGlobalQuestion": "Sei sicuro di voler eliminare definitivamente questo provider di identità?", + "idpDeleteGlobalDescription": "Questo eliminerà definitivamente il provider di identità da tutte le organizzazioni con cui è associato.", + "idpUnassociateTitle": "Disassociare Provider di Identità", + "idpUnassociateQuestion": "Sei sicuro di voler disassociare questo provider di identità da questa organizzazione?", + "idpUnassociateDescription": "Tutti gli utenti associati a questo provider di identità verranno rimossi da questa organizzazione, ma il provider di identità continuerà ad esistere per altre organizzazioni associate.", + "idpUnassociateConfirm": "Conferma Disassociazione Provider di Identità", + "idpUnassociateWarning": "Questo non può essere annullato per questa organizzazione.", + "idpUnassociatedDescription": "Provider di identità disassociato con successo da questa organizzazione", + "idpUnassociateMenu": "Disassocia", + "idpDeleteAllOrgsMenu": "Elimina", + "publicIpEndpoint": "Endpoint", + "lastTriggeredAt": "Ultimo trigger", + "reject": "Rifiuta", + "uptimeDaysAgo": "{count} giorni fa", + "uptimeToday": "Oggi", + "uptimeNoDataAvailable": "Nessun dato disponibile", + "uptimeSuffix": "tempo di attività", + "uptimeDowntimeSuffix": "tempo di inattività", + "uptimeTooltipUptimeLabel": "Tempo di attività", + "uptimeTooltipDowntimeLabel": "Tempo di inattività", + "uptimeOngoing": "in corso", + "uptimeNoMonitoringData": "Nessun dato di monitoraggio", + "uptimeNoData": "Nessun dato", + "uptimeMiniBarDown": "Giù", + "uptimeSectionTitle": "Tempo di attività", + "uptimeSectionDescription": "Disponibilità negli ultimi {days} giorni", + "uptimeAddAlert": "Aggiungi Avviso", + "uptimeViewAlerts": "Visualizza Avvisi", + "uptimeCreateEmailAlert": "Crea Avviso Email", + "uptimeAlertDescriptionSite": "Ricevi notifica via email quando questo sito va offline o torna online.", + "uptimeAlertDescriptionResource": "Ricevi notifica via email quando questa risorsa va offline o torna online.", + "uptimeAlertNamePlaceholder": "Nome avviso", + "uptimeAdditionalEmails": "Email aggiuntive", + "uptimeCreateAlert": "Crea Avviso", + "uptimeAlertNoRecipients": "Nessun destinatario", + "uptimeAlertNoRecipientsDescription": "Si prega di aggiungere almeno un utente, ruolo o e-mail da notificare.", + "uptimeAlertCreated": "Avviso creato", + "uptimeAlertCreatedDescription": "Riceverai una notifica quando questo cambia stato.", + "uptimeAlertCreateFailed": "Errore nella creazione dell'avviso", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Chiave", + "webhookHeaderValuePlaceholder": "Valore", + "alertLabel": "Avviso", + "domainPickerWildcardSubdomainNotAllowed": "I sottodomini wildcard non sono permessi.", + "domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.", + "domainPickerWildcardCertWarningLink": "Scopri di più", + "health": "Salute", + "domainPendingErrorTitle": "Problema di Verifica" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 666ffecb1..57316ea1e 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "이 기능을 활성화하려면 영업팀에 연락하세요.", + "contactSalesBookDemo": "데모 예약하기", + "contactSalesOr": "또는", + "contactSalesContactUs": "문의하기", "setupCreate": "조직, 사이트 및 리소스를 생성합니다.", "headerAuthCompatibilityInfo": "인증 토큰이 없을 때 401 Unauthorized 응답을 강제하도록 설정합니다. 서버 챌린지 없이 자격 증명을 제공하지 않는 브라우저나 특정 HTTP 라이브러리에 필요합니다.", "headerAuthCompatibility": "확장된 호환성", @@ -19,6 +23,14 @@ "componentsInvalidKey": "유효하지 않거나 만료된 라이센스 키가 감지되었습니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", "dismiss": "해제", "subscriptionViolationMessage": "현재 계획의 한계를 초과했습니다. 사이트, 사용자 또는 기타 리소스를 제거하여 계획 내에 머물도록 해결하세요.", + "trialBannerMessage": "시험 사용 기간이 {countdown} 안에 만료됩니다. 업그레이드하여 액세스를 유지하세요.", + "trialBannerExpired": "시험 사용 기간이 만료되었습니다. 지금 업그레이드하여 액세스를 복구하세요.", + "trialActive": "무료 체험 활성화됨", + "trialExpired": "체험 만료됨", + "trialHasEnded": "시험 사용 기간이 종료되었습니다.", + "trialDaysRemaining": "{count, plural, other {#일 남음}}", + "trialDaysLeftShort": "시험 사용 기간 종료까지 {days}일 남음", + "trialGoToBilling": "청구 페이지로 이동", "subscriptionViolationViewBilling": "청구 보기", "componentsLicenseViolation": "라이센스 위반: 이 서버는 {usedSites} 사이트를 사용하고 있으며, 이는 {maxSites} 사이트의 라이센스 한도를 초과합니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", "componentsSupporterMessage": "{tier}로 판골린을 지원해 주셔서 감사합니다!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "구성을 복사했습니다.", "searchSitesProgress": "사이트 검색...", "siteAdd": "사이트 추가", + "sitesTableViewPublicResources": "공용 리소스 보기", + "sitesTableViewPrivateResources": "개인 리소스 보기", "siteInstallNewt": "Newt 설치", "siteInstallNewtDescription": "시스템에서 Newt 실행하기", "WgConfiguration": "WireGuard 구성", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "사이트가 업데이트되었습니다.", "siteGeneralDescription": "이 사이트에 대한 일반 설정을 구성하세요.", "siteSettingDescription": "사이트에서 설정을 구성하세요.", + "siteResourcesTab": "리소스", + "siteResourcesNoneOnSite": "이 사이트에는 아직 공용 또는 개인 리소스가 없습니다.", + "siteResourcesSectionPublic": "공용 리소스", + "siteResourcesSectionPrivate": "개인 리소스", + "siteResourcesSectionPublicDescription": "도메인이나 포트를 통해 외부에 노출되는 리소스.", + "siteResourcesSectionPrivateDescription": "사이트를 통해 개인 네트워크에서 사용할 수 있는 리소스.", + "siteResourcesViewAllPublic": "모든 리소스 보기", + "siteResourcesViewAllPrivate": "모든 리소스 보기", + "siteResourcesDialogDescription": "이 사이트와 연관된 공용 및 개인 리소스의 개요.", + "siteResourcesShowMore": "더 보기", + "siteResourcesPermissionDenied": "이 리소스를 나열할 권한이 없습니다.", + "siteResourcesEmptyPublic": "이 사이트에는 아직 대상 공용 리소스가 없습니다.", + "siteResourcesEmptyPrivate": "이 사이트와 연결된 개인 리소스가 아직 없습니다.", + "siteResourcesHowToAccess": "액세스 방법", + "siteResourcesTargetsOnSite": "이 사이트의 대상", "siteSetting": "{siteName} 설정", "siteNewtTunnel": "뉴트 사이트 (추천)", "siteNewtTunnelDescription": "네트워크의 진입점을 생성하는 가장 쉬운 방법입니다. 추가 설정이 필요 없습니다.", @@ -267,8 +296,11 @@ "orgMissing": "조직 ID가 누락되었습니다", "orgMissingMessage": "조직 ID 없이 초대장을 재생성할 수 없습니다.", "accessUsersManage": "사용자 관리", + "accessUserManage": "사용자 관리", "accessUsersDescription": "이 조직에 액세스할 사용자 초대 및 관리", "accessUsersSearch": "사용자 검색...", + "accessUsersRoleFilterCount": "{count, plural, other {# 역할}}", + "accessUsersRoleFilterClear": "역할 필터 지우기", "accessUserCreate": "사용자 생성", "accessUserRemove": "사용자 제거", "username": "사용자 이름", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "라이센스 키 활성화에 실패했습니다.", "licenseErrorKeyActivateDescription": "라이센스 키를 활성화하는 동안 오류가 발생했습니다", "licenseAbout": "라이센스에 대한 정보", + "licenseBannerTitle": "기업 라이선스 활성화", + "licenseBannerDescription": "자체 호스팅된 Pangolin 인스턴스에서 기업 기능을 잠금 해제하십시오. 라이선스 키를 구입하여 프리미엄 기능을 활성화하고 아래에 추가하십시오.", + "licenseBannerGetLicense": "라이선스 획득", + "licenseBannerViewDocs": "문서 보기", "communityEdition": "커뮤니티 에디션", "licenseAboutDescription": "이것은 상업적 환경에서 Pangolin을 사용하는 비즈니스 및 기업 사용자용입니다. 개인 용도로 Pangolin을 사용하는 경우 이 섹션을 무시할 수 있습니다.", "licenseKeyActivated": "라이센스 키가 활성화되었습니다", @@ -727,6 +763,7 @@ "newtEndpoint": "엔드포인트", "newtId": "ID", "newtSecretKey": "비밀", + "newtVersion": "버전", "architecture": "아키텍처", "sites": "사이트", "siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.", @@ -894,6 +931,7 @@ "idpDisplayName": "이 신원 공급자를 위한 표시 이름", "idpAutoProvisionUsers": "사용자 자동 프로비저닝", "idpAutoProvisionUsersDescription": "활성화되면 사용자가 첫 로그인 시 시스템에 자동으로 생성되며, 사용자와 역할 및 조직을 매핑할 수 있습니다.", + "idpAutoProvisionConfigureAfterCreate": "아이덴티티 공급자가 생성되면 자동 프로비저닝 설정을 구성할 수 있습니다.", "licenseBadge": "EE", "idpType": "제공자 유형", "idpTypeDescription": "구성할 ID 공급자의 유형을 선택하십시오.", @@ -945,7 +983,7 @@ "defaultMappingsRole": "기본 역할 매핑", "defaultMappingsRoleDescription": "이 표현식의 결과는 조직에서 정의된 역할 이름을 문자열로 반환해야 합니다.", "defaultMappingsOrg": "기본 조직 매핑", - "defaultMappingsOrgDescription": "이 표현식은 사용자가 조직에 접근할 수 있도록 조직 ID 또는 true를 반환해야 합니다.", + "defaultMappingsOrgDescription": "이 표현식은 사용자가 조직에 접근할 수 있도록 조직 ID 또는 true를 반환해야 합니다. 설정되지 않으면, 역할 매핑 정의가 충분합니다: 사용자는 유효한 역할 매핑이 해석되는 한 조직에 허용됩니다.", "defaultMappingsSubmit": "기본 매핑 저장", "orgPoliciesEdit": "조직 정책 편집", "org": "조직", @@ -1252,6 +1290,7 @@ "actionViewLogs": "로그 보기", "noneSelected": "선택된 항목 없음", "orgNotFound2": "조직이 없습니다.", + "search": "검색…", "searchPlaceholder": "검색...", "emptySearchOptions": "옵션이 없습니다", "create": "생성", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "관리", "sidebarLogAndAnalytics": "로그 & 통계", "sidebarBluePrints": "청사진", + "sidebarAlerting": "알림", + "sidebarHealthChecks": "상태 확인", "sidebarOrganization": "조직", "sidebarManagement": "관리", "sidebarBillingAndLicenses": "결제 및 라이선스", "sidebarLogsAnalytics": "분석", + "alertingTitle": "알림", + "alertingDescription": "알림에 대한 소스, 트리거 및 작업 정의", + "alertingRules": "알림 규칙", + "alertingSearchRules": "규칙 검색…", + "alertingAddRule": "규칙 생성", + "alertingColumnSource": "소스", + "alertingColumnTrigger": "트리거", + "alertingColumnActions": "작업", + "alertingColumnEnabled": "활성화됨", + "alertingDeleteQuestion": "이 알림 규칙을 삭제하겠습니까.", + "alertingDeleteRule": "알림 규칙 삭제", + "alertingRuleDeleted": "알림 규칙 삭제됨", + "alertingRuleSaved": "알림 규칙 저장됨", + "alertingRuleSavedCreatedDescription": "새 알림 규칙이 생성되었습니다. 이 페이지에서 계속 편집할 수 있습니다.", + "alertingRuleSavedUpdatedDescription": "이 알림 규칙에 대한 변경 사항이 저장되었습니다.", + "alertingEditRule": "알림 규칙 편집", + "alertingCreateRule": "알림 규칙 생성", + "alertingRuleCredenzaDescription": "무엇을 감시할지, 언제 알릴지, 어떻게 알릴지를 선택하세요.", + "alertingRuleNamePlaceholder": "프로덕션 사이트 중단", + "alertingRuleEnabled": "규칙 활성화됨", + "alertingSectionSource": "소스", + "alertingSourceType": "소스 유형", + "alertingSourceSite": "사이트", + "alertingSourceHealthCheck": "상태 확인", + "alertingPickSites": "사이트들", + "alertingPickHealthChecks": "상태 확인들", + "alertingPickResources": "리소스들", + "alertingAllSites": "모든 사이트", + "alertingAllSitesDescription": "모든 사이트에서 알림 발동", + "alertingSpecificSites": "특정 사이트", + "alertingSpecificSitesDescription": "감시할 특정 사이트를 선택하세요", + "alertingAllHealthChecks": "모든 상태 확인", + "alertingAllHealthChecksDescription": "모든 상태 확인에 대한 알림 발동", + "alertingSpecificHealthChecks": "특정 상태 확인", + "alertingSpecificHealthChecksDescription": "감시할 특정 상태 확인을 선택하세요", + "alertingAllResources": "모든 리소스", + "alertingAllResourcesDescription": "모든 리소스에 대한 알림 발동", + "alertingSpecificResources": "특정 리소스", + "alertingSpecificResourcesDescription": "감시할 특정 리소스를 선택하세요", + "alertingSelectResources": "리소스 선택…", + "alertingResourcesSelected": "{count}개의 리소스 선택됨", + "alertingResourcesEmpty": "앞 10개의 결과에서 타겟이 있는 리소스 없음.", + "alertingSectionTrigger": "트리거", + "alertingTrigger": "언제 알림을 받을지", + "alertingTriggerSiteOnline": "사이트 온라인", + "alertingTriggerSiteOffline": "사이트 오프라인", + "alertingTriggerSiteToggle": "사이트 상태 변경", + "alertingTriggerHcHealthy": "상태 확인 정상", + "alertingTriggerHcUnhealthy": "상태 확인 비정상", + "alertingTriggerHcToggle": "상태 확인 상태 변경", + "alertingTriggerResourceHealthy": "리소스 정상", + "alertingTriggerResourceUnhealthy": "리소스 비정상", + "alertingTriggerResourceDegraded": "리소스 열화", + "alertingSearchHealthChecks": "상태 확인 검색…", + "alertingHealthChecksEmpty": "사용 가능한 상태 확인이 없습니다.", + "alertingTriggerResourceToggle": "리소스 상태 변경", + "alertingSourceResource": "리소스", + "alertingSectionActions": "작업", + "alertingAddAction": "작업 추가", + "alertingActionNotify": "이메일", + "alertingActionNotifyDescription": "사용자 또는 역할에게 이메일 알림 전송", + "alertingActionWebhook": "웹훅", + "alertingActionWebhookDescription": "사용자 정의 엔드포인트로 HTTP 요청 보내기", + "alertingExternalIntegration": "외부 통합", + "alertingExternalPagerDutyDescription": "사고 관리를 위해 PagerDuty에 알림 보내기", + "alertingExternalOpsgenieDescription": "대기 중인 관리자로 Opsgenie에 알림 보내기", + "alertingExternalServiceNowDescription": "알림 이벤트로 ServiceNow 사고 생성", + "alertingExternalIncidentIoDescription": "알림 이벤트로 Incident.io 워크플로우 트리거", + "alertingActionType": "작업 유형", + "alertingNotifyUsers": "사용자들", + "alertingNotifyRoles": "역할들", + "alertingNotifyEmails": "이메일 주소들", + "alertingEmailPlaceholder": "이메일 추가 후 Enter 키를 누르세요", + "alertingWebhookMethod": "HTTP 메소드", + "alertingWebhookSecret": "서명 비밀 (선택 사항)", + "alertingWebhookSecretPlaceholder": "HMAC 비밀", + "alertingWebhookHeaders": "헤더들", + "alertingAddHeader": "헤더 추가", + "alertingSelectSites": "사이트 선택…", + "alertingSitesSelected": "{count}개의 사이트 선택됨", + "alertingSelectHealthChecks": "상태 확인 선택…", + "alertingHealthChecksSelected": "{count}개의 상태 확인 선택됨", + "alertingNoHealthChecks": "활성화된 상태 확인이 있는 타겟 없음", + "alertingHealthCheckStub": "상태 확인 소스 선택은 아직 연결되지 않았습니다 - 트리거 및 작업을 계속 구성할 수 있습니다.", + "alertingSelectUsers": "사용자 선택…", + "alertingUsersSelected": "{count}명의 사용자 선택됨", + "alertingSelectRoles": "역할 선택…", + "alertingRolesSelected": "{count}개의 역할 선택됨", + "alertingSummarySites": "사이트 ({count})", + "alertingSummaryAllSites": "모든 사이트", + "alertingSummaryHealthChecks": "상태 확인 ({count})", + "alertingSummaryAllHealthChecks": "모든 상태 확인", + "alertingSummaryResources": "리소스 ({count})", + "alertingSummaryAllResources": "모든 리소스", + "alertingErrorNameRequired": "이름을 입력하세요", + "alertingErrorActionsMin": "최소한 하나의 작업 추가", + "alertingErrorPickSites": "최소한 하나의 사이트 선택", + "alertingErrorPickHealthChecks": "최소한 하나의 상태 확인 선택", + "alertingErrorPickResources": "최소한 하나의 리소스 선택", + "alertingErrorTriggerSite": "사이트 트리거 선택", + "alertingErrorTriggerHealth": "상태 확인 트리거 선택", + "alertingErrorTriggerResource": "리소스 트리거 선택", + "alertingErrorNotifyRecipients": "사용자, 역할 또는 최소 하나의 이메일 선택", + "alertingConfigureSource": "소스 구성", + "alertingConfigureTrigger": "트리거 구성", + "alertingConfigureActions": "작업 구성", + "alertingBackToRules": "규칙으로 돌아가기", + "alertingRuleCooldown": "냉각 시간 (초)", + "alertingRuleCooldownDescription": "같은 규칙에 대해 반복된 알림 사이의 최소 시간. 매번 발생하려면 0으로 설정하세요.", + "alertingDraftBadge": "초안 - 이 규칙을 저장하려면 저장", + "alertingSidebarHint": "여기에서 편집하려면 캔버스의 단계를 클릭하세요.", + "alertingGraphCanvasTitle": "규칙 흐름", + "alertingGraphCanvasDescription": "소스, 트리거 및 작업의 시각적 개요입니다. 노드를 선택하여 패널에서 수정할 수 있습니다.", + "alertingNodeNotConfigured": "아직 구성되지 않음", + "alertingNodeActionsCount": "{count, plural, other {# 작업}}", + "alertingNodeRoleSource": "소스", + "alertingNodeRoleTrigger": "트리거", + "alertingNodeRoleAction": "작업", + "alertingTabRules": "알림 규칙", + "alertingTabHealthChecks": "상태 확인", + "alertingRulesBannerTitle": "알림 받기", + "alertingRulesBannerDescription": "각 규칙은 무엇을 감시할지(사이트, 상태 확인, 리소스), 언제 발동할지(예: 오프라인 또는 비정상), 이메일, 웹훅 또는 통합을 통해 팀에 어떻게 알릴지를 연결합니다. 이 목록을 사용하여 규칙을 생성, 활성화 및 관리하세요.", + "alertingHealthChecksBannerTitle": "건강 및 리소스 모니터링", + "alertingHealthChecksBannerDescription": "상태 확인은 한 번 정의한 HTTP 또는 TCP 모니터링입니다. 그런 다음 이를 알림 규칙의 소스로 사용하여 타겟이 정상 또는 비정상이 되었을 때 알림을 받을 수 있습니다. 리소스의 상태 확인도 여기에 나타납니다.", + "standaloneHcTableTitle": "상태 확인", + "standaloneHcSearchPlaceholder": "상태 확인 검색…", + "standaloneHcAddButton": "상태 확인 생성", + "standaloneHcCreateTitle": "상태 확인 생성", + "standaloneHcEditTitle": "상태 확인 편집", + "standaloneHcDescription": "알림 규칙에 사용할 HTTP 또는 TCP 상태 확인을 구성하세요.", + "standaloneHcNameLabel": "이름", + "standaloneHcNamePlaceholder": "나의 HTTP 모니터", + "standaloneHcDeleteTitle": "상태 확인 삭제", + "standaloneHcDeleteQuestion": "이 상태 확인을 삭제하겠습니까.", + "standaloneHcDeleted": "상태 확인 삭제됨", + "standaloneHcSaved": "상태 확인 저장됨", + "standaloneHcColumnHealth": "건강", + "standaloneHcColumnMode": "모드", + "standaloneHcColumnTarget": "타겟", + "standaloneHcHealthStateHealthy": "정상", + "standaloneHcHealthStateUnhealthy": "비정상", + "standaloneHcHealthStateUnknown": "알 수 없음", + "standaloneHcFilterAnySite": "모든 사이트", + "standaloneHcFilterAnyResource": "모든 리소스", + "standaloneHcFilterMode": "모드", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "핑", + "standaloneHcFilterHealth": "건강", + "standaloneHcFilterEnabled": "활성화됨", + "standaloneHcFilterEnabledOn": "활성화됨", + "standaloneHcFilterEnabledOff": "비활성화됨", + "standaloneHcFilterSiteIdFallback": "사이트 {id}", + "standaloneHcFilterResourceIdFallback": "리소스 {id}", "blueprints": "청사진", "blueprintsDescription": "선언적 구성을 적용하고 이전 실행을 봅니다", "blueprintAdd": "청사진 추가", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "초기 서버 관리자 계정을 생성하세요. 서버 관리자 계정은 하나만 존재할 수 있습니다. 이러한 자격 증명은 나중에 언제든지 변경할 수 있습니다.", "createAdminAccount": "관리자 계정 생성", "setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.", - "certificateStatus": "인증서 상태", + "certificateStatus": "인증서", + "certificateStatusAutoRefreshHint": "상태가 자동으로 새로 고쳐집니다.", "loading": "로딩 중", "loadingAnalytics": "분석 로딩 중", "restart": "재시작", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기", "newtUpdateAvailable": "업데이트 가능", "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", + "pangolinNodeUpdateAvailableInfo": "Pangolin Node의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "domainPickerEnterDomain": "도메인", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "상태 확인 설정", "configureHealthCheckDescription": "{target}에 대한 상태 모니터링 설정", "enableHealthChecks": "상태 확인 활성화", + "healthCheckDisabledStateDescription": "비활성화되면 이 사이트가 상태 확인을 수행하지 않으며 상태가 알 수 없는 것으로 간주됩니다.", "enableHealthChecksDescription": "이 대상을 모니터링하여 건강 상태를 확인하세요. 필요에 따라 대상과 다른 엔드포인트를 모니터링할 수 있습니다.", "healthScheme": "방법", "healthSelectScheme": "방법 선택", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "확인 간격은 최소 5초여야 합니다.", "healthCheckTimeoutMin": "시간 초과는 최소 1초여야 합니다.", "healthCheckRetryMin": "재시도 횟수는 최소 1회여야 합니다.", + "healthCheckMode": "확인 모드", + "healthCheckStrategy": "전략", + "healthCheckModeDescription": "TCP 모드는 연결성만 확인합니다. HTTP 모드는 HTTP 응답을 확인합니다.", + "healthyThreshold": "건강 임계값", + "healthyThresholdDescription": "정상으로 표시되기 전에 연속 성공이 필요합니다.", + "unhealthyThreshold": "비정상 임계값", + "unhealthyThresholdDescription": "비정상으로 표시되기 전에 연속 실패가 필요합니다.", + "healthCheckHealthyThresholdMin": "정상 임계값은 최소 1 이상이어야 합니다", + "healthCheckUnhealthyThresholdMin": "비정상 임계값은 최소 1 이상이어야 합니다", "httpMethod": "HTTP 메소드", "selectHttpMethod": "HTTP 메소드 선택", "domainPickerSubdomainLabel": "서브도메인", + "domainPickerWildcard": "와일드카드", + "domainPickerWildcardPaidOnly": "와일드카드 서브도메인은 유료 기능입니다. 이 기능에 액세스하려면 업그레이드하세요.", "domainPickerBaseDomainLabel": "기본 도메인", "domainPickerSearchDomains": "도메인 검색...", "domainPickerNoDomainsFound": "찾을 수 없는 도메인이 없습니다", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "이 주소는 조직의 유틸리티 서브넷의 일부로, 내부 DNS 해석을 사용하여 별칭 레코드를 해석하는 데 사용됩니다.", "resourcesTableClients": "클라이언트", "resourcesTableAndOnlyAccessibleInternally": "클라이언트와 연결되었을 때만 내부적으로 접근 가능합니다.", - "resourcesTableNoTargets": "대상 없음", "resourcesTableHealthy": "정상", "resourcesTableDegraded": "저하됨", - "resourcesTableOffline": "오프라인", + "resourcesTableUnhealthy": "비정상", "resourcesTableUnknown": "알 수 없음", "resourcesTableNotMonitored": "모니터링되지 않음", + "resourcesTableNoTargets": "대상 없음", "editInternalResourceDialogEditClientResource": "비공개 리소스 수정", "editInternalResourceDialogUpdateResourceProperties": "{resourceName}의 리소스 속성과 대상 구성을 업데이트하세요", "editInternalResourceDialogResourceProperties": "리소스 속성", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "포트", "editInternalResourceDialogModeHost": "호스트", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "스킴", + "editInternalResourceDialogEnableSsl": "SSL 활성화", + "editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.", "editInternalResourceDialogDestination": "대상지", "editInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.", "editInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 또는 호스트 네임 주소입니다.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "이름", "createInternalResourceDialogSite": "사이트", "selectSite": "사이트 선택...", + "multiSitesSelectorSitesCount": "{count, plural, other {# 사이트}}", "noSitesFound": "사이트를 찾을 수 없습니다.", "createInternalResourceDialogProtocol": "프로토콜", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "포트", "createInternalResourceDialogModeHost": "호스트", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "스킴", + "createInternalResourceDialogScheme": "스킴", + "createInternalResourceDialogEnableSsl": "SSL 활성화", + "createInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.", "createInternalResourceDialogDestination": "대상지", "createInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogAlias": "별칭", "createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.", + "internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다", + "internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다", "siteConfiguration": "설정", "siteAcceptClientConnections": "클라이언트 연결 허용", "siteAcceptClientConnectionsDescription": "사용자 장치와 클라이언트가 이 사이트의 리소스에 접근할 수 있도록 허용하세요. 나중에 변경할 수 있습니다.", @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "국제 도메인 감지됨", "willbestoredas": "다음으로 저장됩니다:", - "roleMappingDescription": "자동 프로비저닝이 활성화되면 사용자가 로그인할 때 역할이 할당되는 방법을 결정합니다.", + "roleMappingDescription": "사용자가 이 아이덴티티 공급자로 로그인할 때 역할이 할당되는 방법을 결정합니다.", "selectRole": "역할 선택", "roleMappingExpression": "표현식", "selectRolePlaceholder": "역할 선택", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "조직 인증 페이지에 대한 도메인을 선택하세요.", "domainPickerProvidedDomain": "제공된 도메인", "domainPickerFreeProvidedDomain": "무료 제공된 도메인", + "domainPickerFreeDomainsPaidFeature": "제공된 도메인은 유료 기능입니다. 요금제에 도메인이 포함되도록 구독하세요. - 별도로 도메인을 준비할 필요 없습니다.", "domainPickerVerified": "검증됨", "domainPickerUnverified": "검증되지 않음", - "domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.", + "domainPickerManual": "수동", + "domainPickerInvalidSubdomainStructure": "잘못된 문자는 저장 시 새니타이즈됩니다.", "domainPickerError": "오류", "domainPickerErrorLoadDomains": "조직 도메인 로드 실패", "domainPickerErrorCheckAvailability": "도메인 가용성 확인 실패", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.", "orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.", "orgAuthSignInWithPangolin": "Pangolin으로 로그인", - "orgAuthSignInToOrg": "조직에 로그인", + "orgAuthSignInToOrg": "조직 아이덴티티 제공자 (SSO)", "orgAuthSelectOrgTitle": "조직 로그인", "orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.", "orgAuthOrgIdPlaceholder": "your-organization", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "사용 공개", - "description": "당신의 의도된 사용에 정확히 맞는 라이선스 등급을 선택하세요. 개인 라이선스는 연간 총 수익 100,000 USD 이하의 개인, 비상업적 또는 소규모 상업 활동을 위한 소프트웨어의 무료 사용을 허용합니다. 이러한 제한을 넘는 모든 사용 — 비즈니스, 조직 또는 기타 수익 창출 환경 내에서의 사용 — 은 유효한 엔터프라이즈 라이선스 및 해당 라이선스 수수료의 지불이 필요합니다. 개인 또는 기업 사용자는 모두 Fossorial 상용 라이선스 조건을 준수해야 합니다." + "description": "당신의 의도된 사용에 정확히 맞는 라이선스 등급을 선택하세요. 개인 라이선스는 연간 총 수익 100,000 USD 이하의 개인, 비상업적 또는 소규모 상업 활동을 위한 소프트웨어의 무료 사용을 허용합니다. 이러한 제한을 넘는 모든 사용 - 비즈니스, 조직 또는 기타 수익 창출 환경 내에서의 사용 - 은 유효한 엔터프라이즈 라이선스 및 해당 라이선스 수수료의 지불이 필요합니다. 개인 또는 기업 사용자는 모두 Fossorial 상용 라이선스 조건을 준수해야 합니다." }, "trialPeriodInformation": { "title": "시험 기간 정보", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "스케일", - "description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원." + "description": "기업 기능, 50명의 사용자, 100개의 사이트, 그리고 우선 지원." } }, "personalUseOnly": "개인용으로만 사용 (무료 라이선스 - 결제 없음)", @@ -2422,6 +2648,7 @@ "validPassword": "유효한 비밀번호", "validEmail": "유효한 이메일", "validSSO": "유효한 SSO", + "connectedClient": "연결된 클라이언트", "resourceBlocked": "리소스 차단됨", "droppedByRule": "룰에 의해 드롭됨", "noSessions": "세션 없음", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "클라이언트 추가", "editInternalResourceDialogDestinationLabel": "대상지", "editInternalResourceDialogDestinationDescription": "내부 리소스의 목적지 주소를 지정하세요. 선택한 모드에 따라 이 주소는 호스트명, IP 주소, 또는 CIDR 범위가 될 수 있습니다. 더욱 쉽게 식별할 수 있도록 내부 DNS 별칭을 설정할 수 있습니다.", + "internalResourceFormMultiSiteRoutingHelp": "다중 사이트를 선택하면 높은 가용성을 위해 회복력 있는 라우팅 및 페일오버가 가능해집니다.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "자세히 알아보기", "editInternalResourceDialogPortRestrictionsDescription": "특정 TCP/UDP 포트에 대한 접근을 제한하거나 모든 포트를 허용/차단하십시오.", + "createInternalResourceDialogHttpConfiguration": "HTTP 구성", + "createInternalResourceDialogHttpConfigurationDescription": "이 리소스에 HTTP 또는 HTTPS로 도달하기 위한 도메인을 선택하세요.", + "editInternalResourceDialogHttpConfiguration": "HTTP 구성", + "editInternalResourceDialogHttpConfigurationDescription": "이 리소스에 HTTP 또는 HTTPS로 도달하기 위한 도메인을 선택하세요.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "곧 돌아오겠습니다! 사이트는 현재 예정된 유지보수를 진행 중입니다.", "maintenancePageMessageDescription": "유지보수를 설명하는 상세 메시지", "maintenancePageTimeTitle": "예상 완료 시간(선택 사항)", + "privateMaintenanceScreenTitle": "프라이빗 플레이스홀더 화면", + "privateMaintenanceScreenMessage": "이 도메인은 개인 리소스에서 사용 중입니다. Pangolin 클라이언트를 사용하여 이 리소스에 액세스하세요.", + "privateMaintenanceScreenSteps": "연결된 후에도 이 메시지가 보이면 브라우저의 DNS 캐시가 여전히 이전 주소를 가리킬 수 있습니다. 이를 해결하려면 이 탭이나 브라우저를 완전히 닫고 다시 열고 이 페이지로 돌아가세요.", "maintenanceTime": "예: 2시간, 11월 1일 오후 5시", "maintenanceEstimatedTimeDescription": "유지보수가 완료될 것으로 예상되는 시간", "editDomain": "도메인 수정", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "HTTP 대상지 추가", "httpDestEditDescription": "이 HTTP 이벤트 스트리밍 대상지의 구성을 업데이트하세요.", "httpDestAddDescription": "조직의 이벤트 수신을 위한 새로운 HTTP 엔드포인트를 구성하세요.", + "S3DestEditTitle": "대상지 수정", + "S3DestAddTitle": "S3 대상지 추가", + "S3DestEditDescription": "이 S3 이벤트 스트리밍 대상지의 구성을 업데이트하세요.", + "S3DestAddDescription": "조직의 이벤트를 받기 위한 새로운 S3 엔드포인트를 구성하세요.", + "datadogDestEditTitle": "대상지 수정", + "datadogDestAddTitle": "Datadog 대상지 추가", + "datadogDestEditDescription": "이 Datadog 이벤트 스트리밍 대상지의 구성을 업데이트하세요.", + "datadogDestAddDescription": "조직의 이벤트를 받기 위한 새로운 Datadog 엔드포인트를 구성하세요.", "httpDestTabSettings": "설정", "httpDestTabHeaders": "헤더", "httpDestTabBody": "본문", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "JSON 배열", "httpDestFormatJsonArrayDescription": "각 배치마다 요청 하나씩, 본문은 JSON 배열입니다. 대부분의 일반 웹훅 및 Datadog과 호환됩니다.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "각 배치마다 요청 하나씩, 본문은 줄 구분 JSON — 한 라인에 하나의 객체가 있으며 외부 배열이 없습니다. Splunk HEC, Elastic / OpenSearch, Grafana Loki에 필요합니다.", + "httpDestFormatNdjsonDescription": "각 배치마다 요청 하나씩, 본문은 줄 구분 JSON - 한 라인에 하나의 객체가 있으며 외부 배열이 없습니다. Splunk HEC, Elastic / OpenSearch, Grafana Loki에 필요합니다.", "httpDestFormatSingleTitle": "각 요청 당 하나의 이벤트", "httpDestFormatSingleDescription": "각 개별 이벤트에 대해 별도의 HTTP POST를 전송합니다. 배치를 처리할 수 없는 엔드포인트에만 사용하세요.", "httpDestLogTypesTitle": "로그 유형", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "대상지가 성공적으로 업데이트되었습니다", "httpDestCreatedSuccess": "대상지가 성공적으로 생성되었습니다", "httpDestUpdateFailed": "대상지를 업데이트하는 데 실패했습니다", - "httpDestCreateFailed": "대상지를 생성하는 데 실패했습니다" + "httpDestCreateFailed": "대상지를 생성하는 데 실패했습니다", + "followRedirects": "리디렉션 따라가기", + "followRedirectsDescription": "요청에 대해 HTTP 리디렉션을 자동으로 따라갑니다.", + "alertingErrorWebhookUrl": "웹훅의 유효한 URL을 입력하세요.", + "healthCheckStrategyHttp": "연결성을 확인하고 HTTP 응답 상태를 확인합니다.", + "healthCheckStrategyTcp": "응답을 검사하지 않고 TCP 연결성만 확인합니다.", + "healthCheckStrategySnmp": "네트워크 장비 및 인프라의 상태를 확인하기 위해 SNMP get 요청을 보냅니다.", + "healthCheckStrategyIcmp": "ICMP 에코 요청(핑)을 사용하여 리소스에 대한 접근 가능성을 확인합니다.", + "healthCheckTabStrategy": "전략", + "healthCheckTabConnection": "연결", + "healthCheckTabAdvanced": "고급", + "healthCheckStrategyNotAvailable": "이 전략은 사용할 수 없습니다. 기능을 활성화하려면 영업팀에 문의하세요.", + "uptime30d": "업타임 (30일)", + "idpAddActionCreateNew": "새로운 아이덴티티 공급자 생성", + "idpAddActionImportFromOrg": "다른 조직에서 가져오기", + "idpImportDialogTitle": "아이덴티티 공급자 가져오기", + "idpImportDialogDescription": "관리자인 조직에서 아이덴티티 공급자를 선택하십시오. 이는 이 조직에 연결됩니다.", + "idpImportSearchPlaceholder": "조직 또는 공급자 이름으로 검색...", + "idpImportEmpty": "아이덴티티 공급자를 찾을 수 없습니다.", + "idpImportedDescription": "아이덴티티 공급자가 성공적으로 가져왔습니다.", + "idpDeleteGlobalQuestion": "정말로 이 아이덴티티 공급자를 영구적으로 삭제하시겠습니까?", + "idpDeleteGlobalDescription": "이것은 연관된 모든 조직에서 아이덴티티 공급자를 영구적으로 삭제합니다.", + "idpUnassociateTitle": "아이덴티티 공급자의 연관 해제", + "idpUnassociateQuestion": "정말로 이 조직에서 이 아이덴티티 공급자의 연관을 해제하시겠습니까?", + "idpUnassociateDescription": "이 아이덴티티 공급자와 연관된 모든 사용자는 이 조직에서 제거될 것이지만, 아이덴티티 공급자는 다른 연관된 조직에 계속해서 존재할 것입니다.", + "idpUnassociateConfirm": "아이덴티티 공급자 연관 해제 확인", + "idpUnassociateWarning": "이 조직에서 이것은 되돌릴 수 없습니다.", + "idpUnassociatedDescription": "아이덴티티 공급자가 이 조직에서 성공적으로 연관 해제되었습니다", + "idpUnassociateMenu": "연관 해제", + "idpDeleteAllOrgsMenu": "삭제", + "publicIpEndpoint": "엔드포인트", + "lastTriggeredAt": "마지막 트리거", + "reject": "거부", + "uptimeDaysAgo": "{count}일 전", + "uptimeToday": "오늘", + "uptimeNoDataAvailable": "데이터가 없습니다", + "uptimeSuffix": "가동 시간", + "uptimeDowntimeSuffix": "다운타임", + "uptimeTooltipUptimeLabel": "가동 시간", + "uptimeTooltipDowntimeLabel": "다운타임", + "uptimeOngoing": "진행 중", + "uptimeNoMonitoringData": "모니터링 데이터 없음", + "uptimeNoData": "데이터 없음", + "uptimeMiniBarDown": "중단됨", + "uptimeSectionTitle": "가동 시간", + "uptimeSectionDescription": "지난 {days}일 동안의 가용성", + "uptimeAddAlert": "알림 추가", + "uptimeViewAlerts": "알림 보기", + "uptimeCreateEmailAlert": "이메일 알림 생성", + "uptimeAlertDescriptionSite": "이 사이트가 오프라인 되거나 다시 온라인 될 때 이메일로 알림을 받습니다.", + "uptimeAlertDescriptionResource": "이 리소스가 오프라인 되거나 다시 온라인 될 때 이메일로 알림을 받습니다.", + "uptimeAlertNamePlaceholder": "알림 이름", + "uptimeAdditionalEmails": "추가 이메일", + "uptimeCreateAlert": "알림 생성", + "uptimeAlertNoRecipients": "수신자 없음", + "uptimeAlertNoRecipientsDescription": "통지를 받을 사용자, 역할 또는 이메일을 최소 한 개 추가하세요.", + "uptimeAlertCreated": "알림 생성됨", + "uptimeAlertCreatedDescription": "상태가 변경되면 통지를 받습니다.", + "uptimeAlertCreateFailed": "알림 생성 실패", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "키", + "webhookHeaderValuePlaceholder": "값", + "alertLabel": "알림", + "domainPickerWildcardSubdomainNotAllowed": "와일드카드 서브도메인은 허용되지 않습니다.", + "domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.", + "domainPickerWildcardCertWarningLink": "자세히 알아보기", + "health": "건강", + "domainPendingErrorTitle": "확인 문제" } diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 3458d86b8..fb02be1a8 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Kontakt salgsavdelingen for å aktivere denne funksjonen.", + "contactSalesBookDemo": "Bestill en demo", + "contactSalesOr": "eller", + "contactSalesContactUs": "kontakt oss", "setupCreate": "Opprett organisasjonen, nettstedet og ressursene", "headerAuthCompatibilityInfo": "Aktiver dette for å tvinge frem en 401 Uautorisert-respons når en autentiseringstoken mangler. Dette kreves for nettlesere eller spesifikke HTTP-biblioteker som ikke sender legitimasjon uten en serverutfordring.", "headerAuthCompatibility": "Utvidet kompatibilitet", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Ugyldig eller utgått lisensnøkkel oppdaget. Følg lisensvilkårene for å fortsette å kunne bruke alle funksjonene.", "dismiss": "Avvis", "subscriptionViolationMessage": "Du er utenfor grensen for gjeldende plan. Rett problemet ved å fjerne nettsteder, brukere eller andre ressurser for å bli innenfor planen din.", + "trialBannerMessage": "Din prøveperiode utløper om {countdown}. Oppgrader for å beholde tilgangen.", + "trialBannerExpired": "Prøveperioden din har utløpt. Oppgrader nå for å gjenopprette tilgangen.", + "trialActive": "Gratis prøveversjon aktiv", + "trialExpired": "Prøveperioden er utløpt", + "trialHasEnded": "Din prøveperiode har avsluttet.", + "trialDaysRemaining": "{count, plural, one {# dag igjen} other {# dager igjen}}", + "trialDaysLeftShort": "{days}d igjen av prøveperioden", + "trialGoToBilling": "Gå til faktureringssiden", "subscriptionViolationViewBilling": "Vis fakturering", "componentsLicenseViolation": "Lisens Brudd: Denne serveren bruker {usedSites} områder som overskrider den lisensierte grenser av {maxSites} områder. Følg lisensvilkårene for å fortsette å kunne bruke alle funksjonene.", "componentsSupporterMessage": "Takk for at du støtter Pangolin som en {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "Jeg har kopiert konfigurasjonen", "searchSitesProgress": "Søker i områder...", "siteAdd": "Legg til område", + "sitesTableViewPublicResources": "Vis offentlige ressurser", + "sitesTableViewPrivateResources": "Vis private ressurser", "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Få Newt til å kjøre på systemet ditt", "WgConfiguration": "WireGuard Konfigurasjon", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "Området har blitt oppdatert.", "siteGeneralDescription": "Konfigurer de generelle innstillingene for dette området", "siteSettingDescription": "Konfigurere innstillingene på nettstedet", + "siteResourcesTab": "Ressurser", + "siteResourcesNoneOnSite": "Dette nettstedet har ingen offentlige eller private ressurser enda.", + "siteResourcesSectionPublic": "Offentlige ressurser", + "siteResourcesSectionPrivate": "Private ressurser", + "siteResourcesSectionPublicDescription": "Ressurser eksponert eksternt gjennom domener eller porter.", + "siteResourcesSectionPrivateDescription": "Ressurser tilgjengelig på ditt private nettverk gjennom nettstedet.", + "siteResourcesViewAllPublic": "Vis alle ressurser", + "siteResourcesViewAllPrivate": "Vis alle ressurser", + "siteResourcesDialogDescription": "Oversikt over offentlige og private ressurser assosiert med dette nettstedet.", + "siteResourcesShowMore": "Vis mer", + "siteResourcesPermissionDenied": "Du har ikke tillatelse til å liste opp disse ressursene.", + "siteResourcesEmptyPublic": "Ingen offentlige ressurser retter seg mot dette nettstedet enda.", + "siteResourcesEmptyPrivate": "Ingen private ressurser er assosiert med dette nettstedet enda.", + "siteResourcesHowToAccess": "Hvordan få tilgang", + "siteResourcesTargetsOnSite": "Mål på dette nettstedet", "siteSetting": "{siteName} Innstillinger", "siteNewtTunnel": "Nyhetsnettsted (anbefalt)", "siteNewtTunnelDescription": "Lekkeste måte å lage et inngangspunkt til ethvert nettverk. Ingen ekstra oppsett på.", @@ -267,8 +296,11 @@ "orgMissing": "Organisasjons-ID Mangler", "orgMissingMessage": "Kan ikke regenerere invitasjon uten en organisasjons-ID.", "accessUsersManage": "Administrer brukere", + "accessUserManage": "Administrer brukere", "accessUsersDescription": "Inviter og behandle brukere med tilgang til denne organisasjonen", "accessUsersSearch": "Søk etter brukere...", + "accessUsersRoleFilterCount": "{count, plural, one {# rolle} other {# roller}}", + "accessUsersRoleFilterClear": "Fjern rollesøkefiltre", "accessUserCreate": "Opprett bruker", "accessUserRemove": "Fjern bruker", "username": "Brukernavn", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Aktivering av lisensnøkkel feilet", "licenseErrorKeyActivateDescription": "Det oppstod en feil under aktivering av lisensnøkkelen.", "licenseAbout": "Om Lisensiering", + "licenseBannerTitle": "Aktiver din bedriftslisens", + "licenseBannerDescription": "Lås opp bedriftsfunksjoner for din egenvertede Pangolin-instans. Kjøp en lisensnøkkel for å aktivere premium-funksjoner og legg den inn nedenfor.", + "licenseBannerGetLicense": "Få en lisens", + "licenseBannerViewDocs": "Vis dokumentasjon", "communityEdition": "Fellesskapsutgave", "licenseAboutDescription": "Dette er for bedrifts- og foretaksbrukere som bruker Pangolin i et kommersielt miljø. Hvis du bruker Pangolin til personlig bruk, kan du ignorere denne seksjonen.", "licenseKeyActivated": "Lisensnøkkel aktivert", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Sikkerhetsnøkkel", + "newtVersion": "Versjon", "architecture": "Arkitektur", "sites": "Områder", "siteWgAnyClients": "Bruk hvilken som helst WireGuard klient til å koble til. Du må adressere interne ressurser ved hjelp av peer IP.", @@ -894,6 +931,7 @@ "idpDisplayName": "Et visningsnavn for denne identitetsleverandøren", "idpAutoProvisionUsers": "Automatisk brukerklargjøring", "idpAutoProvisionUsersDescription": "Når aktivert, opprettes brukere automatisk i systemet ved første innlogging, med mulighet til å tilordne brukere til roller og organisasjoner.", + "idpAutoProvisionConfigureAfterCreate": "Du kan konfigurere autoprovisjonsinnstillingene når identitetsleverandøren er opprettet.", "licenseBadge": "EE", "idpType": "Leverandørtype", "idpTypeDescription": "Velg typen identitetsleverandør du ønsker å konfigurere", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Standard rolletilordning", "defaultMappingsRoleDescription": "Resultatet av dette uttrykket må returnere rollenavnet slik det er definert i organisasjonen som en streng.", "defaultMappingsOrg": "Standard organisasjonstilordning", - "defaultMappingsOrgDescription": "Dette uttrykket må returnere organisasjons-ID-en eller «true» for å gi brukeren tilgang til organisasjonen.", + "defaultMappingsOrgDescription": "Når denne er satt, må uttrykket returnere organisasjons-IDen eller «true» for at brukeren skal få tilgang til den organisasjonen. Når den ikke er satt, er det nok å definere en rolletilordning: brukeren gis tilgang så lenge en gyldig rolletilknytting kan løses for dem i organisasjonen.", "defaultMappingsSubmit": "Lagre standard tilordninger", "orgPoliciesEdit": "Rediger Organisasjonspolicy", "org": "Organisasjon", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Vis logger", "noneSelected": "Ingen valgt", "orgNotFound2": "Ingen organisasjoner funnet.", + "search": "Søk…", "searchPlaceholder": "Søk...", "emptySearchOptions": "Ingen valg funnet", "create": "Opprett", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Administrer", "sidebarLogAndAnalytics": "Logg og analyser", "sidebarBluePrints": "Tegninger", + "sidebarAlerting": "Varsling", + "sidebarHealthChecks": "Helsekontroller", "sidebarOrganization": "Organisasjon", "sidebarManagement": "Administrasjon", "sidebarBillingAndLicenses": "Fakturering & lisenser", "sidebarLogsAnalytics": "Analyser", + "alertingTitle": "Varsling", + "alertingDescription": "Definer kilder, triggere og handlinger for varsler", + "alertingRules": "Varslingsregler", + "alertingSearchRules": "Søk i regler…", + "alertingAddRule": "Opprett regel", + "alertingColumnSource": "Kilde", + "alertingColumnTrigger": "Utløser", + "alertingColumnActions": "Handlinger", + "alertingColumnEnabled": "Aktivert", + "alertingDeleteQuestion": "Vennligst bekreft at du vil slette denne varslingsregelen.", + "alertingDeleteRule": "Slett varslingsregel", + "alertingRuleDeleted": "Varslingsregel slettet", + "alertingRuleSaved": "Varslingsregel lagret", + "alertingRuleSavedCreatedDescription": "Din nye varslingsregel ble opprettet. Du kan fortsette å redigere den på denne siden.", + "alertingRuleSavedUpdatedDescription": "Endringene dine i denne varslingsregelen ble lagret.", + "alertingEditRule": "Rediger varslingsregel", + "alertingCreateRule": "Opprett varslingsregel", + "alertingRuleCredenzaDescription": "Velg hva som skal overvåkes, når det skal varsles, og hvordan du vil bli informert", + "alertingRuleNamePlaceholder": "Produksjonsside nede", + "alertingRuleEnabled": "Regel aktivert", + "alertingSectionSource": "Kilde", + "alertingSourceType": "Kildetype", + "alertingSourceSite": "Område", + "alertingSourceHealthCheck": "Helsekontroll", + "alertingPickSites": "Områder", + "alertingPickHealthChecks": "Helsekontroller", + "alertingPickResources": "Ressurser", + "alertingAllSites": "Alle områder", + "alertingAllSitesDescription": "Varsler for alle områder", + "alertingSpecificSites": "Spesifikke områder", + "alertingSpecificSitesDescription": "Velg spesifikke områder for overvåking", + "alertingAllHealthChecks": "Alle helsekontroller", + "alertingAllHealthChecksDescription": "Varsler for alle helsekontroller", + "alertingSpecificHealthChecks": "Spesifikke helsekontroller", + "alertingSpecificHealthChecksDescription": "Velg spesifikke helsekontroller for overvåking", + "alertingAllResources": "Alle ressurser", + "alertingAllResourcesDescription": "Varsler for alle ressurser", + "alertingSpecificResources": "Spesifikke ressurser", + "alertingSpecificResourcesDescription": "Velg spesifikke ressurser for overvåking", + "alertingSelectResources": "Velg ressurser…", + "alertingResourcesSelected": "{count} ressurser valgt", + "alertingResourcesEmpty": "Ingen ressurser med mål i de første 10 resultatene.", + "alertingSectionTrigger": "Utløser", + "alertingTrigger": "Når skal det varsles", + "alertingTriggerSiteOnline": "Nettsted er online", + "alertingTriggerSiteOffline": "Nettsted er offline", + "alertingTriggerSiteToggle": "Endringer i nettstedstatus", + "alertingTriggerHcHealthy": "Helsekontroll sunn", + "alertingTriggerHcUnhealthy": "Helsekontroll usunn", + "alertingTriggerHcToggle": "Endringer i helsekontrollstatus", + "alertingTriggerResourceHealthy": "Ressurs sunn", + "alertingTriggerResourceUnhealthy": "Ressurs usunn", + "alertingTriggerResourceDegraded": "Ressurs forringet", + "alertingSearchHealthChecks": "Søk i helsekontroller…", + "alertingHealthChecksEmpty": "Ingen tilgjengelige helsekontroller.", + "alertingTriggerResourceToggle": "Endringer i ressursstatus", + "alertingSourceResource": "Ressurs", + "alertingSectionActions": "Handlinger", + "alertingAddAction": "Legg til handling", + "alertingActionNotify": "E-post", + "alertingActionNotifyDescription": "Send e-postvarsler til brukere eller roller", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Send en HTTP-forespørsel til et tilpasset endepunkt", + "alertingExternalIntegration": "Ekstern integrasjon", + "alertingExternalPagerDutyDescription": "Send varsler til PagerDuty for hendelseshåndtering", + "alertingExternalOpsgenieDescription": "Rute varsler til Opsgenie for vakt håndtering", + "alertingExternalServiceNowDescription": "Opprett ServiceNow hendelser fra varslingseventer", + "alertingExternalIncidentIoDescription": "Utløs Incident.io arbeidsflyter fra varsels begivenheter", + "alertingActionType": "Handlings type", + "alertingNotifyUsers": "Brukere", + "alertingNotifyRoles": "Roller", + "alertingNotifyEmails": "E-postadresser", + "alertingEmailPlaceholder": "Legg til e-post og trykk Enter", + "alertingWebhookMethod": "HTTP-metode", + "alertingWebhookSecret": "Signeringshemmelig (valgfritt)", + "alertingWebhookSecretPlaceholder": "HMAC-hemmelig", + "alertingWebhookHeaders": "Overskrifter", + "alertingAddHeader": "Legg til header", + "alertingSelectSites": "Velg områder…", + "alertingSitesSelected": "{count} områder valgt", + "alertingSelectHealthChecks": "Velg helsekontroller…", + "alertingHealthChecksSelected": "{count} helsekontroller valgt", + "alertingNoHealthChecks": "Ingen mål med helsekontroller aktivert", + "alertingHealthCheckStub": "Valg av helsekontrollkilde er ikke sluttført ennå - du kan fortsatt konfigurere triggere og handlinger.", + "alertingSelectUsers": "Velg brukere…", + "alertingUsersSelected": "{count} brukere valgt", + "alertingSelectRoles": "Velg roller…", + "alertingRolesSelected": "{count} roller valgt", + "alertingSummarySites": "Områder ({count})", + "alertingSummaryAllSites": "Alle områder", + "alertingSummaryHealthChecks": "Helsekontroller ({count})", + "alertingSummaryAllHealthChecks": "Alle helsekoner", + "alertingSummaryResources": "Ressurser ({count})", + "alertingSummaryAllResources": "Alle ressurser", + "alertingErrorNameRequired": "Skriv inn et navn", + "alertingErrorActionsMin": "Legg til minst én handling", + "alertingErrorPickSites": "Velg minst ett område", + "alertingErrorPickHealthChecks": "Velg minst én helsekontroll", + "alertingErrorPickResources": "Velg minst én ressurs", + "alertingErrorTriggerSite": "Velg en triggetjeneste for nettsted", + "alertingErrorTriggerHealth": "Velg en triggetjeneste for helsekontroll", + "alertingErrorTriggerResource": "Velg en triggetjeneste for ressurs", + "alertingErrorNotifyRecipients": "Velg brukere, roller, eller minst én e-post", + "alertingConfigureSource": "Konfigurer kilde", + "alertingConfigureTrigger": "Konfigurer trigger", + "alertingConfigureActions": "Konfigurer handlinger", + "alertingBackToRules": "Tilbake til regler", + "alertingRuleCooldown": "Nedkjøling (sekunder)", + "alertingRuleCooldownDescription": "Minimum tid mellom gjentatte varsler for samme regel. Sett til 0 for å skyte hver gang.", + "alertingDraftBadge": "Utkast - lagre for å lagre denne regelen", + "alertingSidebarHint": "Klikk på et steg på lerretet for å redigere det her.", + "alertingGraphCanvasTitle": "Regel Flyt", + "alertingGraphCanvasDescription": "Visuell oversikt over kilde, trigger og handlinger. Velg en node for å redigere den i panelet.", + "alertingNodeNotConfigured": "Ikke konfigurert ennå", + "alertingNodeActionsCount": "{count, plural, one {# handling} other {# handlinger}}", + "alertingNodeRoleSource": "Kilde", + "alertingNodeRoleTrigger": "Utløser", + "alertingNodeRoleAction": "Handling", + "alertingTabRules": "Varslingsregler", + "alertingTabHealthChecks": "Helsekontroller", + "alertingRulesBannerTitle": "Bli varslet", + "alertingRulesBannerDescription": "Hver regel binder sammen hva som skal overvåkes (et område, helsekontroll eller ressurs), når det skal varsles (for eksempel offline eller usunn), og hvordan varsle teamet ditt via e-post, webhooks eller integrasjoner. Bruk denne listen for å opprette, aktivere og administrere disse reglene.", + "alertingHealthChecksBannerTitle": "Overvåk helse & ressurser", + "alertingHealthChecksBannerDescription": "Helsekontroller er HTTP- eller TCP-monitorer du definerer én gang. Du kan deretter bruke dem som kilder i varslingsregler slik at du blir varslet når et mål blir sunt eller usunt. Helsekontroller på ressurser vises også her.", + "standaloneHcTableTitle": "Helsekontroller", + "standaloneHcSearchPlaceholder": "Søk i helsekontroller…", + "standaloneHcAddButton": "Opprett helsekontroll", + "standaloneHcCreateTitle": "Opprett helsekontroll", + "standaloneHcEditTitle": "Rediger helsekontroll", + "standaloneHcDescription": "Konfigurer en HTTP- eller TCP-helsekontroll for bruk i varslingsregler.", + "standaloneHcNameLabel": "Navn", + "standaloneHcNamePlaceholder": "Min HTTP-monitor", + "standaloneHcDeleteTitle": "Slett helsekontroll", + "standaloneHcDeleteQuestion": "Vennligst bekreft at du vil slette denne helsekontrollen.", + "standaloneHcDeleted": "Helsekontroll slettet", + "standaloneHcSaved": "Helsekontroll lagret", + "standaloneHcColumnHealth": "Helse", + "standaloneHcColumnMode": "Modus", + "standaloneHcColumnTarget": "Mål", + "standaloneHcHealthStateHealthy": "Sunn", + "standaloneHcHealthStateUnhealthy": "Usunn", + "standaloneHcHealthStateUnknown": "Ukjent", + "standaloneHcFilterAnySite": "Alle områder", + "standaloneHcFilterAnyResource": "Alle ressurser", + "standaloneHcFilterMode": "Modus", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Helse", + "standaloneHcFilterEnabled": "Aktivert", + "standaloneHcFilterEnabledOn": "Aktivert", + "standaloneHcFilterEnabledOff": "Deaktivert", + "standaloneHcFilterSiteIdFallback": "Område {id}", + "standaloneHcFilterResourceIdFallback": "Ressurs {id}", "blueprints": "Tegninger", "blueprintsDescription": "Bruk deklarative konfigurasjoner og vis tidligere kjøringer", "blueprintAdd": "Legg til blåkopi", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Opprett den første serveradministratorkontoen. Det kan bare finnes én serveradministrator. Du kan alltid endre denne påloggingsinformasjonen senere.", "createAdminAccount": "Opprett administratorkonto", "setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.", - "certificateStatus": "Sertifikatstatus", + "certificateStatus": "Sertifikat", + "certificateStatusAutoRefreshHint": "Status oppdateres automatisk.", "loading": "Laster inn", "loadingAnalytics": "Laster inn analyser", "restart": "Start på nytt", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater", "newtUpdateAvailable": "Oppdatering tilgjengelig", "newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", + "pangolinNodeUpdateAvailableInfo": "En ny versjon av Pangolin Node er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", "domainPickerEnterDomain": "Domene", "domainPickerPlaceholder": "minapp.eksempel.no", "domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Konfigurer Helsekontroll", "configureHealthCheckDescription": "Sett opp helsekontroll for {target}", "enableHealthChecks": "Aktiver Helsekontroller", + "healthCheckDisabledStateDescription": "Når deaktivert, vil ikke nettstedet utføre helsekontroller, og tilstanden vil anses som ukjent.", "enableHealthChecksDescription": "Overvåk helsen til dette målet. Du kan overvåke et annet endepunkt enn målet hvis nødvendig.", "healthScheme": "Metode", "healthSelectScheme": "Velg metode", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "Sjekkeintervallet må være minst 5 sekunder", "healthCheckTimeoutMin": "Timeout må være minst 1 sekund", "healthCheckRetryMin": "Forsøk på nytt må være minst 1", + "healthCheckMode": "Sjekk modus", + "healthCheckStrategy": "Strategi", + "healthCheckModeDescription": "TCP-modus verifiserer kun tilkobling. HTTP-modus validerer HTTP-responsen.", + "healthyThreshold": "Sunnhets terskel", + "healthyThresholdDescription": "Suksesser på rad som kreves før man markerer som sunn.", + "unhealthyThreshold": "Usunn terskel", + "unhealthyThresholdDescription": "Feil på rad som kreves før man markerer som usunn.", + "healthCheckHealthyThresholdMin": "Sunnhet terskel må være minst 1", + "healthCheckUnhealthyThresholdMin": "Usunn terskel må være minst 1", "httpMethod": "HTTP-metode", "selectHttpMethod": "Velg HTTP-metode", "domainPickerSubdomainLabel": "Underdomene", + "domainPickerWildcard": "Jokertegn", + "domainPickerWildcardPaidOnly": "Jokertegnsubdomener er en betalt funksjon. Vennligst oppgrader for å få tilgang til denne funksjonen.", "domainPickerBaseDomainLabel": "Grunndomene", "domainPickerSearchDomains": "Søk i domener...", "domainPickerNoDomainsFound": "Ingen domener funnet", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Denne adressen er en del av organisasjonens undernettverk. Den brukes til å løse aliasposter ved hjelp av intern DNS-oppløsning.", "resourcesTableClients": "Klienter", "resourcesTableAndOnlyAccessibleInternally": "og er kun tilgjengelig internt når de er koblet til med en klient.", - "resourcesTableNoTargets": "Ingen mål", "resourcesTableHealthy": "Frisk", "resourcesTableDegraded": "Nedgradert", - "resourcesTableOffline": "Frakoblet", + "resourcesTableUnhealthy": "Usunn", "resourcesTableUnknown": "Ukjent", "resourcesTableNotMonitored": "Ikke overvåket", + "resourcesTableNoTargets": "Ingen mål", "editInternalResourceDialogEditClientResource": "Rediger Private Ressurser", "editInternalResourceDialogUpdateResourceProperties": "Oppdater ressurskonfigurasjonen og få tilgangskontroller for {resourceName}", "editInternalResourceDialogResourceProperties": "Ressursegenskaper", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Vert", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Skjema", + "editInternalResourceDialogEnableSsl": "Aktiver SSL", + "editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.", "editInternalResourceDialogDestination": "Destinasjon", "editInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.", "editInternalResourceDialogDestinationIPDescription": "IP eller vertsnavn til ressursen på nettstedets nettverk.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Navn", "createInternalResourceDialogSite": "Område", "selectSite": "Velg område...", + "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", "noSitesFound": "Ingen områder funnet.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Vert", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Skjema", + "createInternalResourceDialogScheme": "Skjema", + "createInternalResourceDialogEnableSsl": "Aktiver SSL", + "createInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.", "createInternalResourceDialogDestination": "Destinasjon", "createInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.", "createInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.", + "internalResourceDownstreamSchemeRequired": "Skjema er påkrevd for HTTP-ressurser", + "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressurser", "siteConfiguration": "Konfigurasjon", "siteAcceptClientConnections": "Godta klientforbindelser", "siteAcceptClientConnectionsDescription": "Tillat brukere og klienter å få tilgang til ressurser på denne siden. Dette kan endres senere.", @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Internasjonalt domene oppdaget", "willbestoredas": "Vil bli lagret som:", - "roleMappingDescription": "Bestem hvordan roller tilordnes brukere når innloggingen er aktivert når autog-rapportering er aktivert.", + "roleMappingDescription": "Bestem hvordan roller tildeles brukere når de logger inn med denne identitetsleverandøren.", "selectRole": "Velg en rolle", "roleMappingExpression": "Uttrykk", "selectRolePlaceholder": "Velg en rolle", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Velg et domene for organisasjonens autentiseringsside", "domainPickerProvidedDomain": "Gitt domene", "domainPickerFreeProvidedDomain": "Gratis oppgitt domene", + "domainPickerFreeDomainsPaidFeature": "Angitte domener er en betalingsfunksjon. Abonner for å få et domene inkludert i din plan – ingen behov for å ta med ditt eget.", "domainPickerVerified": "Bekreftet", "domainPickerUnverified": "Uverifisert", - "domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.", + "domainPickerManual": "Manuell", + "domainPickerInvalidSubdomainStructure": "Ugyldige tegn vil bli sanitert når de er lagret.", "domainPickerError": "Feil", "domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener", "domainPickerErrorCheckAvailability": "Kunne ikke kontrollere domenetilgjengelighet", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette", "orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.", "orgAuthSignInWithPangolin": "Logg inn med Pangolin", - "orgAuthSignInToOrg": "Logg inn på en organisasjon", + "orgAuthSignInToOrg": "Organisasjonens identitetsleverandør (SSO)", "orgAuthSelectOrgTitle": "Organisasjonsinnlogging", "orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette", "orgAuthOrgIdPlaceholder": "din-organisasjon", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Skala", - "description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte." + "description": "Funksjoner for bedrifter, 50 brukere, 100 nettsteder og prioritert support." } }, "personalUseOnly": "Kun personlig bruk (gratis lisens - ingen kasse)", @@ -2422,6 +2648,7 @@ "validPassword": "Gyldig passord", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Tilkoblet klient", "resourceBlocked": "Ressurs blokkert", "droppedByRule": "Legg i regelen", "noSessions": "Ingen økter", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Legg til klienter", "editInternalResourceDialogDestinationLabel": "Destinasjon", "editInternalResourceDialogDestinationDescription": "Spesifiser destinasjonsadressen for den interne ressursen. Dette kan være et vertsnavn, IP-adresse eller CIDR-sjikt avhengig av valgt modus. Valgfrie oppsett av intern DNS-alias for enklere identifikasjon.", + "internalResourceFormMultiSiteRoutingHelp": "Valg av flere nettsteder muliggjør motstandskraftig ruting og failover for høy tilgjengelighet.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Lær mer", "editInternalResourceDialogPortRestrictionsDescription": "Begrens tilgang til spesifikke TCP/UDP-porter eller tillate/blokkere alle porter.", + "createInternalResourceDialogHttpConfiguration": "HTTP-konfigurasjon", + "createInternalResourceDialogHttpConfigurationDescription": "Velg domenet klienter vil bruke for å nå denne ressursen via HTTP eller HTTPS.", + "editInternalResourceDialogHttpConfiguration": "HTTP-konfigurasjon", + "editInternalResourceDialogHttpConfigurationDescription": "Velg domenet klienter vil bruke for å nå denne ressursen via HTTP eller HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Vi kommer snart tilbake! Vårt nettsted gjennomgår for øyeblikket planlagt vedlikehold.", "maintenancePageMessageDescription": "Detaljert beskjed som forklarer vedlikeholdet", "maintenancePageTimeTitle": "Estimert ferdigstillelsestid (Valgfritt)", + "privateMaintenanceScreenTitle": "Privat plassholder skjerm", + "privateMaintenanceScreenMessage": "Dette domenet brukes på en privatressurs. Koble til ved å bruke Pangolin-klienten for å få tilgang til denne ressursen.", + "privateMaintenanceScreenSteps": "Når du er koblet til, hvis du fortsatt ser denne meldingen, peker kanskje DNS-cachen til nettleseren din fortsatt til den gamle adressen. For å rette på dette: lukk og åpne denne fanen eller nettleseren på nytt, og naviger deretter tilbake til denne siden.", "maintenanceTime": "f.eks. 2 timer, 1. november kl. 17:00", "maintenanceEstimatedTimeDescription": "Når du forventer at vedlikeholdet er ferdigstilt", "editDomain": "Rediger domene", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Legg til HTTP-destinasjon", "httpDestEditDescription": "Oppdater konfigurasjonen for denne HTTP-hendelsesstrømmedestinasjonen.", "httpDestAddDescription": "Konfigurer et nytt HTTP endepunkt for å motta organisasjonens hendelser.", + "S3DestEditTitle": "Rediger destinasjon", + "S3DestAddTitle": "Legg til S3 destinasjon", + "S3DestEditDescription": "Oppdatere konfigurasjonen for denne S3-hendelsesstrømmingsdestinasjonen.", + "S3DestAddDescription": "Konfigurer et nytt S3-endepunkt for å motta organisasjonens hendelser.", + "datadogDestEditTitle": "Rediger destinasjon", + "datadogDestAddTitle": "Legg til Datadog destinasjon", + "datadogDestEditDescription": "Oppdatere konfigurasjonen for denne Datadog-hendelsesstrømmingsdestinasjonen.", + "datadogDestAddDescription": "Konfigurer et nytt Datadog-endepunkt for å motta organisasjonens hendelser.", "httpDestTabSettings": "Innstillinger", "httpDestTabHeaders": "Overskrifter", "httpDestTabBody": "Innhold", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "JSON liste", "httpDestFormatJsonArrayDescription": "Én forespørsel per batch, innholdet er en JSON-liste. Kompatibel med de mest generiske webhooks og Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Én forespørsel per sats, innholdet er nytt avgrenset JSON — et objekt per linje, ingen ytterarray. Kreves av Splunk HEC, Elastisk/OpenSearch, og Grafana Loki.", + "httpDestFormatNdjsonDescription": "Én forespørsel per sats, innholdet er nytt avgrenset JSON - et objekt per linje, ingen ytterarray. Kreves av Splunk HEC, Elastisk/OpenSearch, og Grafana Loki.", "httpDestFormatSingleTitle": "En hendelse per forespørsel", "httpDestFormatSingleDescription": "Sender en separat HTTP POST for hver enkelt hendelse. Bruk bare for endepunkter som ikke kan håndtere batcher.", "httpDestLogTypesTitle": "Logg typer", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Målet er oppdatert", "httpDestCreatedSuccess": "Målet er opprettet", "httpDestUpdateFailed": "Kunne ikke oppdatere destinasjon", - "httpDestCreateFailed": "Kan ikke opprette mål" + "httpDestCreateFailed": "Kan ikke opprette mål", + "followRedirects": "Følg videresendinger", + "followRedirectsDescription": "Følg automatisk HTTP-videresendinger for forespørsler.", + "alertingErrorWebhookUrl": "Vennligst skriv inn en gyldig URL for webhooken.", + "healthCheckStrategyHttp": "Validerer tilkobling og sjekker HTTP-responsstatus.", + "healthCheckStrategyTcp": "Bekrefter kun TCP-tilkobling, uten å inspisere responsen.", + "healthCheckStrategySnmp": "Utfører en SNMP get-forespørsel for å sjekke helsen til nettverksenheter og infrastruktur.", + "healthCheckStrategyIcmp": "Bruker ICMP ekko forespørsler (ping) for å sjekke om en ressurs er tilgjengelig og responsiv.", + "healthCheckTabStrategy": "Strategi", + "healthCheckTabConnection": "Tilkobling", + "healthCheckTabAdvanced": "Avansert", + "healthCheckStrategyNotAvailable": "Denne strategien er ikke tilgjengelig. Vennligst kontakt salgsavdelingen for å aktivere denne funksjonen.", + "uptime30d": "Oppetid (30d)", + "idpAddActionCreateNew": "Opprett ny identitetsleverandør", + "idpAddActionImportFromOrg": "Importer fra en annen organisasjon", + "idpImportDialogTitle": "Importer identitetsleverandør", + "idpImportDialogDescription": "Velg en identitetsleverandør fra en organisasjon der du er admin. Den vil bli knyttet til denne organisasjonen.", + "idpImportSearchPlaceholder": "Søk etter organisasjons- eller leverandørnavn...", + "idpImportEmpty": "Ingen identitetsleverandører funnet.", + "idpImportedDescription": "Identitetsleverandøren ble importert vellykket.", + "idpDeleteGlobalQuestion": "Er du sikker på at du vil slette denne identitetsleverandøren permanent?", + "idpDeleteGlobalDescription": "Dette vil slette identitetsleverandøren permanent fra alle organisasjoner den er tilknyttet.", + "idpUnassociateTitle": "Frakoble identitetsleverandør", + "idpUnassociateQuestion": "Er du sikker på at du vil frakoble denne identitetsleverandøren fra denne organisasjonen?", + "idpUnassociateDescription": "Alle brukere knyttet til denne identitetsleverandøren vil bli fjernet fra denne organisasjonen, men identitetsleverandøren vil fortsatt eksistere for andre tilknyttede organisasjoner.", + "idpUnassociateConfirm": "Bekreft frakobling av identitetsleverandør", + "idpUnassociateWarning": "Dette kan ikke angres for denne organisasjonen.", + "idpUnassociatedDescription": "Identitetsleverandør er vellykket frakoblet fra denne organisasjonen", + "idpUnassociateMenu": "Frakoble", + "idpDeleteAllOrgsMenu": "Slett", + "publicIpEndpoint": "Endepunkt", + "lastTriggeredAt": "Siste utløste", + "reject": "Avvis", + "uptimeDaysAgo": "{count} days ago", + "uptimeToday": "I dag", + "uptimeNoDataAvailable": "Ingen data tilgjengelig", + "uptimeSuffix": "oppetid", + "uptimeDowntimeSuffix": "nedetid", + "uptimeTooltipUptimeLabel": "Oppetid", + "uptimeTooltipDowntimeLabel": "Nedetid", + "uptimeOngoing": "pågående", + "uptimeNoMonitoringData": "Ingen overvåkingsdata", + "uptimeNoData": "Ingen data", + "uptimeMiniBarDown": "Nede", + "uptimeSectionTitle": "Oppetid", + "uptimeSectionDescription": "Tilgjengelighet de siste {days} dagene", + "uptimeAddAlert": "Legg til varsling", + "uptimeViewAlerts": "Vis varsler", + "uptimeCreateEmailAlert": "Opprett e-postvarsel", + "uptimeAlertDescriptionSite": "Få beskjed på e-post når dette nettstedet går offline eller kommer tilbake online.", + "uptimeAlertDescriptionResource": "Få beskjed på e-post når denne ressursen går offline eller kommer tilbake online.", + "uptimeAlertNamePlaceholder": "Varslingsnavn", + "uptimeAdditionalEmails": "Flere e-poster", + "uptimeCreateAlert": "Opprett varsling", + "uptimeAlertNoRecipients": "Ingen mottakere", + "uptimeAlertNoRecipientsDescription": "Vennligst legg til minst én bruker, rolle, eller e-post for å varsle.", + "uptimeAlertCreated": "Varsel opprettet", + "uptimeAlertCreatedDescription": "Du vil bli varslet når dette endrer status.", + "uptimeAlertCreateFailed": "Kunne ikke opprette varsel", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Nøkkel", + "webhookHeaderValuePlaceholder": "Verdi", + "alertLabel": "Varsel", + "domainPickerWildcardSubdomainNotAllowed": "Jokertegnsubdomener er ikke tillatt.", + "domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.", + "domainPickerWildcardCertWarningLink": "Lær mer", + "health": "Helse", + "domainPendingErrorTitle": "Verifiseringsproblem" } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 66d67abd7..0060daa90 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Neem contact op met de verkoopafdeling om deze functie in te schakelen.", + "contactSalesBookDemo": "Boek een demo", + "contactSalesOr": "of", + "contactSalesContactUs": "neem contact met ons op", "setupCreate": "Maak de organisatie, site en bronnen aan", "headerAuthCompatibilityInfo": "Schakel dit in om een 401 Niet Geautoriseerd antwoord af te dwingen wanneer een authenticatietoken ontbreekt. Dit is vereist voor browsers of specifieke HTTP-bibliotheken die geen referenties verzenden zonder een serveruitdaging.", "headerAuthCompatibility": "Uitgebreide compatibiliteit", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Ongeldige of verlopen licentiesleutels gedetecteerd. Volg de licentievoorwaarden om alle functies te blijven gebruiken.", "dismiss": "Uitschakelen", "subscriptionViolationMessage": "U overschrijdt uw huidige abonnement. Corrigeer het probleem door sites, gebruikers of andere bronnen te verwijderen om binnen uw plan te blijven.", + "trialBannerMessage": "Uw proefversie verloopt over {countdown}. Upgrade om toegang te behouden.", + "trialBannerExpired": "Uw proefperiode is verlopen. Upgrade nu om toegang te herstellen.", + "trialActive": "Gratis proefversie actief", + "trialExpired": "Proefversie verlopen", + "trialHasEnded": "Uw proefperiode is geëindigd.", + "trialDaysRemaining": "{count, plural, one {# dag resterend} other {# dagen resterend}}", + "trialDaysLeftShort": "{days}d over in proefversie", + "trialGoToBilling": "Ga naar factureringspagina", "subscriptionViolationViewBilling": "Facturering bekijken", "componentsLicenseViolation": "Licentie overtreding: Deze server gebruikt {usedSites} sites die de gelicentieerde limiet van {maxSites} sites overschrijden. Volg de licentievoorwaarden om door te gaan met het gebruik van alle functies.", "componentsSupporterMessage": "Bedankt voor het ondersteunen van Pangolin als {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "Ik heb de configuratie gekopieerd", "searchSitesProgress": "Sites zoeken...", "siteAdd": "Site toevoegen", + "sitesTableViewPublicResources": "Openbare bronnen bekijken", + "sitesTableViewPrivateResources": "Privébronnen bekijken", "siteInstallNewt": "Installeer Newt", "siteInstallNewtDescription": "Laat Newt draaien op uw systeem", "WgConfiguration": "WireGuard Configuratie", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "De site is bijgewerkt.", "siteGeneralDescription": "Algemene instellingen voor deze site configureren", "siteSettingDescription": "Configureer de instellingen van de site", + "siteResourcesTab": "Bronnen", + "siteResourcesNoneOnSite": "Deze site heeft nog geen openbare of privébronnen.", + "siteResourcesSectionPublic": "Openbare bronnen", + "siteResourcesSectionPrivate": "Privébronnen", + "siteResourcesSectionPublicDescription": "Bronnen extern blootgesteld via domeinen of poorten.", + "siteResourcesSectionPrivateDescription": "Bronnen beschikbaar op uw privénetwerk via de site.", + "siteResourcesViewAllPublic": "Bekijk alle bronnen", + "siteResourcesViewAllPrivate": "Bekijk alle bronnen", + "siteResourcesDialogDescription": "Overzicht van openbare en privébronnen die geassocieerd zijn met deze site.", + "siteResourcesShowMore": "Meer weergeven", + "siteResourcesPermissionDenied": "U heeft geen toestemming om deze bronnen te vermelden.", + "siteResourcesEmptyPublic": "Geen openbare bronnen richten zich nog op deze site.", + "siteResourcesEmptyPrivate": "Er zijn nog geen privébronnen gekoppeld aan deze site.", + "siteResourcesHowToAccess": "Hoe te openen", + "siteResourcesTargetsOnSite": "Doelen op deze site", "siteSetting": "{siteName} instellingen", "siteNewtTunnel": "Nieuwste site (Aanbevolen)", "siteNewtTunnelDescription": "Makkelijkste manier om een ingangspunt in een netwerk te maken. Geen extra opzet.", @@ -267,8 +296,11 @@ "orgMissing": "Organisatie-ID ontbreekt", "orgMissingMessage": "Niet in staat om de uitnodiging te regenereren zonder organisatie-ID.", "accessUsersManage": "Gebruikers beheren", + "accessUserManage": "Beheer gebruiker", "accessUsersDescription": "Nodig uit en beheer gebruikers met toegang tot deze organisatie", "accessUsersSearch": "Gebruikers zoeken...", + "accessUsersRoleFilterCount": "{count, plural, one {# rol} other {# rollen}}", + "accessUsersRoleFilterClear": "Rolfilters wissen", "accessUserCreate": "Gebruiker aanmaken", "accessUserRemove": "Gebruiker verwijderen", "username": "Gebruikersnaam", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Licentiesleutel activeren mislukt", "licenseErrorKeyActivateDescription": "Er is een fout opgetreden tijdens het activeren van de licentiesleutel.", "licenseAbout": "Over licenties", + "licenseBannerTitle": "Activeer Uw Enterprise Licentie", + "licenseBannerDescription": "Ontgrendel enterprise-functies voor uw zelf-gehoste Pangolin-instantie. Koop een licentiesleutel om premium mogelijkheden te activeren, voeg deze vervolgens hieronder toe.", + "licenseBannerGetLicense": "Koop een Licentie", + "licenseBannerViewDocs": "Bekijk Documentatie", "communityEdition": "Community editie", "licenseAboutDescription": "Dit geldt voor gebruikers van bedrijven en ondernemingen die Pangolin in gebruiken in een commerciële omgeving. Als u Pangolin gebruikt voor persoonlijk gebruik, kunt u dit gedeelte negeren.", "licenseKeyActivated": "Licentiesleutel geactiveerd", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Geheim", + "newtVersion": "Versie", "architecture": "Architectuur", "sites": "Sites", "siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je zult interne bronnen moeten aanspreken met behulp van de peer IP.", @@ -894,6 +931,7 @@ "idpDisplayName": "Een weergavenaam voor deze identiteitsprovider", "idpAutoProvisionUsers": "Auto Provisie Gebruikers", "idpAutoProvisionUsersDescription": "Wanneer ingeschakeld, worden gebruikers automatisch in het systeem aangemaakt wanneer ze de eerste keer inloggen met de mogelijkheid om gebruikers toe te wijzen aan rollen en organisaties.", + "idpAutoProvisionConfigureAfterCreate": "U kunt automatische voorzieningsinstellingen configureren zodra de identiteitsprovider is aangemaakt.", "licenseBadge": "EE", "idpType": "Type provider", "idpTypeDescription": "Selecteer het type identiteitsprovider dat u wilt configureren", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Standaard Rol Toewijzing", "defaultMappingsRoleDescription": "Het resultaat van deze uitdrukking moet de rolnaam zoals gedefinieerd in de organisatie als tekenreeks teruggeven.", "defaultMappingsOrg": "Standaard organisatie mapping", - "defaultMappingsOrgDescription": "Deze expressie moet de org-ID teruggeven of waar om de gebruiker toegang te geven tot de organisatie.", + "defaultMappingsOrgDescription": "Wanneer ingesteld, moet deze expressie de organisatie-ID of waar retourneren voor de gebruiker om toegang te krijgen tot die organisatie. Als het niet is ingesteld, is het definiëren van een roltoewijzing voldoende: de gebruiker is toegestaan zolang een geldige roltoewijzing voor hen binnen de organisatie kan worden opgelost.", "defaultMappingsSubmit": "Standaard toewijzingen opslaan", "orgPoliciesEdit": "Organisatie beleid bewerken", "org": "Organisatie", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Logboeken bekijken", "noneSelected": "Niet geselecteerd", "orgNotFound2": "Geen organisaties gevonden.", + "search": "Zoeken…", "searchPlaceholder": "Zoeken...", "emptySearchOptions": "Geen opties gevonden", "create": "Aanmaken", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Beheren", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blauwdrukken", + "sidebarAlerting": "Waarschuwingen", + "sidebarHealthChecks": "Gezondheidscontroles", "sidebarOrganization": "Organisatie", "sidebarManagement": "Beheer", "sidebarBillingAndLicenses": "Facturatie & Licenties", "sidebarLogsAnalytics": "Analyses", + "alertingTitle": "Waarschuwingen", + "alertingDescription": "Definieer bronnen, triggers en acties voor meldingen", + "alertingRules": "Waarschuwingsregels", + "alertingSearchRules": "Zoek regels…", + "alertingAddRule": "Regel aanmaken", + "alertingColumnSource": "Bron", + "alertingColumnTrigger": "Trigger", + "alertingColumnActions": "Acties", + "alertingColumnEnabled": "Ingeschakeld", + "alertingDeleteQuestion": "Bevestig alstublieft dat u deze waarschuwingsregel wilt verwijderen.", + "alertingDeleteRule": "Verwijder waarschuwingsregel", + "alertingRuleDeleted": "Waarschuwingsregel verwijderd", + "alertingRuleSaved": "Waarschuwingsregel opgeslagen", + "alertingRuleSavedCreatedDescription": "Uw nieuwe waarschuwingsregel is aangemaakt. U kunt deze op deze pagina blijven bewerken.", + "alertingRuleSavedUpdatedDescription": "Uw wijzigingen in deze waarschuwingsregel zijn opgeslagen.", + "alertingEditRule": "Bewerk waarschuwingsregel", + "alertingCreateRule": "Waarschuwingsregel aanmaken", + "alertingRuleCredenzaDescription": "Kies wat te bekijken, wanneer het moet gebeuren en hoe te waarschuwen", + "alertingRuleNamePlaceholder": "Productiesite offline", + "alertingRuleEnabled": "Regel ingeschakeld", + "alertingSectionSource": "Bron", + "alertingSourceType": "Brontype", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Gezondheidscontrole", + "alertingPickSites": "Sites", + "alertingPickHealthChecks": "Gezondheidscontroles", + "alertingPickResources": "Bronnen", + "alertingAllSites": "Alle sites", + "alertingAllSitesDescription": "Waarschuwing voor elke site", + "alertingSpecificSites": "Specifieke sites", + "alertingSpecificSitesDescription": "Kies specifieke sites om in de gaten te houden", + "alertingAllHealthChecks": "Alle Gezondheidscontroles", + "alertingAllHealthChecksDescription": "Waarschuwing voor elke gezondheidscontrole", + "alertingSpecificHealthChecks": "Specifieke Gezondheidscontroles", + "alertingSpecificHealthChecksDescription": "Kies specifieke gezondheidscontroles om in de gaten te houden", + "alertingAllResources": "Alle bronnen", + "alertingAllResourcesDescription": "Waarschuwing voor elke bron", + "alertingSpecificResources": "Specifieke bronnen", + "alertingSpecificResourcesDescription": "Kies specifieke bronnen om in de gaten te houden", + "alertingSelectResources": "Selecteer bronnen…", + "alertingResourcesSelected": "{count} bronnen geselecteerd", + "alertingResourcesEmpty": "Geen bronnen met doelen in de eerste 10 resultaten.", + "alertingSectionTrigger": "Trigger", + "alertingTrigger": "Wanneer te waarschuwen", + "alertingTriggerSiteOnline": "Site online", + "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerSiteToggle": "Site status wijzigt", + "alertingTriggerHcHealthy": "Gezondheidscontrole gezond", + "alertingTriggerHcUnhealthy": "Gezondheidscontrole ongezond", + "alertingTriggerHcToggle": "Gezondheidscontrole status verandert", + "alertingTriggerResourceHealthy": "Bron gezond", + "alertingTriggerResourceUnhealthy": "Bron ongezond", + "alertingTriggerResourceDegraded": "Bron gedegradeerd", + "alertingSearchHealthChecks": "Zoek gezondheidscontroles…", + "alertingHealthChecksEmpty": "Geen gezondheidscontroles beschikbaar.", + "alertingTriggerResourceToggle": "Bronstatus wijzigt", + "alertingSourceResource": "Bron", + "alertingSectionActions": "Acties", + "alertingAddAction": "Actie toevoegen", + "alertingActionNotify": "E-mail", + "alertingActionNotifyDescription": "Stuur e-mailmeldingen naar gebruikers of rollen", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Stuur een HTTP-verzoek naar een aangepast eindpunt", + "alertingExternalIntegration": "Externe integratie", + "alertingExternalPagerDutyDescription": "Stuur waarschuwingen naar PagerDuty voor incidentbeheer", + "alertingExternalOpsgenieDescription": "Routeer waarschuwingen naar Opsgenie voor wachtdienstbeheer", + "alertingExternalServiceNowDescription": "Maak ServiceNow-incidenten aan vanuit waarschuwingsgebeurtenissen", + "alertingExternalIncidentIoDescription": "Trigger Incident.io workflows van waarschuwingsgebeurtenissen", + "alertingActionType": "Actietype", + "alertingNotifyUsers": "Gebruikers", + "alertingNotifyRoles": "Rollen", + "alertingNotifyEmails": "E-mailadressen", + "alertingEmailPlaceholder": "Voeg e-mail toe en druk op Enter", + "alertingWebhookMethod": "HTTP-methode", + "alertingWebhookSecret": "Ondertekengeheim (optioneel)", + "alertingWebhookSecretPlaceholder": "HMAC-geheim", + "alertingWebhookHeaders": "Headers", + "alertingAddHeader": "Header toevoegen", + "alertingSelectSites": "Selecteer sites…", + "alertingSitesSelected": "{count} sites geselecteerd", + "alertingSelectHealthChecks": "Selecteer gezondheidscontroles…", + "alertingHealthChecksSelected": "{count} gezondheidscontroles geselecteerd", + "alertingNoHealthChecks": "Geen doelen met ingeschakelde gezondheidscontroles", + "alertingHealthCheckStub": "Gezondheidscontrole brondeselectie is nog niet gekoppeld - u kunt nog steeds triggers en acties configureren.", + "alertingSelectUsers": "Selecteer gebruikers…", + "alertingUsersSelected": "{count} gebruikers geselecteerd", + "alertingSelectRoles": "Selecteer rollen…", + "alertingRolesSelected": "{count} rollen geselecteerd", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryAllSites": "Alle sites", + "alertingSummaryHealthChecks": "Gezondheidscontroles ({count})", + "alertingSummaryAllHealthChecks": "Alle gezondheidscontroles", + "alertingSummaryResources": "Bronnen ({count})", + "alertingSummaryAllResources": "Alle bronnen", + "alertingErrorNameRequired": "Voer een naam in", + "alertingErrorActionsMin": "Voeg minimaal één actie toe", + "alertingErrorPickSites": "Selecteer minimaal één site", + "alertingErrorPickHealthChecks": "Selecteer minimaal één gezondheidscontrole", + "alertingErrorPickResources": "Selecteer minimaal één bron", + "alertingErrorTriggerSite": "Kies een site-trigger", + "alertingErrorTriggerHealth": "Kies een gezondheidscontrole-trigger", + "alertingErrorTriggerResource": "Kies een bron-trigger", + "alertingErrorNotifyRecipients": "Kies gebruikers, rollen of ten minste één e-mail", + "alertingConfigureSource": "Bron configureren", + "alertingConfigureTrigger": "Trigger configureren", + "alertingConfigureActions": "Acties configureren", + "alertingBackToRules": "Terug naar regels", + "alertingRuleCooldown": "Aflkoelperiode (seconden)", + "alertingRuleCooldownDescription": "Minimale tijd tussen herhaalwaarschuwingen voor dezelfde regel. Zet op 0 om elke keer te laten vuren.", + "alertingDraftBadge": "Concept - opslaan om deze regel op te slaan", + "alertingSidebarHint": "Klik op een stap in het canvas om deze hier te bewerken.", + "alertingGraphCanvasTitle": "Regelstroom", + "alertingGraphCanvasDescription": "Visueel overzicht van bron, trigger en acties. Selecteer een node om deze in het paneel te bewerken.", + "alertingNodeNotConfigured": "Nog niet geconfigureerd", + "alertingNodeActionsCount": "{count, plural, one {# actie} other {# acties}}", + "alertingNodeRoleSource": "Bron", + "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleAction": "Actie", + "alertingTabRules": "Waarschuwingsregels", + "alertingTabHealthChecks": "Gezondheidscontroles", + "alertingRulesBannerTitle": "Meldingen ontvangen", + "alertingRulesBannerDescription": "Elke regel koppelt wat te bekijken (een site, gezondheidscontrole of bron), wanneer te vuren (bijvoorbeeld offline of ongezond), en hoe uw team te waarschuwen via e-mail, webhooks of integraties. Gebruik deze lijst om die regels te maken, in te schakelen en te beheren.", + "alertingHealthChecksBannerTitle": "Gezondheid & bronnen bewaken", + "alertingHealthChecksBannerDescription": "Gezondheidscontroles zijn HTTP- of TCP-monitoren die u één keer definieert. U kunt ze vervolgens als bronnen in waarschuwingsregels gebruiken, zodat u meldingen krijgt wanneer een doelwit gezond of ongezond wordt. Gezondheidscontroles van bronnen verschijnen ook hier.", + "standaloneHcTableTitle": "Gezondheidscontroles", + "standaloneHcSearchPlaceholder": "Zoek gezondheidscontroles…", + "standaloneHcAddButton": "Gezondheidscontrole aanmaken", + "standaloneHcCreateTitle": "Gezondheidscontrole aanmaken", + "standaloneHcEditTitle": "Gezondheidscontrole bewerken", + "standaloneHcDescription": "Configureer een HTTP- of TCP-gezondheidscontrole voor gebruik in waarschuwingsregels.", + "standaloneHcNameLabel": "Naam", + "standaloneHcNamePlaceholder": "Mijn HTTP-monitor", + "standaloneHcDeleteTitle": "Gezondheidscontrole verwijderen", + "standaloneHcDeleteQuestion": "Bevestig alstublieft dat u deze gezondheidscontrole wilt verwijderen.", + "standaloneHcDeleted": "Gezondheidscontrole verwijderd", + "standaloneHcSaved": "Gezondheidscontrole opgeslagen", + "standaloneHcColumnHealth": "Gezondheid", + "standaloneHcColumnMode": "Modus", + "standaloneHcColumnTarget": "Doelwit", + "standaloneHcHealthStateHealthy": "Gezond", + "standaloneHcHealthStateUnhealthy": "Ongezond", + "standaloneHcHealthStateUnknown": "Onbekend", + "standaloneHcFilterAnySite": "Alle sites", + "standaloneHcFilterAnyResource": "Alle bronnen", + "standaloneHcFilterMode": "Modus", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Gezondheid", + "standaloneHcFilterEnabled": "Ingeschakeld", + "standaloneHcFilterEnabledOn": "Ingeschakeld", + "standaloneHcFilterEnabledOff": "Uitgeschakeld", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Bron {id}", "blueprints": "Blauwdrukken", "blueprintsDescription": "Gebruik declaratieve configuraties en bekijk vorige uitvoeringen.", "blueprintAdd": "Blauwdruk toevoegen", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.", "createAdminAccount": "Maak een beheeraccount aan", "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.", - "certificateStatus": "Certificaatstatus", + "certificateStatus": "Certificaat", + "certificateStatusAutoRefreshHint": "Status ververst automatisch.", "loading": "Bezig met laden", "loadingAnalytics": "Laden van Analytics", "restart": "Herstarten", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Uitgaveopmerkingen bekijken", "newtUpdateAvailable": "Update beschikbaar", "newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", + "pangolinNodeUpdateAvailableInfo": "Er is een nieuwe versie van Pangolin Node beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", "domainPickerEnterDomain": "Domein", "domainPickerPlaceholder": "mijnapp.voorbeeld.nl", "domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Configureer Gezondheidscontrole", "configureHealthCheckDescription": "Stel gezondheid monitor voor {target} in", "enableHealthChecks": "Inschakelen Gezondheidscontroles", + "healthCheckDisabledStateDescription": "Wanneer uitgeschakeld, zal de site geen gezondheidscontroles uitvoeren en wordt de staat als onbekend beschouwd.", "enableHealthChecksDescription": "Controleer de gezondheid van dit doel. U kunt een ander eindpunt monitoren dan het doel indien vereist.", "healthScheme": "Methode", "healthSelectScheme": "Selecteer methode", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "Controle interval moet minimaal 5 seconden zijn", "healthCheckTimeoutMin": "Timeout moet minimaal 1 seconde zijn", "healthCheckRetryMin": "Herhaal pogingen moet minimaal 1 zijn", + "healthCheckMode": "Controlemodus", + "healthCheckStrategy": "Strategie", + "healthCheckModeDescription": "TCP-modus verifieert alleen connectiviteit. HTTP-modus valideert de HTTP-respons.", + "healthyThreshold": "Gezonde drempel", + "healthyThresholdDescription": "Opeenvolgende successen vereist voordat gemarkeerd wordt als gezond.", + "unhealthyThreshold": "Ongezonde drempel", + "unhealthyThresholdDescription": "Opeenvolgende fouten vereist voordat gemarkeerd wordt als ongezond.", + "healthCheckHealthyThresholdMin": "Gezonde drempel moet minimaal 1 zijn", + "healthCheckUnhealthyThresholdMin": "Ongezonde drempel moet minimaal 1 zijn", "httpMethod": "HTTP-methode", "selectHttpMethod": "Selecteer HTTP-methode", "domainPickerSubdomainLabel": "Subdomein", + "domainPickerWildcard": "Wildcard", + "domainPickerWildcardPaidOnly": "Wildcard-subdomeinen zijn een betaalde functie. Upgrade om deze functie te gebruiken.", "domainPickerBaseDomainLabel": "Basisdomein", "domainPickerSearchDomains": "Zoek domeinen...", "domainPickerNoDomainsFound": "Geen domeinen gevonden", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Dit adres is onderdeel van het hulpprogramma subnet van de organisatie. Het wordt gebruikt om aliasrecords op te lossen met behulp van interne DNS-resolutie.", "resourcesTableClients": "Clienten", "resourcesTableAndOnlyAccessibleInternally": "en zijn alleen intern toegankelijk wanneer verbonden met een client.", - "resourcesTableNoTargets": "Geen doelen", "resourcesTableHealthy": "Gezond", "resourcesTableDegraded": "Verminderde", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Ongezond", "resourcesTableUnknown": "onbekend", "resourcesTableNotMonitored": "Niet gecontroleerd", + "resourcesTableNoTargets": "Geen doelen", "editInternalResourceDialogEditClientResource": "Privépagina bewerken", "editInternalResourceDialogUpdateResourceProperties": "Update de resource configuratie en access control voor {resourceName}", "editInternalResourceDialogResourceProperties": "Bron eigenschappen", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Poort", "editInternalResourceDialogModeHost": "Hostnaam", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Schema", + "editInternalResourceDialogEnableSsl": "SSL inschakelen", + "editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.", "editInternalResourceDialogDestination": "Bestemming", "editInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.", "editInternalResourceDialogDestinationIPDescription": "Het IP of hostnaam adres van de bron op het netwerk van de site.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Naam", "createInternalResourceDialogSite": "Site", "selectSite": "Selecteer site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "Geen sites gevonden.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Poort", "createInternalResourceDialogModeHost": "Hostnaam", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Schema", + "createInternalResourceDialogScheme": "Schema", + "createInternalResourceDialogEnableSsl": "SSL inschakelen", + "createInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.", "createInternalResourceDialogDestination": "Bestemming", "createInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.", "createInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.", + "internalResourceDownstreamSchemeRequired": "Schema is vereist voor HTTP-bronnen", + "internalResourceHttpPortRequired": "Bestemmingspoort is vereist voor HTTP-bronnen", "siteConfiguration": "Configuratie", "siteAcceptClientConnections": "Accepteer clientverbindingen", "siteAcceptClientConnectionsDescription": "Sta gebruikersapparaten en clients toegang toe tot bronnen op deze site. Dit kan later worden gewijzigd.", @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Internationaal Domein Gedetecteerd", "willbestoredas": "Zal worden opgeslagen als:", - "roleMappingDescription": "Bepaal hoe rollen worden toegewezen aan gebruikers wanneer ze inloggen wanneer Auto Provision is ingeschakeld.", + "roleMappingDescription": "Bepaal hoe rollen aan gebruikers worden toegewezen wanneer ze zich aanmelden met deze identiteitsprovider.", "selectRole": "Selecteer een rol", "roleMappingExpression": "Expressie", "selectRolePlaceholder": "Kies een rol", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Selecteer een domein voor de authenticatiepagina van de organisatie", "domainPickerProvidedDomain": "Opgegeven domein", "domainPickerFreeProvidedDomain": "Gratis verstrekt domein", + "domainPickerFreeDomainsPaidFeature": "Geleverde domeinen zijn een betaalde functie. Abonneer je om een domein bij je plan te krijgen - je hoeft er zelf geen mee te brengen.", "domainPickerVerified": "Geverifieerd", "domainPickerUnverified": "Ongeverifieerd", - "domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.", + "domainPickerManual": "Handleiding", + "domainPickerInvalidSubdomainStructure": "Ongeldige tekens worden gesaneerd bij het opslaan.", "domainPickerError": "Foutmelding", "domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen", "domainPickerErrorCheckAvailability": "Kan domein beschikbaarheid niet controleren", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Kies uw identiteitsprovider om door te gaan", "orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.", "orgAuthSignInWithPangolin": "Log in met Pangolin", - "orgAuthSignInToOrg": "Log in bij een organisatie", + "orgAuthSignInToOrg": "Organisatie Identiteitsprovider (SSO)", "orgAuthSelectOrgTitle": "Organisatie Inloggen", "orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan", "orgAuthOrgIdPlaceholder": "jouw-organisatie", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Schaal", - "description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning." + "description": "Enterprise-functies, 50 gebruikers, 100 sites en prioritaire ondersteuning." } }, "personalUseOnly": "Alleen voor persoonlijk gebruik (gratis licentie - geen afrekening)", @@ -2422,6 +2648,7 @@ "validPassword": "Geldig wachtwoord", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Verbonden Client", "resourceBlocked": "Bron geblokkeerd", "droppedByRule": "Achtergelaten door regel", "noSessions": "Geen sessies", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Clienten toevoegen", "editInternalResourceDialogDestinationLabel": "Bestemming", "editInternalResourceDialogDestinationDescription": "Specificeer het bestemmingsadres voor de interne bron. Dit kan een hostnaam, IP-adres of CIDR-bereik zijn, afhankelijk van de geselecteerde modus. Stel optioneel een interne DNS-alias in voor eenvoudigere identificatie.", + "internalResourceFormMultiSiteRoutingHelp": "Selecteren van meerdere sites maakt veerkrachtige routing en failover mogelijk voor hoge beschikbaarheid.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Meer informatie", "editInternalResourceDialogPortRestrictionsDescription": "Beperk toegang tot specifieke TCP/UDP-poorten of sta alle poorten toe/blokkeer.", + "createInternalResourceDialogHttpConfiguration": "HTTP-configuratie", + "createInternalResourceDialogHttpConfigurationDescription": "Kies het domein dat cliënten zullen gebruiken om deze bron via HTTP of HTTPS te bereiken.", + "editInternalResourceDialogHttpConfiguration": "HTTP-configuratie", + "editInternalResourceDialogHttpConfigurationDescription": "Kies het domein dat cliënten zullen gebruiken om deze bron via HTTP of HTTPS te bereiken.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "We keren snel terug! Onze site ondergaat momenteel gepland onderhoud.", "maintenancePageMessageDescription": "Gedetailleerd bericht dat het onderhoud uitlegt", "maintenancePageTimeTitle": "Geschatte voltooiingstijd (optioneel)", + "privateMaintenanceScreenTitle": "Privéscherm maintenance screen", + "privateMaintenanceScreenMessage": "Dit domein wordt gebruikt op een privébron. Verbind met de Pangolin client om toegang te krijgen tot deze bron.", + "privateMaintenanceScreenSteps": "Eenmaal verbonden, als u dit bericht nog steeds ziet, kan het DNS-cache van uw browser nog steeds naar het oude adres wijzen. Om dit te corrigeren: sluit en heropen dit tabblad, of uw browser, dan navigeer weer naar deze pagina.", "maintenanceTime": "bijv. 2 uur, 1 nov om 17:00", "maintenanceEstimatedTimeDescription": "Wanneer u verwacht dat het onderhoud voltooid is", "editDomain": "Domein bewerken", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Voeg HTTP bestemming toe", "httpDestEditDescription": "Werk de configuratie voor deze HTTP-event streaming bestemming bij.", "httpDestAddDescription": "Configureer een nieuw HTTP-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.", + "S3DestEditTitle": "Bestemming bewerken", + "S3DestAddTitle": "S3-bestemming toevoegen", + "S3DestEditDescription": "Werk de configuratie bij voor deze S3-gebeurtenisstreamingbestemming.", + "S3DestAddDescription": "Configureer een nieuw S3-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.", + "datadogDestEditTitle": "Bestemming bewerken", + "datadogDestAddTitle": "Datadog-bestemming toevoegen", + "datadogDestEditDescription": "Werk de configuratie bij voor deze Datadog-gebeurtenisstreamingbestemming.", + "datadogDestAddDescription": "Configureer een nieuw Datadog-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.", "httpDestTabSettings": "Instellingen", "httpDestTabHeaders": "Kopteksten", "httpDestTabBody": "Lichaam", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Bestemming succesvol bijgewerkt", "httpDestCreatedSuccess": "Bestemming succesvol aangemaakt", "httpDestUpdateFailed": "Bijwerken bestemming mislukt", - "httpDestCreateFailed": "Aanmaken bestemming mislukt" + "httpDestCreateFailed": "Aanmaken bestemming mislukt", + "followRedirects": "Volg omleidingen", + "followRedirectsDescription": "Volg automatisch HTTP-omleidingen voor verzoeken.", + "alertingErrorWebhookUrl": "Voer een geldige URL voor de webhook in.", + "healthCheckStrategyHttp": "Valideert connectiviteit en controleert de HTTP-responsstatus.", + "healthCheckStrategyTcp": "Verifieert alleen TCP-connectiviteit zonder de respons te inspecteren.", + "healthCheckStrategySnmp": "Maakt een SNMP-verzoek om de gezondheid van netwerkapparaten en infrastructuur te controleren.", + "healthCheckStrategyIcmp": "Gebruikt ICMP-verzoeken (pings) om te controleren of een bron bereikbaar en responsief is.", + "healthCheckTabStrategy": "Strategie", + "healthCheckTabConnection": "Verbinding", + "healthCheckTabAdvanced": "Geavanceerd", + "healthCheckStrategyNotAvailable": "Deze strategie is niet beschikbaar. Neem contact op met sales om deze functie in te schakelen.", + "uptime30d": "Beschikbaarheid (30d)", + "idpAddActionCreateNew": "Nieuwe identiteitsprovider aanmaken", + "idpAddActionImportFromOrg": "Importeer vanuit een andere organisatie", + "idpImportDialogTitle": "Importeer Identiteitsprovider", + "idpImportDialogDescription": "Kies een identiteitsprovider van een organisatie waar u beheerder bent. Het wordt gekoppeld aan deze organisatie.", + "idpImportSearchPlaceholder": "Zoek op organisatie- of providernamen...", + "idpImportEmpty": "Geen identiteitsproviders gevonden.", + "idpImportedDescription": "Identiteitsprovider succesvol geïmporteerd.", + "idpDeleteGlobalQuestion": "Weet u zeker dat u deze identiteitsprovider permanent wilt verwijderen?", + "idpDeleteGlobalDescription": "Hiermee wordt de identiteitsprovider permanent verwijderd uit alle organisaties waarmee het is geassocieerd.", + "idpUnassociateTitle": "Koppel Identiteitsprovider los", + "idpUnassociateQuestion": "Weet u zeker dat u deze identiteitsprovider van deze organisatie wilt loskoppelen?", + "idpUnassociateDescription": "Alle gebruikers die aan deze identiteitsprovider zijn gekoppeld, worden uit deze organisatie verwijderd, maar de identiteitsprovider blijft bestaan voor andere gerelateerde organisaties.", + "idpUnassociateConfirm": "Bevestig ontkoppelen identiteitsprovider", + "idpUnassociateWarning": "Dit kan niet ongedaan worden gemaakt voor deze organisatie.", + "idpUnassociatedDescription": "Identiteitsprovider succesvol losgekoppeld van deze organisatie", + "idpUnassociateMenu": "Ontkoppelen", + "idpDeleteAllOrgsMenu": "Verwijderen", + "publicIpEndpoint": "Eindpunt", + "lastTriggeredAt": "Laatste Trigger", + "reject": "Afwijzen", + "uptimeDaysAgo": "{count} dagen geleden", + "uptimeToday": "Vandaag", + "uptimeNoDataAvailable": "Geen gegevens beschikbaar", + "uptimeSuffix": "werktijd", + "uptimeDowntimeSuffix": "uitvaltijd", + "uptimeTooltipUptimeLabel": "Werktijd", + "uptimeTooltipDowntimeLabel": "Uitvaltijd", + "uptimeOngoing": "lopend", + "uptimeNoMonitoringData": "Geen monitoringgegevens", + "uptimeNoData": "Geen gegevens", + "uptimeMiniBarDown": "Onder", + "uptimeSectionTitle": "Werktijd", + "uptimeSectionDescription": "Beschikbaarheid over de laatste {days} dagen", + "uptimeAddAlert": "Alarm toevoegen", + "uptimeViewAlerts": "Meldingen bekijken", + "uptimeCreateEmailAlert": "E-mailalert aanmaken", + "uptimeAlertDescriptionSite": "Ontvang een e-mailbericht wanneer deze site offline gaat of weer online komt.", + "uptimeAlertDescriptionResource": "Ontvang een e-mailbericht wanneer deze bron offline gaat of weer online komt.", + "uptimeAlertNamePlaceholder": "Waarschuwingsnaam", + "uptimeAdditionalEmails": "Extra e-mails", + "uptimeCreateAlert": "Alarm aanmaken", + "uptimeAlertNoRecipients": "Geen ontvangers", + "uptimeAlertNoRecipientsDescription": "Voeg ten minste één gebruiker, rol of e-mail toe om te melden.", + "uptimeAlertCreated": "Alarm aangemaakt", + "uptimeAlertCreatedDescription": "U wordt op de hoogte gebracht wanneer dit van status verandert.", + "uptimeAlertCreateFailed": "Kon alarm niet aanmaken", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Sleutel", + "webhookHeaderValuePlaceholder": "Waarde", + "alertLabel": "Waarschuwing", + "domainPickerWildcardSubdomainNotAllowed": "Wildcard-subdomeinen zijn niet toegestaan.", + "domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.", + "domainPickerWildcardCertWarningLink": "Meer informatie", + "health": "Gezondheid", + "domainPendingErrorTitle": "Verificatieprobleem" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 88aebc81d..2fd09d2e4 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Skontaktuj się z działem sprzedaży, aby włączyć tę funkcję.", + "contactSalesBookDemo": "Umów się na demo", + "contactSalesOr": "lub", + "contactSalesContactUs": "skontaktuj się z nami", "setupCreate": "Utwórz organizację, witrynę i zasoby", "headerAuthCompatibilityInfo": "Włącz to, aby wymusić odpowiedź Unauthorized 401, gdy brakuje tokena uwierzytelniania. Jest to wymagane dla przeglądarek lub określonych bibliotek HTTP, które nie wysyłają poświadczeń bez wyzwania serwera.", "headerAuthCompatibility": "Rozszerzona kompatybilność", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Wykryto nieprawidłowe lub wygasłe klucze licencyjne. Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", "dismiss": "Odrzuć", "subscriptionViolationMessage": "Nie masz ograniczeń dla aktualnego planu. Popraw problem poprzez usunięcie stron, użytkowników lub innych zasobów, aby pozostać w swoim planie.", + "trialBannerMessage": "Twój okres próbny wygasa za {countdown}. Uaktualnij, aby zachować dostęp.", + "trialBannerExpired": "Twój okres próbny wygasł. Uaktualnij teraz, aby przywrócić dostęp.", + "trialActive": "Okres próbny aktywny", + "trialExpired": "Okres próbny wygasł", + "trialHasEnded": "Twój okres próbny dobiegł końca.", + "trialDaysRemaining": "{count, plural, one {# dzień pozostaje} few {# dni pozostają} many {# dni pozostaje} other {# dni pozostają}}", + "trialDaysLeftShort": "Pozostało {days}d próbny", + "trialGoToBilling": "Przejdź do strony rozliczeń", "subscriptionViolationViewBilling": "Zobacz rozliczenie", "componentsLicenseViolation": "Naruszenie licencji: Ten serwer używa stron {usedSites} , które przekraczają limit licencyjny stron {maxSites} . Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", "componentsSupporterMessage": "Dziękujemy za wsparcie Pangolina jako {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "Skopiowałem konfigurację", "searchSitesProgress": "Szukaj witryn...", "siteAdd": "Dodaj witrynę", + "sitesTableViewPublicResources": "Zobacz zasoby publiczne", + "sitesTableViewPrivateResources": "Zobacz zasoby prywatne", "siteInstallNewt": "Zainstaluj Newt", "siteInstallNewtDescription": "Uruchom Newt w swoim systemie", "WgConfiguration": "Konfiguracja WireGuard", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "Strona została zaktualizowana.", "siteGeneralDescription": "Skonfiguruj ustawienia ogólne dla tej witryny", "siteSettingDescription": "Skonfiguruj ustawienia na stronie", + "siteResourcesTab": "Zasoby", + "siteResourcesNoneOnSite": "Ta strona nie ma jeszcze żadnych zasobów publicznych ani prywatnych.", + "siteResourcesSectionPublic": "Zasoby publiczne", + "siteResourcesSectionPrivate": "Zasoby prywatne", + "siteResourcesSectionPublicDescription": "Zasoby eksponowane zewnętrznie przez domeny lub porty.", + "siteResourcesSectionPrivateDescription": "Zasoby dostępne w twojej prywatnej sieci przez stronę.", + "siteResourcesViewAllPublic": "Zobacz wszystkie zasoby", + "siteResourcesViewAllPrivate": "Zobacz wszystkie zasoby", + "siteResourcesDialogDescription": "Przegląd zasobów publicznych i prywatnych związanych z tą stroną.", + "siteResourcesShowMore": "Pokaż więcej", + "siteResourcesPermissionDenied": "Nie masz uprawnień do wyświetlania tych zasobów.", + "siteResourcesEmptyPublic": "Brak publicznych zasobów powiązanych z tą stroną.", + "siteResourcesEmptyPrivate": "Brak prywatnych zasobów powiązanych z tą stroną.", + "siteResourcesHowToAccess": "Jak uzyskać dostęp", + "siteResourcesTargetsOnSite": "Cele na tej stronie", "siteSetting": "Ustawienia {siteName}", "siteNewtTunnel": "Newt Site (Rekomendowane)", "siteNewtTunnelDescription": "Najprostszy sposób na stworzenie punktu wejścia w sieci. Nie ma dodatkowej konfiguracji.", @@ -267,8 +296,11 @@ "orgMissing": "Brak ID organizacji", "orgMissingMessage": "Nie można ponownie wygenerować zaproszenia bez ID organizacji.", "accessUsersManage": "Zarządzaj użytkownikami", + "accessUserManage": "Zarządzaj użytkownikiem", "accessUsersDescription": "Zaproś użytkowników z dostępem do tej organizacji i zarządzaj nimi", "accessUsersSearch": "Szukaj użytkowników...", + "accessUsersRoleFilterCount": "{count, plural, one {# rola} few {# role} many {# ról} other {# ról}}", + "accessUsersRoleFilterClear": "Wyczyść filtry ról", "accessUserCreate": "Utwórz użytkownika", "accessUserRemove": "Usuń użytkownika", "username": "Nazwa użytkownika", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Nie udało się aktywować klucza licencji", "licenseErrorKeyActivateDescription": "Wystąpił błąd podczas aktywacji klucza licencyjnego.", "licenseAbout": "O licencjonowaniu", + "licenseBannerTitle": "Aktywuj swoją licencję Enterprise", + "licenseBannerDescription": "Odblokuj funkcje korporacyjne dla swojego autonomicznego wdrożenia Pangolin. Kup klucz licencyjny, aby aktywować możliwości premium, a następnie wprowadź go poniżej.", + "licenseBannerGetLicense": "Uzyskaj licencję", + "licenseBannerViewDocs": "Zobacz dokumentację", "communityEdition": "Edycja Społecznościowa", "licenseAboutDescription": "Dotyczy to przedsiębiorstw i przedsiębiorstw, którzy stosują Pangolin w środowisku handlowym. Jeśli używasz Pangolin do użytku osobistego, możesz zignorować tę sekcję.", "licenseKeyActivated": "Klucz licencyjny aktywowany", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Sekret", + "newtVersion": "Wersja", "architecture": "Architektura", "sites": "Witryny", "siteWgAnyClients": "Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał przekierować wewnętrzne zasoby za pomocą adresu IP.", @@ -894,6 +931,7 @@ "idpDisplayName": "Nazwa wyświetlana dla tego dostawcy tożsamości", "idpAutoProvisionUsers": "Automatyczne tworzenie użytkowników", "idpAutoProvisionUsersDescription": "Gdy włączone, użytkownicy będą automatycznie tworzeni w systemie przy pierwszym logowaniu z możliwością mapowania użytkowników do ról i organizacji.", + "idpAutoProvisionConfigureAfterCreate": "Możesz skonfigurować automatyczne ustawienia provision, gdy dostawca tożsamości zostanie utworzony.", "licenseBadge": "EE", "idpType": "Typ dostawcy", "idpTypeDescription": "Wybierz typ dostawcy tożsamości, który chcesz skonfigurować", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Domyślne mapowanie roli", "defaultMappingsRoleDescription": "JMESPath do wydobycia informacji o roli z tokena ID. Wynik tego wyrażenia musi zwrócić nazwę roli zdefiniowaną w organizacji jako ciąg znaków.", "defaultMappingsOrg": "Domyślne mapowanie organizacji", - "defaultMappingsOrgDescription": "JMESPath do wydobycia informacji o organizacji z tokena ID. To wyrażenie musi zwrócić ID organizacji lub true, aby użytkownik mógł uzyskać dostęp do organizacji.", + "defaultMappingsOrgDescription": "Gdy jest ustawiona, ta wyrażenie musi zwrócić identyfikator organizacji lub true, aby użytkownik mógł uzyskać dostęp do tej organizacji. Gdy nie jest ustawiona, wystarczające jest zdefiniowanie mapowania ról: użytkownik jest dopuszczony, o ile można rozwiązać dla niego ważne mapowanie ról w organizacji.", "defaultMappingsSubmit": "Zapisz domyślne mapowania", "orgPoliciesEdit": "Edytuj politykę organizacji", "org": "Organizacja", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Zobacz dzienniki", "noneSelected": "Nie wybrano", "orgNotFound2": "Nie znaleziono organizacji.", + "search": "Szukaj…", "searchPlaceholder": "Szukaj...", "emptySearchOptions": "Nie znaleziono opcji", "create": "Utwórz", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Zarządzaj", "sidebarLogAndAnalytics": "Dziennik & Analityka", "sidebarBluePrints": "Schematy", + "sidebarAlerting": "Alarmowanie", + "sidebarHealthChecks": "Kontrole zdrowia", "sidebarOrganization": "Organizacja", "sidebarManagement": "Zarządzanie", "sidebarBillingAndLicenses": "Płatność i licencje", "sidebarLogsAnalytics": "Analityka", + "alertingTitle": "Alarmowanie", + "alertingDescription": "Zdefiniuj źródła, ustawienia, i działania dla powiadomień", + "alertingRules": "Reguły alarmowe", + "alertingSearchRules": "Szukaj reguł…", + "alertingAddRule": "Utwórz Regułę", + "alertingColumnSource": "Źródło", + "alertingColumnTrigger": "Ustawienie", + "alertingColumnActions": "Akcje", + "alertingColumnEnabled": "Włączone", + "alertingDeleteQuestion": "Potwierdź, że chcesz usunąć tę regułę alarmową.", + "alertingDeleteRule": "Usuń regułę alarmową", + "alertingRuleDeleted": "Reguła alarmowa usunięta", + "alertingRuleSaved": "Reguła alarmowa zapisana", + "alertingRuleSavedCreatedDescription": "Nowa reguła alarmowa została utworzona. Możesz ją kontynuować edytować na tej stronie.", + "alertingRuleSavedUpdatedDescription": "Twoje zmiany w tej regule alarmowej zostały zapisane.", + "alertingEditRule": "Edytuj regułę alarmową", + "alertingCreateRule": "Utwórz regułę alarmową", + "alertingRuleCredenzaDescription": "Wybierz, co obserwować, kiedy uruchamiać i jak powiadamiać.", + "alertingRuleNamePlaceholder": "Strona produkcyjna w dół", + "alertingRuleEnabled": "Reguła włączona", + "alertingSectionSource": "Źródło", + "alertingSourceType": "Typ źródła", + "alertingSourceSite": "Witryna", + "alertingSourceHealthCheck": "Kontrola zdrowia", + "alertingPickSites": "Witryny", + "alertingPickHealthChecks": "Kontrole zdrowia", + "alertingPickResources": "Zasoby", + "alertingAllSites": "Wszystkie witryny", + "alertingAllSitesDescription": "Alarm uruchomiony dla dowolnej witryny", + "alertingSpecificSites": "Określone witryny", + "alertingSpecificSitesDescription": "Wybierz określone witryny do obserwacji", + "alertingAllHealthChecks": "Wszystkie Kontrole Zdrowia", + "alertingAllHealthChecksDescription": "Alarm uruchomiony dla dowolnej kontroli zdrowia", + "alertingSpecificHealthChecks": "Określone Kontrole Zdrowia", + "alertingSpecificHealthChecksDescription": "Wybierz określone kontrole zdrowia do obserwacji", + "alertingAllResources": "Wszystkie zasoby", + "alertingAllResourcesDescription": "Alarm uruchomiony dla dowolnego zasobu", + "alertingSpecificResources": "Określone Zasoby", + "alertingSpecificResourcesDescription": "Wybierz określone zasoby do obserwacji", + "alertingSelectResources": "Wybierz zasoby…", + "alertingResourcesSelected": "{count} zasobów wybrano", + "alertingResourcesEmpty": "Brak zasobów z celami w pierwszych 10 wynikach.", + "alertingSectionTrigger": "Ustawienie", + "alertingTrigger": "Kiedy alarmować", + "alertingTriggerSiteOnline": "Strona online", + "alertingTriggerSiteOffline": "Strona offline", + "alertingTriggerSiteToggle": "Status strony zmienia się", + "alertingTriggerHcHealthy": "Kontrola zdrowia zdrowa", + "alertingTriggerHcUnhealthy": "Kontrola zdrowia niezdrowa", + "alertingTriggerHcToggle": "Status kontroli zdrowia zmienia się", + "alertingTriggerResourceHealthy": "Zasób zdrowy", + "alertingTriggerResourceUnhealthy": "Zasób niezdrowy", + "alertingTriggerResourceDegraded": "Zasób pogorszony", + "alertingSearchHealthChecks": "Szukaj kontroli zdrowia…", + "alertingHealthChecksEmpty": "Brak dostępnych kontroli zdrowia.", + "alertingTriggerResourceToggle": "Zmiany statusu zasobu", + "alertingSourceResource": "Zasób", + "alertingSectionActions": "Akcje", + "alertingAddAction": "Dodaj Akcję", + "alertingActionNotify": "E-mail", + "alertingActionNotifyDescription": "Wyślij powiadomienia e-mail do użytkowników lub ról", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Wyślij żądanie HTTP do niestandardowego punktu końcowego", + "alertingExternalIntegration": "Integracja Zewnętrzna", + "alertingExternalPagerDutyDescription": "Przesyłaj alerty do PagerDuty do zarządzania incydentami", + "alertingExternalOpsgenieDescription": "Kieruj alerty do Opsgenie dla zarządzania dyżurem", + "alertingExternalServiceNowDescription": "Twórz incydenty ServiceNow z alertów", + "alertingExternalIncidentIoDescription": "Wyzwalaj przepływy Incident.io z alertów", + "alertingActionType": "Typ akcji", + "alertingNotifyUsers": "Użytkownicy", + "alertingNotifyRoles": "Role", + "alertingNotifyEmails": "Adres e-mail", + "alertingEmailPlaceholder": "Dodaj e-mail i naciśnij Enter", + "alertingWebhookMethod": "Metoda HTTP", + "alertingWebhookSecret": "Sekret podpisu (opcjonalny)", + "alertingWebhookSecretPlaceholder": "Sekret HMAC", + "alertingWebhookHeaders": "Nagłówki", + "alertingAddHeader": "Dodaj nagłówek", + "alertingSelectSites": "Wybierz witryny…", + "alertingSitesSelected": "{count} witryny wybrano", + "alertingSelectHealthChecks": "Wybierz wyniki zdrowia…", + "alertingHealthChecksSelected": "{count} wyniki zdrowia wybrane", + "alertingNoHealthChecks": "Brak celów z aktywowanymi kontrolami zdrowia", + "alertingHealthCheckStub": "Wybór źródła kontroli zdrowia jeszcze nie skonfigurowany - możesz nadal skonfigurować wyzwalacze i akcje.", + "alertingSelectUsers": "Wybierz użytkowników…", + "alertingUsersSelected": "{count} użytkowników wybrano", + "alertingSelectRoles": "Wybierz role…", + "alertingRolesSelected": "{count} ról wybrano", + "alertingSummarySites": "Witryny ({count})", + "alertingSummaryAllSites": "Wszystkie witryny", + "alertingSummaryHealthChecks": "Kontrole zdrowia ({count})", + "alertingSummaryAllHealthChecks": "Wszystkie kontrole zdrowia", + "alertingSummaryResources": "Zasoby ({count})", + "alertingSummaryAllResources": "Wszystkie zasoby", + "alertingErrorNameRequired": "Wprowadź nazwę", + "alertingErrorActionsMin": "Dodaj co najmniej jedną akcję", + "alertingErrorPickSites": "Wybierz co najmniej jedną witrynę", + "alertingErrorPickHealthChecks": "Wybierz co najmniej jedną kontrolę zdrowia", + "alertingErrorPickResources": "Wybierz co najmniej jeden zasób", + "alertingErrorTriggerSite": "Wybierz wyzwalacz witryny", + "alertingErrorTriggerHealth": "Wybierz wyzwalacz kontroli zdrowia", + "alertingErrorTriggerResource": "Wybierz wyzwalacz zasobu", + "alertingErrorNotifyRecipients": "Wybierz użytkowników, role lub co najmniej jeden e-mail", + "alertingConfigureSource": "Skonfiguruj źródło", + "alertingConfigureTrigger": "Skonfiguruj wyzwalacz", + "alertingConfigureActions": "Skonfiguruj akcje", + "alertingBackToRules": "Powrót do reguł", + "alertingRuleCooldown": "Czas ochłodzenia (sekundy)", + "alertingRuleCooldownDescription": "Minimalny czas między powtórzonymi alarmami dla tej samej reguły. Ustaw na 0, aby wyzwalać za każdym razem.", + "alertingDraftBadge": "Szkic - zapisz, aby zachować tę regułę", + "alertingSidebarHint": "Kliknij krok na kanwie, aby edytować go tutaj.", + "alertingGraphCanvasTitle": "Przepływ reguł", + "alertingGraphCanvasDescription": "Wizualny podgląd źródła, wyzwalacza i akcji. Wybierz węzeł, aby edytować go w panelu.", + "alertingNodeNotConfigured": "Nie skonfigurowano jeszcze", + "alertingNodeActionsCount": "{count, plural, one {# akcja} few {# akcje} many {# akcji} other {# akcji}}", + "alertingNodeRoleSource": "Źródło", + "alertingNodeRoleTrigger": "Wyzwalacz", + "alertingNodeRoleAction": "Akcja", + "alertingTabRules": "Reguły Alarmowe", + "alertingTabHealthChecks": "Kontrole Zdrowia", + "alertingRulesBannerTitle": "Otrzymaj Powiadomienie", + "alertingRulesBannerDescription": "Każda reguła wiąże ze sobą co obserwować (np. witryna, kontrola zdrowia czy zasób), kiedy uruchomić (np. offline lub niezdrowy), oraz jak powiadomić zespół przez e-mail, webhooks lub integracje. Użyj tej listy, aby utworzyć, włączyć i zarządzać tymi regułami.", + "alertingHealthChecksBannerTitle": "Monitor Zdrowia i Zasobów", + "alertingHealthChecksBannerDescription": "Kontrole zdrowia to monitory HTTP lub TCP, które definiujesz raz. Następnie możesz używać ich jako źródeł w regułach alarmowych, aby otrzymywać powiadomienia, kiedy cel stanie się zdrowy lub niezdrowy. Kontrole zdrowia w zasobach również pojawiają się tutaj.", + "standaloneHcTableTitle": "Kontrole Zdrowia", + "standaloneHcSearchPlaceholder": "Szukaj kontroli zdrowia…", + "standaloneHcAddButton": "Utwórz Kontrolę Zdrowia", + "standaloneHcCreateTitle": "Utwórz Kontrolę Zdrowia", + "standaloneHcEditTitle": "Edytuj Kontrolę Zdrowia", + "standaloneHcDescription": "Skonfiguruj kontrolę zdrowia HTTP lub TCP do wykorzystania w regułach alarmowych.", + "standaloneHcNameLabel": "Nazwa", + "standaloneHcNamePlaceholder": "Mój Monitor HTTP", + "standaloneHcDeleteTitle": "Usuń kontrolę zdrowia", + "standaloneHcDeleteQuestion": "Potwierdź, że chcesz usunąć tę kontrolę zdrowia.", + "standaloneHcDeleted": "Kontrola zdrowia usunięta", + "standaloneHcSaved": "Kontrola zdrowia zapisana", + "standaloneHcColumnHealth": "Zdrowie", + "standaloneHcColumnMode": "Tryb", + "standaloneHcColumnTarget": "Cel", + "standaloneHcHealthStateHealthy": "Zdrowy", + "standaloneHcHealthStateUnhealthy": "Niezdrowy", + "standaloneHcHealthStateUnknown": "Nieznany", + "standaloneHcFilterAnySite": "Wszystkie witryny", + "standaloneHcFilterAnyResource": "Wszystkie zasoby", + "standaloneHcFilterMode": "Tryb", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Zdrowie", + "standaloneHcFilterEnabled": "Włączone", + "standaloneHcFilterEnabledOn": "Włączone", + "standaloneHcFilterEnabledOff": "Wyłączone", + "standaloneHcFilterSiteIdFallback": "Witryna {id}", + "standaloneHcFilterResourceIdFallback": "Zasób {id}", "blueprints": "Schematy", "blueprintsDescription": "Zastosuj konfiguracje deklaracyjne i wyświetl poprzednie operacje", "blueprintAdd": "Dodaj schemat", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Utwórz początkowe konto administratora serwera. Może istnieć tylko jeden administrator serwera. Zawsze można zmienić te dane uwierzytelniające.", "createAdminAccount": "Utwórz konto administratora", "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.", - "certificateStatus": "Status certyfikatu", + "certificateStatus": "Certyfikat", + "certificateStatusAutoRefreshHint": "Status odświeża się automatycznie.", "loading": "Ładowanie", "loadingAnalytics": "Ładowanie Analityki", "restart": "Uruchom ponownie", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Zobacz informacje o wydaniu", "newtUpdateAvailable": "Dostępna aktualizacja", "newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", + "pangolinNodeUpdateAvailableInfo": "Nowa wersja Pangolin Node jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", "domainPickerEnterDomain": "Domena", "domainPickerPlaceholder": "mojapp.example.com", "domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Skonfiguruj Kontrolę Zdrowia", "configureHealthCheckDescription": "Skonfiguruj monitorowanie zdrowia dla {target}", "enableHealthChecks": "Włącz Kontrole Zdrowia", + "healthCheckDisabledStateDescription": "Gdy wyłączone, strona nie będzie wykonywać kontroli zdrowia, a stan zostanie uznany za nieznany.", "enableHealthChecksDescription": "Monitoruj zdrowie tego celu. Możesz monitorować inny punkt końcowy niż docelowy w razie potrzeby.", "healthScheme": "Metoda", "healthSelectScheme": "Wybierz metodę", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "Interwał sprawdzania musi wynosić co najmniej 5 sekund", "healthCheckTimeoutMin": "Limit czasu musi wynosić co najmniej 1 sekundę", "healthCheckRetryMin": "Liczba prób ponowienia musi wynosić co najmniej 1", + "healthCheckMode": "Tryb kontroli", + "healthCheckStrategy": "Strategia", + "healthCheckModeDescription": "Tryb TCP weryfikuje tylko łączność. Tryb HTTP ocenia odpowiedź HTTP.", + "healthyThreshold": "Próg zdrowia", + "healthyThresholdDescription": "Wymagane sukcesy pod rząd, zanim oznaczy się jako zdrowe.", + "unhealthyThreshold": "Próg niezdrowia", + "unhealthyThresholdDescription": "Wymagane niepowodzenia z rzędu, zanim oznaczy się jako niezdrowe.", + "healthCheckHealthyThresholdMin": "Próg zdrowia musi wynosić co najmniej 1", + "healthCheckUnhealthyThresholdMin": "Próg niezdrowia musi wynosić co najmniej 1", "httpMethod": "Metoda HTTP", "selectHttpMethod": "Wybierz metodę HTTP", "domainPickerSubdomainLabel": "Poddomena", + "domainPickerWildcard": "Uniwersalny", + "domainPickerWildcardPaidOnly": "Uniwersalne subdomeny są płatną funkcją. Proszę dokonać aktualizacji, aby uzyskać dostęp do tej funkcji.", "domainPickerBaseDomainLabel": "Domen bazowa", "domainPickerSearchDomains": "Szukaj domen...", "domainPickerNoDomainsFound": "Nie znaleziono domen", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Ten adres jest częścią podsieci użyteczności organizacji. Jest używany do rozwiązywania rekordów aliasu przy użyciu wewnętrznej rozdzielczości DNS.", "resourcesTableClients": "Klientami", "resourcesTableAndOnlyAccessibleInternally": "i są dostępne tylko wewnętrznie po połączeniu z klientem.", - "resourcesTableNoTargets": "Brak celów", "resourcesTableHealthy": "Zdrowe", "resourcesTableDegraded": "Degradacja", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Niezdrowy", "resourcesTableUnknown": "Nieznane", "resourcesTableNotMonitored": "Nie monitorowano", + "resourcesTableNoTargets": "Brak celów", "editInternalResourceDialogEditClientResource": "Edytuj Zasoby Prywatne", "editInternalResourceDialogUpdateResourceProperties": "Aktualizuj konfigurację zasobów i kontrolę dostępu dla {resourceName}", "editInternalResourceDialogResourceProperties": "Właściwości zasobów", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Schemat", + "editInternalResourceDialogEnableSsl": "Włącz SSL", + "editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.", "editInternalResourceDialogDestination": "Miejsce docelowe", "editInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", "editInternalResourceDialogDestinationIPDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Nazwa", "createInternalResourceDialogSite": "Witryna", "selectSite": "Wybierz stronę...", + "multiSitesSelectorSitesCount": "{count, plural, one {# witryna} few {# witryny} many {# witryn} other {# witryn}}", "noSitesFound": "Nie znaleziono stron.", "createInternalResourceDialogProtocol": "Protokół", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Schemat", + "createInternalResourceDialogScheme": "Schemat", + "createInternalResourceDialogEnableSsl": "Włącz SSL", + "createInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.", "createInternalResourceDialogDestination": "Miejsce docelowe", "createInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", "createInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.", + "internalResourceDownstreamSchemeRequired": "Schemat jest wymagany dla zasobów HTTP", + "internalResourceHttpPortRequired": "Port docelowy jest wymagany dla zasobów HTTP", "siteConfiguration": "Konfiguracja", "siteAcceptClientConnections": "Akceptuj połączenia klienta", "siteAcceptClientConnectionsDescription": "Zezwalaj urządzeniom i klientom na dostęp do zasobów na tej stronie. Może to zostać zmienione później.", @@ -1989,7 +2213,7 @@ "description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami", "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.", - "introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin — tunele, zakończenie SSL 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:", + "introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin - tunele, zakończenie SSL 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": { "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." @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Wykryto międzynarodową domenę", "willbestoredas": "Będą przechowywane jako:", - "roleMappingDescription": "Określ jak role są przypisywane do użytkowników podczas logowania się, gdy automatyczne świadczenie jest włączone.", + "roleMappingDescription": "Określ, jak role są przypisywane użytkownikom podczas logowania się z tym dostawcą tożsamości.", "selectRole": "Wybierz rolę", "roleMappingExpression": "Wyrażenie", "selectRolePlaceholder": "Wybierz rolę", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Wybierz domenę dla strony uwierzytelniania organizacji", "domainPickerProvidedDomain": "Dostarczona domena", "domainPickerFreeProvidedDomain": "Darmowa oferowana domena", + "domainPickerFreeDomainsPaidFeature": "Dostarczane domeny to funkcja płatna. Subskrybuj, aby uzyskać domenę w ramach swojego planu - nie ma potrzeby przynoszenia własnej.", "domainPickerVerified": "Zweryfikowano", "domainPickerUnverified": "Niezweryfikowane", - "domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.", + "domainPickerManual": "Podręcznik", + "domainPickerInvalidSubdomainStructure": "Nieprawidłowe znaki zostaną zsanitowane, gdy zostaną zapisane.", "domainPickerError": "Błąd", "domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji", "domainPickerErrorCheckAvailability": "Nie udało się sprawdzić dostępności domeny", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować", "orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.", "orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin", - "orgAuthSignInToOrg": "Zaloguj się do organizacji", + "orgAuthSignInToOrg": "Dostawca tożsamości organizacji (SSO)", "orgAuthSelectOrgTitle": "Logowanie do organizacji", "orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować", "orgAuthOrgIdPlaceholder": "twoja-organizacja", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Skala", - "description": "Cechy przedsiębiorstw, 50 użytkowników, 50 obiektów i wsparcie priorytetowe." + "description": "Funkcje dla przedsiębiorstw, 50 użytkowników, 100 witryn i priorytetowe wsparcie." } }, "personalUseOnly": "Tylko do użytku osobistego (darmowa licencja - bez płatności)", @@ -2422,6 +2648,7 @@ "validPassword": "Prawidłowe hasło", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Połączony Klient", "resourceBlocked": "Zasób zablokowany", "droppedByRule": "Upuszczone przez regułę", "noSessions": "Brak sesji", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Dodaj klientów", "editInternalResourceDialogDestinationLabel": "Miejsce docelowe", "editInternalResourceDialogDestinationDescription": "Określ adres docelowy dla wewnętrznego zasobu. Może to być nazwa hosta, adres IP lub zakres CIDR, w zależności od wybranego trybu. Opcjonalnie ustaw wewnętrzny alias DNS dla łatwiejszej identyfikacji.", + "internalResourceFormMultiSiteRoutingHelp": "Wybór wielu stron umożliwia odporne trasowanie i awarię dla wysokiej dostępności.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Dowiedz się więcej", "editInternalResourceDialogPortRestrictionsDescription": "Ogranicz dostęp do konkretnych portów TCP/UDP lub zezwól/zablokuj wszystkie porty.", + "createInternalResourceDialogHttpConfiguration": "Konfiguracja HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Wybierz domenę, której klienci będą używać, aby dotrzeć do tego zasobu przez HTTP lub HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Konfiguracja HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Wybierz domenę, której klienci będą używać, aby dotrzeć do tego zasobu przez HTTP lub HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Wrócimy wkrótce! Nasza strona przechodzi obecnie zaplanowaną konserwację.", "maintenancePageMessageDescription": "Szczegółowy komunikat wyjaśniający konserwację", "maintenancePageTimeTitle": "Szacowany czas zakończenia (opcjonalnie)", + "privateMaintenanceScreenTitle": "Ekraan prywatnego utrzymania", + "privateMaintenanceScreenMessage": "Ta domena jest wykorzystywana na prywatnym zasobie. Połącz się za pomocą klienta Pangolin, aby uzyskać dostęp do tego zasobu.", + "privateMaintenanceScreenSteps": "Po połączeniu, jeśli nadal widzisz tę wiadomość, pamięć podręczna DNS przeglądarki może nadal wskazywać na stary adres. Aby to naprawić: zamknij i otwórz ponownie tę kartę lub przeglądarkę, a następnie przejdź z powrotem na tę stronę.", "maintenanceTime": "np. 2 godziny, 1 listopad o 17:00", "maintenanceEstimatedTimeDescription": "Kiedy oczekujesz zakończenia konserwacji", "editDomain": "Edytuj domenę", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Dodaj cel HTTP", "httpDestEditDescription": "Aktualizuj konfigurację dla tego celu przesyłania strumieniowego zdarzeń HTTP.", "httpDestAddDescription": "Skonfiguruj nowy punkt końcowy HTTP, aby otrzymywać wydarzenia organizacji.", + "S3DestEditTitle": "Edytuj Miejsce Docelowe", + "S3DestAddTitle": "Dodaj Miejsce Docelowe S3", + "S3DestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń S3.", + "S3DestAddDescription": "Skonfiguruj nowy punkt końcowy S3, aby odbierać zdarzenia Twojej organizacji.", + "datadogDestEditTitle": "Edytuj Miejsce Docelowe", + "datadogDestAddTitle": "Dodaj Miejsce Docelowe Datadog", + "datadogDestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń Datadog.", + "datadogDestAddDescription": "Skonfiguruj nowy punkt końcowy Datadog, aby odbierać zdarzenia Twojej organizacji.", "httpDestTabSettings": "Ustawienia", "httpDestTabHeaders": "Nagłówki", "httpDestTabBody": "Ciało", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "Tablica JSON", "httpDestFormatJsonArrayDescription": "Jedna prośba na partię, treść jest tablicą JSON. Kompatybilna z najbardziej ogólnymi webhookami i Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Jedno żądanie na partię, ciałem jest plik JSON rozdzielony na newline-delimited — jeden obiekt na wiersz, bez tablicy zewnętrznej. Wymagane przez Splunk HEC, Elastic / OpenSesearch i Grafana Loki.", + "httpDestFormatNdjsonDescription": "Jedno żądanie na partię, ciałem jest plik JSON rozdzielony na newline-delimited - jeden obiekt na wiersz, bez tablicy zewnętrznej. Wymagane przez Splunk HEC, Elastic / OpenSesearch i Grafana Loki.", "httpDestFormatSingleTitle": "Jedno wydarzenie na żądanie", "httpDestFormatSingleDescription": "Wysyła oddzielny POST HTTP dla każdego zdarzenia. Użyj tylko dla punktów końcowych, które nie mogą obsługiwać partii.", "httpDestLogTypesTitle": "Typy logów", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Cel został pomyślnie zaktualizowany", "httpDestCreatedSuccess": "Cel został utworzony pomyślnie", "httpDestUpdateFailed": "Nie udało się zaktualizować miejsca docelowego", - "httpDestCreateFailed": "Nie udało się utworzyć miejsca docelowego" + "httpDestCreateFailed": "Nie udało się utworzyć miejsca docelowego", + "followRedirects": "Podążaj za przekierowaniami", + "followRedirectsDescription": "Automatycznie podążaj za przekierowaniami HTTP dla żądań.", + "alertingErrorWebhookUrl": "Proszę wprowadzić poprawny URL dla web hooka.", + "healthCheckStrategyHttp": "Weryfikuje łączność i sprawdza status odpowiedzi HTTP.", + "healthCheckStrategyTcp": "Weryfikuje wyłącznie łączność TCP, bez sprawdzania odpowiedzi.", + "healthCheckStrategySnmp": "Wykonuje żądanie SNMP get w celu sprawdzenia stanu urządzeń sieciowych i infrastruktury.", + "healthCheckStrategyIcmp": "Używa żądań ICMP echo (pingów), aby sprawdzić, czy zasób jest dostępny i reagujący.", + "healthCheckTabStrategy": "Strategia", + "healthCheckTabConnection": "Łączenie", + "healthCheckTabAdvanced": "Zaawansowane", + "healthCheckStrategyNotAvailable": "Strategia ta nie jest dostępna. Skontaktuj się z działem sprzedaży, aby włączyć tę funkcję.", + "uptime30d": "Czas działania (30d)", + "idpAddActionCreateNew": "Utwórz nowego dostawcę tożsamości", + "idpAddActionImportFromOrg": "Importuj z innej organizacji", + "idpImportDialogTitle": "Importuj dostawcę tożsamości", + "idpImportDialogDescription": "Wybierz dostawcę tożsamości z organizacji, w której jesteś administratorem. Zostanie on powiązany z tą organizacją.", + "idpImportSearchPlaceholder": "Szukaj według nazwy organizacji lub dostawcy...", + "idpImportEmpty": "Nie znaleziono dostawców tożsamości.", + "idpImportedDescription": "Dostawca tożsamości został pomyślnie zaimportowany.", + "idpDeleteGlobalQuestion": "Czy na pewno chcesz trwale usunąć tego dostawcę tożsamości?", + "idpDeleteGlobalDescription": "Spowoduje to trwałe usunięcie dostawcy tożsamości ze wszystkich organizacji, z którymi jest powiązany.", + "idpUnassociateTitle": "Odłącz dostawcę tożsamości", + "idpUnassociateQuestion": "Czy na pewno chcesz odłączyć tego dostawcę tożsamości od tej organizacji?", + "idpUnassociateDescription": "Wszystkie użytkownicy powiązani z tym dostawcą tożsamości zostaną usunięci z tej organizacji, ale dostawca tożsamości będzie nadal istniał dla innych powiązanych organizacji.", + "idpUnassociateConfirm": "Potwierdź odłączenie dostawcy tożsamości", + "idpUnassociateWarning": "Tego nie można cofnąć dla tej organizacji.", + "idpUnassociatedDescription": "Dostawca tożsamości pomyślnie odłączony od tej organizacji", + "idpUnassociateMenu": "Odłącz", + "idpDeleteAllOrgsMenu": "Usuń", + "publicIpEndpoint": "Koniec punktu pracy", + "lastTriggeredAt": "Ostatnie Wyzwolenie", + "reject": "Odrzuć", + "uptimeDaysAgo": "{count} dni temu", + "uptimeToday": "Dzisiaj", + "uptimeNoDataAvailable": "Brak danych dostępnych", + "uptimeSuffix": "czas pracy", + "uptimeDowntimeSuffix": "czas przestoju", + "uptimeTooltipUptimeLabel": "Czas pracy", + "uptimeTooltipDowntimeLabel": "Czas przestoju", + "uptimeOngoing": "w toku", + "uptimeNoMonitoringData": "Brak danych monitorowania", + "uptimeNoData": "Brak danych", + "uptimeMiniBarDown": "Nieaktywny", + "uptimeSectionTitle": "Czas pracy", + "uptimeSectionDescription": "Dostępność za ostatnie {days} dni", + "uptimeAddAlert": "Dodaj Alert", + "uptimeViewAlerts": "Zobacz Alerty", + "uptimeCreateEmailAlert": "Utwórz Alert Email", + "uptimeAlertDescriptionSite": "Otrzymuj powiadomienia e-mail, gdy ta strona jest offline lub wraca online.", + "uptimeAlertDescriptionResource": "Otrzymuj powiadomienia e-mail, gdy to zasób jest offline lub wraca online.", + "uptimeAlertNamePlaceholder": "Nazwa alertu", + "uptimeAdditionalEmails": "Dodatkowe adresy e-mail", + "uptimeCreateAlert": "Utwórz Alert", + "uptimeAlertNoRecipients": "Brak odbiorców", + "uptimeAlertNoRecipientsDescription": "Proszę dodać przynajmniej jednego użytkownika, rolę lub adres email do powiadomienia.", + "uptimeAlertCreated": "Alert utworzony", + "uptimeAlertCreatedDescription": "Zostaniesz powiadomiony, gdy status się zmieni.", + "uptimeAlertCreateFailed": "Nie udało się utworzyć alertu", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Klucz", + "webhookHeaderValuePlaceholder": "Wartość", + "alertLabel": "Alert", + "domainPickerWildcardSubdomainNotAllowed": "Uniwersalne subdomeny nie są dozwolone.", + "domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.", + "domainPickerWildcardCertWarningLink": "Dowiedz się więcej", + "health": "Zdrowie", + "domainPendingErrorTitle": "Problem z weryfikacją" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 32ca8e34d..7444606c7 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contacte vendas para ativar esta funcionalidade.", + "contactSalesBookDemo": "Agende uma demonstração", + "contactSalesOr": "ou", + "contactSalesContactUs": "contacte-nos", "setupCreate": "Criar a organização, o site e os recursos", "headerAuthCompatibilityInfo": "Habilite isso para forçar uma resposta 401 Unauthorized quando um token de autenticação estiver faltando. Isso é necessário para navegadores ou bibliotecas HTTP específicas que não enviam credenciais sem um desafio do servidor.", "headerAuthCompatibility": "Compatibilidade Estendida", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Chaves de licença inválidas ou expiradas detectadas. Siga os termos da licença para continuar usando todos os recursos.", "dismiss": "Rejeitar", "subscriptionViolationMessage": "Você está além dos seus limites para o seu plano atual. Corrija o problema removendo sites, usuários, ou outros recursos para ficar em seu plano.", + "trialBannerMessage": "Sua avaliação termina em {countdown}. Faça o upgrade para manter o acesso.", + "trialBannerExpired": "Sua avaliação expirou. Faça o upgrade agora para restaurar o acesso.", + "trialActive": "Avaliação Gratuita Ativa", + "trialExpired": "Avaliação Expirada", + "trialHasEnded": "Sua avaliação terminou.", + "trialDaysRemaining": "{count, plural, one {# dia restante} other {# dias restantes}}", + "trialDaysLeftShort": "{days}d restante na avaliação", + "trialGoToBilling": "Ir para a página de faturamento", "subscriptionViolationViewBilling": "Ver faturamento", "componentsLicenseViolation": "Violação de Licença: Este servidor está usando sites {usedSites} que excedem o limite licenciado de sites {maxSites} . Siga os termos da licença para continuar usando todos os recursos.", "componentsSupporterMessage": "Obrigado por apoiar o Pangolin como um {tier}!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "Eu copiei a configuração", "searchSitesProgress": "Procurar sites...", "siteAdd": "Adicionar Site", + "sitesTableViewPublicResources": "Visualizar Recursos Públicos", + "sitesTableViewPrivateResources": "Visualizar Recursos Privados", "siteInstallNewt": "Instalar Novo", "siteInstallNewtDescription": "Novo item em execução no seu sistema", "WgConfiguration": "Configuração do WireGuard", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "O site foi atualizado.", "siteGeneralDescription": "Configurar as configurações gerais para este site", "siteSettingDescription": "Configurar as configurações no site", + "siteResourcesTab": "Recursos", + "siteResourcesNoneOnSite": "Este site ainda não possui recursos públicos ou privados.", + "siteResourcesSectionPublic": "Recursos Públicos", + "siteResourcesSectionPrivate": "Recursos Privados", + "siteResourcesSectionPublicDescription": "Recursos expostos externamente por meio de domínios ou portas.", + "siteResourcesSectionPrivateDescription": "Recursos disponíveis na sua rede privada por meio do site.", + "siteResourcesViewAllPublic": "Ver todos os recursos", + "siteResourcesViewAllPrivate": "Ver todos os recursos", + "siteResourcesDialogDescription": "Visão geral dos recursos públicos e privados associados a este site.", + "siteResourcesShowMore": "Mostrar Mais", + "siteResourcesPermissionDenied": "Você não tem permissão para listar estes recursos.", + "siteResourcesEmptyPublic": "Ainda não há recursos públicos direcionados para este site.", + "siteResourcesEmptyPrivate": "Ainda não há recursos privados associados a este site.", + "siteResourcesHowToAccess": "Como acessar", + "siteResourcesTargetsOnSite": "Alvos neste site", "siteSetting": "Configurações do {siteName}", "siteNewtTunnel": "Novo Site (Recomendado)", "siteNewtTunnelDescription": "Maneira mais fácil de criar um ponto de entrada em qualquer rede. Nenhuma configuração extra.", @@ -267,8 +296,11 @@ "orgMissing": "ID da Organização Ausente", "orgMissingMessage": "Não é possível regenerar o convite sem um ID de organização.", "accessUsersManage": "Gerir Utilizadores", + "accessUserManage": "Gerir Utilizador", "accessUsersDescription": "Convidar e gerenciar usuários com acesso a esta organização", "accessUsersSearch": "Procurar utilizadores...", + "accessUsersRoleFilterCount": "{count, plural, one {# função} other {# funções}}", + "accessUsersRoleFilterClear": "Limpar filtros de funções", "accessUserCreate": "Criar Usuário", "accessUserRemove": "Remover utilizador", "username": "Usuário:", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Falha ao ativar a chave de licença", "licenseErrorKeyActivateDescription": "Ocorreu um erro ao ativar a chave da licença.", "licenseAbout": "Sobre Licenciamento", + "licenseBannerTitle": "Ative Sua Licença Corporativa", + "licenseBannerDescription": "Desbloqueie recursos empresariais para sua instância de Pangolin autohospedada. Compre uma chave de licença para ativar recursos premium e adicione-a abaixo.", + "licenseBannerGetLicense": "Obter Licença", + "licenseBannerViewDocs": "Ver Documentação", "communityEdition": "Edição da Comunidade", "licenseAboutDescription": "Isto destina-se aos utilizadores empresariais e empresariais que estão a usar o Pangolin num ambiente comercial. Se você estiver usando o Pangolin para uso pessoal, você pode ignorar esta seção.", "licenseKeyActivated": "Chave de licença ativada", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Chave Secreta", + "newtVersion": "Versão", "architecture": "Arquitetura", "sites": "sites", "siteWgAnyClients": "Use qualquer cliente do WireGuard para se conectar. Você terá que endereçar recursos internos usando o IP de pares.", @@ -894,6 +931,7 @@ "idpDisplayName": "Um nome de exibição para este provedor de identidade", "idpAutoProvisionUsers": "Provisionamento Automático de Utilizadores", "idpAutoProvisionUsersDescription": "Quando ativado, os utilizadores serão criados automaticamente no sistema no primeiro login com a capacidade de mapear utilizadores para funções e organizações.", + "idpAutoProvisionConfigureAfterCreate": "Você pode configurar as definições de auto provisão assim que o provedor de identidade for criado.", "licenseBadge": "EE", "idpType": "Tipo de Provedor", "idpTypeDescription": "Selecione o tipo de provedor de identidade que deseja configurar", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Mapeamento de Função Padrão", "defaultMappingsRoleDescription": "JMESPath para extrair informações de função do token ID. O resultado desta expressão deve retornar o nome da função como definido na organização como uma string.", "defaultMappingsOrg": "Mapeamento de Organização Padrão", - "defaultMappingsOrgDescription": "JMESPath para extrair informações da organização do token ID. Esta expressão deve retornar o ID da organização ou verdadeiro para que o utilizador tenha permissão para aceder à organização.", + "defaultMappingsOrgDescription": "Quando definida, esta expressão deve retornar o ID da organização ou verdadeiro para que o usuário acesse essa organização. Quando não definida, a definição de um mapeamento de papel é suficiente: o usuário é permitido desde que um mapeamento de papel válido possa ser resolvido para ele dentro da organização.", "defaultMappingsSubmit": "Guardar Mapeamentos Padrão", "orgPoliciesEdit": "Editar Política da Organização", "org": "Organização", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Visualizar registros", "noneSelected": "Nenhum selecionado", "orgNotFound2": "Nenhuma organização encontrada.", + "search": "Pesquisar…", "searchPlaceholder": "Buscar...", "emptySearchOptions": "Nenhuma opção encontrada", "create": "Criar", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Gerir", "sidebarLogAndAnalytics": "Registo & Análise", "sidebarBluePrints": "Diagramas", + "sidebarAlerting": "Alertas", + "sidebarHealthChecks": "Verificações de Saúde", "sidebarOrganization": "Organização", "sidebarManagement": "Gestão", "sidebarBillingAndLicenses": "Faturamento e Licenças", "sidebarLogsAnalytics": "Análises", + "alertingTitle": "Alertas", + "alertingDescription": "Defina fontes, gatilhos e ações para notificações", + "alertingRules": "Regras de alerta", + "alertingSearchRules": "Pesquisar regras…", + "alertingAddRule": "Criar Regra", + "alertingColumnSource": "Fonte", + "alertingColumnTrigger": "Gatilho", + "alertingColumnActions": "Ações", + "alertingColumnEnabled": "Ativado", + "alertingDeleteQuestion": "Por favor, confirme que deseja excluir esta regra de alerta.", + "alertingDeleteRule": "Excluir regra de alerta", + "alertingRuleDeleted": "Regra de alerta excluída", + "alertingRuleSaved": "Regra de alerta salva", + "alertingRuleSavedCreatedDescription": "Sua nova regra de alerta foi criada. Você pode continuar editando-a nesta página.", + "alertingRuleSavedUpdatedDescription": "As suas alterações para esta regra de alerta foram salvas.", + "alertingEditRule": "Editar Regra de Alerta", + "alertingCreateRule": "Criar Regra de Alerta", + "alertingRuleCredenzaDescription": "Escolha o que observar, quando disparar e como notificar", + "alertingRuleNamePlaceholder": "Site de produção fora do ar", + "alertingRuleEnabled": "Regra ativada", + "alertingSectionSource": "Fonte", + "alertingSourceType": "Tipo de Fonte", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Verificação de Saúde", + "alertingPickSites": "Sites", + "alertingPickHealthChecks": "Verificações de Saúde", + "alertingPickResources": "Recursos", + "alertingAllSites": "Todos os Sites", + "alertingAllSitesDescription": "Alerta disparado para qualquer site", + "alertingSpecificSites": "Sites Específicos", + "alertingSpecificSitesDescription": "Escolha sites específicos para observar", + "alertingAllHealthChecks": "Todas as Verificações de Saúde", + "alertingAllHealthChecksDescription": "Alerta disparado para qualquer verificação de saúde", + "alertingSpecificHealthChecks": "Verificações de Saúde Específicas", + "alertingSpecificHealthChecksDescription": "Escolha verificações de saúde específicas para observar", + "alertingAllResources": "Todos os Recursos", + "alertingAllResourcesDescription": "Alerta disparado para qualquer recurso", + "alertingSpecificResources": "Recursos Específicos", + "alertingSpecificResourcesDescription": "Escolha recursos específicos para observar", + "alertingSelectResources": "Selecionar recursos…", + "alertingResourcesSelected": "{count} recursos selecionados", + "alertingResourcesEmpty": "Nenhum recurso com alvos nos primeiros 10 resultados.", + "alertingSectionTrigger": "Gatilho", + "alertingTrigger": "Quando alertar", + "alertingTriggerSiteOnline": "Site online", + "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerSiteToggle": "Status do site muda", + "alertingTriggerHcHealthy": "Verificação de saúde saudável", + "alertingTriggerHcUnhealthy": "Verificação de saúde não saudável", + "alertingTriggerHcToggle": "Status da verificação de saúde muda", + "alertingTriggerResourceHealthy": "Recurso saudável", + "alertingTriggerResourceUnhealthy": "Recurso não saudável", + "alertingTriggerResourceDegraded": "Recurso degradado", + "alertingSearchHealthChecks": "Pesquisar verificações de saúde…", + "alertingHealthChecksEmpty": "Nenhuma verificação de saúde disponível.", + "alertingTriggerResourceToggle": "Status do recurso muda", + "alertingSourceResource": "Recurso", + "alertingSectionActions": "Ações", + "alertingAddAction": "Adicionar Ação", + "alertingActionNotify": "E-mail", + "alertingActionNotifyDescription": "Enviar notificações por e-mail para usuários ou funções", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Envie uma solicitação HTTP para um endpoint personalizado", + "alertingExternalIntegration": "Integração Externa", + "alertingExternalPagerDutyDescription": "Envie alertas para PagerDuty para gerenciamento de incidentes", + "alertingExternalOpsgenieDescription": "Direcione alertas para Opsgenie para gestão de plantão", + "alertingExternalServiceNowDescription": "Crie incidentes do ServiceNow a partir de eventos de alerta", + "alertingExternalIncidentIoDescription": "Dispare fluxos de trabalho do Incident.io a partir de eventos de alerta", + "alertingActionType": "Tipo de Ação", + "alertingNotifyUsers": "Utilizadores", + "alertingNotifyRoles": "Papéis", + "alertingNotifyEmails": "Endereços de e-mail", + "alertingEmailPlaceholder": "Adicione o e-mail e pressione Enter", + "alertingWebhookMethod": "Método HTTP", + "alertingWebhookSecret": "Segredo de assinatura (opcional)", + "alertingWebhookSecretPlaceholder": "Segredo HMAC", + "alertingWebhookHeaders": "Cabeçalhos", + "alertingAddHeader": "Adicionar cabeçalho", + "alertingSelectSites": "Selecionar sites…", + "alertingSitesSelected": "{count} sites selecionados", + "alertingSelectHealthChecks": "Selecionar verificações de saúde…", + "alertingHealthChecksSelected": "{count} verificações de saúde selecionadas", + "alertingNoHealthChecks": "Nenhum alvo com verificações de saúde ativadas", + "alertingHealthCheckStub": "A seleção da fonte de verificação de saúde ainda não está configurada - você ainda pode configurar gatilhos e ações.", + "alertingSelectUsers": "Selecionar utilizadores…", + "alertingUsersSelected": "{count} utilizadores selecionados", + "alertingSelectRoles": "Selecionar funções…", + "alertingRolesSelected": "{count} funções selecionadas", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryAllSites": "Todos os sites", + "alertingSummaryHealthChecks": "Verificações de saúde ({count})", + "alertingSummaryAllHealthChecks": "Todas as verificações de saúde", + "alertingSummaryResources": "Recursos ({count})", + "alertingSummaryAllResources": "Todos os recursos", + "alertingErrorNameRequired": "Digite um nome", + "alertingErrorActionsMin": "Adicione pelo menos uma ação", + "alertingErrorPickSites": "Selecione pelo menos um site", + "alertingErrorPickHealthChecks": "Selecione pelo menos uma verificação de saúde", + "alertingErrorPickResources": "Selecione pelo menos um recurso", + "alertingErrorTriggerSite": "Escolha um gatilho de site", + "alertingErrorTriggerHealth": "Escolha um gatilho de verificação de saúde", + "alertingErrorTriggerResource": "Escolha um gatilho de recurso", + "alertingErrorNotifyRecipients": "Escolha utilizadores, funções ou pelo menos um e-mail", + "alertingConfigureSource": "Configurar Fonte", + "alertingConfigureTrigger": "Configurar Gatilho", + "alertingConfigureActions": "Configurar Ações", + "alertingBackToRules": "Voltar às Regras", + "alertingRuleCooldown": "Tempo de Resfriamento (segundos)", + "alertingRuleCooldownDescription": "Tempo mínimo entre alertas repetidos para a mesma regra. Defina para 0 para disparar todas as vezes.", + "alertingDraftBadge": "Rascunho - salvar para armazenar esta regra", + "alertingSidebarHint": "Clique em um passo na tela para editá-lo aqui.", + "alertingGraphCanvasTitle": "Fluxo de Regras", + "alertingGraphCanvasDescription": "Visão geral visual de fonte, gatilho e ações. Selecione um nó para editá-lo no painel.", + "alertingNodeNotConfigured": "Ainda não configurado", + "alertingNodeActionsCount": "{count, plural, one {# ação} other {# ações}}", + "alertingNodeRoleSource": "Fonte", + "alertingNodeRoleTrigger": "Gatilho", + "alertingNodeRoleAction": "Ação", + "alertingTabRules": "Regras de Alerta", + "alertingTabHealthChecks": "Verificações de Saúde", + "alertingRulesBannerTitle": "Seja Notificado", + "alertingRulesBannerDescription": "Cada regra une o que observar (um site, verificação de saúde ou recurso), quando disparar (por exemplo, offline ou não saudável) e como notificar sua equipe por e-mail, webhooks ou integrações. Use esta lista para criar, ativar e gerenciar essas regras.", + "alertingHealthChecksBannerTitle": "Monitorar Saúde & Recursos", + "alertingHealthChecksBannerDescription": "As verificações de saúde são monitores HTTP ou TCP que você define uma vez. Você pode, então, usá-los como fontes em regras de alerta, para ser notificado quando um alvo se tornar saudável ou não saudável. As verificações de saúde em recursos também aparecem aqui.", + "standaloneHcTableTitle": "Verificações de Saúde", + "standaloneHcSearchPlaceholder": "Pesquisar verificações de saúde…", + "standaloneHcAddButton": "Criar Verificação de Saúde", + "standaloneHcCreateTitle": "Criar Verificação de Saúde", + "standaloneHcEditTitle": "Editar Verificação de Saúde", + "standaloneHcDescription": "Configure uma verificação de saúde HTTP ou TCP para uso em regras de alerta.", + "standaloneHcNameLabel": "Nome", + "standaloneHcNamePlaceholder": "Meu Monitor HTTP", + "standaloneHcDeleteTitle": "Excluir verificação de saúde", + "standaloneHcDeleteQuestion": "Por favor, confirme que deseja excluir esta verificação de saúde.", + "standaloneHcDeleted": "Verificação de saúde excluída", + "standaloneHcSaved": "Verificação de saúde salva", + "standaloneHcColumnHealth": "Saúde", + "standaloneHcColumnMode": "Modo", + "standaloneHcColumnTarget": "Alvo", + "standaloneHcHealthStateHealthy": "Saudável", + "standaloneHcHealthStateUnhealthy": "Não Saudável", + "standaloneHcHealthStateUnknown": "Desconhecido", + "standaloneHcFilterAnySite": "Todos os sites", + "standaloneHcFilterAnyResource": "Todos os recursos", + "standaloneHcFilterMode": "Modo", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Saúde", + "standaloneHcFilterEnabled": "Ativado", + "standaloneHcFilterEnabledOn": "Ativado", + "standaloneHcFilterEnabledOff": "Desativado", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Recurso {id}", "blueprints": "Diagramas", "blueprintsDescription": "Aplicar configurações declarativas e ver execuções anteriores", "blueprintAdd": "Adicionar Diagrama", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Crie a conta de administrador inicial do servidor. Apenas um administrador do servidor pode existir. Você sempre pode alterar essas credenciais posteriormente.", "createAdminAccount": "Criar Conta de Administrador", "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.", - "certificateStatus": "Status do Certificado", + "certificateStatus": "Certificado", + "certificateStatusAutoRefreshHint": "Status atualiza automaticamente.", "loading": "Carregando", "loadingAnalytics": "Carregando Analytics", "restart": "Reiniciar", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Ver notas de versão", "newtUpdateAvailable": "Nova Atualização Disponível", "newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.", + "pangolinNodeUpdateAvailableInfo": "Uma nova versão do Pangolin Node está disponível. Atualize para a versão mais recente para uma melhor experiência.", "domainPickerEnterDomain": "Domínio", "domainPickerPlaceholder": "myapp.exemplo.com", "domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Configurar Verificação de Saúde", "configureHealthCheckDescription": "Configure a monitorização de saúde para {target}", "enableHealthChecks": "Ativar Verificações de Saúde", + "healthCheckDisabledStateDescription": "Quando desativado, o site não realizará verificações de saúde e o estado será considerado desconhecido.", "enableHealthChecksDescription": "Monitore a saúde deste alvo. Você pode monitorar um ponto de extremidade diferente do alvo, se necessário.", "healthScheme": "Método", "healthSelectScheme": "Selecione o Método", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "O intervalo de verificação deve ser de pelo menos 5 segundos", "healthCheckTimeoutMin": "O tempo limite deve ser de pelo menos 1 segundo", "healthCheckRetryMin": "As tentativas de repetição devem ser pelo menos 1", + "healthCheckMode": "Modo de Verificação", + "healthCheckStrategy": "Estratégia", + "healthCheckModeDescription": "Modo TCP verifica apenas a conectividade. Modo HTTP valida a resposta HTTP.", + "healthyThreshold": "Limite de Saúde", + "healthyThresholdDescription": "Sucessos consecutivos necessários antes de marcar como saudável.", + "unhealthyThreshold": "Limite de Não Saúde", + "unhealthyThresholdDescription": "Falhas consecutivas necessárias antes de marcar como não saudável.", + "healthCheckHealthyThresholdMin": "Limite de saúde deve ser pelo menos 1", + "healthCheckUnhealthyThresholdMin": "Limite de não saúde deve ser pelo menos 1", "httpMethod": "Método HTTP", "selectHttpMethod": "Selecionar método HTTP", "domainPickerSubdomainLabel": "Subdomínio", + "domainPickerWildcard": "Coringa", + "domainPickerWildcardPaidOnly": "Subdomínios curinga são um recurso pago. Por favor, atualize para acessar este recurso.", "domainPickerBaseDomainLabel": "Domínio Base", "domainPickerSearchDomains": "Buscar domínios...", "domainPickerNoDomainsFound": "Nenhum domínio encontrado", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Este endereço faz parte da sub-rede de utilitários da organização. É usado para resolver registros de alias usando resolução de DNS interno.", "resourcesTableClients": "Clientes", "resourcesTableAndOnlyAccessibleInternally": "e são acessíveis apenas internamente quando conectados com um cliente.", - "resourcesTableNoTargets": "Nenhum alvo", "resourcesTableHealthy": "Saudável", "resourcesTableDegraded": "Degradado", - "resourcesTableOffline": "Desconectado", + "resourcesTableUnhealthy": "Não Saudável", "resourcesTableUnknown": "Desconhecido", "resourcesTableNotMonitored": "Não monitorado", + "resourcesTableNoTargets": "Sem alvos", "editInternalResourceDialogEditClientResource": "Editar Recurso Privado", "editInternalResourceDialogUpdateResourceProperties": "Atualizar as configurações de recursos e controles de acesso para {resourceName}", "editInternalResourceDialogResourceProperties": "Propriedades do Recurso", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Porta", "editInternalResourceDialogModeHost": "Servidor", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Esquema", + "editInternalResourceDialogEnableSsl": "Ativar SSL", + "editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.", "editInternalResourceDialogDestination": "Destino", "editInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.", "editInternalResourceDialogDestinationIPDescription": "O IP ou endereço do hostname do recurso na rede do site.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Nome", "createInternalResourceDialogSite": "Site", "selectSite": "Selecionar site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "Nenhum site encontrado.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Porta", "createInternalResourceDialogModeHost": "Servidor", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Esquema", + "createInternalResourceDialogScheme": "Esquema", + "createInternalResourceDialogEnableSsl": "Ativar SSL", + "createInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.", "createInternalResourceDialogDestination": "Destino", "createInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.", "createInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.", + "internalResourceDownstreamSchemeRequired": "Esquema é obrigatório para recursos HTTP", + "internalResourceHttpPortRequired": "Porta de destino é obrigatória para recursos HTTP", "siteConfiguration": "Configuração", "siteAcceptClientConnections": "Aceitar Conexões de Clientes", "siteAcceptClientConnectionsDescription": "Permitir que dispositivos de usuário e clientes acessem recursos neste site. Isso pode ser alterado mais tarde.", @@ -1989,7 +2213,7 @@ "description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos", "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.", - "introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin — seus túneis, terminação SSL 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:", + "introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin - seus túneis, terminação SSL 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": { "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." @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Domínio Internacional Detectado", "willbestoredas": "Será armazenado como:", - "roleMappingDescription": "Determinar como as funções são atribuídas aos usuários quando eles fazem login quando Auto Provisão está habilitada.", + "roleMappingDescription": "Determine como os papéis são atribuídos aos usuários quando eles entram com este provedor de identidade.", "selectRole": "Selecione uma função", "roleMappingExpression": "Expressão", "selectRolePlaceholder": "Escolha uma função", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Selecione um domínio para a página de autenticação da organização", "domainPickerProvidedDomain": "Domínio fornecido", "domainPickerFreeProvidedDomain": "Domínio fornecido grátis", + "domainPickerFreeDomainsPaidFeature": "Os domínios fornecidos são um recurso pago. Assine para obter um domínio incluído no seu plano - não há necessidade de trazer o seu próprio.", "domainPickerVerified": "Verificada", "domainPickerUnverified": "Não verificado", - "domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.", + "domainPickerManual": "Manual", + "domainPickerInvalidSubdomainStructure": "Caracteres inválidos serão sanitizados ao serem salvos.", "domainPickerError": "ERRO", "domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização", "domainPickerErrorCheckAvailability": "Não foi possível verificar a disponibilidade do domínio", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Escolha o seu provedor de identidade para continuar", "orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.", "orgAuthSignInWithPangolin": "Entrar com o Pangolin", - "orgAuthSignInToOrg": "Fazer login em uma organização", + "orgAuthSignInToOrg": "Provedor de Identidade da Organização (SSO)", "orgAuthSelectOrgTitle": "Entrada da Organização", "orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar", "orgAuthOrgIdPlaceholder": "sua-organização", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Divulgação de uso", - "description": "Selecione o nível de licença que reflete corretamente seu uso pretendido. A Licença Pessoal permite o uso livre do Software para atividades comerciais individuais, não comerciais ou em pequena escala com rendimento bruto anual inferior a 100.000 USD. Qualquer uso além destes limites — incluindo uso dentro de um negócio, organização, ou outro ambiente gerador de receitas — requer uma Licença Enterprise válida e o pagamento da taxa aplicável de licenciamento. Todos os usuários, pessoais ou empresariais, devem cumprir os Termos da Licença Comercial Fossorial." + "description": "Selecione o nível de licença que reflete corretamente seu uso pretendido. A Licença Pessoal permite o uso livre do Software para atividades comerciais individuais, não comerciais ou em pequena escala com rendimento bruto anual inferior a 100.000 USD. Qualquer uso além destes limites - incluindo uso dentro de um negócio, organização, ou outro ambiente gerador de receitas - requer uma Licença Enterprise válida e o pagamento da taxa aplicável de licenciamento. Todos os usuários, pessoais ou empresariais, devem cumprir os Termos da Licença Comercial Fossorial." }, "trialPeriodInformation": { "title": "Informações do Período de Avaliação", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Escala", - "description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário." + "description": "Recursos empresariais, 50 usuários, 100 sites, e suporte prioritário." } }, "personalUseOnly": "Uso pessoal apenas (licença gratuita - sem checkout)", @@ -2422,6 +2648,7 @@ "validPassword": "Senha válida", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Cliente Conectado", "resourceBlocked": "Recurso bloqueado", "droppedByRule": "Derrubado pela regra", "noSessions": "Sem Sessões", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Adicionar Clientes", "editInternalResourceDialogDestinationLabel": "Destino", "editInternalResourceDialogDestinationDescription": "Especifique o endereço de destino para o recurso interno. Isso pode ser um nome de host, endereço IP ou intervalo CIDR, dependendo do modo selecionado. Opcionalmente, defina um alias interno de DNS para facilitar a identificação.", + "internalResourceFormMultiSiteRoutingHelp": "Selecionar múltiplos sites permite roteamento resiliente e failover para alta disponibilidade.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Saiba mais", "editInternalResourceDialogPortRestrictionsDescription": "Restrinja o acesso a portas TCP/UDP específicas ou permita/bloqueie todas as portas.", + "createInternalResourceDialogHttpConfiguration": "Configuração HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Escolha o domínio que os clientes usarão para acessar este recurso via HTTP ou HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Configuração HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Escolha o domínio que os clientes usarão para acessar este recurso via HTTP ou HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Voltaremos em breve! Nosso site está passando por manutenção programada.", "maintenancePageMessageDescription": "Mensagem detalhada explicando a manutenção", "maintenancePageTimeTitle": "Hora de Conclusão Estimada (Opcional)", + "privateMaintenanceScreenTitle": "Tela de Placeholder Privada", + "privateMaintenanceScreenMessage": "Este domínio está sendo usado em um recurso privado. Por favor, conecte-se usando o cliente Pangolin para acessar este recurso.", + "privateMaintenanceScreenSteps": "Depois de conectado, se você ainda estiver vendo esta mensagem, o cache DNS do seu navegador pode ainda apontar para o antigo endereço. Para corrigir isso: feche completamente e reabra esta aba, ou o seu navegador, e então navegue de volta para esta página.", "maintenanceTime": "por exemplo, 2 horas, 1 de Nov às 17h00", "maintenanceEstimatedTimeDescription": "Quando você espera que a manutenção seja concluída", "editDomain": "Editar Domínio", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Adicionar Destino HTTP", "httpDestEditDescription": "Atualizar a configuração para este destino de transmissão de eventos HTTP.", "httpDestAddDescription": "Configure um novo ponto de extremidade HTTP para receber eventos da sua organização.", + "S3DestEditTitle": "Editar Destino", + "S3DestAddTitle": "Adicionar Destino S3", + "S3DestEditDescription": "Atualize a configuração para este destino de streaming de eventos S3.", + "S3DestAddDescription": "Configure um novo endpoint S3 para receber os eventos da sua organização.", + "datadogDestEditTitle": "Editar Destino", + "datadogDestAddTitle": "Adicionar Destino Datadog", + "datadogDestEditDescription": "Atualize a configuração para este destino de streaming de eventos Datadog.", + "datadogDestAddDescription": "Configure um novo endpoint Datadog para receber os eventos da sua organização.", "httpDestTabSettings": "Confirgurações", "httpDestTabHeaders": "Cabeçalhos", "httpDestTabBody": "Conteúdo", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "Matriz JSON", "httpDestFormatJsonArrayDescription": "Um pedido por lote, o corpo é um array JSON. Compatível com a maioria dos webhooks genéricos e Datadog.", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "Um pedido por lote, o corpo é um JSON delimitado por nova-linha — um objeto por linha, sem array exterior. Requerido pelo Splunk HEC, Elástico / OpenSearch, e Grafana Loki.", + "httpDestFormatNdjsonDescription": "Um pedido por lote, o corpo é um JSON delimitado por nova-linha - um objeto por linha, sem array exterior. Requerido pelo Splunk HEC, Elástico / OpenSearch, e Grafana Loki.", "httpDestFormatSingleTitle": "Um Evento por Requisição", "httpDestFormatSingleDescription": "Envia um POST HTTP separado para cada evento. Utilize apenas para endpoints que não podem manipular lotes.", "httpDestLogTypesTitle": "Tipos de log", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Destino atualizado com sucesso", "httpDestCreatedSuccess": "Destino criado com sucesso", "httpDestUpdateFailed": "Falha ao atualizar destino", - "httpDestCreateFailed": "Falha ao criar destino" + "httpDestCreateFailed": "Falha ao criar destino", + "followRedirects": "Seguir Redirecionamentos", + "followRedirectsDescription": "Siga automaticamente os redirecionamentos HTTP para requisições.", + "alertingErrorWebhookUrl": "Por favor, insira um URL válido para o webhook.", + "healthCheckStrategyHttp": "Valida conectividade e verifica o status da resposta HTTP.", + "healthCheckStrategyTcp": "Verifica apenas conectividade TCP, sem inspecionar a resposta.", + "healthCheckStrategySnmp": "Faz uma solicitação SNMP para verificar a saúde dos dispositivos e infraestruturas de rede.", + "healthCheckStrategyIcmp": "Usa solicitações de eco ICMP (pings) para verificar se um recurso é acessível e responsivo.", + "healthCheckTabStrategy": "Estratégia", + "healthCheckTabConnection": "Conexão", + "healthCheckTabAdvanced": "Avançado", + "healthCheckStrategyNotAvailable": "Esta estratégia não está disponível. Por favor, contacte vendas para ativar esta funcionalidade.", + "uptime30d": "Uptime (30d)", + "idpAddActionCreateNew": "Criar novo provedor de identidade", + "idpAddActionImportFromOrg": "Importar de outra organização", + "idpImportDialogTitle": "Importar Provedor de Identidade", + "idpImportDialogDescription": "Escolha um provedor de identidade de uma organização onde você é administrador. Ele será vinculado a esta organização.", + "idpImportSearchPlaceholder": "Pesquisar por nome de organização ou provedor...", + "idpImportEmpty": "Nenhum provedor de identidade encontrado.", + "idpImportedDescription": "Provedor de identidade importado com sucesso.", + "idpDeleteGlobalQuestion": "Tem certeza de que deseja eliminar permanentemente este provedor de identidade?", + "idpDeleteGlobalDescription": "Isso eliminará permanentemente o provedor de identidade de todas as organizações com as quais está associado.", + "idpUnassociateTitle": "Desassociar Provedor de Identidade", + "idpUnassociateQuestion": "Tem certeza de que deseja desassociar este provedor de identidade desta organização?", + "idpUnassociateDescription": "Todos os usuários associados a este provedor de identidade serão removidos desta organização, mas o provedor de identidade continuará a existir para outras organizações associadas.", + "idpUnassociateConfirm": "Confirmar Desassociação do Provedor de Identidade", + "idpUnassociateWarning": "Isso não pode ser desfeito para esta organização.", + "idpUnassociatedDescription": "Provedor de identidade desassociado desta organização com sucesso", + "idpUnassociateMenu": "Desassociar", + "idpDeleteAllOrgsMenu": "Excluir", + "publicIpEndpoint": "Endpoint", + "lastTriggeredAt": "Último Gatilho", + "reject": "Rejeitar", + "uptimeDaysAgo": "há {count} dias", + "uptimeToday": "Hoje", + "uptimeNoDataAvailable": "Sem dados disponíveis", + "uptimeSuffix": "tempo de atividade", + "uptimeDowntimeSuffix": "tempo de inatividade", + "uptimeTooltipUptimeLabel": "Tempo de Atividade", + "uptimeTooltipDowntimeLabel": "Tempo de Inatividade", + "uptimeOngoing": "em andamento", + "uptimeNoMonitoringData": "Sem dados de monitoramento", + "uptimeNoData": "Sem dados", + "uptimeMiniBarDown": "Inativo", + "uptimeSectionTitle": "Tempo de Atividade", + "uptimeSectionDescription": "Disponibilidade nos últimos {days} dias", + "uptimeAddAlert": "Adicionar Alerta", + "uptimeViewAlerts": "Visualizar Alertas", + "uptimeCreateEmailAlert": "Criar Alerta por Email", + "uptimeAlertDescriptionSite": "Seja notificado por email quando este site sair do ar ou voltar online.", + "uptimeAlertDescriptionResource": "Seja notificado por email quando este recurso sair do ar ou voltar online.", + "uptimeAlertNamePlaceholder": "Nome do alerta", + "uptimeAdditionalEmails": "Emails Adicionais", + "uptimeCreateAlert": "Criar Alerta", + "uptimeAlertNoRecipients": "Sem destinatários", + "uptimeAlertNoRecipientsDescription": "Por favor, adicione pelo menos um usuário, função ou email para notificar.", + "uptimeAlertCreated": "Alerta criado", + "uptimeAlertCreatedDescription": "Você será notificado quando isso mudar de status.", + "uptimeAlertCreateFailed": "Falha ao criar alerta", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Chave", + "webhookHeaderValuePlaceholder": "Valor", + "alertLabel": "Alerta", + "domainPickerWildcardSubdomainNotAllowed": "Subdomínios curinga não são permitidos.", + "domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.", + "domainPickerWildcardCertWarningLink": "Saiba mais", + "health": "Saúde", + "domainPendingErrorTitle": "Problema de Verificação" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 1e9ad7d2e..d41e8555d 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Свяжитесь с отделом продаж, чтобы включить эту функцию.", + "contactSalesBookDemo": "Записаться на демонстрацию", + "contactSalesOr": "или", + "contactSalesContactUs": "свяжитесь с нами", "setupCreate": "Создать организацию, сайт и ресурсы", "headerAuthCompatibilityInfo": "Включите это, чтобы принудительно вернуть ответ 401 Unauthorized, если отсутствует токен аутентификации. Это требуется для браузеров или определенных библиотек HTTP, которые не отправляют учетные данные без запроса сервера.", "headerAuthCompatibility": "Дополнительная совместимость", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Обнаружены недействительные или просроченные лицензионные ключи. Соблюдайте условия лицензии для использования всех функций.", "dismiss": "Отменить", "subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.", + "trialBannerMessage": "Ваш пробный период истекает через {countdown}. Обновите, чтобы сохранить доступ.", + "trialBannerExpired": "Ваш пробный период истек. Обновите сейчас, чтобы восстановить доступ.", + "trialActive": "Бесплатный пробный период активен", + "trialExpired": "Пробный период истек", + "trialHasEnded": "Ваш пробный период окончен.", + "trialDaysRemaining": "{count, plural, one {# день остался} few {# дня осталось} many {# дней осталось} other {# дней осталось}}", + "trialDaysLeftShort": "Осталось {days}д в пробном периоде", + "trialGoToBilling": "Перейти на страницу выставления счетов", "subscriptionViolationViewBilling": "Просмотр биллинга", "componentsLicenseViolation": "Нарушение лицензии: Сервер использует {usedSites} сайтов, что превышает лицензионный лимит в {maxSites} сайтов. Соблюдайте условия лицензии для использования всех функций.", "componentsSupporterMessage": "Спасибо за поддержку Pangolin в качестве {tier}!", @@ -56,7 +68,7 @@ "siteManageSites": "Управление сайтами", "siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям", "sitesBannerTitle": "Подключить любую сеть", - "sitesBannerDescription": "Сайт — это соединение с удаленной сетью, которое позволяет Pangolin предоставлять доступ к ресурсам, будь они общедоступными или частными, пользователям в любом месте. Установите сетевой коннектор сайта (Newt) там, где можно запустить исполняемый файл или контейнер, чтобы установить соединение.", + "sitesBannerDescription": "Сайт - это соединение с удаленной сетью, которое позволяет Pangolin предоставлять доступ к ресурсам, будь они общедоступными или частными, пользователям в любом месте. Установите сетевой коннектор сайта (Newt) там, где можно запустить исполняемый файл или контейнер, чтобы установить соединение.", "sitesBannerButtonText": "Установить сайт", "approvalsBannerTitle": "Одобрить или запретить доступ к устройству", "approvalsBannerDescription": "Просмотрите и подтвердите или отклоните запросы на доступ к устройству от пользователей. Когда требуется подтверждение устройства, пользователи должны получить одобрение администратора, прежде чем их устройства смогут подключиться к ресурсам вашей организации.", @@ -81,6 +93,8 @@ "siteConfirmCopy": "Я скопировал(а) конфигурацию", "searchSitesProgress": "Поиск сайтов...", "siteAdd": "Добавить сайт", + "sitesTableViewPublicResources": "Просмотр публичных ресурсов", + "sitesTableViewPrivateResources": "Просмотр частных ресурсов", "siteInstallNewt": "Установить Newt", "siteInstallNewtDescription": "Запустите Newt в вашей системе", "WgConfiguration": "Конфигурация WireGuard", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "Сайт был успешно обновлён.", "siteGeneralDescription": "Настройте общие параметры для этого сайта", "siteSettingDescription": "Настройка параметров на сайте", + "siteResourcesTab": "Ресурсы", + "siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.", + "siteResourcesSectionPublic": "Публичные ресурсы", + "siteResourcesSectionPrivate": "Частные ресурсы", + "siteResourcesSectionPublicDescription": "Ресурсы, доступные извне через домены или порты.", + "siteResourcesSectionPrivateDescription": "Ресурсы доступны на вашем частном сетевом ресурсе через сайт.", + "siteResourcesViewAllPublic": "Просмотреть все ресурсы", + "siteResourcesViewAllPrivate": "Просмотреть все ресурсы", + "siteResourcesDialogDescription": "Обзор публичных и частных ресурсов, связанных с этим сайтом.", + "siteResourcesShowMore": "Показать еще", + "siteResourcesPermissionDenied": "У вас нет разрешения на просмотр этих ресурсов.", + "siteResourcesEmptyPublic": "Ни один публичный ресурс еще не нацелен на этот сайт.", + "siteResourcesEmptyPrivate": "С этим сайтом еще не связано ни одного частного ресурса.", + "siteResourcesHowToAccess": "Как получить доступ", + "siteResourcesTargetsOnSite": "Цели на этом сайте", "siteSetting": "Настройки {siteName}", "siteNewtTunnel": "Новый сайт (рекомендуется)", "siteNewtTunnelDescription": "Самый простой способ создать точку входа в любую сеть. Дополнительная настройка не требуется.", @@ -163,7 +192,7 @@ "proxyResourceTitle": "Управление публичными ресурсами", "proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер", "proxyResourcesBannerTitle": "Общедоступный доступ через веб", - "proxyResourcesBannerDescription": "Общедоступные ресурсы — это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", + "proxyResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", "clientResourceTitle": "Управление приватными ресурсами", "clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент", "privateResourcesBannerTitle": "Частный доступ с нулевым доверием", @@ -267,8 +296,11 @@ "orgMissing": "Отсутствует ID организации", "orgMissingMessage": "Невозможно восстановить приглашение без ID организации.", "accessUsersManage": "Управление пользователями", + "accessUserManage": "Управление пользователем", "accessUsersDescription": "Пригласить и управлять пользователями с доступом к этой организации", "accessUsersSearch": "Поиск пользователей...", + "accessUsersRoleFilterCount": "{count, plural, one {# роль} few {# роли} many {# ролей} other {# роли}}", + "accessUsersRoleFilterClear": "Очистить фильтры ролей", "accessUserCreate": "Создать пользователя", "accessUserRemove": "Удалить пользователя", "username": "Имя пользователя", @@ -371,7 +403,7 @@ "provisioningKeysUpdated": "Ключ подготовки обновлен", "provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.", "provisioningKeysBannerTitle": "Ключи подготовки сайта", - "provisioningKeysBannerDescription": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.", + "provisioningKeysBannerDescription": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске - нет необходимости настраивать отдельные учетные данные для каждого сайта.", "provisioningKeysBannerButtonText": "Узнать больше", "pendingSitesBannerTitle": "Ожидающие сайты", "pendingSitesBannerDescription": "Сайты, подключающиеся с помощью ключа настройки, отображаются здесь для проверки.", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Не удалось активировать лицензионный ключ", "licenseErrorKeyActivateDescription": "Произошла ошибка при активации лицензионного ключа.", "licenseAbout": "О лицензировании", + "licenseBannerTitle": "Активируйте вашу корпоративную лицензию", + "licenseBannerDescription": "Откройте доступ к корпоративным функциям для вашей локально размещаемой версии Pangolin. Приобретите лицензионный ключ, чтобы активировать премиум-функции, затем добавьте его ниже.", + "licenseBannerGetLicense": "Получить лицензию", + "licenseBannerViewDocs": "Посмотреть документацию", "communityEdition": "Community Edition", "licenseAboutDescription": "Это для бизнес и корпоративных пользователей, использующих Pangolin в коммерческой среде. Если вы используете Pangolin для личного использования, вы можете игнорировать этот раздел.", "licenseKeyActivated": "Лицензионный ключ активирован", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Секретный ключ", + "newtVersion": "Версия", "architecture": "Архитектура", "sites": "Сайты", "siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.", @@ -894,6 +931,7 @@ "idpDisplayName": "Отображаемое имя для этого поставщика удостоверений", "idpAutoProvisionUsers": "Автоматическое создание пользователей", "idpAutoProvisionUsersDescription": "При включении пользователи будут автоматически создаваться в системе при первом входе с возможностью сопоставления пользователей с ролями и организациями.", + "idpAutoProvisionConfigureAfterCreate": "Вы можете настроить параметры автоматического обеспечения после создания поставщика удостоверений.", "licenseBadge": "EE", "idpType": "Тип поставщика", "idpTypeDescription": "Выберите тип поставщика удостоверений, который вы хотите настроить", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Сопоставление ролей по умолчанию", "defaultMappingsRoleDescription": "Результат этого выражения должен возвращать имя роли, как определено в организации, в виде строки.", "defaultMappingsOrg": "Сопоставление организаций по умолчанию", - "defaultMappingsOrgDescription": "Это выражение должно возвращать ID организации или true для разрешения доступа пользователя к организации.", + "defaultMappingsOrgDescription": "При установке это выражение должно возвращать ID организации или true, чтобы пользователь мог получить доступ к этой организации. При отсутствии настройка отображения роли достаточно: пользователю разрешено войти, пока для него может быть решено отображение гарантированной роли в организации.", "defaultMappingsSubmit": "Сохранить сопоставления по умолчанию", "orgPoliciesEdit": "Редактировать политику организации", "org": "Организация", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Просмотр журналов", "noneSelected": "Ничего не выбрано", "orgNotFound2": "Организации не найдены.", + "search": "Поиск…", "searchPlaceholder": "Поиск...", "emptySearchOptions": "Опции не найдены", "create": "Создать", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Управление", "sidebarLogAndAnalytics": "Журнал и аналитика", "sidebarBluePrints": "Чертежи", + "sidebarAlerting": "Оповещения", + "sidebarHealthChecks": "Проверки здоровья", "sidebarOrganization": "Организация", "sidebarManagement": "Управление", "sidebarBillingAndLicenses": "Биллинг и лицензии", "sidebarLogsAnalytics": "Статистика", + "alertingTitle": "Оповещения", + "alertingDescription": "Определите источники, триггеры и действия для уведомлений", + "alertingRules": "Правила оповещений", + "alertingSearchRules": "Поиск правил…", + "alertingAddRule": "Создать правило", + "alertingColumnSource": "Источник", + "alertingColumnTrigger": "Триггер", + "alertingColumnActions": "Действия", + "alertingColumnEnabled": "Включено", + "alertingDeleteQuestion": "Пожалуйста, подтвердите удаление этого правила оповещений.", + "alertingDeleteRule": "Удалить правило оповещений", + "alertingRuleDeleted": "Правило оповещений удалено", + "alertingRuleSaved": "Правило оповещений сохранено", + "alertingRuleSavedCreatedDescription": "Ваше новое правило оповещений создано. Вы можете продолжать редактировать его на этой странице.", + "alertingRuleSavedUpdatedDescription": "Ваши изменения в этом правиле оповещений были сохранены.", + "alertingEditRule": "Редактировать правило оповещений", + "alertingCreateRule": "Создать правило оповещений", + "alertingRuleCredenzaDescription": "Выберите, что отслеживать, когда срабатывать и как уведомлять", + "alertingRuleNamePlaceholder": "Рабочий сайт не доступен", + "alertingRuleEnabled": "Правило включено", + "alertingSectionSource": "Источник", + "alertingSourceType": "Тип источника", + "alertingSourceSite": "Сайт", + "alertingSourceHealthCheck": "Проверка здоровья", + "alertingPickSites": "Сайты", + "alertingPickHealthChecks": "Проверки здоровья", + "alertingPickResources": "Ресурсы", + "alertingAllSites": "Все сайты", + "alertingAllSitesDescription": "Оповещение срабатывает на любом сайте", + "alertingSpecificSites": "Конкретные сайты", + "alertingSpecificSitesDescription": "Выберите конкретные сайты для отслеживания", + "alertingAllHealthChecks": "Все проверки здоровья", + "alertingAllHealthChecksDescription": "Оповещение срабатывает на любой проверке здоровья", + "alertingSpecificHealthChecks": "Конкретные проверки здоровья", + "alertingSpecificHealthChecksDescription": "Выберите конкретные проверки здоровья для отслеживания", + "alertingAllResources": "Все ресурсы", + "alertingAllResourcesDescription": "Оповещение срабатывает на любом ресурсе", + "alertingSpecificResources": "Конкретные ресурсы", + "alertingSpecificResourcesDescription": "Выберите конкретные ресурсы для отслеживания", + "alertingSelectResources": "Выберите ресурсы…", + "alertingResourcesSelected": "Выбрано {count} ресурсов", + "alertingResourcesEmpty": "Нет ресурсов с целью в первых 10 результатах.", + "alertingSectionTrigger": "Триггер", + "alertingTrigger": "Когда оповестить", + "alertingTriggerSiteOnline": "Сайт онлайн", + "alertingTriggerSiteOffline": "Сайт офлайн", + "alertingTriggerSiteToggle": "Статус сайта изменяется", + "alertingTriggerHcHealthy": "Проверка здоровья успешна", + "alertingTriggerHcUnhealthy": "Проверка здоровья не успешна", + "alertingTriggerHcToggle": "Статус проверки здоровья изменяется", + "alertingTriggerResourceHealthy": "Ресурс в нормальном состоянии", + "alertingTriggerResourceUnhealthy": "Ресурс в ненормальном состоянии", + "alertingTriggerResourceDegraded": "Ресурс ухудшен", + "alertingSearchHealthChecks": "Поиск проверок здоровья…", + "alertingHealthChecksEmpty": "Нет доступных проверок здоровья.", + "alertingTriggerResourceToggle": "Статус ресурса изменяется", + "alertingSourceResource": "Ресурс", + "alertingSectionActions": "Действия", + "alertingAddAction": "Добавить действие", + "alertingActionNotify": "Электронная почта", + "alertingActionNotifyDescription": "Отправляйте email уведомления пользователям или ролям", + "alertingActionWebhook": "Веб-хук", + "alertingActionWebhookDescription": "Отправьте HTTP-запрос на пользовательскую конечную точку", + "alertingExternalIntegration": "Внешняя интеграция", + "alertingExternalPagerDutyDescription": "Отправляйте оповещения в PagerDuty для управления инцидентами", + "alertingExternalOpsgenieDescription": "Маршрутизируйте оповещения в Opsgenie для управления дежурной службой", + "alertingExternalServiceNowDescription": "Создавайте инциденты ServiceNow из событий оповещений", + "alertingExternalIncidentIoDescription": "Запускайте рабочие процессы Incident.io из событий оповещений", + "alertingActionType": "Тип действия", + "alertingNotifyUsers": "Пользователи", + "alertingNotifyRoles": "Роли", + "alertingNotifyEmails": "Email адреса", + "alertingEmailPlaceholder": "Добавьте email и нажмите Enter", + "alertingWebhookMethod": "HTTP метод", + "alertingWebhookSecret": "Секрет подписания (необязательно)", + "alertingWebhookSecretPlaceholder": "HMAC секрет", + "alertingWebhookHeaders": "Заголовки", + "alertingAddHeader": "Добавить заголовок", + "alertingSelectSites": "Выберите сайты…", + "alertingSitesSelected": "Выбрано {count} сайтов", + "alertingSelectHealthChecks": "Выберите проверки здоровья…", + "alertingHealthChecksSelected": "Выбрано {count} проверок здоровья", + "alertingNoHealthChecks": "Цели без включенных проверок здоровья отсутствуют", + "alertingHealthCheckStub": "Выбор источника проверки здоровья ещё не подключён - вы все ещё можете настроить триггеры и действия.", + "alertingSelectUsers": "Выберите пользователей…", + "alertingUsersSelected": "Выбрано {count} пользователей", + "alertingSelectRoles": "Выберите роли…", + "alertingRolesSelected": "Выбрано {count} ролей", + "alertingSummarySites": "Сайты ({count})", + "alertingSummaryAllSites": "Все сайты", + "alertingSummaryHealthChecks": "Проверки здоровья ({count})", + "alertingSummaryAllHealthChecks": "Все проверки здоровья", + "alertingSummaryResources": "Ресурсы ({count})", + "alertingSummaryAllResources": "Все ресурсы", + "alertingErrorNameRequired": "Введите название", + "alertingErrorActionsMin": "Добавьте как минимум одно действие", + "alertingErrorPickSites": "Выберите как минимум один сайт", + "alertingErrorPickHealthChecks": "Выберите как минимум одну проверку здоровья", + "alertingErrorPickResources": "Выберите как минимум один ресурс", + "alertingErrorTriggerSite": "Выберите триггер сайта", + "alertingErrorTriggerHealth": "Выберите триггер проверки здоровья", + "alertingErrorTriggerResource": "Выберите триггер ресурса", + "alertingErrorNotifyRecipients": "Выберите пользователей, роли или как минимум один email", + "alertingConfigureSource": "Настроить источник", + "alertingConfigureTrigger": "Настроить триггер", + "alertingConfigureActions": "Настроить действия", + "alertingBackToRules": "Назад к правилам", + "alertingRuleCooldown": "Охлаждение (секунды)", + "alertingRuleCooldownDescription": "Минимальное время между повторными оповещениями для одного и того же правила. Установите 0 для каждого вызова.", + "alertingDraftBadge": "Черновик - сохраните, чтобы сохранить это правило", + "alertingSidebarHint": "Кликните по шагу на холсте, чтобы редактировать его здесь.", + "alertingGraphCanvasTitle": "Поток правил", + "alertingGraphCanvasDescription": "Визуальный обзор источника, триггера и действий. Выберите узел, чтобы редактировать его в панели.", + "alertingNodeNotConfigured": "Ещё не настроено", + "alertingNodeActionsCount": "{count, plural, one {# действие} few {# действия} many {# действий} other {# действий}}", + "alertingNodeRoleSource": "Источник", + "alertingNodeRoleTrigger": "Триггер", + "alertingNodeRoleAction": "Действие", + "alertingTabRules": "Правила оповещений", + "alertingTabHealthChecks": "Проверки здоровья", + "alertingRulesBannerTitle": "Получить уведомление", + "alertingRulesBannerDescription": "Каждое правило объединяет, что отслеживать (сайт, проверка состояния или ресурс), когда срабатывать (например, оффлайн или нарушение), и как уведомлять вашу команду через email, вебхуки или интеграции. Используйте этот список для создания, включения и управления этими правилами.", + "alertingHealthChecksBannerTitle": "Мониторинг здоровья и ресурсов", + "alertingHealthChecksBannerDescription": "Проверки здоровья — это HTTP или TCP мониторы, которые вы определяете один раз. Затем вы можете использовать их в правилах оповещений, чтобы получать уведомления, когда цель становится здоровой или нездоровой. Проверки здоровья для ресурсов также появляются здесь.", + "standaloneHcTableTitle": "Проверки здоровья", + "standaloneHcSearchPlaceholder": "Поиск проверок здоровья…", + "standaloneHcAddButton": "Создать проверку здоровья", + "standaloneHcCreateTitle": "Создать проверку здоровья", + "standaloneHcEditTitle": "Редактировать проверку здоровья", + "standaloneHcDescription": "Настройте проверку здоровья HTTP или TCP для использования в правилах оповещений.", + "standaloneHcNameLabel": "Имя", + "standaloneHcNamePlaceholder": "Мой HTTP монитор", + "standaloneHcDeleteTitle": "Удалить проверку здоровья", + "standaloneHcDeleteQuestion": "Пожалуйста, подтвердите удаление этой проверки здоровья.", + "standaloneHcDeleted": "Проверка здоровья удалена", + "standaloneHcSaved": "Проверка здоровья сохранена", + "standaloneHcColumnHealth": "Здоровье", + "standaloneHcColumnMode": "Режим", + "standaloneHcColumnTarget": "Цель", + "standaloneHcHealthStateHealthy": "Здоровый", + "standaloneHcHealthStateUnhealthy": "Нездоровый", + "standaloneHcHealthStateUnknown": "Неизвестно", + "standaloneHcFilterAnySite": "Все сайты", + "standaloneHcFilterAnyResource": "Все ресурсы", + "standaloneHcFilterMode": "Режим", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Пинг", + "standaloneHcFilterHealth": "Здоровье", + "standaloneHcFilterEnabled": "Включено", + "standaloneHcFilterEnabledOn": "Включено", + "standaloneHcFilterEnabledOff": "Отключено", + "standaloneHcFilterSiteIdFallback": "Сайт {id}", + "standaloneHcFilterResourceIdFallback": "Ресурс {id}", "blueprints": "Чертежи", "blueprintsDescription": "Применить декларирующие конфигурации и просмотреть предыдущие запуски", "blueprintAdd": "Добавить чертёж", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "Создайте первоначальную учётную запись администратора сервера. Может существовать только один администратор сервера. Вы всегда можете изменить эти учётные данные позже.", "createAdminAccount": "Создать учётную запись администратора", "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", - "certificateStatus": "Статус сертификата", + "certificateStatus": "Сертификат", + "certificateStatusAutoRefreshHint": "Статус обновляется автоматически.", "loading": "Загрузка", "loadingAnalytics": "Загрузка аналитики", "restart": "Перезагрузка", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску", "newtUpdateAvailable": "Доступно обновление", "newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.", + "pangolinNodeUpdateAvailableInfo": "Доступна новая версия Pangolin Node. Пожалуйста, обновитесь до последней версии для лучшего опыта.", "domainPickerEnterDomain": "Домен", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Настроить проверку здоровья", "configureHealthCheckDescription": "Настройте мониторинг состояния для {target}", "enableHealthChecks": "Включить проверки здоровья", + "healthCheckDisabledStateDescription": "Когда отключен, сайт не будет выполнять проверки состояния и состояние будет считаться неизвестным.", "enableHealthChecksDescription": "Мониторинг здоровья этой цели. При необходимости можно контролировать другую конечную точку.", "healthScheme": "Метод", "healthSelectScheme": "Выберите метод", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "Интервал проверки должен составлять не менее 5 секунд", "healthCheckTimeoutMin": "Тайм-аут должен составлять не менее 1 секунды", "healthCheckRetryMin": "Количество попыток должно быть не менее 1", + "healthCheckMode": "Режим проверки", + "healthCheckStrategy": "Стратегия", + "healthCheckModeDescription": "Режим TCP проверяет только возможность подключения. Режим HTTP проверяет ответ HTTP.", + "healthyThreshold": "Порог здорового состояния", + "healthyThresholdDescription": "Последовательные успехи, необходимые перед тем, как пометить как здоровый.", + "unhealthyThreshold": "Порог нездорового состояния", + "unhealthyThresholdDescription": "Последовательные неудачи, необходимые перед тем, как пометить как нездоровый.", + "healthCheckHealthyThresholdMin": "Порог здорового состояния должен быть не менее 1", + "healthCheckUnhealthyThresholdMin": "Порог нездорового состояния должен быть не менее 1", "httpMethod": "HTTP метод", "selectHttpMethod": "Выберите HTTP метод", "domainPickerSubdomainLabel": "Поддомен", + "domainPickerWildcard": "Подстановочный знак", + "domainPickerWildcardPaidOnly": "Wildcard поддомены являются платной функцией. Пожалуйста, обновите подписку, чтобы воспользоваться этой функцией.", "domainPickerBaseDomainLabel": "Основной домен", "domainPickerSearchDomains": "Поиск доменов...", "domainPickerNoDomainsFound": "Доменов не найдено", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Этот адрес является частью вспомогательной подсети организации. Он используется для разрешения псевдонимов с использованием внутреннего разрешения DNS.", "resourcesTableClients": "Клиенты", "resourcesTableAndOnlyAccessibleInternally": "и доступны только внутренне при подключении с клиентом.", - "resourcesTableNoTargets": "Нет ярлыков", "resourcesTableHealthy": "Здоровые", "resourcesTableDegraded": "Ухудшение", - "resourcesTableOffline": "Оффлайн", + "resourcesTableUnhealthy": "Проблемные", "resourcesTableUnknown": "Неизвестен", "resourcesTableNotMonitored": "Не отслеживается", + "resourcesTableNoTargets": "Нет целей", "editInternalResourceDialogEditClientResource": "Изменить приватный ресурс", "editInternalResourceDialogUpdateResourceProperties": "Обновить настройки ресурса и элементы управления доступом для {resourceName}", "editInternalResourceDialogResourceProperties": "Свойства ресурса", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Порт", "editInternalResourceDialogModeHost": "Хост", "editInternalResourceDialogModeCidr": "СИДР", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Схема", + "editInternalResourceDialogEnableSsl": "Включить SSL", + "editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.", "editInternalResourceDialogDestination": "Пункт назначения", "editInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.", "editInternalResourceDialogDestinationIPDescription": "IP или адрес хоста ресурса в сети сайта.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Имя", "createInternalResourceDialogSite": "Сайт", "selectSite": "Выберите сайт...", + "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}}", "noSitesFound": "Сайты не найдены.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Порт", "createInternalResourceDialogModeHost": "Хост", "createInternalResourceDialogModeCidr": "СИДР", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Схема", + "createInternalResourceDialogScheme": "Схема", + "createInternalResourceDialogEnableSsl": "Включить SSL", + "createInternalResourceDialogEnableSslDescription": "Включите SSL/TLS шифрование для защищенных HTTPS соединений с конечной точкой.", "createInternalResourceDialogDestination": "Пункт назначения", "createInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.", "createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", + "internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов", + "internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов", "siteConfiguration": "Конфигурация", "siteAcceptClientConnections": "Принимать подключения клиентов", "siteAcceptClientConnectionsDescription": "Разрешить пользовательским устройствам и клиентам доступ к ресурсам на этом сайте. Это может быть изменено позже.", @@ -1989,7 +2213,7 @@ "description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками", "introTitle": "Управляемый Само-Хост Панголина", "introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.", - "introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin — туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:", + "introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin - туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:", "benefitSimplerOperations": { "title": "Более простые операции", "description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки." @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Обнаружен международный домен", "willbestoredas": "Будет храниться как:", - "roleMappingDescription": "Определите, как роли, назначаемые пользователям, когда они войдут в систему автоматического профиля.", + "roleMappingDescription": "Определите, как роли присваиваются пользователям при входе с этим поставщиком удостоверений.", "selectRole": "Выберите роль", "roleMappingExpression": "Выражение", "selectRolePlaceholder": "Выберите роль", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Выберите домен для страницы аутентификации организации", "domainPickerProvidedDomain": "Домен предоставлен", "domainPickerFreeProvidedDomain": "Бесплатный домен", + "domainPickerFreeDomainsPaidFeature": "Предоставленные домены являются платной функцией. Подпишитесь, чтобы получить домен, включенный в ваш план - не нужно приносить свой собственный.", "domainPickerVerified": "Подтверждено", "domainPickerUnverified": "Не подтверждено", - "domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.", + "domainPickerManual": "Ручной", + "domainPickerInvalidSubdomainStructure": "Недопустимые символы будут очищены при сохранении.", "domainPickerError": "Ошибка", "domainPickerErrorLoadDomains": "Не удалось загрузить домены организации", "domainPickerErrorCheckAvailability": "Не удалось проверить доступность домена", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения", "orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.", "orgAuthSignInWithPangolin": "Войти через Pangolin", - "orgAuthSignInToOrg": "Войти в организацию", + "orgAuthSignInToOrg": "Поставщик удостоверений организации (SSO)", "orgAuthSelectOrgTitle": "Вход в организацию", "orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить", "orgAuthOrgIdPlaceholder": "ваша-организация", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Раскрытие", - "description": "Выберите уровень лицензии, который точно отражает ваше предполагаемое использование. Личная Лицензия разрешает свободное использование Программного Обеспечения для частной, некоммерческой или малой коммерческой деятельности с годовым валовым доходом до $100 000 USD. Любое использование сверх этих пределов — включая использование в бизнесе, организацию, или другой приносящей доход среде — требует действительной лицензии предприятия и уплаты соответствующей лицензионной платы. Все пользователи, будь то Личные или Предприятия, обязаны соблюдать условия коммерческой лицензии Fossoral." + "description": "Выберите уровень лицензии, который точно отражает ваше предполагаемое использование. Личная Лицензия разрешает свободное использование Программного Обеспечения для частной, некоммерческой или малой коммерческой деятельности с годовым валовым доходом до $100 000 USD. Любое использование сверх этих пределов - включая использование в бизнесе, организацию, или другой приносящей доход среде - требует действительной лицензии предприятия и уплаты соответствующей лицензионной платы. Все пользователи, будь то Личные или Предприятия, обязаны соблюдать условия коммерческой лицензии Fossoral." }, "trialPeriodInformation": { "title": "Информация о пробном периоде", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Масштаб", - "description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка." + "description": "Функции корпоративного уровня, 50 пользователей, 100 сайтов и приоритетная поддержка." } }, "personalUseOnly": "Только для личного использования (бесплатная лицензия - без оформления на кассе)", @@ -2422,6 +2648,7 @@ "validPassword": "Допустимый пароль", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "Подключенный клиент", "resourceBlocked": "Ресурс заблокирован", "droppedByRule": "Отброшено по правилам", "noSessions": "Нет сессий", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Добавить клиентов", "editInternalResourceDialogDestinationLabel": "Пункт назначения", "editInternalResourceDialogDestinationDescription": "Укажите адрес назначения для внутреннего ресурса. Это может быть имя хоста, IP-адрес или диапазон CIDR в зависимости от выбранного режима. При необходимости установите внутренний DNS-алиас для облегчения идентификации.", + "internalResourceFormMultiSiteRoutingHelp": "Выбор нескольких сайтов позволяет обеспечить отказоустойчивую маршрутизацию и фейловер для высокой доступности.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Узнать больше", "editInternalResourceDialogPortRestrictionsDescription": "Ограничьте доступ к определенным TCP/UDP-портам или разрешите/заблокируйте все порты.", + "createInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", + "createInternalResourceDialogHttpConfigurationDescription": "Выберите домен, который клиенты будут использовать для доступа к этому ресурсу через HTTP или HTTPS.", + "editInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", + "editInternalResourceDialogHttpConfigurationDescription": "Выберите домен, который клиенты будут использовать для доступа к этому ресурсу через HTTP или HTTPS.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Мы скоро вернемся! Наш сайт в настоящее время проходит плановое техническое обслуживание.", "maintenancePageMessageDescription": "Подробное сообщение, объясняющее обслуживание", "maintenancePageTimeTitle": "Предполагаемое время завершения (необязательно)", + "privateMaintenanceScreenTitle": "Экраны частной заглушки", + "privateMaintenanceScreenMessage": "Этот домен используется на частном ресурсе. Пожалуйста, подключитесь с помощью клиента Pangolin для доступа к этому ресурсу.", + "privateMaintenanceScreenSteps": "После подключения, если это сообщение по-прежнему отображается, кэш DNS вашего браузера может указывать на старый адрес. Чтобы исправить эту неисправность: полностью закройте и снова откройте эту вкладку или браузер, затем вернитесь на эту страницу.", "maintenanceTime": "например, 2 часа, 1 ноября в 5:00 вечера", "maintenanceEstimatedTimeDescription": "Когда вы ожидаете завершения обслуживания", "editDomain": "Редактировать домен", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "Добавить HTTP адрес", "httpDestEditDescription": "Обновление конфигурации для этого HTTP события потокового назначения.", "httpDestAddDescription": "Настройте новую HTTP-конечную точку для получения событий вашей организации.", + "S3DestEditTitle": "Редактировать пункт назначения", + "S3DestAddTitle": "Добавить S3 пункт назначения", + "S3DestEditDescription": "Обновите конфигурацию для этого S3 пункта назначения потоковых событий.", + "S3DestAddDescription": "Настройте новую S3 конечную точку для получения событий вашей организации.", + "datadogDestEditTitle": "Редактировать пункт назначения", + "datadogDestAddTitle": "Добавить пункт назначения Datadog", + "datadogDestEditDescription": "Обновите конфигурацию для этого пункта назначения потоковых событий Datadog.", + "datadogDestAddDescription": "Настройте новую конечную точку Datadog для получения событий вашей организации.", "httpDestTabSettings": "Настройки", "httpDestTabHeaders": "Заголовки", "httpDestTabBody": "Тело", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Адрес назначения успешно обновлен", "httpDestCreatedSuccess": "Адрес назначения успешно создан", "httpDestUpdateFailed": "Не удалось обновить место назначения", - "httpDestCreateFailed": "Не удалось создать место назначения" + "httpDestCreateFailed": "Не удалось создать место назначения", + "followRedirects": "Следовать за перенаправлениями", + "followRedirectsDescription": "Автоматически следуйте HTTP перенаправлениям для запросов.", + "alertingErrorWebhookUrl": "Пожалуйста, введите корректный URL для вебхука.", + "healthCheckStrategyHttp": "Проверяет возможность подключения и проверяет статус HTTP ответа.", + "healthCheckStrategyTcp": "Проверяет только подключение TCP, не инспектируя ответ.", + "healthCheckStrategySnmp": "Выполняет SNMP get-запрос, чтобы проверить состояние сетевых устройств и инфраструктуры.", + "healthCheckStrategyIcmp": "Использует эхо-запросы ICMP (ping), чтобы проверить, доступен ли и отзывчив ли ресурс.", + "healthCheckTabStrategy": "Стратегия", + "healthCheckTabConnection": "Подключение", + "healthCheckTabAdvanced": "Дополнительно", + "healthCheckStrategyNotAvailable": "Эта стратегия недоступна. Пожалуйста, свяжитесь с отделом продаж для включения этой функции.", + "uptime30d": "Время работы (30 дней)", + "idpAddActionCreateNew": "Создать нового поставщика удостоверений", + "idpAddActionImportFromOrg": "Импортировать из другой организации", + "idpImportDialogTitle": "Импортировать поставщика удостоверений", + "idpImportDialogDescription": "Выберите поставщика удостоверений из организации, где вы являетесь администратором. Он будет связан с этой организацией.", + "idpImportSearchPlaceholder": "Поиск по организации или имени поставщика...", + "idpImportEmpty": "Поставщики удостоверений не найдены.", + "idpImportedDescription": "Поставщик удостоверений успешно импортирован.", + "idpDeleteGlobalQuestion": "Вы уверены, что хотите навсегда удалить этого поставщика удостоверений?", + "idpDeleteGlobalDescription": "Это навсегда удалит поставщика удостоверений из всех организаций, с которыми он связан.", + "idpUnassociateTitle": "Рассоединить провайдера удостоверений", + "idpUnassociateQuestion": "Вы уверены, что хотите рассоединить этого поставщика удостоверений с этой организацией?", + "idpUnassociateDescription": "Все пользователи, связанные с этим поставщиком удостоверений, будут удалены из этой организации, но поставщик удостоверений будет продолжать существовать для других связанных организаций.", + "idpUnassociateConfirm": "Подтвердите рассоединение поставщика удостоверений", + "idpUnassociateWarning": "Это не может быть отменено для этой организации.", + "idpUnassociatedDescription": "Поставщик удостоверений успешно рассоединен с этой организацией", + "idpUnassociateMenu": "Рассоединить", + "idpDeleteAllOrgsMenu": "Удалить", + "publicIpEndpoint": "Конечная точка", + "lastTriggeredAt": "Последний триггер", + "reject": "Отклонить", + "uptimeDaysAgo": "{count} дней назад", + "uptimeToday": "Сегодня", + "uptimeNoDataAvailable": "Нет доступных данных", + "uptimeSuffix": "время работы", + "uptimeDowntimeSuffix": "время простоя", + "uptimeTooltipUptimeLabel": "Время работы", + "uptimeTooltipDowntimeLabel": "Время простоя", + "uptimeOngoing": "в процессе", + "uptimeNoMonitoringData": "Отсутствуют данные мониторинга", + "uptimeNoData": "Нет данных", + "uptimeMiniBarDown": "Не работает", + "uptimeSectionTitle": "Время работы", + "uptimeSectionDescription": "Доступность за последние {days} дней", + "uptimeAddAlert": "Добавить предупреждение", + "uptimeViewAlerts": "Просмотр предупреждений", + "uptimeCreateEmailAlert": "Создать оповещение по электронной почте", + "uptimeAlertDescriptionSite": "Получайте уведомления по электронной почте, когда этот сайт выходит из сети или снова подключается.", + "uptimeAlertDescriptionResource": "Получайте уведомления по электронной почте, когда этот ресурс выходит из сети или снова подключается.", + "uptimeAlertNamePlaceholder": "Название предупреждения", + "uptimeAdditionalEmails": "Дополнительные адреса электронной почты", + "uptimeCreateAlert": "Создать предупреждение", + "uptimeAlertNoRecipients": "Нет получателей", + "uptimeAlertNoRecipientsDescription": "Пожалуйста, добавьте хотя бы одного пользователя, роль или email для уведомления.", + "uptimeAlertCreated": "Предупреждение создано", + "uptimeAlertCreatedDescription": "Вы будете уведомлены, когда статус изменится.", + "uptimeAlertCreateFailed": "Не удалось создать предупреждение", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Ключ", + "webhookHeaderValuePlaceholder": "Значение", + "alertLabel": "Предупреждение", + "domainPickerWildcardSubdomainNotAllowed": "Wildcard поддомены не допускаются.", + "domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.", + "domainPickerWildcardCertWarningLink": "Узнать больше", + "health": "Состояние", + "domainPendingErrorTitle": "Проблема с подтверждением" } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index ee14bc2d9..0364d2953 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Bu özelliği etkinleştirmek için satış ekibiyle iletişime geçin.", + "contactSalesBookDemo": "Demo ayırt", + "contactSalesOr": "veya", + "contactSalesContactUs": "bize ulaşın", "setupCreate": "Organizasyonu, siteyi ve kaynakları oluşturun", "headerAuthCompatibilityInfo": "Kimlik doğrulama belirteci eksik olduğunda 401 Yetkisiz yanıtı zorlamak için bunu etkinleştirin. Bu, sunucu sorunu olmadan kimlik bilgilerini göndermeyen tarayıcılar veya belirli HTTP kütüphaneleri için gereklidir.", "headerAuthCompatibility": "Genişletilmiş Uyumluluk", @@ -19,6 +23,14 @@ "componentsInvalidKey": "Geçersiz veya süresi dolmuş lisans anahtarları tespit edildi. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", "dismiss": "Kapat", "subscriptionViolationMessage": "Geçerli planınız için limitlerinizi aştınız. Planınız dahilinde kalmak için siteleri, kullanıcıları veya diğer kaynakları kaldırarak sorunu düzeltin.", + "trialBannerMessage": "Deneme süreniz {countdown} içinde sona eriyor. Erişimi sürdürmek için yükseltin.", + "trialBannerExpired": "Deneme süreniz sona erdi. Erişimi geri yüklemek için şimdi yükseltin.", + "trialActive": "Ücretsiz Deneme Aktif", + "trialExpired": "Deneme Süresi Doldu", + "trialHasEnded": "Deneme süreniz sona erdi.", + "trialDaysRemaining": "{count, plural, one {# gün kaldı} other {# gün kaldı}}", + "trialDaysLeftShort": "Deneme süresi için {days}g kaldı", + "trialGoToBilling": "Fatura sayfasına git", "subscriptionViolationViewBilling": "Faturalamayı görüntüle", "componentsLicenseViolation": "Lisans İhlali: Bu sunucu, lisanslı sınırı olan {maxSites} sitesini aşarak {usedSites} site kullanmaktadır. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", "componentsSupporterMessage": "Pangolin'e {tier} olarak destek olduğunuz için teşekkür ederiz!", @@ -81,6 +93,8 @@ "siteConfirmCopy": "Yapılandırmayı kopyaladım", "searchSitesProgress": "Siteleri ara...", "siteAdd": "Site Ekle", + "sitesTableViewPublicResources": "Genel Kaynakları Görüntüle", + "sitesTableViewPrivateResources": "Özel Kaynakları Görüntüle", "siteInstallNewt": "Newt Yükle", "siteInstallNewtDescription": "Newt'i sisteminizde çalıştırma", "WgConfiguration": "WireGuard Yapılandırması", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "Site güncellendi.", "siteGeneralDescription": "Bu site için genel ayarları yapılandırın", "siteSettingDescription": "Sitenizdeki ayarları yapılandırın", + "siteResourcesTab": "Kaynaklar", + "siteResourcesNoneOnSite": "Bu sitede henüz genel veya özel kaynak yok.", + "siteResourcesSectionPublic": "Genel Kaynaklar", + "siteResourcesSectionPrivate": "Özel Kaynaklar", + "siteResourcesSectionPublicDescription": "Alanlar veya portlar üzerinden dışarıdan açığa çıkan kaynaklar.", + "siteResourcesSectionPrivateDescription": "Site aracılığıyla özel ağınızda mevcut olan kaynaklar.", + "siteResourcesViewAllPublic": "Tüm kaynakları görüntüle", + "siteResourcesViewAllPrivate": "Tüm kaynakları görüntüle", + "siteResourcesDialogDescription": "Bu siteyle ilişkili genel ve özel kaynakların genel bakışı.", + "siteResourcesShowMore": "Daha fazla göster", + "siteResourcesPermissionDenied": "Bu kaynakları listeleme izniniz yok.", + "siteResourcesEmptyPublic": "Bu siteyi hedefleyen herhangi bir genel kaynak yok.", + "siteResourcesEmptyPrivate": "Bu siteyle ilişkilendirilmiş özel kaynak yok.", + "siteResourcesHowToAccess": "Nasıl erişilir", + "siteResourcesTargetsOnSite": "Bu sitedeki hedefler", "siteSetting": "{siteName} Ayarları", "siteNewtTunnel": "Newt Site (Önerilen)", "siteNewtTunnelDescription": "Ağınıza giriş noktası oluşturmanın en kolay yolu. Ekstra kurulum gerekmez.", @@ -267,8 +296,11 @@ "orgMissing": "Organizasyon Kimliği Eksik", "orgMissingMessage": "Organizasyon kimliği olmadan daveti yeniden oluşturmanız mümkün değildir.", "accessUsersManage": "Kullanıcıları Yönet", + "accessUserManage": "Kullanıcıyı Yönet", "accessUsersDescription": "Bu organizasyona erişimi olan kullanıcıları davet edin ve yönetin", "accessUsersSearch": "Kullanıcıları ara...", + "accessUsersRoleFilterCount": "{count, plural, one {# rol} other {# roller}}", + "accessUsersRoleFilterClear": "Rol filtrelerini temizle", "accessUserCreate": "Kullanıcı Oluştur", "accessUserRemove": "Kullanıcıyı Kaldır", "username": "Kullanıcı Adı", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "Lisans anahtarı etkinleştirilemedi", "licenseErrorKeyActivateDescription": "Lisans anahtarı etkinleştirilirken bir hata oluştu.", "licenseAbout": "Lisans Hakkında", + "licenseBannerTitle": "Kurumsal Lisansınızı Etkinleştirin", + "licenseBannerDescription": "Kendi barındırdığınız Pangolin örneğiniz için kurumsal özelliklerin kilidini açın. Premium yetenekleri etkinleştirmek için bir lisans anahtarı satın alın, ardından aşağıya ekleyin.", + "licenseBannerGetLicense": "Lisans Alın", + "licenseBannerViewDocs": "Dokümantasyonu Görüntüleyin", "communityEdition": "Topluluk Sürümü", "licenseAboutDescription": "Bu, Pangolin'i ticari bir ortamda kullanan işletme ve kurumsal kullanıcılar içindir. Pangolin'i kişisel kullanım için kullanıyorsanız, bu bölümü görmezden gelebilirsiniz.", "licenseKeyActivated": "Lisans anahtarı etkinleştirildi", @@ -727,6 +763,7 @@ "newtEndpoint": "Uç Nokta", "newtId": "Kimlik", "newtSecretKey": "Gizli", + "newtVersion": "Sürüm", "architecture": "Mimari", "sites": "Siteler", "siteWgAnyClients": "Herhangi bir WireGuard istemcisi kullanarak bağlanın. Dahili kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.", @@ -894,6 +931,7 @@ "idpDisplayName": "Bu kimlik sağlayıcı için bir görüntü adı", "idpAutoProvisionUsers": "Kullanıcıları Otomatik Sağla", "idpAutoProvisionUsersDescription": "Etkinleştirildiğinde, kullanıcılar rol ve organizasyonlara eşleme yeteneğiyle birlikte sistemde otomatik olarak oluşturulacak.", + "idpAutoProvisionConfigureAfterCreate": "Kimlik sağlayıcı oluşturulduktan sonra otomatik sağlama ayarlarını yapılandırabilirsiniz.", "licenseBadge": " ", "idpType": "Sağlayıcı Türü", "idpTypeDescription": "Yapılandırmak istediğiniz kimlik sağlayıcısı türünü seçin", @@ -945,7 +983,7 @@ "defaultMappingsRole": "Varsayılan Rol Eşleme", "defaultMappingsRoleDescription": "JMESPath to extract role information from the ID token. The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Varsayılan Kuruluş Eşleme", - "defaultMappingsOrgDescription": "JMESPath to extract organization information from the ID token. This expression must return the org ID or true for the user to be allowed to access the organization.", + "defaultMappingsOrgDescription": "Ayarladığınızda, bu ifade kullanıcının o kuruluşa erişmesi için kuruluş kimliğini veya doğru değerini döndürmelidir. Ayarlamadığınızda, rol eşleme tanımlamak yeterlidir: kullanıcı, kuruluş içinde onlar için geçerli bir rol eşlemesi çözümlenebildiği sürece erişime izin verilir.", "defaultMappingsSubmit": "Varsayılan Eşlemeleri Kaydet", "orgPoliciesEdit": "Kuruluş Politikasını Düzenle", "org": "Kuruluş", @@ -1252,6 +1290,7 @@ "actionViewLogs": "Kayıtları Görüntüle", "noneSelected": "Hiçbiri seçili değil", "orgNotFound2": "Hiçbir organizasyon bulunamadı.", + "search": "Ara…", "searchPlaceholder": "Ara...", "emptySearchOptions": "Seçenek bulunamadı", "create": "Oluştur", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "Yönet", "sidebarLogAndAnalytics": "Kayıt & Analiz", "sidebarBluePrints": "Planlar", + "sidebarAlerting": "Uyarı", + "sidebarHealthChecks": "Sağlık kontrolleri", "sidebarOrganization": "Organizasyon", "sidebarManagement": "Yönetim", "sidebarBillingAndLicenses": "Faturalandırma & Lisanslar", "sidebarLogsAnalytics": "Analitik", + "alertingTitle": "Uyarı", + "alertingDescription": "Bildirimler için kaynakları, tetikleyicileri ve eylemleri tanımlayın", + "alertingRules": "Uyarı kuralları", + "alertingSearchRules": "Kuralları ara…", + "alertingAddRule": "Kural Oluştur", + "alertingColumnSource": "Kaynak", + "alertingColumnTrigger": "Tetikle", + "alertingColumnActions": "İşlemler", + "alertingColumnEnabled": "Etkin", + "alertingDeleteQuestion": "Bu uyarı kuralını silmek istediğinizi onaylayın lütfen.", + "alertingDeleteRule": "Uyarı kuralını sil", + "alertingRuleDeleted": "Uyarı kuralı silindi", + "alertingRuleSaved": "Uyarı kuralı kaydedildi", + "alertingRuleSavedCreatedDescription": "Yeni uyarı kuralınız oluşturuldu. Bu sayfada düzenlemeye devam edebilirsiniz.", + "alertingRuleSavedUpdatedDescription": "Bu uyarı kuralındaki değişiklikleriniz kaydedildi.", + "alertingEditRule": "Uyarı Kuralını Düzenle", + "alertingCreateRule": "Uyarı Kuralı Oluştur", + "alertingRuleCredenzaDescription": "Ne izlenecek, ne zaman tetiklenecek ve nasıl bildirilecek, bunları seçin", + "alertingRuleNamePlaceholder": "Üretim sitesi kapalı", + "alertingRuleEnabled": "Kural etkinleştirildi", + "alertingSectionSource": "Kaynak", + "alertingSourceType": "Kaynak türü", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Sağlık kontrolü", + "alertingPickSites": "Siteler", + "alertingPickHealthChecks": "Sağlık kontrolleri", + "alertingPickResources": "Kaynaklar", + "alertingAllSites": "Tüm Siteler", + "alertingAllSitesDescription": "Herhangi bir site için uyarı tetiklenir", + "alertingSpecificSites": "Belirli Siteler", + "alertingSpecificSitesDescription": "İzlemek için belirli siteleri seçin", + "alertingAllHealthChecks": "Tüm Sağlık Kontrolleri", + "alertingAllHealthChecksDescription": "Herhangi bir sağlık kontrolü için uyarı tetiklenir", + "alertingSpecificHealthChecks": "Belirli Sağlık Kontrolleri", + "alertingSpecificHealthChecksDescription": "İzlemek için belirli sağlık kontrollerini seçin", + "alertingAllResources": "Tüm Kaynaklar", + "alertingAllResourcesDescription": "Herhangi bir kaynak için uyarı tetiklenir", + "alertingSpecificResources": "Belirli Kaynaklar", + "alertingSpecificResourcesDescription": "İzlemek için belirli kaynakları seçin", + "alertingSelectResources": "Kaynakları seçin…", + "alertingResourcesSelected": "{count} kaynak seçildi", + "alertingResourcesEmpty": "İlk 10 sonuçta hedefleri olan kaynak yok.", + "alertingSectionTrigger": "Tetikle", + "alertingTrigger": "Uyarı zamanı", + "alertingTriggerSiteOnline": "Site çevrimiçi", + "alertingTriggerSiteOffline": "Site çevrimdışı", + "alertingTriggerSiteToggle": "Site durumu değişiyor", + "alertingTriggerHcHealthy": "Sağlık kontrolü sağlıklı", + "alertingTriggerHcUnhealthy": "Sağlık kontrolü sağlıksız", + "alertingTriggerHcToggle": "Sağlık kontrolü durumu değişiyor", + "alertingTriggerResourceHealthy": "Kaynak sağlıklı", + "alertingTriggerResourceUnhealthy": "Kaynak sağlıksız", + "alertingTriggerResourceDegraded": "Kaynak bozuk", + "alertingSearchHealthChecks": "Sağlık kontrollerini ara…", + "alertingHealthChecksEmpty": "Mevcut sağlık kontrolü yok.", + "alertingTriggerResourceToggle": "Kaynak durumu değişiyor", + "alertingSourceResource": "Kaynak", + "alertingSectionActions": "İşlemler", + "alertingAddAction": "Eylem Ekle", + "alertingActionNotify": "E-posta", + "alertingActionNotifyDescription": "Kullanıcılara veya role e-posta bildirimleri gönder", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Özel bir uç noktaya HTTP isteği gönderin", + "alertingExternalIntegration": "Harici Entegrasyon", + "alertingExternalPagerDutyDescription": "Olay yönetimi için uyarıları PagerDuty'ye gönderin", + "alertingExternalOpsgenieDescription": "Nöbetçi yönetimi için uyarıları Opsgenie'ye yönlendirin", + "alertingExternalServiceNowDescription": "Uyarı olaylarından ServiceNow olayları oluşturun", + "alertingExternalIncidentIoDescription": "Uyarı olaylarından Incident.io iş akışlarını tetikleyin", + "alertingActionType": "Eylem türü", + "alertingNotifyUsers": "Kullanıcılar", + "alertingNotifyRoles": "Roller", + "alertingNotifyEmails": "E-posta adresleri", + "alertingEmailPlaceholder": "E-posta ekleyin ve Enter tuşuna basın", + "alertingWebhookMethod": "HTTP yöntemi", + "alertingWebhookSecret": "İmza sırrı (isteğe bağlı)", + "alertingWebhookSecretPlaceholder": "HMAC sırrı", + "alertingWebhookHeaders": "Başlıklar", + "alertingAddHeader": "Başlık ekle", + "alertingSelectSites": "Siteleri seçin…", + "alertingSitesSelected": "{count} site seçildi", + "alertingSelectHealthChecks": "Sağlık kontrolleri seçin…", + "alertingHealthChecksSelected": "{count} sağlık kontrolü seçildi", + "alertingNoHealthChecks": "Hedefleri etkinleştirilmiş sağlık kontrolleri yok", + "alertingHealthCheckStub": "Sağlık kontrolü kaynak seçimi henüz bağlanmadı - yine de tetikleyicileri ve eylemleri yapılandırabilirsiniz.", + "alertingSelectUsers": "Kullanıcıları seçin…", + "alertingUsersSelected": "{count} kullanıcı seçildi", + "alertingSelectRoles": "Rolleri seçin…", + "alertingRolesSelected": "{count} rol seçildi", + "alertingSummarySites": "Siteler ({count})", + "alertingSummaryAllSites": "Tüm siteler", + "alertingSummaryHealthChecks": "Sağlık kontrolleri ({count})", + "alertingSummaryAllHealthChecks": "Tüm sağlık kontrolleri", + "alertingSummaryResources": "Kaynaklar ({count})", + "alertingSummaryAllResources": "Tüm kaynaklar", + "alertingErrorNameRequired": "Bir ad girin", + "alertingErrorActionsMin": "En az bir eylem ekleyin", + "alertingErrorPickSites": "En az bir site seçin", + "alertingErrorPickHealthChecks": "En az bir sağlık kontrolü seçin", + "alertingErrorPickResources": "En az bir kaynak seçin", + "alertingErrorTriggerSite": "Bir site tetikleyicisi seçin", + "alertingErrorTriggerHealth": "Bir sağlık kontrolü tetikleyicisi seçin", + "alertingErrorTriggerResource": "Bir kaynak tetikleyicisi seçin", + "alertingErrorNotifyRecipients": "Kullanıcıları, rolleri veya en az bir e-posta seçin", + "alertingConfigureSource": "Kaynağı Yapılandır", + "alertingConfigureTrigger": "Tetikleyiciyi Yapılandır", + "alertingConfigureActions": "Eylemleri Yapılandır", + "alertingBackToRules": "Kurallara Geri Dön", + "alertingRuleCooldown": "Serinleme süresi (saniye)", + "alertingRuleCooldownDescription": "Aynı kural için tekrarlanan uyarılar arasında minimum süre. Her seferinde tetiklenmesi için 0 olarak ayarlayın.", + "alertingDraftBadge": "Taslak - bu kuralı kaydetmek için kaydedin", + "alertingSidebarHint": "Düzenlemek için kanvas üzerindeki bir adıma tıklayın.", + "alertingGraphCanvasTitle": "Kural Akışı", + "alertingGraphCanvasDescription": "Kaynak, tetikleyici ve eylemlerin görsel genel bakışı. Düzenlemek için bir düğümü seçin.", + "alertingNodeNotConfigured": "Henüz yapılandırılmadı", + "alertingNodeActionsCount": "{count, plural, one {# eylem} other {# eylemler}}", + "alertingNodeRoleSource": "Kaynak", + "alertingNodeRoleTrigger": "Tetikle", + "alertingNodeRoleAction": "Aksiyon", + "alertingTabRules": "Uyarı Kuralları", + "alertingTabHealthChecks": "Sağlık Kontrolleri", + "alertingRulesBannerTitle": "Bildirim Alın", + "alertingRulesBannerDescription": "Her kural neyin izleneceğini (bir site, sağlık kontrolü veya kaynak), ne zaman tetikleneceğini (örneğin çevrimdışı veya sağlıksız) ve ekibinize e-posta, web kancaları veya entegrasyonlar aracılığıyla nasıl bildirileceğini bağlar. Bu listeyi kullanarak kuralları oluşturun, etkinleştirin ve yönetin.", + "alertingHealthChecksBannerTitle": "Sağlık ve Kaynakları İzleyin", + "alertingHealthChecksBannerDescription": "Sağlık kontrolleri bir kez tanımladığınız HTTP veya TCP monitörleridir. Ardından hedef sağlıklı veya sağlıksız olduğunda bildirilmeniz için onları uyarı kurallarında kaynak olarak kullanabilirsiniz. Kaynaklar üzerindeki sağlık kontrolleri de burada görünür.", + "standaloneHcTableTitle": "Sağlık Kontrolleri", + "standaloneHcSearchPlaceholder": "Sağlık kontrollerini ara…", + "standaloneHcAddButton": "Sağlık Kontrolü Oluştur", + "standaloneHcCreateTitle": "Sağlık Kontrolü Oluştur", + "standaloneHcEditTitle": "Sağlık Kontrolünü Düzenle", + "standaloneHcDescription": "Uyarı kurallarında kullanılmak üzere bir HTTP veya TCP sağlık kontrolü yapılandırın.", + "standaloneHcNameLabel": "Ad", + "standaloneHcNamePlaceholder": "HTTP Monitörüm", + "standaloneHcDeleteTitle": "Sağlık kontrolünü sil", + "standaloneHcDeleteQuestion": "Bu sağlık kontrolünü silmek istediğinizi onaylayın lütfen.", + "standaloneHcDeleted": "Sağlık kontrolü silindi", + "standaloneHcSaved": "Sağlık kontrolü kaydedildi", + "standaloneHcColumnHealth": "Sağlık", + "standaloneHcColumnMode": "Mod", + "standaloneHcColumnTarget": "Hedef", + "standaloneHcHealthStateHealthy": "Sağlıklı", + "standaloneHcHealthStateUnhealthy": "Sağlıksız", + "standaloneHcHealthStateUnknown": "Bilinmiyor", + "standaloneHcFilterAnySite": "Tüm siteler", + "standaloneHcFilterAnyResource": "Tüm kaynaklar", + "standaloneHcFilterMode": "Mod", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Sağlık", + "standaloneHcFilterEnabled": "Etkin", + "standaloneHcFilterEnabledOn": "Etkin", + "standaloneHcFilterEnabledOff": "Devre Dışı", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Kaynak {id}", "blueprints": "Planlar", "blueprintsDescription": "Deklaratif yapılandırmaları uygulayın ve önceki çalışmaları görüntüleyin", "blueprintAdd": "Plan Ekle", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "İlk sunucu yönetici hesabını oluşturun. Yalnızca bir sunucu yöneticisi olabilir. Bu kimlik bilgilerini daha sonra her zaman değiştirebilirsiniz.", "createAdminAccount": "Yönetici Hesabı Oluştur", "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.", - "certificateStatus": "Sertifika Durumu", + "certificateStatus": "Sertifika", + "certificateStatusAutoRefreshHint": "Durum otomatik olarak yenilenir.", "loading": "Yükleniyor", "loadingAnalytics": "Analiz Yükleniyor", "restart": "Yeniden Başlat", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "Yayın Notlarını Görüntüle", "newtUpdateAvailable": "Güncelleme Mevcut", "newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", + "pangolinNodeUpdateAvailableInfo": "Pangolin Node'un yeni bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "domainPickerEnterDomain": "Alan Adı", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "Sağlık Kontrolünü Yapılandır", "configureHealthCheckDescription": "{hedef} için sağlık izleme kurun", "enableHealthChecks": "Sağlık Kontrollerini Etkinleştir", + "healthCheckDisabledStateDescription": "Devre dışı bırakıldığında, site sağlık kontrolleri yapmaz ve durum bilinmeyen olarak kabul edilecektir.", "enableHealthChecksDescription": "Bu hedefin sağlığını izleyin. Gerekirse hedef dışındaki bir son noktayı izleyebilirsiniz.", "healthScheme": "Yöntem", "healthSelectScheme": "Yöntem Seç", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "Kontrol aralığı en az 5 saniye olmalıdır", "healthCheckTimeoutMin": "Zaman aşımı en az 1 saniye olmalıdır", "healthCheckRetryMin": "Tekrar deneme girişimleri en az 1 olmalıdır", + "healthCheckMode": "Modu Kontrol Et", + "healthCheckStrategy": "Strateji", + "healthCheckModeDescription": "TCP modu yalnızca bağlantıyı doğrular. HTTP modu HTTP yanıtını doğrular.", + "healthyThreshold": "Sağlıklı Eşik", + "healthyThresholdDescription": "Sağlıklı olarak işaretlenmeden önce gereken ardışık başarılar.", + "unhealthyThreshold": "Sağlıksız Eşik", + "unhealthyThresholdDescription": "Sağlıksız olarak işaretlenmeden önce gereken ardışık başarısızlıklar.", + "healthCheckHealthyThresholdMin": "Sağlıklı eşik en az 1 olmalıdır", + "healthCheckUnhealthyThresholdMin": "Sağlıksız eşik en az 1 olmalıdır", "httpMethod": "HTTP Yöntemi", "selectHttpMethod": "HTTP yöntemini seçin", "domainPickerSubdomainLabel": "Alt Alan Adı", + "domainPickerWildcard": "Genel karakter", + "domainPickerWildcardPaidOnly": "Genel alt alanlar ücretli bir özelliktir. Bu özelliğe erişmek için lütfen yükseltin.", "domainPickerBaseDomainLabel": "Temel Alan Adı", "domainPickerSearchDomains": "Alan adlarını ara...", "domainPickerNoDomainsFound": "Hiçbir alan adı bulunamadı", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "Bu adres, kuruluşun yardımcı ağ alt bantının bir parçasıdır. Alias kayıtlarını çözümlemek için dahili DNS çözümlemesi kullanılır.", "resourcesTableClients": "İstemciler", "resourcesTableAndOnlyAccessibleInternally": "veyalnızca bir istemci ile bağlandığında dahili olarak erişilebilir.", - "resourcesTableNoTargets": "Hedef yok", "resourcesTableHealthy": "Sağlıklı", "resourcesTableDegraded": "Düşük Performanslı", - "resourcesTableOffline": "Çevrimdışı", + "resourcesTableUnhealthy": "Sağlıksız", "resourcesTableUnknown": "Bilinmiyor", "resourcesTableNotMonitored": "İzlenmiyor", + "resourcesTableNoTargets": "Hedef yok", "editInternalResourceDialogEditClientResource": "Özel Kaynak Düzenleyin", "editInternalResourceDialogUpdateResourceProperties": "{resourceName} için kaynak ayarlarını ve erişim kontrollerini güncelleyin", "editInternalResourceDialogResourceProperties": "Kaynak Özellikleri", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "Bağlantı Noktası", "editInternalResourceDialogModeHost": "Ev Sahibi", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "Şema", + "editInternalResourceDialogEnableSsl": "SSL'i Etkinleştir", + "editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.", "editInternalResourceDialogDestination": "Hedef", "editInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.", "editInternalResourceDialogDestinationIPDescription": "Kaynağın site ağındaki IP veya ana bilgisayar adresi.", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "Ad", "createInternalResourceDialogSite": "Site", "selectSite": "Site seç...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# siteler}}", "noSitesFound": "Site bulunamadı.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "Bağlantı Noktası", "createInternalResourceDialogModeHost": "Ev Sahibi", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "Şema", + "createInternalResourceDialogScheme": "Şema", + "createInternalResourceDialogEnableSsl": "SSL'i Etkinleştir", + "createInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.", "createInternalResourceDialogDestination": "Hedef", "createInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.", "createInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.", "createInternalResourceDialogAlias": "Takma Ad", "createInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.", + "internalResourceDownstreamSchemeRequired": "HTTP kaynakları için şema gereklidir", + "internalResourceHttpPortRequired": "HTTP kaynakları için hedef bağlantı noktası gereklidir", "siteConfiguration": "Yapılandırma", "siteAcceptClientConnections": "İstemci Bağlantılarını Kabul Et", "siteAcceptClientConnectionsDescription": "Kullanıcı cihazları ve istemcilerin bu sitedeki kaynaklara erişmesine izin verin. Bu daha sonra değiştirilebilir.", @@ -1989,7 +2213,7 @@ "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", "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": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz — tünelleriniz, SSL 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:", + "introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz - tünelleriniz, SSL 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": { "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." @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi", "willbestoredas": "Şu şekilde depolanacak:", - "roleMappingDescription": "Otomatik Sağlama etkinleştirildiğinde kullanıcıların oturum açarken rollerin nasıl atandığını belirleyin.", + "roleMappingDescription": "Bu kimlik sağlayıcı ile oturum açıldığında kullanıcılara rollerin nasıl atandığını belirleyin.", "selectRole": "Bir Rol Seçin", "roleMappingExpression": "İfade", "selectRolePlaceholder": "Bir rol seçin", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "Kuruluşun kimlik doğrulama sayfası için bir alan seçin", "domainPickerProvidedDomain": "Sağlanan Alan Adı", "domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı", + "domainPickerFreeDomainsPaidFeature": "Sağlanan alan adları ücretli bir özelliktir. Planınıza dahil bir alan adı almak için abone olun - kendi alan adınızı getirmenize gerek yok.", "domainPickerVerified": "Doğrulandı", "domainPickerUnverified": "Doğrulanmadı", - "domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.", + "domainPickerManual": "Manuel", + "domainPickerInvalidSubdomainStructure": "Geçersiz karakterler kaydedildiğinde temizlenecektir.", "domainPickerError": "Hata", "domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi", "domainPickerErrorCheckAvailability": "Alan adı kullanılabilirliği kontrol edilemedi", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "Devam etmek için kimlik sağlayıcınızı seçin", "orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.", "orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap", - "orgAuthSignInToOrg": "Bir kuruluşa giriş yapın", + "orgAuthSignInToOrg": "Kuruluş Kimlik Sağlayıcısı (SSO)", "orgAuthSelectOrgTitle": "Kuruluş Giriş", "orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin", "orgAuthOrgIdPlaceholder": "kuruluşunuz", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "Kullanım Açıklaması", - "description": "Kullanım amacınızı doğru bir şekilde yansıtan lisans seviyesini seçin. Kişisel Lisans, yazılımın bireysel, ticari olmayan veya yıllık geliri 100,000 ABD Dolarının altında olan küçük ölçekli ticari faaliyetlerde ücretsiz kullanılmasına izin verir. Bu sınırların ötesinde kullanım — bir işletme, organizasyon veya diğer gelir getirici ortamlarda kullanım dahil olmak üzere — geçerli bir Kurumsal Lisans ve ilgili lisans ücretinin ödenmesini gerektirir. Tüm kullanıcılar, ister Kişisel ister Kurumsal, Fossorial Ticari Lisans Şartlarına uymalıdır." + "description": "Kullanım amacınızı doğru bir şekilde yansıtan lisans seviyesini seçin. Kişisel Lisans, yazılımın bireysel, ticari olmayan veya yıllık geliri 100,000 ABD Dolarının altında olan küçük ölçekli ticari faaliyetlerde ücretsiz kullanılmasına izin verir. Bu sınırların ötesinde kullanım - bir işletme, organizasyon veya diğer gelir getirici ortamlarda kullanım dahil olmak üzere - geçerli bir Kurumsal Lisans ve ilgili lisans ücretinin ödenmesini gerektirir. Tüm kullanıcılar, ister Kişisel ister Kurumsal, Fossorial Ticari Lisans Şartlarına uymalıdır." }, "trialPeriodInformation": { "title": "Deneme Süresi Bilgileri", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "Ölçek", - "description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek." + "description": "Kurumsal özellikler, 50 kullanıcı, 100 site ve öncelikli destek." } }, "personalUseOnly": "Kişisel kullanım için (ücretsiz lisans - ödeme yok)", @@ -2422,6 +2648,7 @@ "validPassword": "Geçerli Şifre", "validEmail": "Geçerli E-posta", "validSSO": "Geçerli SSO", + "connectedClient": "Bağlı İstemci", "resourceBlocked": "Kaynak Engellendi", "droppedByRule": "Kurallara Göre Çıkartıldı", "noSessions": "Oturum Yok", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "Müşteriler Ekle", "editInternalResourceDialogDestinationLabel": "Hedef", "editInternalResourceDialogDestinationDescription": "Dahili kaynak için hedef adresi belirtin. Seçilen moda bağlı olarak bu bir ana bilgisayar adı, IP adresi veya CIDR aralığı olabilir. Daha kolay tanımlama için isteğe bağlı olarak dahili bir DNS takma adı ayarlayın.", + "internalResourceFormMultiSiteRoutingHelp": "Birden fazla site seçmek, yüksek kullanılabilirlik için dirençli yönlendirme ve yedeklik sağlar.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Daha fazla bilgi", "editInternalResourceDialogPortRestrictionsDescription": "Belirtilen TCP/UDP portlarına erişimi kısıtlayın veya tüm portlara izin/engelleme verin.", + "createInternalResourceDialogHttpConfiguration": "HTTP yapılandırması", + "createInternalResourceDialogHttpConfigurationDescription": "HTTP veya HTTPS üzerinden bu kaynağa ulaşmak için istemcilerin kullanacağı alan adını seçin.", + "editInternalResourceDialogHttpConfiguration": "HTTP yapılandırması", + "editInternalResourceDialogHttpConfigurationDescription": "HTTP veya HTTPS üzerinden bu kaynağa ulaşmak için istemcilerin kullanacağı alan adını seçin.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "Yakında geri döneceğiz! Sitemiz şu anda planlı bakım altındadır.", "maintenancePageMessageDescription": "Bakımın detaylarını açıklayan mesaj", "maintenancePageTimeTitle": "Tahmini Tamamlanma Süresi (İsteğe Bağlı)", + "privateMaintenanceScreenTitle": "Özel Yer Tutucu Ekran", + "privateMaintenanceScreenMessage": "Bu alan adı özel bir kaynak üzerinde kullanılmaktadır. Bu kaynağa erişmek için Pangolin istemcisini kullanarak bağlanın.", + "privateMaintenanceScreenSteps": "Bağlanıldıktan sonra, hâlâ bu mesajı görüyorsanız tarayıcınızın DNS önbelleği eski adrese işaret ediyor olabilir. Bunu düzeltmek için: bu sekmeyi veya tarayıcınızı tamamen kapatıp tekrar açın, ardından bu sayfaya geri dönün.", "maintenanceTime": "ör. 2 saat, 1 Kasım saat 17:00", "maintenanceEstimatedTimeDescription": "Bakımın ne zaman tamamlanmasını bekliyorsunuz", "editDomain": "Alan Adını Düzenle", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "HTTP Hedefi Ekle", "httpDestEditDescription": "Bu HTTP olay akışı hedefine yapılandırmayı güncelleyin.", "httpDestAddDescription": "Organizasyonunuzun olaylarını almak için yeni bir HTTP uç noktası yapılandırın.", + "S3DestEditTitle": "Hedefi Düzenle", + "S3DestAddTitle": "S3 Hedefi Ekle", + "S3DestEditDescription": "Bu S3 olay akışı hedefi için yapılandırmayı güncelleyin.", + "S3DestAddDescription": "Kuruluşunuzun olaylarını almak için yeni bir S3 uç noktası yapılandırın.", + "datadogDestEditTitle": "Hedefi Düzenle", + "datadogDestAddTitle": "Datadog Hedefi Ekle", + "datadogDestEditDescription": "Bu Datadog olay akışı hedefi için yapılandırmayı güncelleyin.", + "datadogDestAddDescription": "Kuruluşunuzun olaylarını almak için yeni bir Datadog uç noktası yapılandırın.", "httpDestTabSettings": "Ayarlar", "httpDestTabHeaders": "Başlıklar", "httpDestTabBody": "Gövde", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "Hedef başarıyla güncellendi", "httpDestCreatedSuccess": "Hedef başarıyla oluşturuldu", "httpDestUpdateFailed": "Hedef güncellenemedi", - "httpDestCreateFailed": "Hedef oluşturulamadı" + "httpDestCreateFailed": "Hedef oluşturulamadı", + "followRedirects": "Yönlendirmeleri Takip Et", + "followRedirectsDescription": "İstekler için HTTP yönlendirmelerini otomatik olarak takip edin.", + "alertingErrorWebhookUrl": "Webhook için geçerli bir URL girin lütfen.", + "healthCheckStrategyHttp": "Bağlantıyı doğrular ve HTTP yanıt durumunu kontrol eder.", + "healthCheckStrategyTcp": "Yanıtı denetlemeden sadece TCP bağlantısını doğrular.", + "healthCheckStrategySnmp": "Ağ aygıtlarının ve altyapısının sağlığını kontrol etmek için bir SNMP alma isteği yapar.", + "healthCheckStrategyIcmp": "Bir kaynağın erişilebilir ve yanıt verebilir olup olmadığını kontrol etmek için ICMP yankı isteklerini (ping) kullanır.", + "healthCheckTabStrategy": "Strateji", + "healthCheckTabConnection": "Bağlantı", + "healthCheckTabAdvanced": "Gelişmiş", + "healthCheckStrategyNotAvailable": "Bu strateji kullanılamıyor. Bu özelliği etkinleştirmek için lütfen satış ekibiyle iletişime geçin.", + "uptime30d": "Çalışma Süresi (30g)", + "idpAddActionCreateNew": "Yeni kimlik sağlayıcı oluştur", + "idpAddActionImportFromOrg": "Başka bir kuruluştan içe aktar", + "idpImportDialogTitle": "Kimlik Sağlayıcı İçe Aktar", + "idpImportDialogDescription": "Bir kuruluştan yönetici olduğunuz bir kimlik sağlayıcı seçin. Bu kuruluşla ilişkilendirilecektir.", + "idpImportSearchPlaceholder": "Kuruluş veya sağlayıcı adına göre ara...", + "idpImportEmpty": "Hiçbir kimlik sağlayıcı bulunamadı.", + "idpImportedDescription": "Kimlik sağlayıcı başarıyla içe aktarıldı.", + "idpDeleteGlobalQuestion": "Bu kimlik sağlayıcıyı kalıcı olarak silmek istediğinizden emin misiniz?", + "idpDeleteGlobalDescription": "Bu, kimlik sağlayıcıyı ilişkilendirildiği tüm kuruluşlardan kalıcı olarak silecektir.", + "idpUnassociateTitle": "Kimlik Sağlayıcının İlişkisini Kes", + "idpUnassociateQuestion": "Bu kimlik sağlayıcının bu kuruluştan ilişiğini kesmek istediğinizden emin misiniz?", + "idpUnassociateDescription": "Bu kimlik sağlayıcı ile ilişkilendirilen tüm kullanıcılar bu kuruluştan kaldırılacaktır, ancak kimlik sağlayıcı diğer ilişkilendirilen kuruluşlar için var olmaya devam edecektir.", + "idpUnassociateConfirm": "Kimlik Sağlayıcının İlişkisinin Kesilmesini Onayla", + "idpUnassociateWarning": "Bu işlem bu kuruluş için geri alınamaz.", + "idpUnassociatedDescription": "Kimlik sağlayıcı bu kuruluştan başarıyla ayrıldı", + "idpUnassociateMenu": "İlişkiyi Kes", + "idpDeleteAllOrgsMenu": "Sil", + "publicIpEndpoint": "Uç Nokta", + "lastTriggeredAt": "Son Tetikleyici", + "reject": "Reddet", + "uptimeDaysAgo": "{count} gün önce", + "uptimeToday": "Bugün", + "uptimeNoDataAvailable": "Veri yok", + "uptimeSuffix": "çalışma süresi", + "uptimeDowntimeSuffix": "çalışma dışı", + "uptimeTooltipUptimeLabel": "Çalışma süresi", + "uptimeTooltipDowntimeLabel": "Çalışma dışı", + "uptimeOngoing": "devam eden", + "uptimeNoMonitoringData": "İzleme verisi yok", + "uptimeNoData": "Veri yok", + "uptimeMiniBarDown": "Kapalı", + "uptimeSectionTitle": "Çalışma Süresi", + "uptimeSectionDescription": "Son {days} gün boyunca kullanılabilirlik", + "uptimeAddAlert": "Uyarı Ekle", + "uptimeViewAlerts": "Uyarıları Görüntüle", + "uptimeCreateEmailAlert": "E-posta Uyarısı Oluştur", + "uptimeAlertDescriptionSite": "Bu site çevrimdışıyken veya yeniden çevrimiçi olduğunda e-posta ile bildirim alın.", + "uptimeAlertDescriptionResource": "Bu kaynak çevrimdışıyken veya yeniden çevrimiçi olduğunda e-posta ile bildirim alın.", + "uptimeAlertNamePlaceholder": "Uyarı adı", + "uptimeAdditionalEmails": "Ek E-postalar", + "uptimeCreateAlert": "Uyarı Oluştur", + "uptimeAlertNoRecipients": "Alıcı yok", + "uptimeAlertNoRecipientsDescription": "Lütfen en az bir kullanıcı, rol veya e-posta ekleyin.", + "uptimeAlertCreated": "Uyarı oluşturuldu", + "uptimeAlertCreatedDescription": "Durum değiştiğinde haberdar edileceksiniz.", + "uptimeAlertCreateFailed": "Uyarı oluşturulamadı", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Anahtar", + "webhookHeaderValuePlaceholder": "Değer", + "alertLabel": "Uyarı", + "domainPickerWildcardSubdomainNotAllowed": "Genel alt alanlara izin verilmiyor.", + "domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.", + "domainPickerWildcardCertWarningLink": "Daha fazla bilgi", + "health": "Sağlık", + "domainPendingErrorTitle": "Doğrulama Sorunu" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 0d21cda66..6b7531b18 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "联系销售以启用此功能。", + "contactSalesBookDemo": "预订演示", + "contactSalesOr": "或", + "contactSalesContactUs": "联系我们", "setupCreate": "创建组织、站点和资源", "headerAuthCompatibilityInfo": "启用此功能以在身份验证令牌缺失时强制返回401未授权响应。对于不在没有服务器挑战的情况下不发送凭证的浏览器或特定HTTP库,这是必需的。", "headerAuthCompatibility": "扩展兼容性", @@ -19,6 +23,14 @@ "componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。", "dismiss": "忽略", "subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。", + "trialBannerMessage": "您的试用将在 {countdown} 到期。升级以保持访问。", + "trialBannerExpired": "您的试用已到期。立即升级以恢复访问。", + "trialActive": "免费试用中", + "trialExpired": "试用到期", + "trialHasEnded": "您的试用已结束。", + "trialDaysRemaining": "{count, plural, one {# day remaining} other {# days remaining}}", + "trialDaysLeftShort": "试用期剩余 {days} 天", + "trialGoToBilling": "转到账单页面", "subscriptionViolationViewBilling": "查看计费", "componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。", "componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。", @@ -81,6 +93,8 @@ "siteConfirmCopy": "我已经复制了配置信息", "searchSitesProgress": "搜索站点...", "siteAdd": "添加站点", + "sitesTableViewPublicResources": "查看公共资源", + "sitesTableViewPrivateResources": "查看私有资源", "siteInstallNewt": "安装 Newt", "siteInstallNewtDescription": "在您的系统中运行 Newt", "WgConfiguration": "WireGuard 配置", @@ -98,6 +112,21 @@ "siteUpdatedDescription": "网站已更新。", "siteGeneralDescription": "配置此站点的常规设置", "siteSettingDescription": "配置站点设置", + "siteResourcesTab": "资源", + "siteResourcesNoneOnSite": "此站点尚无公开或私人资源。", + "siteResourcesSectionPublic": "公共资源", + "siteResourcesSectionPrivate": "私有资源", + "siteResourcesSectionPublicDescription": "通过域名或端口公开的资源。", + "siteResourcesSectionPrivateDescription": "通过站点可在您的私有网络上访问的资源。", + "siteResourcesViewAllPublic": "查看所有资源", + "siteResourcesViewAllPrivate": "查看所有资源", + "siteResourcesDialogDescription": "此站点的公开和私有资源概览。", + "siteResourcesShowMore": "显示更多", + "siteResourcesPermissionDenied": "您无权列出这些资源。", + "siteResourcesEmptyPublic": "尚无针对该站点的公共资源。", + "siteResourcesEmptyPrivate": "尚无与此站点关联的私有资源。", + "siteResourcesHowToAccess": "如何访问", + "siteResourcesTargetsOnSite": "此站点上的目标", "siteSetting": "{siteName} 设置", "siteNewtTunnel": "新站点 (推荐)", "siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。", @@ -267,8 +296,11 @@ "orgMissing": "缺少组织 ID", "orgMissingMessage": "没有组织ID,无法重新生成邀请。", "accessUsersManage": "管理用户", + "accessUserManage": "管理用户", "accessUsersDescription": "邀请和管理访问此组织的用户", "accessUsersSearch": "搜索用户...", + "accessUsersRoleFilterCount": "{count, plural, one {# role} other {# roles}}", + "accessUsersRoleFilterClear": "清除角色过滤器", "accessUserCreate": "创建用户", "accessUserRemove": "删除用户", "username": "用户名", @@ -405,6 +437,10 @@ "licenseErrorKeyActivate": "激活许可证密钥失败", "licenseErrorKeyActivateDescription": "激活许可证密钥时出错。", "licenseAbout": "关于许可协议", + "licenseBannerTitle": "启用您的企业许可证", + "licenseBannerDescription": "为您自行托管的Pangolin实例解锁企业功能。购买许可证密钥以激活高级功能,然后在下方添加。", + "licenseBannerGetLicense": "获取许可证", + "licenseBannerViewDocs": "查看文档", "communityEdition": "社区版", "licenseAboutDescription": "这是针对商业环境中使用Pangolin的商业和企业用户。 如果您正在使用 Pangolin 供个人使用,您可以忽略此部分。", "licenseKeyActivated": "授权密钥已激活", @@ -727,6 +763,7 @@ "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "密钥", + "newtVersion": "版本", "architecture": "架构", "sites": "站点", "siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。", @@ -894,6 +931,7 @@ "idpDisplayName": "此身份提供商的显示名称", "idpAutoProvisionUsers": "自动提供用户", "idpAutoProvisionUsersDescription": "如果启用,用户将在首次登录时自动在系统中创建,并且能够映射用户到角色和组织。", + "idpAutoProvisionConfigureAfterCreate": "您可以在创建身份提供者后配置自动配置设置。", "licenseBadge": "EE", "idpType": "提供者类型", "idpTypeDescription": "选择您想要配置的身份提供者类型", @@ -945,7 +983,7 @@ "defaultMappingsRole": "默认角色映射", "defaultMappingsRoleDescription": "此表达式的结果必须返回组织中定义的角色名称作为字符串。", "defaultMappingsOrg": "默认组织映射", - "defaultMappingsOrgDescription": "此表达式必须返回 组织ID 或 true 才能允许用户访问组织。", + "defaultMappingsOrgDescription": "设置时,此表达式必须返回组织ID或true才能让用户访问该组织。如果未设置,定义角色映射就足够了:只要在组织内可以为用户找出有效角色映射,用户就被允许进入。", "defaultMappingsSubmit": "保存默认映射", "orgPoliciesEdit": "编辑组织策略", "org": "组织", @@ -1252,6 +1290,7 @@ "actionViewLogs": "查看日志", "noneSelected": "未选择", "orgNotFound2": "未找到组织。", + "search": "搜索…", "searchPlaceholder": "搜索...", "emptySearchOptions": "未找到选项", "create": "创建", @@ -1336,10 +1375,167 @@ "sidebarGeneral": "管理", "sidebarLogAndAnalytics": "日志与分析", "sidebarBluePrints": "蓝图", + "sidebarAlerting": "告警", + "sidebarHealthChecks": "健康检查", "sidebarOrganization": "组织", "sidebarManagement": "管理", "sidebarBillingAndLicenses": "帐单和许可证", "sidebarLogsAnalytics": "分析", + "alertingTitle": "告警", + "alertingDescription": "定义通知的来源、触发器和操作", + "alertingRules": "告警规则", + "alertingSearchRules": "搜索规则…", + "alertingAddRule": "创建规则", + "alertingColumnSource": "来源", + "alertingColumnTrigger": "触发", + "alertingColumnActions": "操作", + "alertingColumnEnabled": "已启用", + "alertingDeleteQuestion": "请确认您要删除此告警规则。", + "alertingDeleteRule": "删除告警规则", + "alertingRuleDeleted": "告警规则已删除", + "alertingRuleSaved": "告警规则已保存", + "alertingRuleSavedCreatedDescription": "您的新告警规则已创建。您可以在此页面继续编辑它。", + "alertingRuleSavedUpdatedDescription": "对此告警规则的更改已保存。", + "alertingEditRule": "编辑告警规则", + "alertingCreateRule": "创建告警规则", + "alertingRuleCredenzaDescription": "选择要监视的内容、何时触发以及如何通知", + "alertingRuleNamePlaceholder": "生产站点故障", + "alertingRuleEnabled": "规则已启用", + "alertingSectionSource": "来源", + "alertingSourceType": "来源类型", + "alertingSourceSite": "站点", + "alertingSourceHealthCheck": "健康检查", + "alertingPickSites": "站点", + "alertingPickHealthChecks": "健康检查", + "alertingPickResources": "资源", + "alertingAllSites": "所有站点", + "alertingAllSitesDescription": "任何站点的告警触发", + "alertingSpecificSites": "特定站点", + "alertingSpecificSitesDescription": "选择要监视的特定站点", + "alertingAllHealthChecks": "所有健康检查", + "alertingAllHealthChecksDescription": "任何健康检查的告警触发", + "alertingSpecificHealthChecks": "特定健康检查", + "alertingSpecificHealthChecksDescription": "选择要监视的特定健康检查", + "alertingAllResources": "所有资源", + "alertingAllResourcesDescription": "任何资源的告警触发", + "alertingSpecificResources": "特定资源", + "alertingSpecificResourcesDescription": "选择要监视的特定资源", + "alertingSelectResources": "选择资源…", + "alertingResourcesSelected": "{count} 个资源已选择", + "alertingResourcesEmpty": "在前 10 个结果中没有带目标的资源。", + "alertingSectionTrigger": "触发", + "alertingTrigger": "何时告警", + "alertingTriggerSiteOnline": "站点在线", + "alertingTriggerSiteOffline": "站点离线", + "alertingTriggerSiteToggle": "站点状态变更", + "alertingTriggerHcHealthy": "健康检查正常", + "alertingTriggerHcUnhealthy": "健康检查不正常", + "alertingTriggerHcToggle": "健康检查状态变更", + "alertingTriggerResourceHealthy": "资源正常", + "alertingTriggerResourceUnhealthy": "资源不正常", + "alertingTriggerResourceDegraded": "资源降级", + "alertingSearchHealthChecks": "搜索健康检查…", + "alertingHealthChecksEmpty": "无可用健康检查。", + "alertingTriggerResourceToggle": "资源状态变更", + "alertingSourceResource": "资源", + "alertingSectionActions": "操作", + "alertingAddAction": "新增操作", + "alertingActionNotify": "电子邮件", + "alertingActionNotifyDescription": "向用户或角色发送电子邮件通知", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "发送 HTTP 请求到自定义终端", + "alertingExternalIntegration": "外部集成", + "alertingExternalPagerDutyDescription": "将告警发送给 PagerDuty 以进行事件管理", + "alertingExternalOpsgenieDescription": "将告警路由到 Opsgenie 进行电话值班管理", + "alertingExternalServiceNowDescription": "从告警事件创建 ServiceNow 事件", + "alertingExternalIncidentIoDescription": "从告警事件触发 Incident.io 工作流程", + "alertingActionType": "操作类型", + "alertingNotifyUsers": "用户", + "alertingNotifyRoles": "角色", + "alertingNotifyEmails": "电子邮件地址", + "alertingEmailPlaceholder": "添加电子邮件并按回车键", + "alertingWebhookMethod": "HTTP 方法", + "alertingWebhookSecret": "签名密钥(可选)", + "alertingWebhookSecretPlaceholder": "HMAC 密钥", + "alertingWebhookHeaders": "标头", + "alertingAddHeader": "添加标头", + "alertingSelectSites": "选择站点…", + "alertingSitesSelected": "{count} 个站点已选择", + "alertingSelectHealthChecks": "选择健康检查…", + "alertingHealthChecksSelected": "{count} 个健康检查已选择", + "alertingNoHealthChecks": "没有启用健康检查的目标", + "alertingHealthCheckStub": "健康检查来源选择尚未配置 - 你仍然可以配置触发器和操作。", + "alertingSelectUsers": "选择用户…", + "alertingUsersSelected": "{count} 个用户已选择", + "alertingSelectRoles": "选择角色…", + "alertingRolesSelected": "{count} 个角色已选择", + "alertingSummarySites": "站点 ({count})", + "alertingSummaryAllSites": "所有站点", + "alertingSummaryHealthChecks": "健康检查 ({count})", + "alertingSummaryAllHealthChecks": "所有健康检查", + "alertingSummaryResources": "资源 ({count})", + "alertingSummaryAllResources": "所有资源", + "alertingErrorNameRequired": "输入名称", + "alertingErrorActionsMin": "添加至少一个操作", + "alertingErrorPickSites": "至少选择一个站点", + "alertingErrorPickHealthChecks": "至少选择一个健康检查", + "alertingErrorPickResources": "至少选择一个资源", + "alertingErrorTriggerSite": "选择站点触发器", + "alertingErrorTriggerHealth": "选择健康检查触发器", + "alertingErrorTriggerResource": "选择资源触发器", + "alertingErrorNotifyRecipients": "选择用户、角色或至少一个电子邮件", + "alertingConfigureSource": "配置来源", + "alertingConfigureTrigger": "配置触发器", + "alertingConfigureActions": "配置操作", + "alertingBackToRules": "返回规则", + "alertingRuleCooldown": "冷却时间(秒)", + "alertingRuleCooldownDescription": "相同规则间隔重复告警的最小时间。设置为 0 固定触发。", + "alertingDraftBadge": "草稿 - 保存以存储此规则", + "alertingSidebarHint": "点击画布上的步骤在此处编辑。", + "alertingGraphCanvasTitle": "规则流程", + "alertingGraphCanvasDescription": "源、触发器和操作的视觉概况。选择一个节点,在面板上进行编辑。", + "alertingNodeNotConfigured": "尚未配置", + "alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}", + "alertingNodeRoleSource": "来源", + "alertingNodeRoleTrigger": "触发", + "alertingNodeRoleAction": "行为", + "alertingTabRules": "告警规则", + "alertingTabHealthChecks": "健康检查", + "alertingRulesBannerTitle": "获取通知", + "alertingRulesBannerDescription": "每条规则都连接要监视的对象(站点、健康检查或资源),触发时间(例如离线或不健康),以及如何通过电子邮件、Webhooks 或集成将通知发送给团队。使用此列表创建、启用和管理这些规则。", + "alertingHealthChecksBannerTitle": "监视健康和资源", + "alertingHealthChecksBannerDescription": "健康检查是您一次定义的 HTTP 或 TCP 监控。然后可以将它们用作告警规则中的来源,以便目标变得正常或不正常时得到通知。资源上的健康检查也会出现在此处。", + "standaloneHcTableTitle": "健康检查", + "standaloneHcSearchPlaceholder": "搜索健康检查…", + "standaloneHcAddButton": "创建健康检查", + "standaloneHcCreateTitle": "创建健康检查", + "standaloneHcEditTitle": "编辑健康检查", + "standaloneHcDescription": "配置 HTTP 或 TCP 健康检查以用于告警规则。", + "standaloneHcNameLabel": "名称", + "standaloneHcNamePlaceholder": "我的 HTTP 监视器", + "standaloneHcDeleteTitle": "删除健康检查", + "standaloneHcDeleteQuestion": "请确认您要删除此健康检查。", + "standaloneHcDeleted": "健康检查已删除", + "standaloneHcSaved": "健康检查已保存", + "standaloneHcColumnHealth": "健康", + "standaloneHcColumnMode": "模式", + "standaloneHcColumnTarget": "目标", + "standaloneHcHealthStateHealthy": "健康", + "standaloneHcHealthStateUnhealthy": "不健康", + "standaloneHcHealthStateUnknown": "未知", + "standaloneHcFilterAnySite": "所有站点", + "standaloneHcFilterAnyResource": "所有资源", + "standaloneHcFilterMode": "模式", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "健康", + "standaloneHcFilterEnabled": "已启用", + "standaloneHcFilterEnabledOn": "已启用", + "standaloneHcFilterEnabledOff": "已禁用", + "standaloneHcFilterSiteIdFallback": "站点 {id}", + "standaloneHcFilterResourceIdFallback": "资源 {id}", "blueprints": "蓝图", "blueprintsDescription": "应用声明配置并查看先前运行的", "blueprintAdd": "添加蓝图", @@ -1401,7 +1597,8 @@ "initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", "createAdminAccount": "创建管理员帐户", "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", - "certificateStatus": "证书状态", + "certificateStatus": "证书", + "certificateStatusAutoRefreshHint": "状态自动刷新。", "loading": "加载中", "loadingAnalytics": "加载分析", "restart": "重启", @@ -1470,6 +1667,7 @@ "pangolinUpdateAvailableReleaseNotes": "查看发布说明", "newtUpdateAvailable": "更新可用", "newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。", + "pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。请更新到最新版本以获得最佳体验。", "domainPickerEnterDomain": "域名", "domainPickerPlaceholder": "example.com", "domainPickerDescription": "输入资源的完整域名以查看可用选项。", @@ -1709,6 +1907,7 @@ "configureHealthCheck": "配置健康检查", "configureHealthCheckDescription": "为 {target} 设置健康监控", "enableHealthChecks": "启用健康检查", + "healthCheckDisabledStateDescription": "禁用后,站点不会进行健康检查,状态将被视为未知。", "enableHealthChecksDescription": "监视此目标的健康状况。如果需要,您可以监视一个不同的终点。", "healthScheme": "方法", "healthSelectScheme": "选择方法", @@ -1758,9 +1957,20 @@ "healthCheckIntervalMin": "检查间隔必须至少为 5 秒", "healthCheckTimeoutMin": "超时必须至少为 1 秒", "healthCheckRetryMin": "重试次数必须至少为 1 次", + "healthCheckMode": "检查模式", + "healthCheckStrategy": "策略", + "healthCheckModeDescription": "TCP 模式仅验证连接性。HTTP 模式验证 HTTP 响应。", + "healthyThreshold": "正常阈值", + "healthyThresholdDescription": "标记为正常之前所需的连续成功次数。", + "unhealthyThreshold": "不正常阈值", + "unhealthyThresholdDescription": "标记为不正常之前所需的连续失败次数。", + "healthCheckHealthyThresholdMin": "健康阈值至少为 1", + "healthCheckUnhealthyThresholdMin": "不健康阈值至少为 1", "httpMethod": "HTTP 方法", "selectHttpMethod": "选择 HTTP 方法", "domainPickerSubdomainLabel": "子域名", + "domainPickerWildcard": "通配符", + "domainPickerWildcardPaidOnly": "通配符子域是付费功能。请升级以使用此功能。", "domainPickerBaseDomainLabel": "根域名", "domainPickerSearchDomains": "搜索域名...", "domainPickerNoDomainsFound": "未找到域名", @@ -1786,12 +1996,12 @@ "resourcesTableAliasAddressInfo": "此地址是组织实用子网的一部分。它用来使用内部DNS解析来解析别名记录。", "resourcesTableClients": "客户端", "resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。", - "resourcesTableNoTargets": "没有目标", "resourcesTableHealthy": "健康的", "resourcesTableDegraded": "降级", - "resourcesTableOffline": "离线的", + "resourcesTableUnhealthy": "不健康", "resourcesTableUnknown": "未知的", "resourcesTableNotMonitored": "未监视的", + "resourcesTableNoTargets": "无目标", "editInternalResourceDialogEditClientResource": "编辑私有资源", "editInternalResourceDialogUpdateResourceProperties": "更新{resourceName}的资源配置和访问控制。", "editInternalResourceDialogResourceProperties": "资源属性", @@ -1817,6 +2027,11 @@ "editInternalResourceDialogModePort": "端口", "editInternalResourceDialogModeHost": "主机", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogScheme": "方案", + "editInternalResourceDialogEnableSsl": "启用 SSL", + "editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。", "editInternalResourceDialogDestination": "目标", "editInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。", "editInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。", @@ -1832,6 +2047,7 @@ "createInternalResourceDialogName": "名称", "createInternalResourceDialogSite": "站点", "selectSite": "选择站点...", + "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "noSitesFound": "未找到站点。", "createInternalResourceDialogProtocol": "协议", "createInternalResourceDialogTcp": "TCP", @@ -1860,11 +2076,19 @@ "createInternalResourceDialogModePort": "端口", "createInternalResourceDialogModeHost": "主机", "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "scheme": "方案", + "createInternalResourceDialogScheme": "方案", + "createInternalResourceDialogEnableSsl": "启用 SSL", + "createInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。", "createInternalResourceDialogDestination": "目标", "createInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。", "createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。", + "internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案", + "internalResourceHttpPortRequired": "HTTP 资源需要目的端口", "siteConfiguration": "配置", "siteAcceptClientConnections": "接受客户端连接", "siteAcceptClientConnectionsDescription": "允许用户设备和客户端访问此站点上的资源。这可以稍后更改。", @@ -1989,7 +2213,7 @@ "description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器", "introTitle": "托管自托管的潘戈林公司", "introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。", - "introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 — — 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:", + "introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 - - 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:", "benefitSimplerOperations": { "title": "简单的操作", "description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。" @@ -2022,7 +2246,7 @@ }, "internationaldomaindetected": "检测到国际域", "willbestoredas": "储存为:", - "roleMappingDescription": "确定当用户启用自动配送时如何分配他们的角色。", + "roleMappingDescription": "确定当用户使用此身份提供者登陆时如何分配角色。", "selectRole": "选择角色", "roleMappingExpression": "表达式", "selectRolePlaceholder": "选择角色", @@ -2114,9 +2338,11 @@ "selectDomainForOrgAuthPage": "选择组织认证页面的域", "domainPickerProvidedDomain": "提供的域", "domainPickerFreeProvidedDomain": "免费提供的域", + "domainPickerFreeDomainsPaidFeature": "提供的域名是付费功能。订阅即可将域名包含在您的计划中-无需自带域名。", "domainPickerVerified": "已验证", "domainPickerUnverified": "未验证", - "domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。", + "domainPickerManual": "手动", + "domainPickerInvalidSubdomainStructure": "保存时将清除无效字符。", "domainPickerError": "错误", "domainPickerErrorLoadDomains": "加载组织域名失败", "domainPickerErrorCheckAvailability": "检查域可用性失败", @@ -2129,7 +2355,7 @@ "orgAuthChooseIdpDescription": "选择您的身份提供商以继续", "orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。", "orgAuthSignInWithPangolin": "使用 Pangolin 登录", - "orgAuthSignInToOrg": "登录到组织", + "orgAuthSignInToOrg": "组织身份提供商 (SSO)", "orgAuthSelectOrgTitle": "组织登录", "orgAuthSelectOrgDescription": "输入您的组织ID以继续", "orgAuthOrgIdPlaceholder": "您的组织", @@ -2290,7 +2516,7 @@ "alerts": { "commercialUseDisclosure": { "title": "使用情况披露", - "description": "选择能准确反映您预定用途的许可等级。 个人许可证允许对个人、非商业性或小型商业活动免费使用软件,年收入毛额不到100 000美元。 超出这些限度的任何用途,包括在企业、组织内的用途。 或其他创收环境——需要有效的企业许可证和支付适用的许可证费用。 所有用户,不论是个人还是企业,都必须遵守寄养商业许可证条款。" + "description": "选择能准确反映您预定用途的许可等级。 个人许可证允许对个人、非商业性或小型商业活动免费使用软件,年收入毛额不到100 000美元。 超出这些限度的任何用途,包括在企业、组织内的用途。 或其他创收环境--需要有效的企业许可证和支付适用的许可证费用。 所有用户,不论是个人还是企业,都必须遵守寄养商业许可证条款。" }, "trialPeriodInformation": { "title": "试用期信息", @@ -2345,7 +2571,7 @@ }, "scale": { "title": "缩放比例", - "description": "企业特征、50个用户、50个站点和优先支持。" + "description": "企业功能,50个用户,100个站点,以及优先支持。" } }, "personalUseOnly": "仅限个人使用(免费许可 - 无需结账)", @@ -2422,6 +2648,7 @@ "validPassword": "有效密码", "validEmail": "Valid email", "validSSO": "Valid SSO", + "connectedClient": "已连接客户端", "resourceBlocked": "资源被阻止", "droppedByRule": "被规则删除", "noSessions": "无会话", @@ -2660,7 +2887,13 @@ "editInternalResourceDialogAddClients": "添加客户端", "editInternalResourceDialogDestinationLabel": "目标", "editInternalResourceDialogDestinationDescription": "指定内部资源的目标地址。根据选择的模式,这可以是主机名、IP地址或CIDR范围。可选的,设置一个内部DNS别名以便于识别。", + "internalResourceFormMultiSiteRoutingHelp": "选择多个站点可以实现高可用性的弹性路由和故障转移。", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "了解更多", "editInternalResourceDialogPortRestrictionsDescription": "限制访问特定的TCP/UDP端口或允许/阻止所有端口。", + "createInternalResourceDialogHttpConfiguration": "HTTP 配置", + "createInternalResourceDialogHttpConfigurationDescription": "选择客户将使用的域名通过 HTTP 或 HTTPS 访问此资源。", + "editInternalResourceDialogHttpConfiguration": "HTTP 配置", + "editInternalResourceDialogHttpConfigurationDescription": "选择客户将使用的域名通过 HTTP 或 HTTPS 访问此资源。", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", @@ -2699,6 +2932,9 @@ "maintenancePageMessagePlaceholder": "我们很快回来! 我们的网站目前正在进行计划中的维护。", "maintenancePageMessageDescription": "详细说明维护的消息", "maintenancePageTimeTitle": "预计完成时间(可选)", + "privateMaintenanceScreenTitle": "私有占位符界面", + "privateMaintenanceScreenMessage": "此域名正在私有资源上使用。请连接 Pangolin 客户端以访问此资源。", + "privateMaintenanceScreenSteps": "连接后,如果您仍然看到此消息,说明您的浏览器的DNS缓存可能仍指向旧地址。解决方法:完全关闭并重新打开此标签页或浏览器,然后返回此页面。", "maintenanceTime": "例如,2小时,11月1日下午5:00", "maintenanceEstimatedTimeDescription": "您期望维护完成的时间", "editDomain": "编辑域名", @@ -2836,6 +3072,14 @@ "httpDestAddTitle": "添加 HTTP 目标", "httpDestEditDescription": "更新此 HTTP 事件流媒体目的地的配置。", "httpDestAddDescription": "配置新的 HTTP 端点来接收您的组织事件。", + "S3DestEditTitle": "编辑目的地", + "S3DestAddTitle": "添加 S3 目的地", + "S3DestEditDescription": "更新此 S3 事件流目的地的配置。", + "S3DestAddDescription": "配置新的 S3 终端以接收您的组织事件。", + "datadogDestEditTitle": "编辑目的地", + "datadogDestAddTitle": "添加 Datadog 目的地", + "datadogDestEditDescription": "更新此 Datadog 事件流目的地的配置。", + "datadogDestAddDescription": "配置新的 Datadog 终端以接收您的组织事件。", "httpDestTabSettings": "设置", "httpDestTabHeaders": "信头", "httpDestTabBody": "正文内容", @@ -2875,7 +3119,7 @@ "httpDestFormatJsonArrayTitle": "JSON 数组", "httpDestFormatJsonArrayDescription": "每批一个请求,实体是一个 JSON 数组。与大多数通用的 Web 钩子和数据兼容。", "httpDestFormatNdjsonTitle": "NDJSON", - "httpDestFormatNdjsonDescription": "每批有一个请求,物体是换行符限制的 JSON ——每行一个对象,不是外部数组。 Sluk HEC、Elastic / OpenSearch和Grafana Loki所需。", + "httpDestFormatNdjsonDescription": "每批有一个请求,物体是换行符限制的 JSON --每行一个对象,不是外部数组。 Sluk HEC、Elastic / OpenSearch和Grafana Loki所需。", "httpDestFormatSingleTitle": "每个请求一个事件", "httpDestFormatSingleDescription": "为每个事件单独发送一个 HTTP POST。仅用于无法处理批量的端点。", "httpDestLogTypesTitle": "日志类型", @@ -2893,5 +3137,72 @@ "httpDestUpdatedSuccess": "目标已成功更新", "httpDestCreatedSuccess": "目标创建成功", "httpDestUpdateFailed": "更新目标失败", - "httpDestCreateFailed": "创建目标失败" + "httpDestCreateFailed": "创建目标失败", + "followRedirects": "遵循重定向", + "followRedirectsDescription": "自动跟踪请求的 HTTP 重定向。", + "alertingErrorWebhookUrl": "请输入有效的 Webhook URL。", + "healthCheckStrategyHttp": "验证连接并检查 HTTP 响应状态。", + "healthCheckStrategyTcp": "只验证 TCP 连接性,不检查响应。", + "healthCheckStrategySnmp": "进行 SNMP get 请求以检查网络设备和基础架构的健康状况。", + "healthCheckStrategyIcmp": "使用 ICMP 回显请求(ping)检查资源是否可达并响应。", + "healthCheckTabStrategy": "策略", + "healthCheckTabConnection": "连接", + "healthCheckTabAdvanced": "高级", + "healthCheckStrategyNotAvailable": "此策略不可用。请联系销售以启用此功能。", + "uptime30d": "正常运行时间(30天)", + "idpAddActionCreateNew": "创建新的身份提供者", + "idpAddActionImportFromOrg": "从另一个组织导入", + "idpImportDialogTitle": "导入身份提供者", + "idpImportDialogDescription": "从您是管理员的组织中选择一个身份提供者。它将关联到本组织。", + "idpImportSearchPlaceholder": "按组织或提供者名称搜索……", + "idpImportEmpty": "未找到身份提供者。", + "idpImportedDescription": "身份提供者已成功导入。", + "idpDeleteGlobalQuestion": "您确定要永久删除此身份提供者吗?", + "idpDeleteGlobalDescription": "这将永久删除与其关联的所有组织中的身份提供者。", + "idpUnassociateTitle": "取消关联身份提供者", + "idpUnassociateQuestion": "您确定要将此身份提供者从此组织中取消关联吗?", + "idpUnassociateDescription": "与此身份提供者关联的所有用户将从该组织中移除,但身份提供者仍会继续存在于关联的其他组织中。", + "idpUnassociateConfirm": "确认取消关联身份提供者", + "idpUnassociateWarning": "此操作无法对该组织撤销。", + "idpUnassociatedDescription": "身份提供者已成功从该组织中取消关联", + "idpUnassociateMenu": "取消关联", + "idpDeleteAllOrgsMenu": "删除", + "publicIpEndpoint": "终端", + "lastTriggeredAt": "最后触发", + "reject": "拒绝", + "uptimeDaysAgo": "{count} 天前", + "uptimeToday": "今天", + "uptimeNoDataAvailable": "暂无数据", + "uptimeSuffix": "正常运行时间", + "uptimeDowntimeSuffix": "停机时间", + "uptimeTooltipUptimeLabel": "正常运行", + "uptimeTooltipDowntimeLabel": "停机", + "uptimeOngoing": "正在进行", + "uptimeNoMonitoringData": "无监控数据", + "uptimeNoData": "无数据", + "uptimeMiniBarDown": "停机", + "uptimeSectionTitle": "正常运行时间", + "uptimeSectionDescription": "过去 {days} 天的可用性", + "uptimeAddAlert": "添加警报", + "uptimeViewAlerts": "查看警报", + "uptimeCreateEmailAlert": "创建电子邮件警报", + "uptimeAlertDescriptionSite": "当此站点下线或恢复上线时,将通过电子邮件通知您。", + "uptimeAlertDescriptionResource": "当此资源下线或恢复上线时,将通过电子邮件通知您。", + "uptimeAlertNamePlaceholder": "警报名称", + "uptimeAdditionalEmails": "附加电子邮件", + "uptimeCreateAlert": "创建警报", + "uptimeAlertNoRecipients": "无收件人", + "uptimeAlertNoRecipientsDescription": "请至少添加一个用户、角色或电子邮件进行通知。", + "uptimeAlertCreated": "警报已创建", + "uptimeAlertCreatedDescription": "状态变化时将通知您。", + "uptimeAlertCreateFailed": "创建警报失败", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "关键字", + "webhookHeaderValuePlaceholder": "值", + "alertLabel": "警报", + "domainPickerWildcardSubdomainNotAllowed": "不允许使用通配符子域。", + "domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。", + "domainPickerWildcardCertWarningLink": "了解更多", + "health": "健康", + "domainPendingErrorTitle": "验证问题" } diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 8b9d05f53..1ef8061e2 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1,2399 +1,2403 @@ { - "setupCreate": "創建您的第一個組織、網站和資源", - "headerAuthCompatibilityInfo": "啟用此選項以在缺少驗證令牌時強制回傳 401 未授權回應。這對於不會在沒有伺服器挑戰的情況下發送憑證的瀏覽器或特定 HTTP 函式庫是必需的。", - "headerAuthCompatibility": "擴展相容性", - "setupNewOrg": "新建組織", - "setupCreateOrg": "創建組織", - "setupCreateResources": "創建資源", - "setupOrgName": "組織名稱", - "orgDisplayName": "這是您組織的顯示名稱。", - "orgId": "組織ID", - "setupIdentifierMessage": "這是您組織的唯一標識符。這是與顯示名稱分開的。", - "setupErrorIdentifier": "組織ID 已被使用。請另選一個。", - "componentsErrorNoMemberCreate": "您目前不是任何組織的成員。創建組織以開始操作。", - "componentsErrorNoMember": "您目前不是任何組織的成員。", - "welcome": "歡迎使用 Pangolin", - "welcomeTo": "歡迎來到", - "componentsCreateOrg": "創建組織", - "componentsMember": "您屬於 {count, plural, =0 {沒有組織} one {一個組織} other {# 個組織}}。", - "componentsInvalidKey": "檢測到無效或過期的許可證金鑰。按照許可證條款操作以繼續使用所有功能。", - "dismiss": "忽略", - "componentsLicenseViolation": "許可證超限:該伺服器使用了 {usedSites} 個站點,已超過授權的 {maxSites} 個。請遵守許可證條款以繼續使用全部功能。", - "componentsSupporterMessage": "感謝您的支持!您現在是 Pangolin 的 {tier} 用戶。", - "inviteErrorNotValid": "很抱歉,但看起來你試圖訪問的邀請尚未被接受或不再有效。", - "inviteErrorUser": "很抱歉,但看起來你想要訪問的邀請不是這個用戶。", - "inviteLoginUser": "請確保您以正確的用戶登錄。", - "inviteErrorNoUser": "很抱歉,但看起來你想訪問的邀請不是一個存在的用戶。", - "inviteCreateUser": "請先創建一個帳戶。", - "goHome": "返回首頁", - "inviteLogInOtherUser": "以不同的用戶登錄", - "createAnAccount": "創建帳戶", - "inviteNotAccepted": "邀請未接受", - "authCreateAccount": "創建一個帳戶以開始", - "authNoAccount": "沒有帳戶?", - "email": "電子郵件地址", - "password": "密碼", - "confirmPassword": "確認密碼", - "createAccount": "創建帳戶", - "viewSettings": "查看設置", - "delete": "刪除", - "name": "名稱", - "online": "在線", - "offline": "離線的", - "site": "站點", - "dataIn": "數據輸入", - "dataOut": "數據輸出", - "connectionType": "連接類型", - "tunnelType": "隧道類型", - "local": "本地的", - "edit": "編輯", - "siteConfirmDelete": "確認刪除站點", - "siteDelete": "刪除站點", - "siteMessageRemove": "一旦移除,站點將無法訪問。與站點相關的所有目標也將被移除。", - "siteQuestionRemove": "您確定要從組織中刪除該站點嗎?", - "siteManageSites": "管理站點", - "siteDescription": "允許通過安全隧道連接到您的網路", - "sitesBannerTitle": "連接任何網路", - "sitesBannerDescription": "站點是與遠端網路的連接,使 Pangolin 能夠為任何地方的使用者提供對公共或私有資源的存取。在任何可以執行二進位檔案或容器的地方安裝站點網路連接器 (Newt) 以建立連接。", - "sitesBannerButtonText": "安裝站點", - "siteCreate": "創建站點", - "siteCreateDescription2": "按照下面的步驟創建和連接一個新站點", - "siteCreateDescription": "創建一個新站點開始連接您的資源", - "close": "關閉", - "siteErrorCreate": "創建站點出錯", - "siteErrorCreateKeyPair": "找不到金鑰對或站點預設值", - "siteErrorCreateDefaults": "未找到站點預設值", - "method": "方法", - "siteMethodDescription": "這是您將如何顯示連接。", - "siteLearnNewt": "學習如何在您的系統上安裝 Newt", - "siteSeeConfigOnce": "您只能看到一次配置。", - "siteLoadWGConfig": "正在載入 WireGuard 配置...", - "siteDocker": "擴展 Docker 部署詳細資訊", - "toggle": "切換", - "dockerCompose": "Docker Compose", - "dockerRun": "Docker Run", - "siteLearnLocal": "本地站點不需要隧道連接,點擊了解更多", - "siteConfirmCopy": "我已經複製了配置資訊", - "searchSitesProgress": "搜索站點...", - "siteAdd": "添加站點", - "siteInstallNewt": "安裝 Newt", - "siteInstallNewtDescription": "在您的系統中運行 Newt", - "WgConfiguration": "WireGuard 配置", - "WgConfigurationDescription": "使用以下配置連接到您的網路", - "operatingSystem": "操作系統", - "commands": "命令", - "recommended": "推薦", - "siteNewtDescription": "為獲得最佳用戶體驗,請使用 Newt。其底層採用 WireGuard 技術,可直接通過 Pangolin 控制台,使用區域網路地址訪問您私有網路中的資源。", - "siteRunsInDocker": "在 Docker 中運行", - "siteRunsInShell": "在 macOS 、 Linux 和 Windows 的 Shell 中運行", - "siteErrorDelete": "刪除站點出錯", - "siteErrorUpdate": "更新站點失敗", - "siteErrorUpdateDescription": "更新站點時出錯。", - "siteUpdated": "站點已更新", - "siteUpdatedDescription": "網站已更新。", - "siteGeneralDescription": "配置此站點的常規設置", - "siteSettingDescription": "配置您網站上的設置", - "siteSetting": "{siteName} 設置", - "siteNewtTunnel": "Newt 隧道 (推薦)", - "siteNewtTunnelDescription": "最簡單的方式來連接到您的網路。不需要任何額外設置。", - "siteWg": "基本 WireGuard", - "siteWgDescription": "使用任何 WireGuard 用戶端來建立隧道。需要手動配置 NAT。", - "siteWgDescriptionSaas": "使用任何 WireGuard 用戶端建立隧道。需要手動配置 NAT。僅適用於自託管節點。", - "siteLocalDescription": "僅限本地資源。不需要隧道。", - "siteLocalDescriptionSaas": "僅本地資源。沒有隧道。僅在遠程節點上可用。", - "siteSeeAll": "查看所有站點", - "siteTunnelDescription": "確定如何連接到您的網站", - "siteNewtCredentials": "Newt 憑證", - "siteNewtCredentialsDescription": "這是 Newt 伺服器的身份驗證憑證", - "remoteNodeCredentialsDescription": "這是遠端節點與伺服器進行驗證的方式", - "siteCredentialsSave": "保存您的憑證", - "siteCredentialsSaveDescription": "您只能看到一次。請確保將其複製並保存到一個安全的地方。", - "siteInfo": "站點資訊", - "status": "狀態", - "shareTitle": "管理共享連結", - "shareDescription": "創建可共享的連結,允許暫時或永久訪問您的資源", - "shareSearch": "搜索共享連結...", - "shareCreate": "創建共享連結", - "shareErrorDelete": "刪除連結失敗", - "shareErrorDeleteMessage": "刪除連結時出錯", - "shareDeleted": "連結已刪除", - "shareDeletedDescription": "連結已刪除", - "shareTokenDescription": "您的訪問令牌可以透過兩種方式傳遞:作為查詢參數或請求頭。 每次驗證訪問請求都必須從用戶端傳遞。", - "accessToken": "訪問令牌", - "usageExamples": "用法範例", - "tokenId": "令牌 ID", - "requestHeades": "請求頭", - "queryParameter": "查詢參數", - "importantNote": "重要提示", - "shareImportantDescription": "出於安全考慮,建議盡可能在使用請求頭傳遞參數,因為查詢參數可能會被瀏覽器歷史記錄或伺服器日誌記錄。", - "token": "令牌", - "shareTokenSecurety": "請妥善保管您的訪問令牌,不要將其暴露在公開訪問的區域或用戶端代碼中。", - "shareErrorFetchResource": "獲取資源失敗", - "shareErrorFetchResourceDescription": "獲取資源時出錯", - "shareErrorCreate": "無法創建共享連結", - "shareErrorCreateDescription": "創建共享連結時出錯", - "shareCreateDescription": "任何具有此連結的人都可以訪問資源", - "shareTitleOptional": "標題 (可選)", - "expireIn": "過期時間", - "neverExpire": "永不過期", - "shareExpireDescription": "過期時間是連結可以使用並提供對資源的訪問時間。 此時間後,連結將不再工作,使用此連結的用戶將失去對資源的訪問。", - "shareSeeOnce": "您只能看到一次此連結。請確保複製它。", - "shareAccessHint": "任何具有此連結的人都可以訪問該資源。小心地分享它。", - "shareTokenUsage": "查看訪問令牌使用情況", - "createLink": "創建連結", - "resourcesNotFound": "找不到資源", - "resourceSearch": "搜索資源", - "openMenu": "打開菜單", - "resource": "資源", - "title": "標題", - "created": "已創建", - "expires": "過期時間", - "never": "永不過期", - "shareErrorSelectResource": "請選擇一個資源", - "proxyResourceTitle": "管理公開資源", - "proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源", - "proxyResourcesBannerTitle": "基於網頁的公開存取", - "proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", - "clientResourceTitle": "管理私有資源", - "clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源", - "privateResourcesBannerTitle": "零信任私有存取", - "privateResourcesBannerDescription": "私有資源使用零信任安全性,確保使用者和機器只能存取您明確授權的資源。連接使用者裝置或機器客戶端以透過安全的虛擬私人網路存取這些資源。", - "resourcesSearch": "搜索資源...", - "resourceAdd": "添加資源", - "resourceErrorDelte": "刪除資源時出錯", - "authentication": "認證", - "protected": "受到保護", - "notProtected": "未受到保護", - "resourceMessageRemove": "一旦刪除,資源將不再可訪問。與該資源相關的所有目標也將被刪除。", - "resourceQuestionRemove": "您確定要從組織中刪除資源嗎?", - "resourceHTTP": "HTTPS 資源", - "resourceHTTPDescription": "使用子域或根域名通過 HTTPS 向您的應用程式提出代理請求。", - "resourceRaw": "TCP/UDP 資源", - "resourceRawDescription": "使用 TCP/UDP 使用埠號向您的應用提出代理請求。", - "resourceCreate": "創建資源", - "resourceCreateDescription": "按照下面的步驟創建新資源", - "resourceSeeAll": "查看所有資源", - "resourceInfo": "資源資訊", - "resourceNameDescription": "這是資源的顯示名稱。", - "siteSelect": "選擇站點", - "siteSearch": "搜索站點", - "siteNotFound": "未找到站點。", - "selectCountry": "選擇國家", - "searchCountries": "搜索國家...", - "noCountryFound": "找不到國家。", - "siteSelectionDescription": "此站點將為目標提供連接。", - "resourceType": "資源類型", - "resourceTypeDescription": "確定如何訪問您的資源", - "resourceHTTPSSettings": "HTTPS 設置", - "resourceHTTPSSettingsDescription": "配置如何通過 HTTPS 訪問您的資源", - "domainType": "域類型", - "subdomain": "子域名", - "baseDomain": "根域名", - "subdomnainDescription": "您的資源可以訪問的子域名。", - "resourceRawSettings": "TCP/UDP 設置", - "resourceRawSettingsDescription": "設定如何透過 TCP/UDP 存取資源", - "protocol": "協議", - "protocolSelect": "選擇協議", - "resourcePortNumber": "埠號", - "resourcePortNumberDescription": "代理請求的外部埠號。", - "cancel": "取消", - "resourceConfig": "配置片段", - "resourceConfigDescription": "複製並黏貼這些配置片段以設置您的 TCP/UDP 資源", - "resourceAddEntrypoints": "Traefik: 添加入口點", - "resourceExposePorts": "Gerbil:在 Docker Compose 中顯示埠", - "resourceLearnRaw": "學習如何配置 TCP/UDP 資源", - "resourceBack": "返回資源", - "resourceGoTo": "轉到資源", - "resourceDelete": "刪除資源", - "resourceDeleteConfirm": "確認刪除資源", - "visibility": "可見性", - "enabled": "已啟用", - "disabled": "已禁用", - "general": "概覽", - "generalSettings": "常規設置", - "proxy": "代理伺服器", - "internal": "內部設置", - "rules": "規則", - "resourceSettingDescription": "配置您資源上的設置", - "resourceSetting": "{resourceName} 設置", - "alwaysAllow": "一律允許", - "alwaysDeny": "一律拒絕", - "passToAuth": "傳遞至認證", - "orgSettingsDescription": "配置您組織的一般設定", - "orgGeneralSettings": "組織設置", - "orgGeneralSettingsDescription": "管理您的機構詳細資訊和配置", - "saveGeneralSettings": "保存常規設置", - "saveSettings": "保存設置", - "orgDangerZone": "危險區域", - "orgDangerZoneDescription": "一旦刪除該組織,將無法恢復,請務必確認。", - "orgDelete": "刪除組織", - "orgDeleteConfirm": "確認刪除組織", - "orgMessageRemove": "此操作不可逆,這將刪除所有相關數據。", - "orgMessageConfirm": "要確認,請在下面輸入組織名稱。", - "orgQuestionRemove": "您確定要刪除組織嗎?", - "orgUpdated": "組織已更新", - "orgUpdatedDescription": "組織已更新。", - "orgErrorUpdate": "更新組織失敗", - "orgErrorUpdateMessage": "更新組織時出錯。", - "orgErrorFetch": "獲取組織失敗", - "orgErrorFetchMessage": "列出您的組織時出錯", - "orgErrorDelete": "刪除組織失敗", - "orgErrorDeleteMessage": "刪除組織時出錯。", - "orgDeleted": "組織已刪除", - "orgDeletedMessage": "組織及其數據已被刪除。", - "orgMissing": "缺少組織 ID", - "orgMissingMessage": "沒有組織ID,無法重新生成邀請。", - "accessUsersManage": "管理用戶", - "accessUsersDescription": "邀請用戶並位他們添加角色以管理訪問您的組織", - "accessUsersSearch": "搜索用戶...", - "accessUserCreate": "創建用戶", - "accessUserRemove": "刪除用戶", - "username": "使用者名稱", - "identityProvider": "身份提供商", - "role": "角色", - "nameRequired": "名稱是必填項", - "accessRolesManage": "管理角色", - "accessRolesDescription": "配置角色來管理訪問您的組織", - "accessRolesSearch": "搜索角色...", - "accessRolesAdd": "添加角色", - "accessRoleDelete": "刪除角色", - "description": "描述", - "inviteTitle": "打開邀請", - "inviteDescription": "管理您給其他用戶的邀請", - "inviteSearch": "搜索邀請...", - "minutes": "分鐘", - "hours": "小時", - "days": "天", - "weeks": "周", - "months": "月", - "years": "年", - "day": "{count, plural, other {# 天}}", - "apiKeysTitle": "API 金鑰", - "apiKeysConfirmCopy2": "您必須確認您已複製 API 金鑰。", - "apiKeysErrorCreate": "創建 API 金鑰出錯", - "apiKeysErrorSetPermission": "設置權限出錯", - "apiKeysCreate": "生成 API 金鑰", - "apiKeysCreateDescription": "為您的組織生成一個新的 API 金鑰", - "apiKeysGeneralSettings": "權限", - "apiKeysGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", - "apiKeysList": "您的 API 金鑰", - "apiKeysSave": "保存您的 API 金鑰", - "apiKeysSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全的位置。", - "apiKeysInfo": "您的 API 金鑰是:", - "apiKeysConfirmCopy": "我已複製 API 金鑰", - "generate": "生成", - "done": "完成", - "apiKeysSeeAll": "查看所有 API 金鑰", - "apiKeysPermissionsErrorLoadingActions": "載入 API 金鑰操作時出錯", - "apiKeysPermissionsErrorUpdate": "設置權限出錯", - "apiKeysPermissionsUpdated": "權限已更新", - "apiKeysPermissionsUpdatedDescription": "權限已更新。", - "apiKeysPermissionsGeneralSettings": "權限", - "apiKeysPermissionsGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", - "apiKeysPermissionsSave": "保存權限", - "apiKeysPermissionsTitle": "權限", - "apiKeys": "API 金鑰", - "searchApiKeys": "搜索 API 金鑰...", - "apiKeysAdd": "生成 API 金鑰", - "apiKeysErrorDelete": "刪除 API 金鑰出錯", - "apiKeysErrorDeleteMessage": "刪除 API 金鑰出錯", - "apiKeysQuestionRemove": "您確定要從組織中刪除 API 金鑰嗎?", - "apiKeysMessageRemove": "一旦刪除,此API金鑰將無法被使用。", - "apiKeysDeleteConfirm": "確認刪除 API 金鑰", - "apiKeysDelete": "刪除 API 金鑰", - "apiKeysManage": "管理 API 金鑰", - "apiKeysDescription": "API 金鑰用於認證集成 API", - "apiKeysSettings": "{apiKeyName} 設置", - "userTitle": "管理所有用戶", - "userDescription": "查看和管理系統中的所有用戶", - "userAbount": "關於用戶管理", - "userAbountDescription": "此表格顯示系統中所有根用戶對象。每個用戶可能屬於多個組織。 從組織中刪除用戶不會刪除其根用戶對象 - 他們將保留在系統中。 要從系統中完全刪除用戶,您必須使用此表格中的刪除操作刪除其根用戶對象。", - "userServer": "伺服器用戶", - "userSearch": "搜索伺服器用戶...", - "userErrorDelete": "刪除用戶時出錯", - "userDeleteConfirm": "確認刪除用戶", - "userDeleteServer": "從伺服器刪除用戶", - "userMessageRemove": "該用戶將被從所有組織中刪除並完全從伺服器中刪除。", - "userQuestionRemove": "您確定要從伺服器永久刪除用戶嗎?", - "licenseKey": "許可證金鑰", - "valid": "有效", - "numberOfSites": "站點數量", - "licenseKeySearch": "搜索許可證金鑰...", - "licenseKeyAdd": "添加許可證金鑰", - "type": "類型", - "licenseKeyRequired": "需要許可證金鑰", - "licenseTermsAgree": "您必須同意許可條款", - "licenseErrorKeyLoad": "載入許可證金鑰失敗", - "licenseErrorKeyLoadDescription": "載入許可證金鑰時出錯。", - "licenseErrorKeyDelete": "刪除許可證金鑰失敗", - "licenseErrorKeyDeleteDescription": "刪除許可證金鑰時出錯。", - "licenseKeyDeleted": "許可證金鑰已刪除", - "licenseKeyDeletedDescription": "許可證金鑰已被刪除。", - "licenseErrorKeyActivate": "啟用許可證金鑰失敗", - "licenseErrorKeyActivateDescription": "啟用許可證金鑰時出錯。", - "licenseAbout": "關於許可協議", - "communityEdition": "社區版", - "licenseAboutDescription": "這是針對商業環境中使用Pangolin的商業和企業用戶。 如果您正在使用 Pangolin 供個人使用,您可以忽略此部分。", - "licenseKeyActivated": "授權金鑰已啟用", - "licenseKeyActivatedDescription": "已成功啟用許可證金鑰。", - "licenseErrorKeyRecheck": "重新檢查許可證金鑰失敗", - "licenseErrorKeyRecheckDescription": "重新檢查許可證金鑰時出錯。", - "licenseErrorKeyRechecked": "重新檢查許可證金鑰", - "licenseErrorKeyRecheckedDescription": "已重新檢查所有許可證金鑰", - "licenseActivateKey": "啟用許可證金鑰", - "licenseActivateKeyDescription": "輸入一個許可金鑰來啟用它。", - "licenseActivate": "啟用許可證", - "licenseAgreement": "通過檢查此框,您確認您已經閱讀並同意與您的許可證金鑰相關的許可條款。", - "fossorialLicense": "查看Fossorial Commercial License和訂閱條款", - "licenseMessageRemove": "這將刪除許可證金鑰和它授予的所有相關權限。", - "licenseMessageConfirm": "要確認,請在下面輸入許可證金鑰。", - "licenseQuestionRemove": "您確定要刪除許可證金鑰?", - "licenseKeyDelete": "刪除許可證金鑰", - "licenseKeyDeleteConfirm": "確認刪除許可證金鑰", - "licenseTitle": "管理許可證狀態", - "licenseTitleDescription": "查看和管理系統中的許可證金鑰", - "licenseHost": "主機許可證", - "licenseHostDescription": "管理主機的主許可證金鑰。", - "licensedNot": "未授權", - "hostId": "主機 ID", - "licenseReckeckAll": "重新檢查所有金鑰", - "licenseSiteUsage": "站點使用情況", - "licenseSiteUsageDecsription": "查看使用此許可的站點數量。", - "licenseNoSiteLimit": "使用未經許可主機的站點數量沒有限制。", - "licensePurchase": "購買許可證", - "licensePurchaseSites": "購買更多站點", - "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 個站點", - "licenseSitesUsed": "{count, plural, =0 {# 站點} one {# 站點} other {# 站點}}", - "licensePurchaseDescription": "請選擇您希望 {selectedMode, select, license {直接購買許可證,您可以隨時增加更多站點。} other {為現有許可證購買更多站點}}", - "licenseFee": "許可證費用", - "licensePriceSite": "每個站點的價格", - "total": "總計", - "licenseContinuePayment": "繼續付款", - "pricingPage": "定價頁面", - "pricingPortal": "前往付款頁面", - "licensePricingPage": "關於最新的價格和折扣,請訪問 ", - "invite": "邀請", - "inviteRegenerate": "重新生成邀請", - "inviteRegenerateDescription": "撤銷以前的邀請並創建一個新的邀請", - "inviteRemove": "移除邀請", - "inviteRemoveError": "刪除邀請失敗", - "inviteRemoveErrorDescription": "刪除邀請時出錯。", - "inviteRemoved": "邀請已刪除", - "inviteRemovedDescription": "為 {email} 創建的邀請已刪除", - "inviteQuestionRemove": "您確定要刪除邀請嗎?", - "inviteMessageRemove": "一旦刪除,這個邀請將不再有效。", - "inviteMessageConfirm": "要確認,請在下面輸入邀請的電子郵件地址。", - "inviteQuestionRegenerate": "您確定要重新邀請 {email} 嗎?這將會撤銷掉之前的邀請", - "inviteRemoveConfirm": "確認刪除邀請", - "inviteRegenerated": "重新生成邀請", - "inviteSent": "邀請郵件已成功發送至 {email}。", - "inviteSentEmail": "發送電子郵件通知給用戶", - "inviteGenerate": "已為 {email} 創建新的邀請。", - "inviteDuplicateError": "重複的邀請", - "inviteDuplicateErrorDescription": "此用戶的邀請已存在。", - "inviteRateLimitError": "超出速率限制", - "inviteRateLimitErrorDescription": "您超過了每小時3次再生的限制。請稍後再試。", - "inviteRegenerateError": "重新生成邀請失敗", - "inviteRegenerateErrorDescription": "重新生成邀請時出錯。", - "inviteValidityPeriod": "有效期", - "inviteValidityPeriodSelect": "選擇有效期", - "inviteRegenerateMessage": "邀請已重新生成。用戶必須訪問下面的連結才能接受邀請。", - "inviteRegenerateButton": "重新生成", - "expiresAt": "到期於", - "accessRoleUnknown": "未知角色", - "placeholder": "占位符", - "userErrorOrgRemove": "刪除用戶失敗", - "userErrorOrgRemoveDescription": "刪除用戶時出錯。", - "userOrgRemoved": "用戶已刪除", - "userOrgRemovedDescription": "已將 {email} 從組織中移除。", - "userQuestionOrgRemove": "您確定要從組織中刪除此用戶嗎?", - "userMessageOrgRemove": "一旦刪除,這個用戶將不再能夠訪問組織。 你總是可以稍後重新邀請他們,但他們需要再次接受邀請。", - "userRemoveOrgConfirm": "確認刪除用戶", - "userRemoveOrg": "從組織中刪除用戶", - "users": "用戶", - "accessRoleMember": "成員", - "accessRoleOwner": "所有者", - "userConfirmed": "已確認", - "idpNameInternal": "內部設置", - "emailInvalid": "無效的電子郵件地址", - "inviteValidityDuration": "請選擇持續時間", - "accessRoleSelectPlease": "請選擇一個角色", - "usernameRequired": "必須輸入使用者名稱", - "idpSelectPlease": "請選擇身份提供商", - "idpGenericOidc": "通用的 OAuth2/OIDC 提供商。", - "accessRoleErrorFetch": "獲取角色失敗", - "accessRoleErrorFetchDescription": "獲取角色時出錯", - "idpErrorFetch": "獲取身份提供者失敗", - "idpErrorFetchDescription": "獲取身份提供者時出錯", - "userErrorExists": "用戶已存在", - "userErrorExistsDescription": "此用戶已經是組織成員。", - "inviteError": "邀請用戶失敗", - "inviteErrorDescription": "邀請用戶時出錯", - "userInvited": "用戶邀請", - "userInvitedDescription": "用戶已被成功邀請。", - "userErrorCreate": "創建用戶失敗", - "userErrorCreateDescription": "創建用戶時出錯", - "userCreated": "用戶已創建", - "userCreatedDescription": "用戶已成功創建。", - "userTypeInternal": "內部用戶", - "userTypeInternalDescription": "邀請用戶直接加入您的組織。", - "userTypeExternal": "外部用戶", - "userTypeExternalDescription": "創建一個具有外部身份提供商的用戶。", - "accessUserCreateDescription": "按照下面的步驟創建一個新用戶", - "userSeeAll": "查看所有用戶", - "userTypeTitle": "用戶類型", - "userTypeDescription": "確定如何創建用戶", - "userSettings": "用戶資訊", - "userSettingsDescription": "輸入新用戶的詳細資訊", - "inviteEmailSent": "發送邀請郵件給用戶", - "inviteValid": "有效", - "selectDuration": "選擇持續時間", - "selectResource": "選擇資源", - "filterByResource": "依資源篩選", - "resetFilters": "重設篩選條件", - "totalBlocked": "被 Pangolin 阻擋的請求", - "totalRequests": "總請求數", - "requestsByCountry": "依國家/地區的請求", - "requestsByDay": "依日期的請求", - "blocked": "已阻擋", - "allowed": "已允許", - "topCountries": "熱門國家/地區", - "accessRoleSelect": "選擇角色", - "inviteEmailSentDescription": "一封電子郵件已經發送給用戶,帶有下面的訪問連結。他們必須訪問該連結才能接受邀請。", - "inviteSentDescription": "用戶已被邀請。他們必須訪問下面的連結才能接受邀請。", - "inviteExpiresIn": "邀請將在{days, plural, other {# 天}}後過期。", - "idpTitle": "身份提供商", - "idpSelect": "為外部用戶選擇身份提供商", - "idpNotConfigured": "沒有配置身份提供者。請在創建外部用戶之前配置身份提供者。", - "usernameUniq": "這必須匹配所選身份提供者中存在的唯一使用者名稱。", - "emailOptional": "電子郵件(可選)", - "nameOptional": "名稱(可選)", - "accessControls": "訪問控制", - "userDescription2": "管理此用戶的設置", - "accessRoleErrorAdd": "添加用戶到角色失敗", - "accessRoleErrorAddDescription": "添加用戶到角色時出錯。", - "userSaved": "用戶已保存", - "userSavedDescription": "用戶已更新。", - "autoProvisioned": "自動設置", - "autoProvisionedDescription": "允許此用戶由身份提供商自動管理", - "accessControlsDescription": "管理此用戶在組織中可以訪問和做什麼", - "accessControlsSubmit": "保存訪問控制", - "roles": "角色", - "accessUsersRoles": "管理用戶和角色", - "accessUsersRolesDescription": "邀請用戶並將他們添加到角色以管理訪問您的組織", - "key": "關鍵字", - "createdAt": "創建於", - "proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。", - "proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。", - "proxyEnableSSL": "啟用 SSL", - "proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。", - "target": "目標", - "configureTarget": "配置目標", - "targetErrorFetch": "獲取目標失敗", - "targetErrorFetchDescription": "獲取目標時出錯", - "siteErrorFetch": "獲取資源失敗", - "siteErrorFetchDescription": "獲取資源時出錯", - "targetErrorDuplicate": "重複的目標", - "targetErrorDuplicateDescription": "具有這些設置的目標已存在", - "targetWireGuardErrorInvalidIp": "無效的目標IP", - "targetWireGuardErrorInvalidIpDescription": "目標IP必須在站點子網內", - "targetsUpdated": "目標已更新", - "targetsUpdatedDescription": "目標和設置更新成功", - "targetsErrorUpdate": "更新目標失敗", - "targetsErrorUpdateDescription": "更新目標時出錯", - "targetTlsUpdate": "TLS 設置已更新", - "targetTlsUpdateDescription": "您的 TLS 設置已成功更新", - "targetErrorTlsUpdate": "更新 TLS 設置失敗", - "targetErrorTlsUpdateDescription": "更新 TLS 設置時出錯", - "proxyUpdated": "代理設置已更新", - "proxyUpdatedDescription": "您的代理設置已成功更新", - "proxyErrorUpdate": "更新代理設置失敗", - "proxyErrorUpdateDescription": "更新代理設置時出錯", - "targetAddr": "IP / 域名", - "targetPort": "埠", - "targetProtocol": "協議", - "targetTlsSettings": "安全連接配置", - "targetTlsSettingsDescription": "配置資源的 SSL/TLS 設置", - "targetTlsSettingsAdvanced": "高級TLS設置", - "targetTlsSni": "TLS 伺服器名稱", - "targetTlsSniDescription": "SNI使用的 TLS 伺服器名稱。留空使用預設值。", - "targetTlsSubmit": "保存設置", - "targets": "目標配置", - "targetsDescription": "設置目標來路由流量到您的後端服務", - "targetStickySessions": "啟用置頂會話", - "targetStickySessionsDescription": "將連接保持在同一個後端目標的整個會話中。", - "methodSelect": "選擇方法", - "targetSubmit": "添加目標", - "targetNoOne": "此資源沒有任何目標。添加目標來配置向您後端發送請求的位置。", - "targetNoOneDescription": "在上面添加多個目標將啟用負載平衡。", - "targetsSubmit": "保存目標", - "addTarget": "添加目標", - "targetErrorInvalidIp": "無效的 IP 地址", - "targetErrorInvalidIpDescription": "請輸入有效的IP位址或主機名", - "targetErrorInvalidPort": "無效的埠", - "targetErrorInvalidPortDescription": "請輸入有效的埠號", - "targetErrorNoSite": "沒有選擇站點", - "targetErrorNoSiteDescription": "請選擇目標站點", - "targetCreated": "目標已創建", - "targetCreatedDescription": "目標已成功創建", - "targetErrorCreate": "創建目標失敗", - "targetErrorCreateDescription": "創建目標時出錯", - "tlsServerName": "TLS 伺服器名稱", - "tlsServerNameDescription": "用於 SNI 的 TLS 伺服器名稱", - "save": "保存", - "proxyAdditional": "附加代理設置", - "proxyAdditionalDescription": "配置你的資源如何處理代理設置", - "proxyCustomHeader": "自訂主機 Header", - "proxyCustomHeaderDescription": "代理請求時設置的 Header。留空則使用預設值。", - "proxyAdditionalSubmit": "保存代理設置", - "subnetMaskErrorInvalid": "子網掩碼無效。必須在 0 和 32 之間。", - "ipAddressErrorInvalidFormat": "無效的 IP 地址格式", - "ipAddressErrorInvalidOctet": "無效的 IP 地址", - "path": "路徑", - "matchPath": "匹配路徑", - "ipAddressRange": "IP 範圍", - "rulesErrorFetch": "獲取規則失敗", - "rulesErrorFetchDescription": "獲取規則時出錯", - "rulesErrorDuplicate": "複製規則", - "rulesErrorDuplicateDescription": "帶有這些設置的規則已存在", - "rulesErrorInvalidIpAddressRange": "無效的 CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "請輸入一個有效的 CIDR 值", - "rulesErrorInvalidUrl": "無效的 URL 路徑", - "rulesErrorInvalidUrlDescription": "請輸入一個有效的 URL 路徑值", - "rulesErrorInvalidIpAddress": "無效的 IP", - "rulesErrorInvalidIpAddressDescription": "請輸入一個有效的IP位址", - "rulesErrorUpdate": "更新規則失敗", - "rulesErrorUpdateDescription": "更新規則時出錯", - "rulesUpdated": "啟用規則", - "rulesUpdatedDescription": "規則已更新", - "rulesMatchIpAddressRangeDescription": "以 CIDR 格式輸入地址(如:103.21.244.0/22)", - "rulesMatchIpAddress": "輸入IP位址(例如,103.21.244.12)", - "rulesMatchUrl": "輸入一個 URL 路徑或模式(例如/api/v1/todos 或 /api/v1/*)", - "rulesErrorInvalidPriority": "無效的優先度", - "rulesErrorInvalidPriorityDescription": "請輸入一個有效的優先度", - "rulesErrorDuplicatePriority": "重複的優先度", - "rulesErrorDuplicatePriorityDescription": "請輸入唯一的優先度", - "ruleUpdated": "規則已更新", - "ruleUpdatedDescription": "規則更新成功", - "ruleErrorUpdate": "操作失敗", - "ruleErrorUpdateDescription": "保存過程中發生錯誤", - "rulesPriority": "優先權", - "rulesAction": "行為", - "rulesMatchType": "匹配類型", - "value": "值", - "rulesAbout": "關於規則", - "rulesAboutDescription": "規則使您能夠依據特定條件控制資源訪問權限。您可以創建基於 IP 地址或 URL 路徑的規則,以允許或拒絕訪問。", - "rulesActions": "行動", - "rulesActionAlwaysAllow": "總是允許:繞過所有身份驗證方法", - "rulesActionAlwaysDeny": "總是拒絕:阻止所有請求;無法嘗試驗證", - "rulesActionPassToAuth": "傳遞至認證:允許嘗試身份驗證方法", - "rulesMatchCriteria": "匹配條件", - "rulesMatchCriteriaIpAddress": "匹配一個指定的 IP 地址", - "rulesMatchCriteriaIpAddressRange": "在 CIDR 符號中匹配一系列IP位址", - "rulesMatchCriteriaUrl": "匹配一個 URL 路徑或模式", - "rulesEnable": "啟用規則", - "rulesEnableDescription": "啟用或禁用此資源的規則評估", - "rulesResource": "資源規則配置", - "rulesResourceDescription": "配置規則來控制對您資源的訪問", - "ruleSubmit": "添加規則", - "rulesNoOne": "沒有規則。使用表單添加規則。", - "rulesOrder": "規則按優先順序評定。", - "rulesSubmit": "保存規則", - "resourceErrorCreate": "創建資源時出錯", - "resourceErrorCreateDescription": "創建資源時出錯", - "resourceErrorCreateMessage": "創建資源時發生錯誤:", - "resourceErrorCreateMessageDescription": "發生意外錯誤", - "sitesErrorFetch": "獲取站點出錯", - "sitesErrorFetchDescription": "獲取站點時出錯", - "domainsErrorFetch": "獲取域名出錯", - "domainsErrorFetchDescription": "獲取域時出錯", - "none": "無", - "unknown": "未知", - "resources": "資源", - "resourcesDescription": "資源是您私有網路中運行的應用程式的代理。您可以為私有網路中的任何 HTTP/HTTPS 或 TCP/UDP 服務創建資源。每個資源都必須連接到一個站點,以通過加密的 WireGuard 隧道實現私密且安全的連接。", - "resourcesWireGuardConnect": "採用 WireGuard 提供的加密安全連接", - "resourcesMultipleAuthenticationMethods": "配置多個身份驗證方法", - "resourcesUsersRolesAccess": "基於用戶和角色的訪問控制", - "resourcesErrorUpdate": "切換資源失敗", - "resourcesErrorUpdateDescription": "更新資源時出錯", - "access": "訪問權限", - "shareLink": "{resource} 的分享連結", - "resourceSelect": "選擇資源", - "shareLinks": "分享連結", - "share": "分享連結", - "shareDescription2": "創建資源共享連結。連結提供對資源的臨時或無限制訪問。 當您創建連結時,您可以配置連結的到期時間。", - "shareEasyCreate": "輕鬆創建和分享", - "shareConfigurableExpirationDuration": "可配置的過期時間", - "shareSecureAndRevocable": "安全和可撤銷的", - "nameMin": "名稱長度必須大於 {len} 字元。", - "nameMax": "名稱長度必須小於 {len} 字元。", - "sitesConfirmCopy": "請確認您已經複製了配置。", - "unknownCommand": "未知命令", - "newtErrorFetchReleases": "無法獲取版本資訊: {err}", - "newtErrorFetchLatest": "無法獲取最新版資訊: {err}", - "newtEndpoint": "Newt 端點", - "newtId": "Newt ID", - "newtSecretKey": "Newt 私鑰", - "architecture": "架構", - "sites": "站點", - "siteWgAnyClients": "使用任何 WireGuard 用戶端連接。您必須使用對等IP解決您的內部資源。", - "siteWgCompatibleAllClients": "與所有 WireGuard 用戶端相容", - "siteWgManualConfigurationRequired": "需要手動配置", - "userErrorNotAdminOrOwner": "用戶不是管理員或所有者", - "pangolinSettings": "設置 - Pangolin", - "accessRoleYour": "您的角色:", - "accessRoleSelect2": "選擇角色", - "accessUserSelect": "選擇一個用戶", - "otpEmailEnter": "輸入電子郵件", - "otpEmailEnterDescription": "在輸入欄位輸入後按 Enter 鍵添加電子郵件。", - "otpEmailErrorInvalid": "無效的信箱地址。通配符(*)必須占據整個開頭部分。", - "otpEmailSmtpRequired": "需要先配置 SMTP", - "otpEmailSmtpRequiredDescription": "必須在伺服器上啟用 SMTP 才能使用一次性密碼驗證。", - "otpEmailTitle": "一次性密碼", - "otpEmailTitleDescription": "資源訪問需要基於電子郵件的身份驗證", - "otpEmailWhitelist": "電子郵件白名單", - "otpEmailWhitelistList": "白名單郵件", - "otpEmailWhitelistListDescription": "只有擁有這些電子郵件地址的用戶才能訪問此資源。 他們將被提示輸入一次性密碼發送到他們的電子郵件。 通配符 (*@example.com) 可以用來允許來自一個域名的任何電子郵件地址。", - "otpEmailWhitelistSave": "保存白名單", - "passwordAdd": "添加密碼", - "passwordRemove": "刪除密碼", - "pincodeAdd": "添加 PIN 碼", - "pincodeRemove": "移除 PIN 碼", - "resourceAuthMethods": "身份驗證方法", - "resourceAuthMethodsDescriptions": "允許透過額外的認證方法訪問資源", - "resourceAuthSettingsSave": "保存成功", - "resourceAuthSettingsSaveDescription": "已保存身份驗證設置", - "resourceErrorAuthFetch": "獲取數據失敗", - "resourceErrorAuthFetchDescription": "獲取數據時出錯", - "resourceErrorPasswordRemove": "刪除資源密碼出錯", - "resourceErrorPasswordRemoveDescription": "刪除資源密碼時出錯", - "resourceErrorPasswordSetup": "設置資源密碼出錯", - "resourceErrorPasswordSetupDescription": "設置資源密碼時出錯", - "resourceErrorPincodeRemove": "刪除資源固定碼時出錯", - "resourceErrorPincodeRemoveDescription": "刪除資源PIN碼時出錯", - "resourceErrorPincodeSetup": "設置資源 PIN 碼時出錯", - "resourceErrorPincodeSetupDescription": "設置資源 PIN 碼時發生錯誤", - "resourceErrorUsersRolesSave": "設置角色失敗", - "resourceErrorUsersRolesSaveDescription": "設置角色時出錯", - "resourceErrorWhitelistSave": "保存白名單失敗", - "resourceErrorWhitelistSaveDescription": "保存白名單時出錯", - "resourcePasswordSubmit": "啟用密碼保護", - "resourcePasswordProtection": "密碼保護 {status}", - "resourcePasswordRemove": "已刪除資源密碼", - "resourcePasswordRemoveDescription": "已成功刪除資源密碼", - "resourcePasswordSetup": "設置資源密碼", - "resourcePasswordSetupDescription": "已成功設置資源密碼", - "resourcePasswordSetupTitle": "設置密碼", - "resourcePasswordSetupTitleDescription": "設置密碼來保護此資源", - "resourcePincode": "PIN 碼", - "resourcePincodeSubmit": "啟用 PIN 碼保護", - "resourcePincodeProtection": "PIN 碼保護 {status}", - "resourcePincodeRemove": "資源 PIN 碼已刪除", - "resourcePincodeRemoveDescription": "已成功刪除資源 PIN 碼", - "resourcePincodeSetup": "資源 PIN 碼已設置", - "resourcePincodeSetupDescription": "資源 PIN 碼已成功設置", - "resourcePincodeSetupTitle": "設置 PIN 碼", - "resourcePincodeSetupTitleDescription": "設置 PIN 碼來保護此資源", - "resourceRoleDescription": "管理員總是可以訪問此資源。", - "resourceUsersRoles": "用戶和角色", - "resourceUsersRolesDescription": "配置用戶和角色可以訪問此資源", - "resourceUsersRolesSubmit": "保存用戶和角色", - "resourceWhitelistSave": "保存成功", - "resourceWhitelistSaveDescription": "白名單設置已保存", - "ssoUse": "使用平台 SSO", - "ssoUseDescription": "對於所有啟用此功能的資源,現有用戶只需登錄一次。", - "proxyErrorInvalidPort": "無效的埠號", - "subdomainErrorInvalid": "無效的子域", - "domainErrorFetch": "獲取域名失敗", - "domainErrorFetchDescription": "獲取域名時出錯", - "resourceErrorUpdate": "更新資源失敗", - "resourceErrorUpdateDescription": "更新資源時出錯", - "resourceUpdated": "資源已更新", - "resourceUpdatedDescription": "資源已成功更新", - "resourceErrorTransfer": "轉移資源失敗", - "resourceErrorTransferDescription": "轉移資源時出錯", - "resourceTransferred": "資源已傳輸", - "resourceTransferredDescription": "資源已成功傳輸", - "resourceErrorToggle": "切換資源失敗", - "resourceErrorToggleDescription": "更新資源時出錯", - "resourceVisibilityTitle": "可見性", - "resourceVisibilityTitleDescription": "完全啟用或禁用資源可見性", - "resourceGeneral": "常規設置", - "resourceGeneralDescription": "配置此資源的常規設置", - "resourceEnable": "啟用資源", - "resourceTransfer": "轉移資源", - "resourceTransferDescription": "將此資源轉移到另一個站點", - "resourceTransferSubmit": "轉移資源", - "siteDestination": "目標站點", - "searchSites": "搜索站點", - "countries": "國家/地區", - "accessRoleCreate": "創建角色", - "accessRoleCreateDescription": "創建一個新角色來分組用戶並管理他們的權限。", - "accessRoleCreateSubmit": "創建角色", - "accessRoleCreated": "角色已創建", - "accessRoleCreatedDescription": "角色已成功創建。", - "accessRoleErrorCreate": "創建角色失敗", - "accessRoleErrorCreateDescription": "創建角色時出錯。", - "accessRoleErrorNewRequired": "需要新角色", - "accessRoleErrorRemove": "刪除角色失敗", - "accessRoleErrorRemoveDescription": "刪除角色時出錯。", - "accessRoleName": "角色名稱", - "accessRoleQuestionRemove": "您即將刪除 {name} 角色。 此操作無法撤銷。", - "accessRoleRemove": "刪除角色", - "accessRoleRemoveDescription": "從組織中刪除角色", - "accessRoleRemoveSubmit": "刪除角色", - "accessRoleRemoved": "角色已刪除", - "accessRoleRemovedDescription": "角色已成功刪除。", - "accessRoleRequiredRemove": "刪除此角色之前,請選擇一個新角色來轉移現有成員。", - "manage": "管理", - "sitesNotFound": "未找到站點。", - "pangolinServerAdmin": "伺服器管理員 - Pangolin", - "licenseTierProfessional": "專業許可證", - "licenseTierEnterprise": "企業許可證", - "licenseTierPersonal": "個人許可證", - "licensed": "已授權", - "yes": "是", - "no": "否", - "sitesAdditional": "其他站點", - "licenseKeys": "許可證金鑰", - "sitestCountDecrease": "減少站點數量", - "sitestCountIncrease": "增加站點數量", - "idpManage": "管理身份提供商", - "idpManageDescription": "查看和管理系統中的身份提供商", - "idpDeletedDescription": "身份提供商刪除成功", - "idpOidc": "OAuth2/OIDC", - "idpQuestionRemove": "您確定要永久刪除身份提供者嗎?", - "idpMessageRemove": "這將刪除身份提供者和所有相關的配置。透過此提供者進行身份驗證的用戶將無法登錄。", - "idpMessageConfirm": "要確認,請在下面輸入身份提供者的名稱。", - "idpConfirmDelete": "確認刪除身份提供商", - "idpDelete": "刪除身份提供商", - "idp": "身份提供商", - "idpSearch": "搜索身份提供者...", - "idpAdd": "添加身份提供商", - "idpClientIdRequired": "用戶端 ID 是必需的。", - "idpClientSecretRequired": "用戶端金鑰是必需的。", - "idpErrorAuthUrlInvalid": "身份驗證 URL 必須是有效的 URL。", - "idpErrorTokenUrlInvalid": "令牌 URL 必須是有效的 URL。", - "idpPathRequired": "標識符路徑是必需的。", - "idpScopeRequired": "授權範圍是必需的。", - "idpOidcDescription": "配置 OpenID 連接身份提供商", - "idpCreatedDescription": "身份提供商創建成功", - "idpCreate": "創建身份提供商", - "idpCreateDescription": "配置用戶身份驗證的新身份提供商", - "idpSeeAll": "查看所有身份提供商", - "idpSettingsDescription": "配置身份提供者的基本資訊", - "idpDisplayName": "此身份提供商的顯示名稱", - "idpAutoProvisionUsers": "自動提供用戶", - "idpAutoProvisionUsersDescription": "如果啟用,用戶將在首次登錄時自動在系統中創建,並且能夠映射用戶到角色和組織。", - "licenseBadge": "EE", - "idpType": "提供者類型", - "idpTypeDescription": "選擇您想要配置的身份提供者類型", - "idpOidcConfigure": "OAuth2/OIDC 配置", - "idpOidcConfigureDescription": "配置 OAuth2/OIDC 供應商端點和憑據", - "idpClientId": "用戶端ID", - "idpClientIdDescription": "來自您身份提供商的 OAuth2 用戶端 ID", - "idpClientSecret": "用戶端金鑰", - "idpClientSecretDescription": "來自身份提供商的 OAuth2 用戶端金鑰", - "idpAuthUrl": "授權 URL", - "idpAuthUrlDescription": "OAuth2 授權端點的 URL", - "idpTokenUrl": "令牌 URL", - "idpTokenUrlDescription": "OAuth2 令牌端點的 URL", - "idpOidcConfigureAlert": "重要提示", - "idpOidcConfigureAlertDescription": "創建身份提供方後,您需要在其設置中配置回調 URL。回調 URL 會在創建成功後提供。", - "idpToken": "令牌配置", - "idpTokenDescription": "配置如何從 ID 令牌中提取用戶資訊", - "idpJmespathAbout": "關於 JMESPath", - "idpJmespathAboutDescription": "以下路徑使用 JMESPath 語法從 ID 令牌中提取值。", - "idpJmespathAboutDescriptionLink": "了解更多 JMESPath 資訊", - "idpJmespathLabel": "標識符路徑", - "idpJmespathLabelDescription": "ID 令牌中用戶標識符的路徑", - "idpJmespathEmailPathOptional": "信箱路徑(可選)", - "idpJmespathEmailPathOptionalDescription": "ID 令牌中用戶信箱的路徑", - "idpJmespathNamePathOptional": "使用者名稱路徑(可選)", - "idpJmespathNamePathOptionalDescription": "ID 令牌中使用者名稱的路徑", - "idpOidcConfigureScopes": "作用域(Scopes)", - "idpOidcConfigureScopesDescription": "以空格分隔的 OAuth2 請求作用域列表", - "idpSubmit": "創建身份提供商", - "orgPolicies": "組織策略", - "idpSettings": "{idpName} 設置", - "idpCreateSettingsDescription": "配置身份提供商的設置", - "roleMapping": "角色映射", - "orgMapping": "組織映射", - "orgPoliciesSearch": "搜索組織策略...", - "orgPoliciesAdd": "添加組織策略", - "orgRequired": "組織是必填項", - "error": "錯誤", - "success": "成功", - "orgPolicyAddedDescription": "策略添加成功", - "orgPolicyUpdatedDescription": "策略更新成功", - "orgPolicyDeletedDescription": "已成功刪除策略", - "defaultMappingsUpdatedDescription": "默認映射更新成功", - "orgPoliciesAbout": "關於組織政策", - "orgPoliciesAboutDescription": "組織策略用於根據用戶的 ID 令牌來控制對組織的訪問。 您可以指定 JMESPath 表達式來提取角色和組織資訊從 ID 令牌中提取資訊。", - "orgPoliciesAboutDescriptionLink": "欲了解更多資訊,請參閱文件。", - "defaultMappingsOptional": "默認映射(可選)", - "defaultMappingsOptionalDescription": "當沒有為某個組織定義組織的政策時,使用默認映射。 您可以指定默認角色和組織映射回到這裡。", - "defaultMappingsRole": "默認角色映射", - "defaultMappingsRoleDescription": "此表達式的結果必須返回組織中定義的角色名稱作為字串。", - "defaultMappingsOrg": "默認組織映射", - "defaultMappingsOrgDescription": "此表達式必須返回 組織ID 或 true 才能允許用戶訪問組織。", - "defaultMappingsSubmit": "保存默認映射", - "orgPoliciesEdit": "編輯組織策略", - "org": "組織", - "orgSelect": "選擇組織", - "orgSearch": "搜索", - "orgNotFound": "找不到組織。", - "roleMappingPathOptional": "角色映射路徑(可選)", - "orgMappingPathOptional": "組織映射路徑(可選)", - "orgPolicyUpdate": "更新策略", - "orgPolicyAdd": "添加策略", - "orgPolicyConfig": "配置組織訪問權限", - "idpUpdatedDescription": "身份提供商更新成功", - "redirectUrl": "重定向網址", - "orgIdpRedirectUrls": "重新導向網址", - "redirectUrlAbout": "關於重定向網址", - "redirectUrlAboutDescription": "這是用戶在驗證後將被重定向到的URL。您需要在身份提供商設置中配置此URL。", - "pangolinAuth": "認證 - Pangolin", - "verificationCodeLengthRequirements": "您的驗證碼必須是 8 個字元。", - "errorOccurred": "發生錯誤", - "emailErrorVerify": "驗證電子郵件失敗:", - "emailVerified": "電子郵件驗證成功!重定向您...", - "verificationCodeErrorResend": "無法重新發送驗證碼:", - "verificationCodeResend": "驗證碼已重新發送", - "verificationCodeResendDescription": "我們已將驗證碼重新發送到您的電子郵件地址。請檢查您的收件箱。", - "emailVerify": "驗證電子郵件", - "emailVerifyDescription": "輸入驗證碼發送到您的電子郵件地址。", - "verificationCode": "驗證碼", - "verificationCodeEmailSent": "我們向您的電子郵件地址發送了驗證碼。", - "submit": "提交", - "emailVerifyResendProgress": "正在重新發送...", - "emailVerifyResend": "沒有收到代碼?點擊此處重新發送", - "passwordNotMatch": "密碼不匹配", - "signupError": "註冊時出錯", - "pangolinLogoAlt": "Pangolin 標誌", - "inviteAlready": "看起來您已被邀請!", - "inviteAlreadyDescription": "要接受邀請,您必須登錄或創建一個帳戶。", - "signupQuestion": "已經有一個帳戶?", - "login": "登錄", - "resourceNotFound": "找不到資源", - "resourceNotFoundDescription": "您要訪問的資源不存在。", - "pincodeRequirementsLength": "PIN碼必須是 6 位數字", - "pincodeRequirementsChars": "PIN 必須只包含數字", - "passwordRequirementsLength": "密碼必須至少 1 個字元長", - "passwordRequirementsTitle": "密碼要求:", - "passwordRequirementLength": "至少 8 個字元長", - "passwordRequirementUppercase": "至少一個大寫字母", - "passwordRequirementLowercase": "至少一個小寫字母", - "passwordRequirementNumber": "至少一個數字", - "passwordRequirementSpecial": "至少一個特殊字元", - "passwordRequirementsMet": "✓ 密碼滿足所有要求", - "passwordStrength": "密碼強度", - "passwordStrengthWeak": "弱", - "passwordStrengthMedium": "中", - "passwordStrengthStrong": "強", - "passwordRequirements": "要求:", - "passwordRequirementLengthText": "8+ 個字元", - "passwordRequirementUppercaseText": "大寫字母 (A-Z)", - "passwordRequirementLowercaseText": "小寫字母 (a-z)", - "passwordRequirementNumberText": "數字 (0-9)", - "passwordRequirementSpecialText": "特殊字元 (!@#$%...)", - "passwordsDoNotMatch": "密碼不匹配", - "otpEmailRequirementsLength": "OTP 必須至少 1 個字元長", - "otpEmailSent": "OTP 已發送", - "otpEmailSentDescription": "OTP 已經發送到您的電子郵件", - "otpEmailErrorAuthenticate": "通過電子郵件身份驗證失敗", - "pincodeErrorAuthenticate": "Pincode 驗證失敗", - "passwordErrorAuthenticate": "密碼驗證失敗", - "poweredBy": "支持者:", - "authenticationRequired": "需要身份驗證", - "authenticationMethodChoose": "請選擇您偏好的方式來訪問 {name}", - "authenticationRequest": "您必須通過身份驗證才能訪問 {name}", - "user": "用戶", - "pincodeInput": "6 位數字 PIN 碼", - "pincodeSubmit": "使用 PIN 登錄", - "passwordSubmit": "使用密碼登錄", - "otpEmailDescription": "一次性代碼將發送到此電子郵件。", - "otpEmailSend": "發送一次性代碼", - "otpEmail": "一次性密碼 (OTP)", - "otpEmailSubmit": "提交 OTP", - "backToEmail": "回到電子郵件", - "noSupportKey": "伺服器當前未使用支持者金鑰,歡迎支持本項目!", - "accessDenied": "訪問被拒絕", - "accessDeniedDescription": "當前帳戶無權訪問此資源。如認為這是錯誤,請與管理員聯繫。", - "accessTokenError": "檢查訪問令牌時出錯", - "accessGranted": "已授予訪問", - "accessUrlInvalid": "訪問 URL 無效", - "accessGrantedDescription": "您已獲准訪問此資源,正在為您跳轉...", - "accessUrlInvalidDescription": "此共享訪問URL無效。請聯絡資源所有者獲取新URL。", - "tokenInvalid": "無效的令牌", - "pincodeInvalid": "無效的代碼", - "passwordErrorRequestReset": "請求重設失敗:", - "passwordErrorReset": "重設密碼失敗:", - "passwordResetSuccess": "密碼重設成功!返回登錄...", - "passwordReset": "重設密碼", - "passwordResetDescription": "按照步驟重設您的密碼", - "passwordResetSent": "我們將發送一個驗證碼到這個電子郵件地址。", - "passwordResetCode": "驗證碼", - "passwordResetCodeDescription": "請檢查您的電子郵件以獲取驗證碼。", - "generatePasswordResetCode": "產生密碼重設代碼", - "passwordResetCodeGenerated": "密碼重設代碼已產生", - "passwordResetCodeGeneratedDescription": "請將此代碼分享給使用者。他們可以用它來重設密碼。", - "passwordResetUrl": "重設網址", - "passwordNew": "新密碼", - "passwordNewConfirm": "確認新密碼", - "changePassword": "更改密碼", - "changePasswordDescription": "更新您的帳戶密碼", - "oldPassword": "當前密碼", - "newPassword": "新密碼", - "confirmNewPassword": "確認新密碼", - "changePasswordError": "更改密碼失敗", - "changePasswordErrorDescription": "更改您的密碼時出錯", - "changePasswordSuccess": "密碼修改成功", - "changePasswordSuccessDescription": "您的密碼已成功更新", - "passwordExpiryRequired": "需要密碼過期", - "passwordExpiryDescription": "該機構要求您每 {maxDays} 天更改一次密碼。", - "changePasswordNow": "現在更改密碼", - "pincodeAuth": "驗證器代碼", - "pincodeSubmit2": "提交代碼", - "passwordResetSubmit": "請求重設", - "passwordResetAlreadyHaveCode": "輸入代碼", - "passwordResetSmtpRequired": "請聯絡您的管理員", - "passwordResetSmtpRequiredDescription": "需要密碼重設代碼才能重設您的密碼。請聯絡您的管理員尋求協助。", - "passwordBack": "回到密碼", - "loginBack": "返回登錄", - "signup": "註冊", - "loginStart": "登錄以開始", - "idpOidcTokenValidating": "正在驗證 OIDC 令牌", - "idpOidcTokenResponse": "驗證 OIDC 令牌響應", - "idpErrorOidcTokenValidating": "驗證 OIDC 令牌出錯", - "idpConnectingTo": "連接到{name}", - "idpConnectingToDescription": "正在驗證您的身份", - "idpConnectingToProcess": "正在連接...", - "idpConnectingToFinished": "已連接", - "idpErrorConnectingTo": "無法連接到 {name},請聯絡管理員協助處理。", - "idpErrorNotFound": "找不到 IdP", - "inviteInvalid": "無效邀請", - "inviteInvalidDescription": "邀請連結無效。", - "inviteErrorWrongUser": "邀請不是該用戶的", - "inviteErrorUserNotExists": "用戶不存在。請先創建帳戶。", - "inviteErrorLoginRequired": "您必須登錄才能接受邀請", - "inviteErrorExpired": "邀請可能已過期", - "inviteErrorRevoked": "邀請可能已被吊銷了", - "inviteErrorTypo": "邀請連結中可能有一個類型", - "pangolinSetup": "認證 - Pangolin", - "orgNameRequired": "組織名稱是必需的", - "orgIdRequired": "組織ID是必需的", - "orgErrorCreate": "創建組織時出錯", - "pageNotFound": "找不到頁面", - "pageNotFoundDescription": "哎呀!您正在尋找的頁面不存在。", - "overview": "概覽", - "home": "首頁", - "accessControl": "訪問控制", - "settings": "設置", - "usersAll": "所有用戶", - "license": "許可協議", - "pangolinDashboard": "儀錶板 - Pangolin", - "noResults": "未找到任何結果。", - "terabytes": "{count} TB", - "gigabytes": "{count} GB", - "megabytes": "{count} MB", - "tagsEntered": "已輸入的標籤", - "tagsEnteredDescription": "這些是您輸入的標籤。", - "tagsWarnCannotBeLessThanZero": "最大標籤和最小標籤不能小於 0", - "tagsWarnNotAllowedAutocompleteOptions": "標記不允許為每個自動完成選項", - "tagsWarnInvalid": "無效的標籤,每個有效標籤", - "tagWarnTooShort": "標籤 {tagText} 太短", - "tagWarnTooLong": "標籤 {tagText} 太長", - "tagsWarnReachedMaxNumber": "已達到允許標籤的最大數量", - "tagWarnDuplicate": "未添加重複標籤 {tagText}", - "supportKeyInvalid": "無效金鑰", - "supportKeyInvalidDescription": "您的支持者金鑰無效。", - "supportKeyValid": "有效的金鑰", - "supportKeyValidDescription": "您的支持者金鑰已被驗證。感謝您的支持!", - "supportKeyErrorValidationDescription": "驗證支持者金鑰失敗。", - "supportKey": "支持開發和通過一個 Pangolin !", - "supportKeyDescription": "購買支持者鑰匙,幫助我們繼續為社區發展 Pangolin 。 您的貢獻使我們能夠投入更多的時間來維護和添加所有人的新功能。 我們永遠不會用這個來支付牆上的功能。這與任何商業版是分開的。", - "supportKeyPet": "您還可以領養並見到屬於自己的 Pangolin!", - "supportKeyPurchase": "付款通過 GitHub 進行處理,之後您可以在以下位置獲取您的金鑰:", - "supportKeyPurchaseLink": "我們的網站", - "supportKeyPurchase2": "並在這裡兌換。", - "supportKeyLearnMore": "了解更多。", - "supportKeyOptions": "請選擇最適合您的選項。", - "supportKetOptionFull": "完全支持者", - "forWholeServer": "適用於整個伺服器", - "lifetimePurchase": "終身購買", - "supporterStatus": "支持者狀態", - "buy": "購買", - "supportKeyOptionLimited": "有限支持者", - "forFiveUsers": "適用於 5 或更少用戶", - "supportKeyRedeem": "兌換支持者金鑰", - "supportKeyHideSevenDays": "隱藏 7 天", - "supportKeyEnter": "輸入支持者金鑰", - "supportKeyEnterDescription": "見到你自己的 Pangolin!", - "githubUsername": "GitHub 使用者名稱", - "supportKeyInput": "支持者金鑰", - "supportKeyBuy": "購買支持者金鑰", - "logoutError": "註銷錯誤", - "signingAs": "登錄為", - "serverAdmin": "伺服器管理員", - "managedSelfhosted": "託管自託管", - "otpEnable": "啟用雙因子認證", - "otpDisable": "禁用雙因子認證", - "logout": "登出", - "licenseTierProfessionalRequired": "需要專業版", - "licenseTierProfessionalRequiredDescription": "此功能僅在專業版可用。", - "actionGetOrg": "獲取組織", - "updateOrgUser": "更新組織用戶", - "createOrgUser": "創建組織用戶", - "actionUpdateOrg": "更新組織", - "actionRemoveInvitation": "移除邀請", - "actionUpdateUser": "更新用戶", - "actionGetUser": "獲取用戶", - "actionGetOrgUser": "獲取組織用戶", - "actionListOrgDomains": "列出組織域", - "actionCreateSite": "創建站點", - "actionDeleteSite": "刪除站點", - "actionGetSite": "獲取站點", - "actionListSites": "站點列表", - "actionApplyBlueprint": "應用藍圖", - "actionListBlueprints": "藍圖列表", - "actionGetBlueprint": "獲取藍圖", - "setupToken": "設置令牌", - "setupTokenDescription": "從伺服器控制台輸入設定令牌。", - "setupTokenRequired": "需要設置令牌", - "actionUpdateSite": "更新站點", - "actionListSiteRoles": "允許站點角色列表", - "actionCreateResource": "創建資源", - "actionDeleteResource": "刪除資源", - "actionGetResource": "獲取資源", - "actionListResource": "列出資源", - "actionUpdateResource": "更新資源", - "actionListResourceUsers": "列出資源用戶", - "actionSetResourceUsers": "設置資源用戶", - "actionSetAllowedResourceRoles": "設置允許的資源角色", - "actionListAllowedResourceRoles": "列出允許的資源角色", - "actionSetResourcePassword": "設置資源密碼", - "actionSetResourcePincode": "設置資源粉碼", - "actionSetResourceEmailWhitelist": "設置資源電子郵件白名單", - "actionGetResourceEmailWhitelist": "獲取資源電子郵件白名單", - "actionCreateTarget": "創建目標", - "actionDeleteTarget": "刪除目標", - "actionGetTarget": "獲取目標", - "actionListTargets": "列表目標", - "actionUpdateTarget": "更新目標", - "actionCreateRole": "創建角色", - "actionDeleteRole": "刪除角色", - "actionGetRole": "獲取角色", - "actionListRole": "角色列表", - "actionUpdateRole": "更新角色", - "actionListAllowedRoleResources": "列表允許的角色資源", - "actionInviteUser": "邀請用戶", - "actionRemoveUser": "刪除用戶", - "actionListUsers": "列出用戶", - "actionAddUserRole": "添加用戶角色", - "actionSetUserOrgRoles": "Set User Roles", - "actionGenerateAccessToken": "生成訪問令牌", - "actionDeleteAccessToken": "刪除訪問令牌", - "actionListAccessTokens": "訪問令牌", - "actionCreateResourceRule": "創建資源規則", - "actionDeleteResourceRule": "刪除資源規則", - "actionListResourceRules": "列出資源規則", - "actionUpdateResourceRule": "更新資源規則", - "actionListOrgs": "列出組織", - "actionCheckOrgId": "檢查組織ID", - "actionCreateOrg": "創建組織", - "actionDeleteOrg": "刪除組織", - "actionListApiKeys": "列出 API 金鑰", - "actionListApiKeyActions": "列出 API 金鑰動作", - "actionSetApiKeyActions": "設置 API 金鑰允許的操作", - "actionCreateApiKey": "創建 API 金鑰", - "actionDeleteApiKey": "刪除 API 金鑰", - "actionCreateIdp": "創建 IDP", - "actionUpdateIdp": "更新 IDP", - "actionDeleteIdp": "刪除 IDP", - "actionListIdps": "列出 IDP", - "actionGetIdp": "獲取 IDP", - "actionCreateIdpOrg": "創建 IDP 組織策略", - "actionDeleteIdpOrg": "刪除 IDP 組織策略", - "actionListIdpOrgs": "列出 IDP 組織", - "actionUpdateIdpOrg": "更新 IDP 組織", - "actionCreateClient": "創建用戶端", - "actionDeleteClient": "刪除用戶端", - "actionUpdateClient": "更新用戶端", - "actionListClients": "列出用戶端", - "actionGetClient": "獲取用戶端", - "actionCreateSiteResource": "創建站點資源", - "actionDeleteSiteResource": "刪除站點資源", - "actionGetSiteResource": "獲取站點資源", - "actionListSiteResources": "列出站點資源", - "actionUpdateSiteResource": "更新站點資源", - "actionListInvitations": "邀請列表", - "actionExportLogs": "匯出日誌", - "actionViewLogs": "查看日誌", - "noneSelected": "未選擇", - "orgNotFound2": "未找到組織。", - "searchProgress": "搜索中...", - "create": "創建", - "orgs": "組織", - "loginError": "登錄時出錯", - "loginRequiredForDevice": "需要登入以驗證您的裝置。", - "passwordForgot": "忘記密碼?", - "otpAuth": "兩步驗證", - "otpAuthDescription": "從您的身份驗證程序中輸入代碼或您的單次備份代碼。", - "otpAuthSubmit": "提交代碼", - "idpContinue": "或者繼續", - "otpAuthBack": "返回登錄", - "navbar": "導航菜單", - "navbarDescription": "應用程式的主導航菜單", - "navbarDocsLink": "文件", - "otpErrorEnable": "無法啟用 2FA", - "otpErrorEnableDescription": "啟用 2FA 時出錯", - "otpSetupCheckCode": "請輸入您的 6 位數字代碼", - "otpSetupCheckCodeRetry": "無效的代碼。請重試。", - "otpSetup": "啟用兩步驗證", - "otpSetupDescription": "用額外的保護層來保護您的帳戶", - "otpSetupScanQr": "用您的身份驗證程序掃描此二維碼或手動輸入金鑰:", - "otpSetupSecretCode": "驗證器代碼", - "otpSetupSuccess": "啟用兩步驗證", - "otpSetupSuccessStoreBackupCodes": "您的帳戶現在更加安全。不要忘記保存您的備份代碼。", - "otpErrorDisable": "無法禁用 2FA", - "otpErrorDisableDescription": "禁用 2FA 時出錯", - "otpRemove": "禁用兩步驗證", - "otpRemoveDescription": "為您的帳戶禁用兩步驗證", - "otpRemoveSuccess": "雙重身份驗證已禁用", - "otpRemoveSuccessMessage": "您的帳戶已禁用雙重身份驗證。您可以隨時再次啟用它。", - "otpRemoveSubmit": "禁用兩步驗證", - "paginator": "第 {current} 頁,共 {last} 頁", - "paginatorToFirst": "轉到第一頁", - "paginatorToPrevious": "轉到上一頁", - "paginatorToNext": "轉到下一頁", - "paginatorToLast": "轉到最後一頁", - "copyText": "複製文本", - "copyTextFailed": "複製文本失敗: ", - "copyTextClipboard": "複製到剪貼簿", - "inviteErrorInvalidConfirmation": "無效確認", - "passwordRequired": "必須填寫密碼", - "allowAll": "允許所有", - "permissionsAllowAll": "允許所有權限", - "githubUsernameRequired": "必須填寫 GitHub 使用者名稱", - "supportKeyRequired": "必須填寫支持者金鑰", - "passwordRequirementsChars": "密碼至少需要 8 個字元", - "language": "語言", - "verificationCodeRequired": "必須輸入代碼", - "userErrorNoUpdate": "沒有要更新的用戶", - "siteErrorNoUpdate": "沒有要更新的站點", - "resourceErrorNoUpdate": "沒有可更新的資源", - "authErrorNoUpdate": "沒有要更新的身份驗證資訊", - "orgErrorNoUpdate": "沒有要更新的組織", - "orgErrorNoProvided": "未提供組織", - "apiKeysErrorNoUpdate": "沒有要更新的 API 金鑰", - "sidebarOverview": "概覽", - "sidebarHome": "首頁", - "sidebarSites": "站點", - "sidebarResources": "資源", - "sidebarProxyResources": "公開", - "sidebarClientResources": "私有", - "sidebarAccessControl": "訪問控制", - "sidebarLogsAndAnalytics": "日誌與分析", - "sidebarUsers": "用戶", - "sidebarAdmin": "管理員", - "sidebarInvitations": "邀請", - "sidebarRoles": "角色", - "sidebarShareableLinks": "分享連結", - "sidebarApiKeys": "API 金鑰", - "sidebarSettings": "設置", - "sidebarAllUsers": "所有用戶", - "sidebarIdentityProviders": "身份提供商", - "sidebarLicense": "證書", - "sidebarClients": "用戶端", - "sidebarUserDevices": "使用者", - "sidebarMachineClients": "機器", - "sidebarDomains": "域", - "sidebarGeneral": "管理", - "sidebarLogAndAnalytics": "日誌與分析", - "sidebarBluePrints": "藍圖", - "sidebarOrganization": "組織", - "sidebarLogsAnalytics": "分析", - "blueprints": "藍圖", - "blueprintsDescription": "應用聲明配置並查看先前運行的", - "blueprintAdd": "添加藍圖", - "blueprintGoBack": "查看所有藍圖", - "blueprintCreate": "創建藍圖", - "blueprintCreateDescription2": "按照下面的步驟創建和應用新的藍圖", - "blueprintDetails": "藍圖詳細資訊", - "blueprintDetailsDescription": "查看應用藍圖的結果和發生的任何錯誤", - "blueprintInfo": "藍圖資訊", - "message": "留言", - "blueprintContentsDescription": "定義描述您基礎設施的 YAML 內容", - "blueprintErrorCreateDescription": "應用藍圖時出錯", - "blueprintErrorCreate": "創建藍圖時出錯", - "searchBlueprintProgress": "搜索藍圖...", - "appliedAt": "應用於", - "source": "來源", - "contents": "目錄", - "parsedContents": "解析內容 (只讀)", - "enableDockerSocket": "啟用 Docker 藍圖", - "enableDockerSocketDescription": "啟用 Docker Socket 標籤擦除藍圖標籤。套接字路徑必須提供給新的。", - "enableDockerSocketLink": "了解更多", - "viewDockerContainers": "查看停靠容器", - "containersIn": "{siteName} 中的容器", - "selectContainerDescription": "選擇任何容器作為目標的主機名。點擊埠使用埠。", - "containerName": "名稱", - "containerImage": "圖片", - "containerState": "狀態", - "containerNetworks": "網路", - "containerHostnameIp": "主機名/IP", - "containerLabels": "標籤", - "containerLabelsCount": "{count, plural, other {# 標籤}}", - "containerLabelsTitle": "容器標籤", - "containerLabelEmpty": "<為空>", - "containerPorts": "埠", - "containerPortsMore": "+{count} 更多", - "containerActions": "行動", - "select": "選擇", - "noContainersMatchingFilters": "沒有找到匹配當前過濾器的容器。", - "showContainersWithoutPorts": "顯示沒有埠的容器", - "showStoppedContainers": "顯示已停止的容器", - "noContainersFound": "未找到容器。請確保 Docker 容器正在運行。", - "searchContainersPlaceholder": "在 {count} 個容器中搜索...", - "searchResultsCount": "{count, plural, other {# 個結果}}", - "filters": "篩選器", - "filterOptions": "過濾器選項", - "filterPorts": "埠", - "filterStopped": "已停止", - "clearAllFilters": "清除所有過濾器", - "columns": "列", - "toggleColumns": "切換列", - "refreshContainersList": "刷新容器列表", - "searching": "搜索中...", - "noContainersFoundMatching": "未找到與 \"{filter}\" 匹配的容器。", - "light": "淺色", - "dark": "深色", - "system": "系統", - "theme": "主題", - "subnetRequired": "子網是必填項", - "initialSetupTitle": "初始伺服器設置", - "initialSetupDescription": "創建初始伺服器管理員帳戶。 只能存在一個伺服器管理員。 您可以隨時更改這些憑據。", - "createAdminAccount": "創建管理員帳戶", - "setupErrorCreateAdmin": "創建伺服器管理員帳戶時發生錯誤。", - "certificateStatus": "證書狀態", - "loading": "載入中", - "restart": "重啟", - "domains": "域", - "domainsDescription": "管理您的組織域", - "domainsSearch": "搜索域...", - "domainAdd": "添加域", - "domainAddDescription": "在您的組織中註冊新域", - "domainCreate": "創建域", - "domainCreatedDescription": "域創建成功", - "domainDeletedDescription": "成功刪除域", - "domainQuestionRemove": "您確定要從您的帳戶中刪除域名嗎?", - "domainMessageRemove": "移除後,該域將不再與您的帳戶關聯。", - "domainConfirmDelete": "確認刪除域", - "domainDelete": "刪除域", - "domain": "域", - "selectDomainTypeNsName": "域委派(NS)", - "selectDomainTypeNsDescription": "此域及其所有子域。當您希望控制整個域區域時使用此選項。", - "selectDomainTypeCnameName": "單個域(CNAME)", - "selectDomainTypeCnameDescription": "僅此特定域。用於單個子域或特定域條目。", - "selectDomainTypeWildcardName": "通配符域", - "selectDomainTypeWildcardDescription": "此域名及其子域名。", - "domainDelegation": "單個域", - "selectType": "選擇一個類型", - "actions": "操作", - "refresh": "刷新", - "refreshError": "刷新數據失敗", - "verified": "已驗證", - "pending": "待定", - "sidebarBilling": "計費", - "billing": "計費", - "orgBillingDescription": "管理您的帳單資訊和訂閱", - "github": "GitHub", - "pangolinHosted": "Pangolin 託管", - "fossorial": "Fossorial", - "completeAccountSetup": "完成帳戶設定", - "completeAccountSetupDescription": "設置您的密碼以開始", - "accountSetupSent": "我們將發送帳號設定代碼到該電子郵件地址。", - "accountSetupCode": "設置代碼", - "accountSetupCodeDescription": "請檢查您的信箱以獲取設置代碼。", - "passwordCreate": "創建密碼", - "passwordCreateConfirm": "確認密碼", - "accountSetupSubmit": "發送設置代碼", - "completeSetup": "完成設置", - "accountSetupSuccess": "帳號設定完成!歡迎來到 Pangolin!", - "documentation": "文件", - "saveAllSettings": "保存所有設置", - "saveResourceTargets": "儲存目標", - "saveResourceHttp": "儲存代理設定", - "saveProxyProtocol": "儲存代理協定設定", - "settingsUpdated": "設置已更新", - "settingsUpdatedDescription": "所有設置已成功更新", - "settingsErrorUpdate": "設置更新失敗", - "settingsErrorUpdateDescription": "更新設置時發生錯誤", - "sidebarCollapse": "摺疊", - "sidebarExpand": "展開", - "productUpdateMoreInfo": "還有 {noOfUpdates} 項更新", - "productUpdateInfo": "{noOfUpdates} 項更新", - "productUpdateWhatsNew": "新功能", - "productUpdateTitle": "產品更新", - "productUpdateEmpty": "沒有更新", - "dismissAll": "全部關閉", - "pangolinUpdateAvailable": "有可用更新", - "pangolinUpdateAvailableInfo": "版本 {version} 已準備好安裝", - "pangolinUpdateAvailableReleaseNotes": "查看發行說明", - "newtUpdateAvailable": "更新可用", - "newtUpdateAvailableInfo": "新版本的 Newt 已可用。請更新到最新版本以獲得最佳體驗。", - "domainPickerEnterDomain": "域名", - "domainPickerPlaceholder": "example.com", - "domainPickerDescription": "輸入資源的完整域名以查看可用選項。", - "domainPickerDescriptionSaas": "輸入完整域名、子域或名稱以查看可用選項。", - "domainPickerTabAll": "所有", - "domainPickerTabOrganization": "組織", - "domainPickerTabProvided": "提供的", - "domainPickerSortAsc": "A-Z", - "domainPickerSortDesc": "Z-A", - "domainPickerCheckingAvailability": "檢查可用性...", - "domainPickerNoMatchingDomains": "未找到匹配的域名。嘗試不同的域名或檢查您組織的域名設置。", - "domainPickerOrganizationDomains": "組織域", - "domainPickerProvidedDomains": "提供的域", - "domainPickerSubdomain": "子域:{subdomain}", - "domainPickerNamespace": "命名空間:{namespace}", - "domainPickerShowMore": "顯示更多", - "regionSelectorTitle": "選擇區域", - "regionSelectorInfo": "選擇區域以幫助提升您所在地的性能。您不必與伺服器在相同的區域。", - "regionSelectorPlaceholder": "選擇一個區域", - "regionSelectorComingSoon": "即將推出", - "billingLoadingSubscription": "正在載入訂閱...", - "billingFreeTier": "免費層", - "billingWarningOverLimit": "警告:您已超出一個或多個使用限制。在您修改訂閱或調整使用情況之前,您的站點將無法連接。", - "billingUsageLimitsOverview": "使用限制概覽", - "billingMonitorUsage": "監控您的使用情況以對比已配置的限制。如需提高限制請聯絡我們 support@pangolin.net。", - "billingDataUsage": "數據使用情況", - "billingOnlineTime": "站點在線時間", - "billingUsers": "活躍用戶", - "billingDomains": "活躍域", - "billingRemoteExitNodes": "活躍自託管節點", - "billingNoLimitConfigured": "未配置限制", - "billingEstimatedPeriod": "估計結算週期", - "billingIncludedUsage": "包含的使用量", - "billingIncludedUsageDescription": "您當前訂閱計劃中包含的使用量", - "billingFreeTierIncludedUsage": "免費層使用額度", - "billingIncluded": "包含", - "billingEstimatedTotal": "預計總額:", - "billingNotes": "備註", - "billingEstimateNote": "這是根據您當前使用情況的估算。", - "billingActualChargesMayVary": "實際費用可能會有變化。", - "billingBilledAtEnd": "您將在結算週期結束時被計費。", - "billingModifySubscription": "修改訂閱", - "billingStartSubscription": "開始訂閱", - "billingRecurringCharge": "週期性收費", - "billingManageSubscriptionSettings": "管理您的訂閱設置和偏好", - "billingNoActiveSubscription": "您沒有活躍的訂閱。開始訂閱以增加使用限制。", - "billingFailedToLoadSubscription": "無法載入訂閱", - "billingFailedToLoadUsage": "無法載入使用情況", - "billingFailedToGetCheckoutUrl": "無法獲取結帳網址", - "billingPleaseTryAgainLater": "請稍後再試。", - "billingCheckoutError": "結帳錯誤", - "billingFailedToGetPortalUrl": "無法獲取門戶網址", - "billingPortalError": "門戶錯誤", - "billingDataUsageInfo": "當連接到雲端時,您將為透過安全隧道傳輸的所有數據收取費用。 這包括您所有站點的進出流量。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取數據。", - "billingOnlineTimeInfo": "您要根據您的網站連接到雲端的時間長短收取費用。 例如,44,640 分鐘等於一個 24/7 全月運行的網站。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取費用。", - "billingUsersInfo": "根據您組織中的活躍用戶數量收費。按日計算帳單。", - "billingDomainInfo": "根據組織中活躍域的數量收費。按日計算帳單。", - "billingRemoteExitNodesInfo": "根據您組織中已管理節點的數量收費。按日計算帳單。", - "domainNotFound": "域未找到", - "domainNotFoundDescription": "此資源已禁用,因為該域不再在我們的系統中存在。請為此資源設置一個新域。", - "failed": "失敗", - "createNewOrgDescription": "創建一個新組織", - "organization": "組織", - "port": "埠", - "securityKeyManage": "管理安全金鑰", - "securityKeyDescription": "添加或刪除用於無密碼認證的安全金鑰", - "securityKeyRegister": "註冊新的安全金鑰", - "securityKeyList": "您的安全金鑰", - "securityKeyNone": "尚未註冊安全金鑰", - "securityKeyNameRequired": "名稱為必填項", - "securityKeyRemove": "刪除", - "securityKeyLastUsed": "上次使用:{date}", - "securityKeyNameLabel": "名稱", - "securityKeyRegisterSuccess": "安全金鑰註冊成功", - "securityKeyRegisterError": "註冊安全金鑰失敗", - "securityKeyRemoveSuccess": "安全金鑰刪除成功", - "securityKeyRemoveError": "刪除安全金鑰失敗", - "securityKeyLoadError": "載入安全金鑰失敗", - "securityKeyLogin": "使用安全金鑰繼續", - "securityKeyAuthError": "使用安全金鑰認證失敗", - "securityKeyRecommendation": "考慮在其他設備上註冊另一個安全金鑰,以確保不會被鎖定在您的帳戶之外。", - "registering": "註冊中...", - "securityKeyPrompt": "請使用您的安全金鑰驗證身份。確保您的安全金鑰已連接並準備好。", - "securityKeyBrowserNotSupported": "您的瀏覽器不支持安全金鑰。請使用像 Chrome、Firefox 或 Safari 這樣的現代瀏覽器。", - "securityKeyPermissionDenied": "請允許訪問您的安全金鑰以繼續登錄。", - "securityKeyRemovedTooQuickly": "請保持您的安全金鑰連接,直到登錄過程完成。", - "securityKeyNotSupported": "您的安全金鑰可能不相容。請嘗試不同的安全金鑰。", - "securityKeyUnknownError": "使用安全金鑰時出現問題。請再試一次。", - "twoFactorRequired": "註冊安全金鑰需要兩步驗證。", - "twoFactor": "兩步驗證", - "twoFactorAuthentication": "兩步驗證", - "twoFactorDescription": "這個組織需要雙重身份驗證。", - "enableTwoFactor": "啟用兩步驗證", - "organizationSecurityPolicy": "組織安全政策", - "organizationSecurityPolicyDescription": "此機構擁有安全要求,您必須先滿足才能訪問", - "securityRequirements": "安全要求", - "allRequirementsMet": "已滿足所有要求", - "completeRequirementsToContinue": "完成下面的要求以繼續訪問此組織", - "youCanNowAccessOrganization": "您現在可以訪問此組織", - "reauthenticationRequired": "會話長度", - "reauthenticationDescription": "該機構要求您每 {maxDays} 天登錄一次。", - "reauthenticationDescriptionHours": "該機構要求您每 {maxHours} 小時登錄一次。", - "reauthenticateNow": "再次登錄", - "adminEnabled2FaOnYourAccount": "管理員已為 {email} 啟用兩步驗證。請完成設置以繼續。", - "securityKeyAdd": "添加安全金鑰", - "securityKeyRegisterTitle": "註冊新安全金鑰", - "securityKeyRegisterDescription": "連接您的安全金鑰並輸入名稱以便識別", - "securityKeyTwoFactorRequired": "要求兩步驗證", - "securityKeyTwoFactorDescription": "請輸入你的兩步驗證代碼以註冊安全金鑰", - "securityKeyTwoFactorRemoveDescription": "請輸入你的兩步驗證代碼以移除安全金鑰", - "securityKeyTwoFactorCode": "雙因素代碼", - "securityKeyRemoveTitle": "移除安全金鑰", - "securityKeyRemoveDescription": "輸入您的密碼以移除安全金鑰 \"{name}\"", - "securityKeyNoKeysRegistered": "沒有註冊安全金鑰", - "securityKeyNoKeysDescription": "添加安全金鑰以加強您的帳戶安全", - "createDomainRequired": "必須輸入域", - "createDomainAddDnsRecords": "添加 DNS 記錄", - "createDomainAddDnsRecordsDescription": "將以下 DNS 記錄添加到您的域名提供商以完成設置。", - "createDomainNsRecords": "NS 記錄", - "createDomainRecord": "記錄", - "createDomainType": "類型:", - "createDomainName": "名稱:", - "createDomainValue": "值:", - "createDomainCnameRecords": "CNAME 記錄", - "createDomainARecords": "A記錄", - "createDomainRecordNumber": "記錄 {number}", - "createDomainTxtRecords": "TXT 記錄", - "createDomainSaveTheseRecords": "保存這些記錄", - "createDomainSaveTheseRecordsDescription": "務必保存這些 DNS 記錄,因為您將無法再次查看它們。", - "createDomainDnsPropagation": "DNS 傳播", - "createDomainDnsPropagationDescription": "DNS 更改可能需要一些時間才能在網路上傳播。這可能需要從幾分鐘到 48 小時,具體取決於您的 DNS 提供商和 TTL 設置。", - "resourcePortRequired": "非 HTTP 資源必須輸入埠號", - "resourcePortNotAllowed": "HTTP 資源不應設置埠號", - "billingPricingCalculatorLink": "價格計算機", - "signUpTerms": { - "IAgreeToThe": "我同意", - "termsOfService": "服務條款", - "and": "和", - "privacyPolicy": "隱私政策" - }, - "signUpMarketing": { - "keepMeInTheLoop": "透過電子郵件接收新聞、更新和新功能通知。" - }, - "siteRequired": "需要站點。", - "olmTunnel": "Olm 隧道", - "olmTunnelDescription": "使用 Olm 進行用戶端連接", - "errorCreatingClient": "創建用戶端出錯", - "clientDefaultsNotFound": "未找到用戶端預設值", - "createClient": "創建用戶端", - "createClientDescription": "創建一個新用戶端來連接您的站點", - "seeAllClients": "查看所有用戶端", - "clientInformation": "用戶端資訊", - "clientNamePlaceholder": "用戶端名稱", - "address": "地址", - "subnetPlaceholder": "子網", - "addressDescription": "此用戶端將用於連接的地址", - "selectSites": "選擇站點", - "sitesDescription": "用戶端將與所選站點進行連接", - "clientInstallOlm": "安裝 Olm", - "clientInstallOlmDescription": "在您的系統上運行 Olm", - "clientOlmCredentials": "Olm 憑據", - "clientOlmCredentialsDescription": "這是 Olm 伺服器的身份驗證方式", - "olmEndpoint": "Olm 端點", - "olmId": "Olm ID", - "olmSecretKey": "Olm 私鑰", - "clientCredentialsSave": "保存您的憑據", - "clientCredentialsSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全位置。", - "generalSettingsDescription": "配置此用戶端的常規設置", - "clientUpdated": "用戶端已更新", - "clientUpdatedDescription": "用戶端已更新。", - "clientUpdateFailed": "更新用戶端失敗", - "clientUpdateError": "更新用戶端時出錯。", - "sitesFetchFailed": "獲取站點失敗", - "sitesFetchError": "獲取站點時出錯。", - "olmErrorFetchReleases": "獲取 Olm 發布版本時出錯。", - "olmErrorFetchLatest": "獲取最新 Olm 發布版本時出錯。", - "enterCidrRange": "輸入 CIDR 範圍", - "resourceEnableProxy": "啟用公共代理", - "resourceEnableProxyDescription": "啟用到此資源的公共代理。這允許外部網路通過開放埠訪問資源。需要 Traefik 配置。", - "externalProxyEnabled": "外部代理已啟用", - "addNewTarget": "添加新目標", - "targetsList": "目標列表", - "advancedMode": "高級模式", - "advancedSettings": "進階設定", - "targetErrorDuplicateTargetFound": "找到重複的目標", - "healthCheckHealthy": "正常", - "healthCheckUnhealthy": "不正常", - "healthCheckUnknown": "未知", - "healthCheck": "健康檢查", - "configureHealthCheck": "配置健康檢查", - "configureHealthCheckDescription": "為 {target} 設置健康監控", - "enableHealthChecks": "啟用健康檢查", - "enableHealthChecksDescription": "監視此目標的健康狀況。如果需要,您可以監視一個不同的終點。", - "healthScheme": "方法", - "healthSelectScheme": "選擇方法", - "healthCheckPortInvalid": "健康檢查連接埠必須介於 1 到 65535 之間", - "healthCheckPath": "路徑", - "healthHostname": "IP / 主機", - "healthPort": "埠", - "healthCheckPathDescription": "用於檢查健康狀態的路徑。", - "healthyIntervalSeconds": "正常間隔", - "unhealthyIntervalSeconds": "不正常間隔", - "IntervalSeconds": "正常間隔", - "timeoutSeconds": "超時", - "timeIsInSeconds": "時間以秒為單位", - "retryAttempts": "重試次數", - "expectedResponseCodes": "期望響應代碼", - "expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。", - "customHeaders": "自訂 Headers", - "customHeadersDescription": "Header 斷行分隔:Header 名稱:值", - "headersValidationError": "Header 必須是格式:Header 名稱:值。", - "saveHealthCheck": "保存健康檢查", - "healthCheckSaved": "健康檢查已保存", - "healthCheckSavedDescription": "健康檢查配置已成功保存。", - "healthCheckError": "健康檢查錯誤", - "healthCheckErrorDescription": "保存健康檢查配置時出錯", - "healthCheckPathRequired": "健康檢查路徑為必填項", - "healthCheckMethodRequired": "HTTP 方法為必填項", - "healthCheckIntervalMin": "檢查間隔必須至少為 5 秒", - "healthCheckTimeoutMin": "超時必須至少為 1 秒", - "healthCheckRetryMin": "重試次數必須至少為 1 次", - "httpMethod": "HTTP 方法", - "selectHttpMethod": "選擇 HTTP 方法", - "domainPickerSubdomainLabel": "子域名", - "domainPickerBaseDomainLabel": "根域名", - "domainPickerSearchDomains": "搜索域名...", - "domainPickerNoDomainsFound": "未找到域名", - "domainPickerLoadingDomains": "載入域名...", - "domainPickerSelectBaseDomain": "選擇根域名...", - "domainPickerNotAvailableForCname": "不適用於 CNAME 域", - "domainPickerEnterSubdomainOrLeaveBlank": "輸入子域名或留空以使用根域名。", - "domainPickerEnterSubdomainToSearch": "輸入一個子域名以搜索並從可用免費域名中選擇。", - "domainPickerFreeDomains": "免費域名", - "domainPickerSearchForAvailableDomains": "搜索可用域名", - "domainPickerNotWorkSelfHosted": "注意:自託管實例當前不提供免費的域名。", - "resourceDomain": "域名", - "resourceEditDomain": "編輯域名", - "siteName": "站點名稱", - "proxyPort": "埠", - "resourcesTableProxyResources": "代理資源", - "resourcesTableClientResources": "用戶端資源", - "resourcesTableNoProxyResourcesFound": "未找到代理資源。", - "resourcesTableNoInternalResourcesFound": "未找到內部資源。", - "resourcesTableDestination": "目標", - "resourcesTableAlias": "別名", - "resourcesTableClients": "用戶端", - "resourcesTableAndOnlyAccessibleInternally": "且僅在與用戶端連接時可內部訪問。", - "resourcesTableNoTargets": "無目標", - "resourcesTableHealthy": "健康", - "resourcesTableDegraded": "降級", - "resourcesTableOffline": "離線", - "resourcesTableUnknown": "未知", - "resourcesTableNotMonitored": "未監控", - "editInternalResourceDialogEditClientResource": "編輯用戶端資源", - "editInternalResourceDialogUpdateResourceProperties": "更新 {resourceName} 的資源屬性和目標配置。", - "editInternalResourceDialogResourceProperties": "資源屬性", - "editInternalResourceDialogName": "名稱", - "editInternalResourceDialogProtocol": "協議", - "editInternalResourceDialogSitePort": "站點埠", - "editInternalResourceDialogTargetConfiguration": "目標配置", - "editInternalResourceDialogCancel": "取消", - "editInternalResourceDialogSaveResource": "保存資源", - "editInternalResourceDialogSuccess": "成功", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "內部資源更新成功", - "editInternalResourceDialogError": "錯誤", - "editInternalResourceDialogFailedToUpdateInternalResource": "更新內部資源失敗", - "editInternalResourceDialogNameRequired": "名稱為必填項", - "editInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", - "editInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", - "editInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", - "editInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", - "editInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", - "editInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", - "editInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠", - "editInternalResourceDialogMode": "模式", - "editInternalResourceDialogModePort": "連接埠", - "editInternalResourceDialogModeHost": "主機", - "editInternalResourceDialogModeCidr": "CIDR", - "editInternalResourceDialogDestination": "目的地", - "editInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", - "editInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名稱位址。", - "editInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", - "editInternalResourceDialogAlias": "別名", - "editInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", - "createInternalResourceDialogNoSitesAvailable": "暫無可用站點", - "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一個子網的 Newt 站點來創建內部資源。", - "createInternalResourceDialogClose": "關閉", - "createInternalResourceDialogCreateClientResource": "創建用戶端資源", - "createInternalResourceDialogCreateClientResourceDescription": "創建一個新資源,該資源將可供連接到所選站點的用戶端訪問。", - "createInternalResourceDialogResourceProperties": "資源屬性", - "createInternalResourceDialogName": "名稱", - "createInternalResourceDialogSite": "站點", - "selectSite": "選擇站點...", - "noSitesFound": "找不到站點。", - "createInternalResourceDialogProtocol": "協議", - "createInternalResourceDialogTcp": "TCP", - "createInternalResourceDialogUdp": "UDP", - "createInternalResourceDialogSitePort": "站點埠", - "createInternalResourceDialogSitePortDescription": "使用此埠在連接到用戶端時訪問站點上的資源。", - "createInternalResourceDialogTargetConfiguration": "目標配置", - "createInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名地址。", - "createInternalResourceDialogDestinationPortDescription": "資源在目標 IP 上可訪問的埠。", - "createInternalResourceDialogCancel": "取消", - "createInternalResourceDialogCreateResource": "創建資源", - "createInternalResourceDialogSuccess": "成功", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "內部資源創建成功", - "createInternalResourceDialogError": "錯誤", - "createInternalResourceDialogFailedToCreateInternalResource": "創建內部資源失敗", - "createInternalResourceDialogNameRequired": "名稱為必填項", - "createInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", - "createInternalResourceDialogPleaseSelectSite": "請選擇一個站點", - "createInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", - "createInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", - "createInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", - "createInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", - "createInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", - "createInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠", - "createInternalResourceDialogMode": "模式", - "createInternalResourceDialogModePort": "連接埠", - "createInternalResourceDialogModeHost": "主機", - "createInternalResourceDialogModeCidr": "CIDR", - "createInternalResourceDialogDestination": "目的地", - "createInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", - "createInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", - "createInternalResourceDialogAlias": "別名", - "createInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", - "siteConfiguration": "配置", - "siteAcceptClientConnections": "接受用戶端連接", - "siteAcceptClientConnectionsDescription": "允許其他設備透過此 Newt 實例使用用戶端作為閘道器連接。", - "siteAddress": "站點地址", - "siteAddressDescription": "指定主機的 IP 位址以供用戶端連接。這是 Pangolin 網路中站點的內部地址,供用戶端訪問。必須在 Org 子網內。", - "siteNameDescription": "站點的顯示名稱,可以稍後更改。", - "autoLoginExternalIdp": "自動使用外部 IDP 登錄", - "autoLoginExternalIdpDescription": "立即將用戶重定向到外部 IDP 進行身份驗證。", - "selectIdp": "選擇 IDP", - "selectIdpPlaceholder": "選擇一個 IDP...", - "selectIdpRequired": "在啟用自動登錄時,請選擇一個 IDP。", - "autoLoginTitle": "重定向中", - "autoLoginDescription": "正在將您重定向到外部身份提供商進行身份驗證。", - "autoLoginProcessing": "準備身份驗證...", - "autoLoginRedirecting": "重定向到登錄...", - "autoLoginError": "自動登錄錯誤", - "autoLoginErrorNoRedirectUrl": "未從身份提供商收到重定向 URL。", - "autoLoginErrorGeneratingUrl": "生成身份驗證 URL 失敗。", - "remoteExitNodeManageRemoteExitNodes": "遠程節點", - "remoteExitNodeDescription": "自我主機一個或多個遠程節點來擴展您的網路連接並減少對雲的依賴性", - "remoteExitNodes": "節點", - "searchRemoteExitNodes": "搜索節點...", - "remoteExitNodeAdd": "添加節點", - "remoteExitNodeErrorDelete": "刪除節點時出錯", - "remoteExitNodeQuestionRemove": "您確定要從組織中刪除該節點嗎?", - "remoteExitNodeMessageRemove": "一旦刪除,該節點將不再能夠訪問。", - "remoteExitNodeConfirmDelete": "確認刪除節點", - "remoteExitNodeDelete": "刪除節點", - "sidebarRemoteExitNodes": "遠程節點", - "remoteExitNodeId": "ID", - "remoteExitNodeSecretKey": "密鑰", - "remoteExitNodeCreate": { - "title": "創建節點", - "description": "創建一個新節點來擴展您的網路連接", - "viewAllButton": "查看所有節點", - "strategy": { - "title": "創建策略", - "description": "選擇此選項以手動配置您的節點或生成新憑據。", - "adopt": { - "title": "採納節點", - "description": "如果您已經擁有該節點的憑據,請選擇此項。" - }, - "generate": { - "title": "生成金鑰", - "description": "如果您想為節點生成新金鑰,請選擇此選項" - } + "setupCreate": "創建您的第一個組織、網站和資源", + "headerAuthCompatibilityInfo": "啟用此選項以在缺少驗證令牌時強制回傳 401 未授權回應。這對於不會在沒有伺服器挑戰的情況下發送憑證的瀏覽器或特定 HTTP 函式庫是必需的。", + "headerAuthCompatibility": "擴展相容性", + "setupNewOrg": "新建組織", + "setupCreateOrg": "創建組織", + "setupCreateResources": "創建資源", + "setupOrgName": "組織名稱", + "orgDisplayName": "這是您組織的顯示名稱。", + "orgId": "組織ID", + "setupIdentifierMessage": "這是您組織的唯一標識符。這是與顯示名稱分開的。", + "setupErrorIdentifier": "組織ID 已被使用。請另選一個。", + "componentsErrorNoMemberCreate": "您目前不是任何組織的成員。創建組織以開始操作。", + "componentsErrorNoMember": "您目前不是任何組織的成員。", + "welcome": "歡迎使用 Pangolin", + "welcomeTo": "歡迎來到", + "componentsCreateOrg": "創建組織", + "componentsMember": "您屬於 {count, plural, =0 {沒有組織} one {一個組織} other {# 個組織}}。", + "componentsInvalidKey": "檢測到無效或過期的許可證金鑰。按照許可證條款操作以繼續使用所有功能。", + "dismiss": "忽略", + "componentsLicenseViolation": "許可證超限:該伺服器使用了 {usedSites} 個站點,已超過授權的 {maxSites} 個。請遵守許可證條款以繼續使用全部功能。", + "componentsSupporterMessage": "感謝您的支持!您現在是 Pangolin 的 {tier} 用戶。", + "inviteErrorNotValid": "很抱歉,但看起來你試圖訪問的邀請尚未被接受或不再有效。", + "inviteErrorUser": "很抱歉,但看起來你想要訪問的邀請不是這個用戶。", + "inviteLoginUser": "請確保您以正確的用戶登錄。", + "inviteErrorNoUser": "很抱歉,但看起來你想訪問的邀請不是一個存在的用戶。", + "inviteCreateUser": "請先創建一個帳戶。", + "goHome": "返回首頁", + "inviteLogInOtherUser": "以不同的用戶登錄", + "createAnAccount": "創建帳戶", + "inviteNotAccepted": "邀請未接受", + "authCreateAccount": "創建一個帳戶以開始", + "authNoAccount": "沒有帳戶?", + "email": "電子郵件地址", + "password": "密碼", + "confirmPassword": "確認密碼", + "createAccount": "創建帳戶", + "viewSettings": "查看設置", + "delete": "刪除", + "name": "名稱", + "online": "在線", + "offline": "離線的", + "site": "站點", + "dataIn": "數據輸入", + "dataOut": "數據輸出", + "connectionType": "連接類型", + "tunnelType": "隧道類型", + "local": "本地的", + "edit": "編輯", + "siteConfirmDelete": "確認刪除站點", + "siteDelete": "刪除站點", + "siteMessageRemove": "一旦移除,站點將無法訪問。與站點相關的所有目標也將被移除。", + "siteQuestionRemove": "您確定要從組織中刪除該站點嗎?", + "siteManageSites": "管理站點", + "siteDescription": "允許通過安全隧道連接到您的網路", + "sitesBannerTitle": "連接任何網路", + "sitesBannerDescription": "站點是與遠端網路的連接,使 Pangolin 能夠為任何地方的使用者提供對公共或私有資源的存取。在任何可以執行二進位檔案或容器的地方安裝站點網路連接器 (Newt) 以建立連接。", + "sitesBannerButtonText": "安裝站點", + "siteCreate": "創建站點", + "siteCreateDescription2": "按照下面的步驟創建和連接一個新站點", + "siteCreateDescription": "創建一個新站點開始連接您的資源", + "close": "關閉", + "siteErrorCreate": "創建站點出錯", + "siteErrorCreateKeyPair": "找不到金鑰對或站點預設值", + "siteErrorCreateDefaults": "未找到站點預設值", + "method": "方法", + "siteMethodDescription": "這是您將如何顯示連接。", + "siteLearnNewt": "學習如何在您的系統上安裝 Newt", + "siteSeeConfigOnce": "您只能看到一次配置。", + "siteLoadWGConfig": "正在載入 WireGuard 配置...", + "siteDocker": "擴展 Docker 部署詳細資訊", + "toggle": "切換", + "dockerCompose": "Docker Compose", + "dockerRun": "Docker Run", + "siteLearnLocal": "本地站點不需要隧道連接,點擊了解更多", + "siteConfirmCopy": "我已經複製了配置資訊", + "searchSitesProgress": "搜索站點...", + "siteAdd": "添加站點", + "siteInstallNewt": "安裝 Newt", + "siteInstallNewtDescription": "在您的系統中運行 Newt", + "WgConfiguration": "WireGuard 配置", + "WgConfigurationDescription": "使用以下配置連接到您的網路", + "operatingSystem": "操作系統", + "commands": "命令", + "recommended": "推薦", + "siteNewtDescription": "為獲得最佳用戶體驗,請使用 Newt。其底層採用 WireGuard 技術,可直接通過 Pangolin 控制台,使用區域網路地址訪問您私有網路中的資源。", + "siteRunsInDocker": "在 Docker 中運行", + "siteRunsInShell": "在 macOS 、 Linux 和 Windows 的 Shell 中運行", + "siteErrorDelete": "刪除站點出錯", + "siteErrorUpdate": "更新站點失敗", + "siteErrorUpdateDescription": "更新站點時出錯。", + "siteUpdated": "站點已更新", + "siteUpdatedDescription": "網站已更新。", + "siteGeneralDescription": "配置此站點的常規設置", + "siteSettingDescription": "配置您網站上的設置", + "siteSetting": "{siteName} 設置", + "siteNewtTunnel": "Newt 隧道 (推薦)", + "siteNewtTunnelDescription": "最簡單的方式來連接到您的網路。不需要任何額外設置。", + "siteWg": "基本 WireGuard", + "siteWgDescription": "使用任何 WireGuard 用戶端來建立隧道。需要手動配置 NAT。", + "siteWgDescriptionSaas": "使用任何 WireGuard 用戶端建立隧道。需要手動配置 NAT。僅適用於自託管節點。", + "siteLocalDescription": "僅限本地資源。不需要隧道。", + "siteLocalDescriptionSaas": "僅本地資源。沒有隧道。僅在遠程節點上可用。", + "siteSeeAll": "查看所有站點", + "siteTunnelDescription": "確定如何連接到您的網站", + "siteNewtCredentials": "Newt 憑證", + "siteNewtCredentialsDescription": "這是 Newt 伺服器的身份驗證憑證", + "remoteNodeCredentialsDescription": "這是遠端節點與伺服器進行驗證的方式", + "siteCredentialsSave": "保存您的憑證", + "siteCredentialsSaveDescription": "您只能看到一次。請確保將其複製並保存到一個安全的地方。", + "siteInfo": "站點資訊", + "status": "狀態", + "shareTitle": "管理共享連結", + "shareDescription": "創建可共享的連結,允許暫時或永久訪問您的資源", + "shareSearch": "搜索共享連結...", + "shareCreate": "創建共享連結", + "shareErrorDelete": "刪除連結失敗", + "shareErrorDeleteMessage": "刪除連結時出錯", + "shareDeleted": "連結已刪除", + "shareDeletedDescription": "連結已刪除", + "shareTokenDescription": "您的訪問令牌可以透過兩種方式傳遞:作為查詢參數或請求頭。 每次驗證訪問請求都必須從用戶端傳遞。", + "accessToken": "訪問令牌", + "usageExamples": "用法範例", + "tokenId": "令牌 ID", + "requestHeades": "請求頭", + "queryParameter": "查詢參數", + "importantNote": "重要提示", + "shareImportantDescription": "出於安全考慮,建議盡可能在使用請求頭傳遞參數,因為查詢參數可能會被瀏覽器歷史記錄或伺服器日誌記錄。", + "token": "令牌", + "shareTokenSecurety": "請妥善保管您的訪問令牌,不要將其暴露在公開訪問的區域或用戶端代碼中。", + "shareErrorFetchResource": "獲取資源失敗", + "shareErrorFetchResourceDescription": "獲取資源時出錯", + "shareErrorCreate": "無法創建共享連結", + "shareErrorCreateDescription": "創建共享連結時出錯", + "shareCreateDescription": "任何具有此連結的人都可以訪問資源", + "shareTitleOptional": "標題 (可選)", + "expireIn": "過期時間", + "neverExpire": "永不過期", + "shareExpireDescription": "過期時間是連結可以使用並提供對資源的訪問時間。 此時間後,連結將不再工作,使用此連結的用戶將失去對資源的訪問。", + "shareSeeOnce": "您只能看到一次此連結。請確保複製它。", + "shareAccessHint": "任何具有此連結的人都可以訪問該資源。小心地分享它。", + "shareTokenUsage": "查看訪問令牌使用情況", + "createLink": "創建連結", + "resourcesNotFound": "找不到資源", + "resourceSearch": "搜索資源", + "openMenu": "打開菜單", + "resource": "資源", + "title": "標題", + "created": "已創建", + "expires": "過期時間", + "never": "永不過期", + "shareErrorSelectResource": "請選擇一個資源", + "proxyResourceTitle": "管理公開資源", + "proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源", + "proxyResourcesBannerTitle": "基於網頁的公開存取", + "proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", + "clientResourceTitle": "管理私有資源", + "clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源", + "privateResourcesBannerTitle": "零信任私有存取", + "privateResourcesBannerDescription": "私有資源使用零信任安全性,確保使用者和機器只能存取您明確授權的資源。連接使用者裝置或機器客戶端以透過安全的虛擬私人網路存取這些資源。", + "resourcesSearch": "搜索資源...", + "resourceAdd": "添加資源", + "resourceErrorDelte": "刪除資源時出錯", + "authentication": "認證", + "protected": "受到保護", + "notProtected": "未受到保護", + "resourceMessageRemove": "一旦刪除,資源將不再可訪問。與該資源相關的所有目標也將被刪除。", + "resourceQuestionRemove": "您確定要從組織中刪除資源嗎?", + "resourceHTTP": "HTTPS 資源", + "resourceHTTPDescription": "使用子域或根域名通過 HTTPS 向您的應用程式提出代理請求。", + "resourceRaw": "TCP/UDP 資源", + "resourceRawDescription": "使用 TCP/UDP 使用埠號向您的應用提出代理請求。", + "resourceCreate": "創建資源", + "resourceCreateDescription": "按照下面的步驟創建新資源", + "resourceSeeAll": "查看所有資源", + "resourceInfo": "資源資訊", + "resourceNameDescription": "這是資源的顯示名稱。", + "siteSelect": "選擇站點", + "siteSearch": "搜索站點", + "siteNotFound": "未找到站點。", + "selectCountry": "選擇國家", + "searchCountries": "搜索國家...", + "noCountryFound": "找不到國家。", + "siteSelectionDescription": "此站點將為目標提供連接。", + "resourceType": "資源類型", + "resourceTypeDescription": "確定如何訪問您的資源", + "resourceHTTPSSettings": "HTTPS 設置", + "resourceHTTPSSettingsDescription": "配置如何通過 HTTPS 訪問您的資源", + "domainType": "域類型", + "subdomain": "子域名", + "baseDomain": "根域名", + "subdomnainDescription": "您的資源可以訪問的子域名。", + "resourceRawSettings": "TCP/UDP 設置", + "resourceRawSettingsDescription": "設定如何透過 TCP/UDP 存取資源", + "protocol": "協議", + "protocolSelect": "選擇協議", + "resourcePortNumber": "埠號", + "resourcePortNumberDescription": "代理請求的外部埠號。", + "cancel": "取消", + "resourceConfig": "配置片段", + "resourceConfigDescription": "複製並黏貼這些配置片段以設置您的 TCP/UDP 資源", + "resourceAddEntrypoints": "Traefik: 添加入口點", + "resourceExposePorts": "Gerbil:在 Docker Compose 中顯示埠", + "resourceLearnRaw": "學習如何配置 TCP/UDP 資源", + "resourceBack": "返回資源", + "resourceGoTo": "轉到資源", + "resourceDelete": "刪除資源", + "resourceDeleteConfirm": "確認刪除資源", + "visibility": "可見性", + "enabled": "已啟用", + "disabled": "已禁用", + "general": "概覽", + "generalSettings": "常規設置", + "proxy": "代理伺服器", + "internal": "內部設置", + "rules": "規則", + "resourceSettingDescription": "配置您資源上的設置", + "resourceSetting": "{resourceName} 設置", + "alwaysAllow": "一律允許", + "alwaysDeny": "一律拒絕", + "passToAuth": "傳遞至認證", + "orgSettingsDescription": "配置您組織的一般設定", + "orgGeneralSettings": "組織設置", + "orgGeneralSettingsDescription": "管理您的機構詳細資訊和配置", + "saveGeneralSettings": "保存常規設置", + "saveSettings": "保存設置", + "orgDangerZone": "危險區域", + "orgDangerZoneDescription": "一旦刪除該組織,將無法恢復,請務必確認。", + "orgDelete": "刪除組織", + "orgDeleteConfirm": "確認刪除組織", + "orgMessageRemove": "此操作不可逆,這將刪除所有相關數據。", + "orgMessageConfirm": "要確認,請在下面輸入組織名稱。", + "orgQuestionRemove": "您確定要刪除組織嗎?", + "orgUpdated": "組織已更新", + "orgUpdatedDescription": "組織已更新。", + "orgErrorUpdate": "更新組織失敗", + "orgErrorUpdateMessage": "更新組織時出錯。", + "orgErrorFetch": "獲取組織失敗", + "orgErrorFetchMessage": "列出您的組織時出錯", + "orgErrorDelete": "刪除組織失敗", + "orgErrorDeleteMessage": "刪除組織時出錯。", + "orgDeleted": "組織已刪除", + "orgDeletedMessage": "組織及其數據已被刪除。", + "orgMissing": "缺少組織 ID", + "orgMissingMessage": "沒有組織ID,無法重新生成邀請。", + "accessUsersManage": "管理用戶", + "accessUsersDescription": "邀請用戶並位他們添加角色以管理訪問您的組織", + "accessUsersSearch": "搜索用戶...", + "accessUserCreate": "創建用戶", + "accessUserRemove": "刪除用戶", + "username": "使用者名稱", + "identityProvider": "身份提供商", + "role": "角色", + "nameRequired": "名稱是必填項", + "accessRolesManage": "管理角色", + "accessRolesDescription": "配置角色來管理訪問您的組織", + "accessRolesSearch": "搜索角色...", + "accessRolesAdd": "添加角色", + "accessRoleDelete": "刪除角色", + "description": "描述", + "inviteTitle": "打開邀請", + "inviteDescription": "管理您給其他用戶的邀請", + "inviteSearch": "搜索邀請...", + "minutes": "分鐘", + "hours": "小時", + "days": "天", + "weeks": "周", + "months": "月", + "years": "年", + "day": "{count, plural, other {# 天}}", + "apiKeysTitle": "API 金鑰", + "apiKeysConfirmCopy2": "您必須確認您已複製 API 金鑰。", + "apiKeysErrorCreate": "創建 API 金鑰出錯", + "apiKeysErrorSetPermission": "設置權限出錯", + "apiKeysCreate": "生成 API 金鑰", + "apiKeysCreateDescription": "為您的組織生成一個新的 API 金鑰", + "apiKeysGeneralSettings": "權限", + "apiKeysGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", + "apiKeysList": "您的 API 金鑰", + "apiKeysSave": "保存您的 API 金鑰", + "apiKeysSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全的位置。", + "apiKeysInfo": "您的 API 金鑰是:", + "apiKeysConfirmCopy": "我已複製 API 金鑰", + "generate": "生成", + "done": "完成", + "apiKeysSeeAll": "查看所有 API 金鑰", + "apiKeysPermissionsErrorLoadingActions": "載入 API 金鑰操作時出錯", + "apiKeysPermissionsErrorUpdate": "設置權限出錯", + "apiKeysPermissionsUpdated": "權限已更新", + "apiKeysPermissionsUpdatedDescription": "權限已更新。", + "apiKeysPermissionsGeneralSettings": "權限", + "apiKeysPermissionsGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", + "apiKeysPermissionsSave": "保存權限", + "apiKeysPermissionsTitle": "權限", + "apiKeys": "API 金鑰", + "searchApiKeys": "搜索 API 金鑰...", + "apiKeysAdd": "生成 API 金鑰", + "apiKeysErrorDelete": "刪除 API 金鑰出錯", + "apiKeysErrorDeleteMessage": "刪除 API 金鑰出錯", + "apiKeysQuestionRemove": "您確定要從組織中刪除 API 金鑰嗎?", + "apiKeysMessageRemove": "一旦刪除,此API金鑰將無法被使用。", + "apiKeysDeleteConfirm": "確認刪除 API 金鑰", + "apiKeysDelete": "刪除 API 金鑰", + "apiKeysManage": "管理 API 金鑰", + "apiKeysDescription": "API 金鑰用於認證集成 API", + "apiKeysSettings": "{apiKeyName} 設置", + "userTitle": "管理所有用戶", + "userDescription": "查看和管理系統中的所有用戶", + "userAbount": "關於用戶管理", + "userAbountDescription": "此表格顯示系統中所有根用戶對象。每個用戶可能屬於多個組織。 從組織中刪除用戶不會刪除其根用戶對象 - 他們將保留在系統中。 要從系統中完全刪除用戶,您必須使用此表格中的刪除操作刪除其根用戶對象。", + "userServer": "伺服器用戶", + "userSearch": "搜索伺服器用戶...", + "userErrorDelete": "刪除用戶時出錯", + "userDeleteConfirm": "確認刪除用戶", + "userDeleteServer": "從伺服器刪除用戶", + "userMessageRemove": "該用戶將被從所有組織中刪除並完全從伺服器中刪除。", + "userQuestionRemove": "您確定要從伺服器永久刪除用戶嗎?", + "licenseKey": "許可證金鑰", + "valid": "有效", + "numberOfSites": "站點數量", + "licenseKeySearch": "搜索許可證金鑰...", + "licenseKeyAdd": "添加許可證金鑰", + "type": "類型", + "licenseKeyRequired": "需要許可證金鑰", + "licenseTermsAgree": "您必須同意許可條款", + "licenseErrorKeyLoad": "載入許可證金鑰失敗", + "licenseErrorKeyLoadDescription": "載入許可證金鑰時出錯。", + "licenseErrorKeyDelete": "刪除許可證金鑰失敗", + "licenseErrorKeyDeleteDescription": "刪除許可證金鑰時出錯。", + "licenseKeyDeleted": "許可證金鑰已刪除", + "licenseKeyDeletedDescription": "許可證金鑰已被刪除。", + "licenseErrorKeyActivate": "啟用許可證金鑰失敗", + "licenseErrorKeyActivateDescription": "啟用許可證金鑰時出錯。", + "licenseAbout": "關於許可協議", + "licenseBannerTitle": "Enable Your Enterprise License", + "licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.", + "licenseBannerGetLicense": "Get a License", + "licenseBannerViewDocs": "View Documentation", + "communityEdition": "社區版", + "licenseAboutDescription": "這是針對商業環境中使用Pangolin的商業和企業用戶。 如果您正在使用 Pangolin 供個人使用,您可以忽略此部分。", + "licenseKeyActivated": "授權金鑰已啟用", + "licenseKeyActivatedDescription": "已成功啟用許可證金鑰。", + "licenseErrorKeyRecheck": "重新檢查許可證金鑰失敗", + "licenseErrorKeyRecheckDescription": "重新檢查許可證金鑰時出錯。", + "licenseErrorKeyRechecked": "重新檢查許可證金鑰", + "licenseErrorKeyRecheckedDescription": "已重新檢查所有許可證金鑰", + "licenseActivateKey": "啟用許可證金鑰", + "licenseActivateKeyDescription": "輸入一個許可金鑰來啟用它。", + "licenseActivate": "啟用許可證", + "licenseAgreement": "通過檢查此框,您確認您已經閱讀並同意與您的許可證金鑰相關的許可條款。", + "fossorialLicense": "查看Fossorial Commercial License和訂閱條款", + "licenseMessageRemove": "這將刪除許可證金鑰和它授予的所有相關權限。", + "licenseMessageConfirm": "要確認,請在下面輸入許可證金鑰。", + "licenseQuestionRemove": "您確定要刪除許可證金鑰?", + "licenseKeyDelete": "刪除許可證金鑰", + "licenseKeyDeleteConfirm": "確認刪除許可證金鑰", + "licenseTitle": "管理許可證狀態", + "licenseTitleDescription": "查看和管理系統中的許可證金鑰", + "licenseHost": "主機許可證", + "licenseHostDescription": "管理主機的主許可證金鑰。", + "licensedNot": "未授權", + "hostId": "主機 ID", + "licenseReckeckAll": "重新檢查所有金鑰", + "licenseSiteUsage": "站點使用情況", + "licenseSiteUsageDecsription": "查看使用此許可的站點數量。", + "licenseNoSiteLimit": "使用未經許可主機的站點數量沒有限制。", + "licensePurchase": "購買許可證", + "licensePurchaseSites": "購買更多站點", + "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 個站點", + "licenseSitesUsed": "{count, plural, =0 {# 站點} one {# 站點} other {# 站點}}", + "licensePurchaseDescription": "請選擇您希望 {selectedMode, select, license {直接購買許可證,您可以隨時增加更多站點。} other {為現有許可證購買更多站點}}", + "licenseFee": "許可證費用", + "licensePriceSite": "每個站點的價格", + "total": "總計", + "licenseContinuePayment": "繼續付款", + "pricingPage": "定價頁面", + "pricingPortal": "前往付款頁面", + "licensePricingPage": "關於最新的價格和折扣,請訪問 ", + "invite": "邀請", + "inviteRegenerate": "重新生成邀請", + "inviteRegenerateDescription": "撤銷以前的邀請並創建一個新的邀請", + "inviteRemove": "移除邀請", + "inviteRemoveError": "刪除邀請失敗", + "inviteRemoveErrorDescription": "刪除邀請時出錯。", + "inviteRemoved": "邀請已刪除", + "inviteRemovedDescription": "為 {email} 創建的邀請已刪除", + "inviteQuestionRemove": "您確定要刪除邀請嗎?", + "inviteMessageRemove": "一旦刪除,這個邀請將不再有效。", + "inviteMessageConfirm": "要確認,請在下面輸入邀請的電子郵件地址。", + "inviteQuestionRegenerate": "您確定要重新邀請 {email} 嗎?這將會撤銷掉之前的邀請", + "inviteRemoveConfirm": "確認刪除邀請", + "inviteRegenerated": "重新生成邀請", + "inviteSent": "邀請郵件已成功發送至 {email}。", + "inviteSentEmail": "發送電子郵件通知給用戶", + "inviteGenerate": "已為 {email} 創建新的邀請。", + "inviteDuplicateError": "重複的邀請", + "inviteDuplicateErrorDescription": "此用戶的邀請已存在。", + "inviteRateLimitError": "超出速率限制", + "inviteRateLimitErrorDescription": "您超過了每小時3次再生的限制。請稍後再試。", + "inviteRegenerateError": "重新生成邀請失敗", + "inviteRegenerateErrorDescription": "重新生成邀請時出錯。", + "inviteValidityPeriod": "有效期", + "inviteValidityPeriodSelect": "選擇有效期", + "inviteRegenerateMessage": "邀請已重新生成。用戶必須訪問下面的連結才能接受邀請。", + "inviteRegenerateButton": "重新生成", + "expiresAt": "到期於", + "accessRoleUnknown": "未知角色", + "placeholder": "占位符", + "userErrorOrgRemove": "刪除用戶失敗", + "userErrorOrgRemoveDescription": "刪除用戶時出錯。", + "userOrgRemoved": "用戶已刪除", + "userOrgRemovedDescription": "已將 {email} 從組織中移除。", + "userQuestionOrgRemove": "您確定要從組織中刪除此用戶嗎?", + "userMessageOrgRemove": "一旦刪除,這個用戶將不再能夠訪問組織。 你總是可以稍後重新邀請他們,但他們需要再次接受邀請。", + "userRemoveOrgConfirm": "確認刪除用戶", + "userRemoveOrg": "從組織中刪除用戶", + "users": "用戶", + "accessRoleMember": "成員", + "accessRoleOwner": "所有者", + "userConfirmed": "已確認", + "idpNameInternal": "內部設置", + "emailInvalid": "無效的電子郵件地址", + "inviteValidityDuration": "請選擇持續時間", + "accessRoleSelectPlease": "請選擇一個角色", + "usernameRequired": "必須輸入使用者名稱", + "idpSelectPlease": "請選擇身份提供商", + "idpGenericOidc": "通用的 OAuth2/OIDC 提供商。", + "accessRoleErrorFetch": "獲取角色失敗", + "accessRoleErrorFetchDescription": "獲取角色時出錯", + "idpErrorFetch": "獲取身份提供者失敗", + "idpErrorFetchDescription": "獲取身份提供者時出錯", + "userErrorExists": "用戶已存在", + "userErrorExistsDescription": "此用戶已經是組織成員。", + "inviteError": "邀請用戶失敗", + "inviteErrorDescription": "邀請用戶時出錯", + "userInvited": "用戶邀請", + "userInvitedDescription": "用戶已被成功邀請。", + "userErrorCreate": "創建用戶失敗", + "userErrorCreateDescription": "創建用戶時出錯", + "userCreated": "用戶已創建", + "userCreatedDescription": "用戶已成功創建。", + "userTypeInternal": "內部用戶", + "userTypeInternalDescription": "邀請用戶直接加入您的組織。", + "userTypeExternal": "外部用戶", + "userTypeExternalDescription": "創建一個具有外部身份提供商的用戶。", + "accessUserCreateDescription": "按照下面的步驟創建一個新用戶", + "userSeeAll": "查看所有用戶", + "userTypeTitle": "用戶類型", + "userTypeDescription": "確定如何創建用戶", + "userSettings": "用戶資訊", + "userSettingsDescription": "輸入新用戶的詳細資訊", + "inviteEmailSent": "發送邀請郵件給用戶", + "inviteValid": "有效", + "selectDuration": "選擇持續時間", + "selectResource": "選擇資源", + "filterByResource": "依資源篩選", + "resetFilters": "重設篩選條件", + "totalBlocked": "被 Pangolin 阻擋的請求", + "totalRequests": "總請求數", + "requestsByCountry": "依國家/地區的請求", + "requestsByDay": "依日期的請求", + "blocked": "已阻擋", + "allowed": "已允許", + "topCountries": "熱門國家/地區", + "accessRoleSelect": "選擇角色", + "inviteEmailSentDescription": "一封電子郵件已經發送給用戶,帶有下面的訪問連結。他們必須訪問該連結才能接受邀請。", + "inviteSentDescription": "用戶已被邀請。他們必須訪問下面的連結才能接受邀請。", + "inviteExpiresIn": "邀請將在{days, plural, other {# 天}}後過期。", + "idpTitle": "身份提供商", + "idpSelect": "為外部用戶選擇身份提供商", + "idpNotConfigured": "沒有配置身份提供者。請在創建外部用戶之前配置身份提供者。", + "usernameUniq": "這必須匹配所選身份提供者中存在的唯一使用者名稱。", + "emailOptional": "電子郵件(可選)", + "nameOptional": "名稱(可選)", + "accessControls": "訪問控制", + "userDescription2": "管理此用戶的設置", + "accessRoleErrorAdd": "添加用戶到角色失敗", + "accessRoleErrorAddDescription": "添加用戶到角色時出錯。", + "userSaved": "用戶已保存", + "userSavedDescription": "用戶已更新。", + "autoProvisioned": "自動設置", + "autoProvisionedDescription": "允許此用戶由身份提供商自動管理", + "accessControlsDescription": "管理此用戶在組織中可以訪問和做什麼", + "accessControlsSubmit": "保存訪問控制", + "roles": "角色", + "accessUsersRoles": "管理用戶和角色", + "accessUsersRolesDescription": "邀請用戶並將他們添加到角色以管理訪問您的組織", + "key": "關鍵字", + "createdAt": "創建於", + "proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。", + "proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。", + "proxyEnableSSL": "啟用 SSL", + "proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。", + "target": "目標", + "configureTarget": "配置目標", + "targetErrorFetch": "獲取目標失敗", + "targetErrorFetchDescription": "獲取目標時出錯", + "siteErrorFetch": "獲取資源失敗", + "siteErrorFetchDescription": "獲取資源時出錯", + "targetErrorDuplicate": "重複的目標", + "targetErrorDuplicateDescription": "具有這些設置的目標已存在", + "targetWireGuardErrorInvalidIp": "無效的目標IP", + "targetWireGuardErrorInvalidIpDescription": "目標IP必須在站點子網內", + "targetsUpdated": "目標已更新", + "targetsUpdatedDescription": "目標和設置更新成功", + "targetsErrorUpdate": "更新目標失敗", + "targetsErrorUpdateDescription": "更新目標時出錯", + "targetTlsUpdate": "TLS 設置已更新", + "targetTlsUpdateDescription": "您的 TLS 設置已成功更新", + "targetErrorTlsUpdate": "更新 TLS 設置失敗", + "targetErrorTlsUpdateDescription": "更新 TLS 設置時出錯", + "proxyUpdated": "代理設置已更新", + "proxyUpdatedDescription": "您的代理設置已成功更新", + "proxyErrorUpdate": "更新代理設置失敗", + "proxyErrorUpdateDescription": "更新代理設置時出錯", + "targetAddr": "IP / 域名", + "targetPort": "埠", + "targetProtocol": "協議", + "targetTlsSettings": "安全連接配置", + "targetTlsSettingsDescription": "配置資源的 SSL/TLS 設置", + "targetTlsSettingsAdvanced": "高級TLS設置", + "targetTlsSni": "TLS 伺服器名稱", + "targetTlsSniDescription": "SNI使用的 TLS 伺服器名稱。留空使用預設值。", + "targetTlsSubmit": "保存設置", + "targets": "目標配置", + "targetsDescription": "設置目標來路由流量到您的後端服務", + "targetStickySessions": "啟用置頂會話", + "targetStickySessionsDescription": "將連接保持在同一個後端目標的整個會話中。", + "methodSelect": "選擇方法", + "targetSubmit": "添加目標", + "targetNoOne": "此資源沒有任何目標。添加目標來配置向您後端發送請求的位置。", + "targetNoOneDescription": "在上面添加多個目標將啟用負載平衡。", + "targetsSubmit": "保存目標", + "addTarget": "添加目標", + "targetErrorInvalidIp": "無效的 IP 地址", + "targetErrorInvalidIpDescription": "請輸入有效的IP位址或主機名", + "targetErrorInvalidPort": "無效的埠", + "targetErrorInvalidPortDescription": "請輸入有效的埠號", + "targetErrorNoSite": "沒有選擇站點", + "targetErrorNoSiteDescription": "請選擇目標站點", + "targetCreated": "目標已創建", + "targetCreatedDescription": "目標已成功創建", + "targetErrorCreate": "創建目標失敗", + "targetErrorCreateDescription": "創建目標時出錯", + "tlsServerName": "TLS 伺服器名稱", + "tlsServerNameDescription": "用於 SNI 的 TLS 伺服器名稱", + "save": "保存", + "proxyAdditional": "附加代理設置", + "proxyAdditionalDescription": "配置你的資源如何處理代理設置", + "proxyCustomHeader": "自訂主機 Header", + "proxyCustomHeaderDescription": "代理請求時設置的 Header。留空則使用預設值。", + "proxyAdditionalSubmit": "保存代理設置", + "subnetMaskErrorInvalid": "子網掩碼無效。必須在 0 和 32 之間。", + "ipAddressErrorInvalidFormat": "無效的 IP 地址格式", + "ipAddressErrorInvalidOctet": "無效的 IP 地址", + "path": "路徑", + "matchPath": "匹配路徑", + "ipAddressRange": "IP 範圍", + "rulesErrorFetch": "獲取規則失敗", + "rulesErrorFetchDescription": "獲取規則時出錯", + "rulesErrorDuplicate": "複製規則", + "rulesErrorDuplicateDescription": "帶有這些設置的規則已存在", + "rulesErrorInvalidIpAddressRange": "無效的 CIDR", + "rulesErrorInvalidIpAddressRangeDescription": "請輸入一個有效的 CIDR 值", + "rulesErrorInvalidUrl": "無效的 URL 路徑", + "rulesErrorInvalidUrlDescription": "請輸入一個有效的 URL 路徑值", + "rulesErrorInvalidIpAddress": "無效的 IP", + "rulesErrorInvalidIpAddressDescription": "請輸入一個有效的IP位址", + "rulesErrorUpdate": "更新規則失敗", + "rulesErrorUpdateDescription": "更新規則時出錯", + "rulesUpdated": "啟用規則", + "rulesUpdatedDescription": "規則已更新", + "rulesMatchIpAddressRangeDescription": "以 CIDR 格式輸入地址(如:103.21.244.0/22)", + "rulesMatchIpAddress": "輸入IP位址(例如,103.21.244.12)", + "rulesMatchUrl": "輸入一個 URL 路徑或模式(例如/api/v1/todos 或 /api/v1/*)", + "rulesErrorInvalidPriority": "無效的優先度", + "rulesErrorInvalidPriorityDescription": "請輸入一個有效的優先度", + "rulesErrorDuplicatePriority": "重複的優先度", + "rulesErrorDuplicatePriorityDescription": "請輸入唯一的優先度", + "ruleUpdated": "規則已更新", + "ruleUpdatedDescription": "規則更新成功", + "ruleErrorUpdate": "操作失敗", + "ruleErrorUpdateDescription": "保存過程中發生錯誤", + "rulesPriority": "優先權", + "rulesAction": "行為", + "rulesMatchType": "匹配類型", + "value": "值", + "rulesAbout": "關於規則", + "rulesAboutDescription": "規則使您能夠依據特定條件控制資源訪問權限。您可以創建基於 IP 地址或 URL 路徑的規則,以允許或拒絕訪問。", + "rulesActions": "行動", + "rulesActionAlwaysAllow": "總是允許:繞過所有身份驗證方法", + "rulesActionAlwaysDeny": "總是拒絕:阻止所有請求;無法嘗試驗證", + "rulesActionPassToAuth": "傳遞至認證:允許嘗試身份驗證方法", + "rulesMatchCriteria": "匹配條件", + "rulesMatchCriteriaIpAddress": "匹配一個指定的 IP 地址", + "rulesMatchCriteriaIpAddressRange": "在 CIDR 符號中匹配一系列IP位址", + "rulesMatchCriteriaUrl": "匹配一個 URL 路徑或模式", + "rulesEnable": "啟用規則", + "rulesEnableDescription": "啟用或禁用此資源的規則評估", + "rulesResource": "資源規則配置", + "rulesResourceDescription": "配置規則來控制對您資源的訪問", + "ruleSubmit": "添加規則", + "rulesNoOne": "沒有規則。使用表單添加規則。", + "rulesOrder": "規則按優先順序評定。", + "rulesSubmit": "保存規則", + "resourceErrorCreate": "創建資源時出錯", + "resourceErrorCreateDescription": "創建資源時出錯", + "resourceErrorCreateMessage": "創建資源時發生錯誤:", + "resourceErrorCreateMessageDescription": "發生意外錯誤", + "sitesErrorFetch": "獲取站點出錯", + "sitesErrorFetchDescription": "獲取站點時出錯", + "domainsErrorFetch": "獲取域名出錯", + "domainsErrorFetchDescription": "獲取域時出錯", + "none": "無", + "unknown": "未知", + "resources": "資源", + "resourcesDescription": "資源是您私有網路中運行的應用程式的代理。您可以為私有網路中的任何 HTTP/HTTPS 或 TCP/UDP 服務創建資源。每個資源都必須連接到一個站點,以通過加密的 WireGuard 隧道實現私密且安全的連接。", + "resourcesWireGuardConnect": "採用 WireGuard 提供的加密安全連接", + "resourcesMultipleAuthenticationMethods": "配置多個身份驗證方法", + "resourcesUsersRolesAccess": "基於用戶和角色的訪問控制", + "resourcesErrorUpdate": "切換資源失敗", + "resourcesErrorUpdateDescription": "更新資源時出錯", + "access": "訪問權限", + "shareLink": "{resource} 的分享連結", + "resourceSelect": "選擇資源", + "shareLinks": "分享連結", + "share": "分享連結", + "shareDescription2": "創建資源共享連結。連結提供對資源的臨時或無限制訪問。 當您創建連結時,您可以配置連結的到期時間。", + "shareEasyCreate": "輕鬆創建和分享", + "shareConfigurableExpirationDuration": "可配置的過期時間", + "shareSecureAndRevocable": "安全和可撤銷的", + "nameMin": "名稱長度必須大於 {len} 字元。", + "nameMax": "名稱長度必須小於 {len} 字元。", + "sitesConfirmCopy": "請確認您已經複製了配置。", + "unknownCommand": "未知命令", + "newtErrorFetchReleases": "無法獲取版本資訊: {err}", + "newtErrorFetchLatest": "無法獲取最新版資訊: {err}", + "newtEndpoint": "Newt 端點", + "newtId": "Newt ID", + "newtSecretKey": "Newt 私鑰", + "architecture": "架構", + "sites": "站點", + "siteWgAnyClients": "使用任何 WireGuard 用戶端連接。您必須使用對等IP解決您的內部資源。", + "siteWgCompatibleAllClients": "與所有 WireGuard 用戶端相容", + "siteWgManualConfigurationRequired": "需要手動配置", + "userErrorNotAdminOrOwner": "用戶不是管理員或所有者", + "pangolinSettings": "設置 - Pangolin", + "accessRoleYour": "您的角色:", + "accessRoleSelect2": "選擇角色", + "accessUserSelect": "選擇一個用戶", + "otpEmailEnter": "輸入電子郵件", + "otpEmailEnterDescription": "在輸入欄位輸入後按 Enter 鍵添加電子郵件。", + "otpEmailErrorInvalid": "無效的信箱地址。通配符(*)必須占據整個開頭部分。", + "otpEmailSmtpRequired": "需要先配置 SMTP", + "otpEmailSmtpRequiredDescription": "必須在伺服器上啟用 SMTP 才能使用一次性密碼驗證。", + "otpEmailTitle": "一次性密碼", + "otpEmailTitleDescription": "資源訪問需要基於電子郵件的身份驗證", + "otpEmailWhitelist": "電子郵件白名單", + "otpEmailWhitelistList": "白名單郵件", + "otpEmailWhitelistListDescription": "只有擁有這些電子郵件地址的用戶才能訪問此資源。 他們將被提示輸入一次性密碼發送到他們的電子郵件。 通配符 (*@example.com) 可以用來允許來自一個域名的任何電子郵件地址。", + "otpEmailWhitelistSave": "保存白名單", + "passwordAdd": "添加密碼", + "passwordRemove": "刪除密碼", + "pincodeAdd": "添加 PIN 碼", + "pincodeRemove": "移除 PIN 碼", + "resourceAuthMethods": "身份驗證方法", + "resourceAuthMethodsDescriptions": "允許透過額外的認證方法訪問資源", + "resourceAuthSettingsSave": "保存成功", + "resourceAuthSettingsSaveDescription": "已保存身份驗證設置", + "resourceErrorAuthFetch": "獲取數據失敗", + "resourceErrorAuthFetchDescription": "獲取數據時出錯", + "resourceErrorPasswordRemove": "刪除資源密碼出錯", + "resourceErrorPasswordRemoveDescription": "刪除資源密碼時出錯", + "resourceErrorPasswordSetup": "設置資源密碼出錯", + "resourceErrorPasswordSetupDescription": "設置資源密碼時出錯", + "resourceErrorPincodeRemove": "刪除資源固定碼時出錯", + "resourceErrorPincodeRemoveDescription": "刪除資源PIN碼時出錯", + "resourceErrorPincodeSetup": "設置資源 PIN 碼時出錯", + "resourceErrorPincodeSetupDescription": "設置資源 PIN 碼時發生錯誤", + "resourceErrorUsersRolesSave": "設置角色失敗", + "resourceErrorUsersRolesSaveDescription": "設置角色時出錯", + "resourceErrorWhitelistSave": "保存白名單失敗", + "resourceErrorWhitelistSaveDescription": "保存白名單時出錯", + "resourcePasswordSubmit": "啟用密碼保護", + "resourcePasswordProtection": "密碼保護 {status}", + "resourcePasswordRemove": "已刪除資源密碼", + "resourcePasswordRemoveDescription": "已成功刪除資源密碼", + "resourcePasswordSetup": "設置資源密碼", + "resourcePasswordSetupDescription": "已成功設置資源密碼", + "resourcePasswordSetupTitle": "設置密碼", + "resourcePasswordSetupTitleDescription": "設置密碼來保護此資源", + "resourcePincode": "PIN 碼", + "resourcePincodeSubmit": "啟用 PIN 碼保護", + "resourcePincodeProtection": "PIN 碼保護 {status}", + "resourcePincodeRemove": "資源 PIN 碼已刪除", + "resourcePincodeRemoveDescription": "已成功刪除資源 PIN 碼", + "resourcePincodeSetup": "資源 PIN 碼已設置", + "resourcePincodeSetupDescription": "資源 PIN 碼已成功設置", + "resourcePincodeSetupTitle": "設置 PIN 碼", + "resourcePincodeSetupTitleDescription": "設置 PIN 碼來保護此資源", + "resourceRoleDescription": "管理員總是可以訪問此資源。", + "resourceUsersRoles": "用戶和角色", + "resourceUsersRolesDescription": "配置用戶和角色可以訪問此資源", + "resourceUsersRolesSubmit": "保存用戶和角色", + "resourceWhitelistSave": "保存成功", + "resourceWhitelistSaveDescription": "白名單設置已保存", + "ssoUse": "使用平台 SSO", + "ssoUseDescription": "對於所有啟用此功能的資源,現有用戶只需登錄一次。", + "proxyErrorInvalidPort": "無效的埠號", + "subdomainErrorInvalid": "無效的子域", + "domainErrorFetch": "獲取域名失敗", + "domainErrorFetchDescription": "獲取域名時出錯", + "resourceErrorUpdate": "更新資源失敗", + "resourceErrorUpdateDescription": "更新資源時出錯", + "resourceUpdated": "資源已更新", + "resourceUpdatedDescription": "資源已成功更新", + "resourceErrorTransfer": "轉移資源失敗", + "resourceErrorTransferDescription": "轉移資源時出錯", + "resourceTransferred": "資源已傳輸", + "resourceTransferredDescription": "資源已成功傳輸", + "resourceErrorToggle": "切換資源失敗", + "resourceErrorToggleDescription": "更新資源時出錯", + "resourceVisibilityTitle": "可見性", + "resourceVisibilityTitleDescription": "完全啟用或禁用資源可見性", + "resourceGeneral": "常規設置", + "resourceGeneralDescription": "配置此資源的常規設置", + "resourceEnable": "啟用資源", + "resourceTransfer": "轉移資源", + "resourceTransferDescription": "將此資源轉移到另一個站點", + "resourceTransferSubmit": "轉移資源", + "siteDestination": "目標站點", + "searchSites": "搜索站點", + "countries": "國家/地區", + "accessRoleCreate": "創建角色", + "accessRoleCreateDescription": "創建一個新角色來分組用戶並管理他們的權限。", + "accessRoleCreateSubmit": "創建角色", + "accessRoleCreated": "角色已創建", + "accessRoleCreatedDescription": "角色已成功創建。", + "accessRoleErrorCreate": "創建角色失敗", + "accessRoleErrorCreateDescription": "創建角色時出錯。", + "accessRoleErrorNewRequired": "需要新角色", + "accessRoleErrorRemove": "刪除角色失敗", + "accessRoleErrorRemoveDescription": "刪除角色時出錯。", + "accessRoleName": "角色名稱", + "accessRoleQuestionRemove": "您即將刪除 {name} 角色。 此操作無法撤銷。", + "accessRoleRemove": "刪除角色", + "accessRoleRemoveDescription": "從組織中刪除角色", + "accessRoleRemoveSubmit": "刪除角色", + "accessRoleRemoved": "角色已刪除", + "accessRoleRemovedDescription": "角色已成功刪除。", + "accessRoleRequiredRemove": "刪除此角色之前,請選擇一個新角色來轉移現有成員。", + "manage": "管理", + "sitesNotFound": "未找到站點。", + "pangolinServerAdmin": "伺服器管理員 - Pangolin", + "licenseTierProfessional": "專業許可證", + "licenseTierEnterprise": "企業許可證", + "licenseTierPersonal": "個人許可證", + "licensed": "已授權", + "yes": "是", + "no": "否", + "sitesAdditional": "其他站點", + "licenseKeys": "許可證金鑰", + "sitestCountDecrease": "減少站點數量", + "sitestCountIncrease": "增加站點數量", + "idpManage": "管理身份提供商", + "idpManageDescription": "查看和管理系統中的身份提供商", + "idpDeletedDescription": "身份提供商刪除成功", + "idpOidc": "OAuth2/OIDC", + "idpQuestionRemove": "您確定要永久刪除身份提供者嗎?", + "idpMessageRemove": "這將刪除身份提供者和所有相關的配置。透過此提供者進行身份驗證的用戶將無法登錄。", + "idpMessageConfirm": "要確認,請在下面輸入身份提供者的名稱。", + "idpConfirmDelete": "確認刪除身份提供商", + "idpDelete": "刪除身份提供商", + "idp": "身份提供商", + "idpSearch": "搜索身份提供者...", + "idpAdd": "添加身份提供商", + "idpClientIdRequired": "用戶端 ID 是必需的。", + "idpClientSecretRequired": "用戶端金鑰是必需的。", + "idpErrorAuthUrlInvalid": "身份驗證 URL 必須是有效的 URL。", + "idpErrorTokenUrlInvalid": "令牌 URL 必須是有效的 URL。", + "idpPathRequired": "標識符路徑是必需的。", + "idpScopeRequired": "授權範圍是必需的。", + "idpOidcDescription": "配置 OpenID 連接身份提供商", + "idpCreatedDescription": "身份提供商創建成功", + "idpCreate": "創建身份提供商", + "idpCreateDescription": "配置用戶身份驗證的新身份提供商", + "idpSeeAll": "查看所有身份提供商", + "idpSettingsDescription": "配置身份提供者的基本資訊", + "idpDisplayName": "此身份提供商的顯示名稱", + "idpAutoProvisionUsers": "自動提供用戶", + "idpAutoProvisionUsersDescription": "如果啟用,用戶將在首次登錄時自動在系統中創建,並且能夠映射用戶到角色和組織。", + "licenseBadge": "EE", + "idpType": "提供者類型", + "idpTypeDescription": "選擇您想要配置的身份提供者類型", + "idpOidcConfigure": "OAuth2/OIDC 配置", + "idpOidcConfigureDescription": "配置 OAuth2/OIDC 供應商端點和憑據", + "idpClientId": "用戶端ID", + "idpClientIdDescription": "來自您身份提供商的 OAuth2 用戶端 ID", + "idpClientSecret": "用戶端金鑰", + "idpClientSecretDescription": "來自身份提供商的 OAuth2 用戶端金鑰", + "idpAuthUrl": "授權 URL", + "idpAuthUrlDescription": "OAuth2 授權端點的 URL", + "idpTokenUrl": "令牌 URL", + "idpTokenUrlDescription": "OAuth2 令牌端點的 URL", + "idpOidcConfigureAlert": "重要提示", + "idpOidcConfigureAlertDescription": "創建身份提供方後,您需要在其設置中配置回調 URL。回調 URL 會在創建成功後提供。", + "idpToken": "令牌配置", + "idpTokenDescription": "配置如何從 ID 令牌中提取用戶資訊", + "idpJmespathAbout": "關於 JMESPath", + "idpJmespathAboutDescription": "以下路徑使用 JMESPath 語法從 ID 令牌中提取值。", + "idpJmespathAboutDescriptionLink": "了解更多 JMESPath 資訊", + "idpJmespathLabel": "標識符路徑", + "idpJmespathLabelDescription": "ID 令牌中用戶標識符的路徑", + "idpJmespathEmailPathOptional": "信箱路徑(可選)", + "idpJmespathEmailPathOptionalDescription": "ID 令牌中用戶信箱的路徑", + "idpJmespathNamePathOptional": "使用者名稱路徑(可選)", + "idpJmespathNamePathOptionalDescription": "ID 令牌中使用者名稱的路徑", + "idpOidcConfigureScopes": "作用域(Scopes)", + "idpOidcConfigureScopesDescription": "以空格分隔的 OAuth2 請求作用域列表", + "idpSubmit": "創建身份提供商", + "orgPolicies": "組織策略", + "idpSettings": "{idpName} 設置", + "idpCreateSettingsDescription": "配置身份提供商的設置", + "roleMapping": "角色映射", + "orgMapping": "組織映射", + "orgPoliciesSearch": "搜索組織策略...", + "orgPoliciesAdd": "添加組織策略", + "orgRequired": "組織是必填項", + "error": "錯誤", + "success": "成功", + "orgPolicyAddedDescription": "策略添加成功", + "orgPolicyUpdatedDescription": "策略更新成功", + "orgPolicyDeletedDescription": "已成功刪除策略", + "defaultMappingsUpdatedDescription": "默認映射更新成功", + "orgPoliciesAbout": "關於組織政策", + "orgPoliciesAboutDescription": "組織策略用於根據用戶的 ID 令牌來控制對組織的訪問。 您可以指定 JMESPath 表達式來提取角色和組織資訊從 ID 令牌中提取資訊。", + "orgPoliciesAboutDescriptionLink": "欲了解更多資訊,請參閱文件。", + "defaultMappingsOptional": "默認映射(可選)", + "defaultMappingsOptionalDescription": "當沒有為某個組織定義組織的政策時,使用默認映射。 您可以指定默認角色和組織映射回到這裡。", + "defaultMappingsRole": "默認角色映射", + "defaultMappingsRoleDescription": "此表達式的結果必須返回組織中定義的角色名稱作為字串。", + "defaultMappingsOrg": "默認組織映射", + "defaultMappingsOrgDescription": "此表達式必須返回 組織ID 或 true 才能允許用戶訪問組織。", + "defaultMappingsSubmit": "保存默認映射", + "orgPoliciesEdit": "編輯組織策略", + "org": "組織", + "orgSelect": "選擇組織", + "orgSearch": "搜索", + "orgNotFound": "找不到組織。", + "roleMappingPathOptional": "角色映射路徑(可選)", + "orgMappingPathOptional": "組織映射路徑(可選)", + "orgPolicyUpdate": "更新策略", + "orgPolicyAdd": "添加策略", + "orgPolicyConfig": "配置組織訪問權限", + "idpUpdatedDescription": "身份提供商更新成功", + "redirectUrl": "重定向網址", + "orgIdpRedirectUrls": "重新導向網址", + "redirectUrlAbout": "關於重定向網址", + "redirectUrlAboutDescription": "這是用戶在驗證後將被重定向到的URL。您需要在身份提供商設置中配置此URL。", + "pangolinAuth": "認證 - Pangolin", + "verificationCodeLengthRequirements": "您的驗證碼必須是 8 個字元。", + "errorOccurred": "發生錯誤", + "emailErrorVerify": "驗證電子郵件失敗:", + "emailVerified": "電子郵件驗證成功!重定向您...", + "verificationCodeErrorResend": "無法重新發送驗證碼:", + "verificationCodeResend": "驗證碼已重新發送", + "verificationCodeResendDescription": "我們已將驗證碼重新發送到您的電子郵件地址。請檢查您的收件箱。", + "emailVerify": "驗證電子郵件", + "emailVerifyDescription": "輸入驗證碼發送到您的電子郵件地址。", + "verificationCode": "驗證碼", + "verificationCodeEmailSent": "我們向您的電子郵件地址發送了驗證碼。", + "submit": "提交", + "emailVerifyResendProgress": "正在重新發送...", + "emailVerifyResend": "沒有收到代碼?點擊此處重新發送", + "passwordNotMatch": "密碼不匹配", + "signupError": "註冊時出錯", + "pangolinLogoAlt": "Pangolin 標誌", + "inviteAlready": "看起來您已被邀請!", + "inviteAlreadyDescription": "要接受邀請,您必須登錄或創建一個帳戶。", + "signupQuestion": "已經有一個帳戶?", + "login": "登錄", + "resourceNotFound": "找不到資源", + "resourceNotFoundDescription": "您要訪問的資源不存在。", + "pincodeRequirementsLength": "PIN碼必須是 6 位數字", + "pincodeRequirementsChars": "PIN 必須只包含數字", + "passwordRequirementsLength": "密碼必須至少 1 個字元長", + "passwordRequirementsTitle": "密碼要求:", + "passwordRequirementLength": "至少 8 個字元長", + "passwordRequirementUppercase": "至少一個大寫字母", + "passwordRequirementLowercase": "至少一個小寫字母", + "passwordRequirementNumber": "至少一個數字", + "passwordRequirementSpecial": "至少一個特殊字元", + "passwordRequirementsMet": "✓ 密碼滿足所有要求", + "passwordStrength": "密碼強度", + "passwordStrengthWeak": "弱", + "passwordStrengthMedium": "中", + "passwordStrengthStrong": "強", + "passwordRequirements": "要求:", + "passwordRequirementLengthText": "8+ 個字元", + "passwordRequirementUppercaseText": "大寫字母 (A-Z)", + "passwordRequirementLowercaseText": "小寫字母 (a-z)", + "passwordRequirementNumberText": "數字 (0-9)", + "passwordRequirementSpecialText": "特殊字元 (!@#$%...)", + "passwordsDoNotMatch": "密碼不匹配", + "otpEmailRequirementsLength": "OTP 必須至少 1 個字元長", + "otpEmailSent": "OTP 已發送", + "otpEmailSentDescription": "OTP 已經發送到您的電子郵件", + "otpEmailErrorAuthenticate": "通過電子郵件身份驗證失敗", + "pincodeErrorAuthenticate": "Pincode 驗證失敗", + "passwordErrorAuthenticate": "密碼驗證失敗", + "poweredBy": "支持者:", + "authenticationRequired": "需要身份驗證", + "authenticationMethodChoose": "請選擇您偏好的方式來訪問 {name}", + "authenticationRequest": "您必須通過身份驗證才能訪問 {name}", + "user": "用戶", + "pincodeInput": "6 位數字 PIN 碼", + "pincodeSubmit": "使用 PIN 登錄", + "passwordSubmit": "使用密碼登錄", + "otpEmailDescription": "一次性代碼將發送到此電子郵件。", + "otpEmailSend": "發送一次性代碼", + "otpEmail": "一次性密碼 (OTP)", + "otpEmailSubmit": "提交 OTP", + "backToEmail": "回到電子郵件", + "noSupportKey": "伺服器當前未使用支持者金鑰,歡迎支持本項目!", + "accessDenied": "訪問被拒絕", + "accessDeniedDescription": "當前帳戶無權訪問此資源。如認為這是錯誤,請與管理員聯繫。", + "accessTokenError": "檢查訪問令牌時出錯", + "accessGranted": "已授予訪問", + "accessUrlInvalid": "訪問 URL 無效", + "accessGrantedDescription": "您已獲准訪問此資源,正在為您跳轉...", + "accessUrlInvalidDescription": "此共享訪問URL無效。請聯絡資源所有者獲取新URL。", + "tokenInvalid": "無效的令牌", + "pincodeInvalid": "無效的代碼", + "passwordErrorRequestReset": "請求重設失敗:", + "passwordErrorReset": "重設密碼失敗:", + "passwordResetSuccess": "密碼重設成功!返回登錄...", + "passwordReset": "重設密碼", + "passwordResetDescription": "按照步驟重設您的密碼", + "passwordResetSent": "我們將發送一個驗證碼到這個電子郵件地址。", + "passwordResetCode": "驗證碼", + "passwordResetCodeDescription": "請檢查您的電子郵件以獲取驗證碼。", + "generatePasswordResetCode": "產生密碼重設代碼", + "passwordResetCodeGenerated": "密碼重設代碼已產生", + "passwordResetCodeGeneratedDescription": "請將此代碼分享給使用者。他們可以用它來重設密碼。", + "passwordResetUrl": "重設網址", + "passwordNew": "新密碼", + "passwordNewConfirm": "確認新密碼", + "changePassword": "更改密碼", + "changePasswordDescription": "更新您的帳戶密碼", + "oldPassword": "當前密碼", + "newPassword": "新密碼", + "confirmNewPassword": "確認新密碼", + "changePasswordError": "更改密碼失敗", + "changePasswordErrorDescription": "更改您的密碼時出錯", + "changePasswordSuccess": "密碼修改成功", + "changePasswordSuccessDescription": "您的密碼已成功更新", + "passwordExpiryRequired": "需要密碼過期", + "passwordExpiryDescription": "該機構要求您每 {maxDays} 天更改一次密碼。", + "changePasswordNow": "現在更改密碼", + "pincodeAuth": "驗證器代碼", + "pincodeSubmit2": "提交代碼", + "passwordResetSubmit": "請求重設", + "passwordResetAlreadyHaveCode": "輸入代碼", + "passwordResetSmtpRequired": "請聯絡您的管理員", + "passwordResetSmtpRequiredDescription": "需要密碼重設代碼才能重設您的密碼。請聯絡您的管理員尋求協助。", + "passwordBack": "回到密碼", + "loginBack": "返回登錄", + "signup": "註冊", + "loginStart": "登錄以開始", + "idpOidcTokenValidating": "正在驗證 OIDC 令牌", + "idpOidcTokenResponse": "驗證 OIDC 令牌響應", + "idpErrorOidcTokenValidating": "驗證 OIDC 令牌出錯", + "idpConnectingTo": "連接到{name}", + "idpConnectingToDescription": "正在驗證您的身份", + "idpConnectingToProcess": "正在連接...", + "idpConnectingToFinished": "已連接", + "idpErrorConnectingTo": "無法連接到 {name},請聯絡管理員協助處理。", + "idpErrorNotFound": "找不到 IdP", + "inviteInvalid": "無效邀請", + "inviteInvalidDescription": "邀請連結無效。", + "inviteErrorWrongUser": "邀請不是該用戶的", + "inviteErrorUserNotExists": "用戶不存在。請先創建帳戶。", + "inviteErrorLoginRequired": "您必須登錄才能接受邀請", + "inviteErrorExpired": "邀請可能已過期", + "inviteErrorRevoked": "邀請可能已被吊銷了", + "inviteErrorTypo": "邀請連結中可能有一個類型", + "pangolinSetup": "認證 - Pangolin", + "orgNameRequired": "組織名稱是必需的", + "orgIdRequired": "組織ID是必需的", + "orgErrorCreate": "創建組織時出錯", + "pageNotFound": "找不到頁面", + "pageNotFoundDescription": "哎呀!您正在尋找的頁面不存在。", + "overview": "概覽", + "home": "首頁", + "accessControl": "訪問控制", + "settings": "設置", + "usersAll": "所有用戶", + "license": "許可協議", + "pangolinDashboard": "儀錶板 - Pangolin", + "noResults": "未找到任何結果。", + "terabytes": "{count} TB", + "gigabytes": "{count} GB", + "megabytes": "{count} MB", + "tagsEntered": "已輸入的標籤", + "tagsEnteredDescription": "這些是您輸入的標籤。", + "tagsWarnCannotBeLessThanZero": "最大標籤和最小標籤不能小於 0", + "tagsWarnNotAllowedAutocompleteOptions": "標記不允許為每個自動完成選項", + "tagsWarnInvalid": "無效的標籤,每個有效標籤", + "tagWarnTooShort": "標籤 {tagText} 太短", + "tagWarnTooLong": "標籤 {tagText} 太長", + "tagsWarnReachedMaxNumber": "已達到允許標籤的最大數量", + "tagWarnDuplicate": "未添加重複標籤 {tagText}", + "supportKeyInvalid": "無效金鑰", + "supportKeyInvalidDescription": "您的支持者金鑰無效。", + "supportKeyValid": "有效的金鑰", + "supportKeyValidDescription": "您的支持者金鑰已被驗證。感謝您的支持!", + "supportKeyErrorValidationDescription": "驗證支持者金鑰失敗。", + "supportKey": "支持開發和通過一個 Pangolin !", + "supportKeyDescription": "購買支持者鑰匙,幫助我們繼續為社區發展 Pangolin 。 您的貢獻使我們能夠投入更多的時間來維護和添加所有人的新功能。 我們永遠不會用這個來支付牆上的功能。這與任何商業版是分開的。", + "supportKeyPet": "您還可以領養並見到屬於自己的 Pangolin!", + "supportKeyPurchase": "付款通過 GitHub 進行處理,之後您可以在以下位置獲取您的金鑰:", + "supportKeyPurchaseLink": "我們的網站", + "supportKeyPurchase2": "並在這裡兌換。", + "supportKeyLearnMore": "了解更多。", + "supportKeyOptions": "請選擇最適合您的選項。", + "supportKetOptionFull": "完全支持者", + "forWholeServer": "適用於整個伺服器", + "lifetimePurchase": "終身購買", + "supporterStatus": "支持者狀態", + "buy": "購買", + "supportKeyOptionLimited": "有限支持者", + "forFiveUsers": "適用於 5 或更少用戶", + "supportKeyRedeem": "兌換支持者金鑰", + "supportKeyHideSevenDays": "隱藏 7 天", + "supportKeyEnter": "輸入支持者金鑰", + "supportKeyEnterDescription": "見到你自己的 Pangolin!", + "githubUsername": "GitHub 使用者名稱", + "supportKeyInput": "支持者金鑰", + "supportKeyBuy": "購買支持者金鑰", + "logoutError": "註銷錯誤", + "signingAs": "登錄為", + "serverAdmin": "伺服器管理員", + "managedSelfhosted": "託管自託管", + "otpEnable": "啟用雙因子認證", + "otpDisable": "禁用雙因子認證", + "logout": "登出", + "licenseTierProfessionalRequired": "需要專業版", + "licenseTierProfessionalRequiredDescription": "此功能僅在專業版可用。", + "actionGetOrg": "獲取組織", + "updateOrgUser": "更新組織用戶", + "createOrgUser": "創建組織用戶", + "actionUpdateOrg": "更新組織", + "actionRemoveInvitation": "移除邀請", + "actionUpdateUser": "更新用戶", + "actionGetUser": "獲取用戶", + "actionGetOrgUser": "獲取組織用戶", + "actionListOrgDomains": "列出組織域", + "actionCreateSite": "創建站點", + "actionDeleteSite": "刪除站點", + "actionGetSite": "獲取站點", + "actionListSites": "站點列表", + "actionApplyBlueprint": "應用藍圖", + "actionListBlueprints": "藍圖列表", + "actionGetBlueprint": "獲取藍圖", + "setupToken": "設置令牌", + "setupTokenDescription": "從伺服器控制台輸入設定令牌。", + "setupTokenRequired": "需要設置令牌", + "actionUpdateSite": "更新站點", + "actionListSiteRoles": "允許站點角色列表", + "actionCreateResource": "創建資源", + "actionDeleteResource": "刪除資源", + "actionGetResource": "獲取資源", + "actionListResource": "列出資源", + "actionUpdateResource": "更新資源", + "actionListResourceUsers": "列出資源用戶", + "actionSetResourceUsers": "設置資源用戶", + "actionSetAllowedResourceRoles": "設置允許的資源角色", + "actionListAllowedResourceRoles": "列出允許的資源角色", + "actionSetResourcePassword": "設置資源密碼", + "actionSetResourcePincode": "設置資源粉碼", + "actionSetResourceEmailWhitelist": "設置資源電子郵件白名單", + "actionGetResourceEmailWhitelist": "獲取資源電子郵件白名單", + "actionCreateTarget": "創建目標", + "actionDeleteTarget": "刪除目標", + "actionGetTarget": "獲取目標", + "actionListTargets": "列表目標", + "actionUpdateTarget": "更新目標", + "actionCreateRole": "創建角色", + "actionDeleteRole": "刪除角色", + "actionGetRole": "獲取角色", + "actionListRole": "角色列表", + "actionUpdateRole": "更新角色", + "actionListAllowedRoleResources": "列表允許的角色資源", + "actionInviteUser": "邀請用戶", + "actionRemoveUser": "刪除用戶", + "actionListUsers": "列出用戶", + "actionAddUserRole": "添加用戶角色", + "actionSetUserOrgRoles": "Set User Roles", + "actionGenerateAccessToken": "生成訪問令牌", + "actionDeleteAccessToken": "刪除訪問令牌", + "actionListAccessTokens": "訪問令牌", + "actionCreateResourceRule": "創建資源規則", + "actionDeleteResourceRule": "刪除資源規則", + "actionListResourceRules": "列出資源規則", + "actionUpdateResourceRule": "更新資源規則", + "actionListOrgs": "列出組織", + "actionCheckOrgId": "檢查組織ID", + "actionCreateOrg": "創建組織", + "actionDeleteOrg": "刪除組織", + "actionListApiKeys": "列出 API 金鑰", + "actionListApiKeyActions": "列出 API 金鑰動作", + "actionSetApiKeyActions": "設置 API 金鑰允許的操作", + "actionCreateApiKey": "創建 API 金鑰", + "actionDeleteApiKey": "刪除 API 金鑰", + "actionCreateIdp": "創建 IDP", + "actionUpdateIdp": "更新 IDP", + "actionDeleteIdp": "刪除 IDP", + "actionListIdps": "列出 IDP", + "actionGetIdp": "獲取 IDP", + "actionCreateIdpOrg": "創建 IDP 組織策略", + "actionDeleteIdpOrg": "刪除 IDP 組織策略", + "actionListIdpOrgs": "列出 IDP 組織", + "actionUpdateIdpOrg": "更新 IDP 組織", + "actionCreateClient": "創建用戶端", + "actionDeleteClient": "刪除用戶端", + "actionUpdateClient": "更新用戶端", + "actionListClients": "列出用戶端", + "actionGetClient": "獲取用戶端", + "actionCreateSiteResource": "創建站點資源", + "actionDeleteSiteResource": "刪除站點資源", + "actionGetSiteResource": "獲取站點資源", + "actionListSiteResources": "列出站點資源", + "actionUpdateSiteResource": "更新站點資源", + "actionListInvitations": "邀請列表", + "actionExportLogs": "匯出日誌", + "actionViewLogs": "查看日誌", + "noneSelected": "未選擇", + "orgNotFound2": "未找到組織。", + "searchProgress": "搜索中...", + "create": "創建", + "orgs": "組織", + "loginError": "登錄時出錯", + "loginRequiredForDevice": "需要登入以驗證您的裝置。", + "passwordForgot": "忘記密碼?", + "otpAuth": "兩步驗證", + "otpAuthDescription": "從您的身份驗證程序中輸入代碼或您的單次備份代碼。", + "otpAuthSubmit": "提交代碼", + "idpContinue": "或者繼續", + "otpAuthBack": "返回登錄", + "navbar": "導航菜單", + "navbarDescription": "應用程式的主導航菜單", + "navbarDocsLink": "文件", + "otpErrorEnable": "無法啟用 2FA", + "otpErrorEnableDescription": "啟用 2FA 時出錯", + "otpSetupCheckCode": "請輸入您的 6 位數字代碼", + "otpSetupCheckCodeRetry": "無效的代碼。請重試。", + "otpSetup": "啟用兩步驗證", + "otpSetupDescription": "用額外的保護層來保護您的帳戶", + "otpSetupScanQr": "用您的身份驗證程序掃描此二維碼或手動輸入金鑰:", + "otpSetupSecretCode": "驗證器代碼", + "otpSetupSuccess": "啟用兩步驗證", + "otpSetupSuccessStoreBackupCodes": "您的帳戶現在更加安全。不要忘記保存您的備份代碼。", + "otpErrorDisable": "無法禁用 2FA", + "otpErrorDisableDescription": "禁用 2FA 時出錯", + "otpRemove": "禁用兩步驗證", + "otpRemoveDescription": "為您的帳戶禁用兩步驗證", + "otpRemoveSuccess": "雙重身份驗證已禁用", + "otpRemoveSuccessMessage": "您的帳戶已禁用雙重身份驗證。您可以隨時再次啟用它。", + "otpRemoveSubmit": "禁用兩步驗證", + "paginator": "第 {current} 頁,共 {last} 頁", + "paginatorToFirst": "轉到第一頁", + "paginatorToPrevious": "轉到上一頁", + "paginatorToNext": "轉到下一頁", + "paginatorToLast": "轉到最後一頁", + "copyText": "複製文本", + "copyTextFailed": "複製文本失敗: ", + "copyTextClipboard": "複製到剪貼簿", + "inviteErrorInvalidConfirmation": "無效確認", + "passwordRequired": "必須填寫密碼", + "allowAll": "允許所有", + "permissionsAllowAll": "允許所有權限", + "githubUsernameRequired": "必須填寫 GitHub 使用者名稱", + "supportKeyRequired": "必須填寫支持者金鑰", + "passwordRequirementsChars": "密碼至少需要 8 個字元", + "language": "語言", + "verificationCodeRequired": "必須輸入代碼", + "userErrorNoUpdate": "沒有要更新的用戶", + "siteErrorNoUpdate": "沒有要更新的站點", + "resourceErrorNoUpdate": "沒有可更新的資源", + "authErrorNoUpdate": "沒有要更新的身份驗證資訊", + "orgErrorNoUpdate": "沒有要更新的組織", + "orgErrorNoProvided": "未提供組織", + "apiKeysErrorNoUpdate": "沒有要更新的 API 金鑰", + "sidebarOverview": "概覽", + "sidebarHome": "首頁", + "sidebarSites": "站點", + "sidebarResources": "資源", + "sidebarProxyResources": "公開", + "sidebarClientResources": "私有", + "sidebarAccessControl": "訪問控制", + "sidebarLogsAndAnalytics": "日誌與分析", + "sidebarUsers": "用戶", + "sidebarAdmin": "管理員", + "sidebarInvitations": "邀請", + "sidebarRoles": "角色", + "sidebarShareableLinks": "分享連結", + "sidebarApiKeys": "API 金鑰", + "sidebarSettings": "設置", + "sidebarAllUsers": "所有用戶", + "sidebarIdentityProviders": "身份提供商", + "sidebarLicense": "證書", + "sidebarClients": "用戶端", + "sidebarUserDevices": "使用者", + "sidebarMachineClients": "機器", + "sidebarDomains": "域", + "sidebarGeneral": "管理", + "sidebarLogAndAnalytics": "日誌與分析", + "sidebarBluePrints": "藍圖", + "sidebarOrganization": "組織", + "sidebarLogsAnalytics": "分析", + "blueprints": "藍圖", + "blueprintsDescription": "應用聲明配置並查看先前運行的", + "blueprintAdd": "添加藍圖", + "blueprintGoBack": "查看所有藍圖", + "blueprintCreate": "創建藍圖", + "blueprintCreateDescription2": "按照下面的步驟創建和應用新的藍圖", + "blueprintDetails": "藍圖詳細資訊", + "blueprintDetailsDescription": "查看應用藍圖的結果和發生的任何錯誤", + "blueprintInfo": "藍圖資訊", + "message": "留言", + "blueprintContentsDescription": "定義描述您基礎設施的 YAML 內容", + "blueprintErrorCreateDescription": "應用藍圖時出錯", + "blueprintErrorCreate": "創建藍圖時出錯", + "searchBlueprintProgress": "搜索藍圖...", + "appliedAt": "應用於", + "source": "來源", + "contents": "目錄", + "parsedContents": "解析內容 (只讀)", + "enableDockerSocket": "啟用 Docker 藍圖", + "enableDockerSocketDescription": "啟用 Docker Socket 標籤擦除藍圖標籤。套接字路徑必須提供給新的。", + "enableDockerSocketLink": "了解更多", + "viewDockerContainers": "查看停靠容器", + "containersIn": "{siteName} 中的容器", + "selectContainerDescription": "選擇任何容器作為目標的主機名。點擊埠使用埠。", + "containerName": "名稱", + "containerImage": "圖片", + "containerState": "狀態", + "containerNetworks": "網路", + "containerHostnameIp": "主機名/IP", + "containerLabels": "標籤", + "containerLabelsCount": "{count, plural, other {# 標籤}}", + "containerLabelsTitle": "容器標籤", + "containerLabelEmpty": "<為空>", + "containerPorts": "埠", + "containerPortsMore": "+{count} 更多", + "containerActions": "行動", + "select": "選擇", + "noContainersMatchingFilters": "沒有找到匹配當前過濾器的容器。", + "showContainersWithoutPorts": "顯示沒有埠的容器", + "showStoppedContainers": "顯示已停止的容器", + "noContainersFound": "未找到容器。請確保 Docker 容器正在運行。", + "searchContainersPlaceholder": "在 {count} 個容器中搜索...", + "searchResultsCount": "{count, plural, other {# 個結果}}", + "filters": "篩選器", + "filterOptions": "過濾器選項", + "filterPorts": "埠", + "filterStopped": "已停止", + "clearAllFilters": "清除所有過濾器", + "columns": "列", + "toggleColumns": "切換列", + "refreshContainersList": "刷新容器列表", + "searching": "搜索中...", + "noContainersFoundMatching": "未找到與 \"{filter}\" 匹配的容器。", + "light": "淺色", + "dark": "深色", + "system": "系統", + "theme": "主題", + "subnetRequired": "子網是必填項", + "initialSetupTitle": "初始伺服器設置", + "initialSetupDescription": "創建初始伺服器管理員帳戶。 只能存在一個伺服器管理員。 您可以隨時更改這些憑據。", + "createAdminAccount": "創建管理員帳戶", + "setupErrorCreateAdmin": "創建伺服器管理員帳戶時發生錯誤。", + "certificateStatus": "證書狀態", + "loading": "載入中", + "restart": "重啟", + "domains": "域", + "domainsDescription": "管理您的組織域", + "domainsSearch": "搜索域...", + "domainAdd": "添加域", + "domainAddDescription": "在您的組織中註冊新域", + "domainCreate": "創建域", + "domainCreatedDescription": "域創建成功", + "domainDeletedDescription": "成功刪除域", + "domainQuestionRemove": "您確定要從您的帳戶中刪除域名嗎?", + "domainMessageRemove": "移除後,該域將不再與您的帳戶關聯。", + "domainConfirmDelete": "確認刪除域", + "domainDelete": "刪除域", + "domain": "域", + "selectDomainTypeNsName": "域委派(NS)", + "selectDomainTypeNsDescription": "此域及其所有子域。當您希望控制整個域區域時使用此選項。", + "selectDomainTypeCnameName": "單個域(CNAME)", + "selectDomainTypeCnameDescription": "僅此特定域。用於單個子域或特定域條目。", + "selectDomainTypeWildcardName": "通配符域", + "selectDomainTypeWildcardDescription": "此域名及其子域名。", + "domainDelegation": "單個域", + "selectType": "選擇一個類型", + "actions": "操作", + "refresh": "刷新", + "refreshError": "刷新數據失敗", + "verified": "已驗證", + "pending": "待定", + "sidebarBilling": "計費", + "billing": "計費", + "orgBillingDescription": "管理您的帳單資訊和訂閱", + "github": "GitHub", + "pangolinHosted": "Pangolin 託管", + "fossorial": "Fossorial", + "completeAccountSetup": "完成帳戶設定", + "completeAccountSetupDescription": "設置您的密碼以開始", + "accountSetupSent": "我們將發送帳號設定代碼到該電子郵件地址。", + "accountSetupCode": "設置代碼", + "accountSetupCodeDescription": "請檢查您的信箱以獲取設置代碼。", + "passwordCreate": "創建密碼", + "passwordCreateConfirm": "確認密碼", + "accountSetupSubmit": "發送設置代碼", + "completeSetup": "完成設置", + "accountSetupSuccess": "帳號設定完成!歡迎來到 Pangolin!", + "documentation": "文件", + "saveAllSettings": "保存所有設置", + "saveResourceTargets": "儲存目標", + "saveResourceHttp": "儲存代理設定", + "saveProxyProtocol": "儲存代理協定設定", + "settingsUpdated": "設置已更新", + "settingsUpdatedDescription": "所有設置已成功更新", + "settingsErrorUpdate": "設置更新失敗", + "settingsErrorUpdateDescription": "更新設置時發生錯誤", + "sidebarCollapse": "摺疊", + "sidebarExpand": "展開", + "productUpdateMoreInfo": "還有 {noOfUpdates} 項更新", + "productUpdateInfo": "{noOfUpdates} 項更新", + "productUpdateWhatsNew": "新功能", + "productUpdateTitle": "產品更新", + "productUpdateEmpty": "沒有更新", + "dismissAll": "全部關閉", + "pangolinUpdateAvailable": "有可用更新", + "pangolinUpdateAvailableInfo": "版本 {version} 已準備好安裝", + "pangolinUpdateAvailableReleaseNotes": "查看發行說明", + "newtUpdateAvailable": "更新可用", + "newtUpdateAvailableInfo": "新版本的 Newt 已可用。請更新到最新版本以獲得最佳體驗。", + "domainPickerEnterDomain": "域名", + "domainPickerPlaceholder": "example.com", + "domainPickerDescription": "輸入資源的完整域名以查看可用選項。", + "domainPickerDescriptionSaas": "輸入完整域名、子域或名稱以查看可用選項。", + "domainPickerTabAll": "所有", + "domainPickerTabOrganization": "組織", + "domainPickerTabProvided": "提供的", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "檢查可用性...", + "domainPickerNoMatchingDomains": "未找到匹配的域名。嘗試不同的域名或檢查您組織的域名設置。", + "domainPickerOrganizationDomains": "組織域", + "domainPickerProvidedDomains": "提供的域", + "domainPickerSubdomain": "子域:{subdomain}", + "domainPickerNamespace": "命名空間:{namespace}", + "domainPickerShowMore": "顯示更多", + "regionSelectorTitle": "選擇區域", + "regionSelectorInfo": "選擇區域以幫助提升您所在地的性能。您不必與伺服器在相同的區域。", + "regionSelectorPlaceholder": "選擇一個區域", + "regionSelectorComingSoon": "即將推出", + "billingLoadingSubscription": "正在載入訂閱...", + "billingFreeTier": "免費層", + "billingWarningOverLimit": "警告:您已超出一個或多個使用限制。在您修改訂閱或調整使用情況之前,您的站點將無法連接。", + "billingUsageLimitsOverview": "使用限制概覽", + "billingMonitorUsage": "監控您的使用情況以對比已配置的限制。如需提高限制請聯絡我們 support@pangolin.net。", + "billingDataUsage": "數據使用情況", + "billingOnlineTime": "站點在線時間", + "billingUsers": "活躍用戶", + "billingDomains": "活躍域", + "billingRemoteExitNodes": "活躍自託管節點", + "billingNoLimitConfigured": "未配置限制", + "billingEstimatedPeriod": "估計結算週期", + "billingIncludedUsage": "包含的使用量", + "billingIncludedUsageDescription": "您當前訂閱計劃中包含的使用量", + "billingFreeTierIncludedUsage": "免費層使用額度", + "billingIncluded": "包含", + "billingEstimatedTotal": "預計總額:", + "billingNotes": "備註", + "billingEstimateNote": "這是根據您當前使用情況的估算。", + "billingActualChargesMayVary": "實際費用可能會有變化。", + "billingBilledAtEnd": "您將在結算週期結束時被計費。", + "billingModifySubscription": "修改訂閱", + "billingStartSubscription": "開始訂閱", + "billingRecurringCharge": "週期性收費", + "billingManageSubscriptionSettings": "管理您的訂閱設置和偏好", + "billingNoActiveSubscription": "您沒有活躍的訂閱。開始訂閱以增加使用限制。", + "billingFailedToLoadSubscription": "無法載入訂閱", + "billingFailedToLoadUsage": "無法載入使用情況", + "billingFailedToGetCheckoutUrl": "無法獲取結帳網址", + "billingPleaseTryAgainLater": "請稍後再試。", + "billingCheckoutError": "結帳錯誤", + "billingFailedToGetPortalUrl": "無法獲取門戶網址", + "billingPortalError": "門戶錯誤", + "billingDataUsageInfo": "當連接到雲端時,您將為透過安全隧道傳輸的所有數據收取費用。 這包括您所有站點的進出流量。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取數據。", + "billingOnlineTimeInfo": "您要根據您的網站連接到雲端的時間長短收取費用。 例如,44,640 分鐘等於一個 24/7 全月運行的網站。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取費用。", + "billingUsersInfo": "根據您組織中的活躍用戶數量收費。按日計算帳單。", + "billingDomainInfo": "根據組織中活躍域的數量收費。按日計算帳單。", + "billingRemoteExitNodesInfo": "根據您組織中已管理節點的數量收費。按日計算帳單。", + "domainNotFound": "域未找到", + "domainNotFoundDescription": "此資源已禁用,因為該域不再在我們的系統中存在。請為此資源設置一個新域。", + "failed": "失敗", + "createNewOrgDescription": "創建一個新組織", + "organization": "組織", + "port": "埠", + "securityKeyManage": "管理安全金鑰", + "securityKeyDescription": "添加或刪除用於無密碼認證的安全金鑰", + "securityKeyRegister": "註冊新的安全金鑰", + "securityKeyList": "您的安全金鑰", + "securityKeyNone": "尚未註冊安全金鑰", + "securityKeyNameRequired": "名稱為必填項", + "securityKeyRemove": "刪除", + "securityKeyLastUsed": "上次使用:{date}", + "securityKeyNameLabel": "名稱", + "securityKeyRegisterSuccess": "安全金鑰註冊成功", + "securityKeyRegisterError": "註冊安全金鑰失敗", + "securityKeyRemoveSuccess": "安全金鑰刪除成功", + "securityKeyRemoveError": "刪除安全金鑰失敗", + "securityKeyLoadError": "載入安全金鑰失敗", + "securityKeyLogin": "使用安全金鑰繼續", + "securityKeyAuthError": "使用安全金鑰認證失敗", + "securityKeyRecommendation": "考慮在其他設備上註冊另一個安全金鑰,以確保不會被鎖定在您的帳戶之外。", + "registering": "註冊中...", + "securityKeyPrompt": "請使用您的安全金鑰驗證身份。確保您的安全金鑰已連接並準備好。", + "securityKeyBrowserNotSupported": "您的瀏覽器不支持安全金鑰。請使用像 Chrome、Firefox 或 Safari 這樣的現代瀏覽器。", + "securityKeyPermissionDenied": "請允許訪問您的安全金鑰以繼續登錄。", + "securityKeyRemovedTooQuickly": "請保持您的安全金鑰連接,直到登錄過程完成。", + "securityKeyNotSupported": "您的安全金鑰可能不相容。請嘗試不同的安全金鑰。", + "securityKeyUnknownError": "使用安全金鑰時出現問題。請再試一次。", + "twoFactorRequired": "註冊安全金鑰需要兩步驗證。", + "twoFactor": "兩步驗證", + "twoFactorAuthentication": "兩步驗證", + "twoFactorDescription": "這個組織需要雙重身份驗證。", + "enableTwoFactor": "啟用兩步驗證", + "organizationSecurityPolicy": "組織安全政策", + "organizationSecurityPolicyDescription": "此機構擁有安全要求,您必須先滿足才能訪問", + "securityRequirements": "安全要求", + "allRequirementsMet": "已滿足所有要求", + "completeRequirementsToContinue": "完成下面的要求以繼續訪問此組織", + "youCanNowAccessOrganization": "您現在可以訪問此組織", + "reauthenticationRequired": "會話長度", + "reauthenticationDescription": "該機構要求您每 {maxDays} 天登錄一次。", + "reauthenticationDescriptionHours": "該機構要求您每 {maxHours} 小時登錄一次。", + "reauthenticateNow": "再次登錄", + "adminEnabled2FaOnYourAccount": "管理員已為 {email} 啟用兩步驗證。請完成設置以繼續。", + "securityKeyAdd": "添加安全金鑰", + "securityKeyRegisterTitle": "註冊新安全金鑰", + "securityKeyRegisterDescription": "連接您的安全金鑰並輸入名稱以便識別", + "securityKeyTwoFactorRequired": "要求兩步驗證", + "securityKeyTwoFactorDescription": "請輸入你的兩步驗證代碼以註冊安全金鑰", + "securityKeyTwoFactorRemoveDescription": "請輸入你的兩步驗證代碼以移除安全金鑰", + "securityKeyTwoFactorCode": "雙因素代碼", + "securityKeyRemoveTitle": "移除安全金鑰", + "securityKeyRemoveDescription": "輸入您的密碼以移除安全金鑰 \"{name}\"", + "securityKeyNoKeysRegistered": "沒有註冊安全金鑰", + "securityKeyNoKeysDescription": "添加安全金鑰以加強您的帳戶安全", + "createDomainRequired": "必須輸入域", + "createDomainAddDnsRecords": "添加 DNS 記錄", + "createDomainAddDnsRecordsDescription": "將以下 DNS 記錄添加到您的域名提供商以完成設置。", + "createDomainNsRecords": "NS 記錄", + "createDomainRecord": "記錄", + "createDomainType": "類型:", + "createDomainName": "名稱:", + "createDomainValue": "值:", + "createDomainCnameRecords": "CNAME 記錄", + "createDomainARecords": "A記錄", + "createDomainRecordNumber": "記錄 {number}", + "createDomainTxtRecords": "TXT 記錄", + "createDomainSaveTheseRecords": "保存這些記錄", + "createDomainSaveTheseRecordsDescription": "務必保存這些 DNS 記錄,因為您將無法再次查看它們。", + "createDomainDnsPropagation": "DNS 傳播", + "createDomainDnsPropagationDescription": "DNS 更改可能需要一些時間才能在網路上傳播。這可能需要從幾分鐘到 48 小時,具體取決於您的 DNS 提供商和 TTL 設置。", + "resourcePortRequired": "非 HTTP 資源必須輸入埠號", + "resourcePortNotAllowed": "HTTP 資源不應設置埠號", + "billingPricingCalculatorLink": "價格計算機", + "signUpTerms": { + "IAgreeToThe": "我同意", + "termsOfService": "服務條款", + "and": "和", + "privacyPolicy": "隱私政策" }, - "adopt": { - "title": "採納現有節點", - "description": "輸入您想要採用的現有節點的憑據", - "nodeIdLabel": "節點 ID", - "nodeIdDescription": "您想要採用的現有節點的 ID", - "secretLabel": "金鑰", - "secretDescription": "現有節點的秘密金鑰", - "submitButton": "採用節點" + "signUpMarketing": { + "keepMeInTheLoop": "透過電子郵件接收新聞、更新和新功能通知。" }, - "generate": { - "title": "生成的憑據", - "description": "使用這些生成的憑據來配置您的節點", - "nodeIdTitle": "節點 ID", - "secretTitle": "金鑰", - "saveCredentialsTitle": "將憑據添加到配置中", - "saveCredentialsDescription": "將這些憑據添加到您的自託管 Pangolin 節點設定檔中以完成連接。", - "submitButton": "創建節點" + "siteRequired": "需要站點。", + "olmTunnel": "Olm 隧道", + "olmTunnelDescription": "使用 Olm 進行用戶端連接", + "errorCreatingClient": "創建用戶端出錯", + "clientDefaultsNotFound": "未找到用戶端預設值", + "createClient": "創建用戶端", + "createClientDescription": "創建一個新用戶端來連接您的站點", + "seeAllClients": "查看所有用戶端", + "clientInformation": "用戶端資訊", + "clientNamePlaceholder": "用戶端名稱", + "address": "地址", + "subnetPlaceholder": "子網", + "addressDescription": "此用戶端將用於連接的地址", + "selectSites": "選擇站點", + "sitesDescription": "用戶端將與所選站點進行連接", + "clientInstallOlm": "安裝 Olm", + "clientInstallOlmDescription": "在您的系統上運行 Olm", + "clientOlmCredentials": "Olm 憑據", + "clientOlmCredentialsDescription": "這是 Olm 伺服器的身份驗證方式", + "olmEndpoint": "Olm 端點", + "olmId": "Olm ID", + "olmSecretKey": "Olm 私鑰", + "clientCredentialsSave": "保存您的憑據", + "clientCredentialsSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全位置。", + "generalSettingsDescription": "配置此用戶端的常規設置", + "clientUpdated": "用戶端已更新", + "clientUpdatedDescription": "用戶端已更新。", + "clientUpdateFailed": "更新用戶端失敗", + "clientUpdateError": "更新用戶端時出錯。", + "sitesFetchFailed": "獲取站點失敗", + "sitesFetchError": "獲取站點時出錯。", + "olmErrorFetchReleases": "獲取 Olm 發布版本時出錯。", + "olmErrorFetchLatest": "獲取最新 Olm 發布版本時出錯。", + "enterCidrRange": "輸入 CIDR 範圍", + "resourceEnableProxy": "啟用公共代理", + "resourceEnableProxyDescription": "啟用到此資源的公共代理。這允許外部網路通過開放埠訪問資源。需要 Traefik 配置。", + "externalProxyEnabled": "外部代理已啟用", + "addNewTarget": "添加新目標", + "targetsList": "目標列表", + "advancedMode": "高級模式", + "advancedSettings": "進階設定", + "targetErrorDuplicateTargetFound": "找到重複的目標", + "healthCheckHealthy": "正常", + "healthCheckUnhealthy": "不正常", + "healthCheckUnknown": "未知", + "healthCheck": "健康檢查", + "configureHealthCheck": "配置健康檢查", + "configureHealthCheckDescription": "為 {target} 設置健康監控", + "enableHealthChecks": "啟用健康檢查", + "enableHealthChecksDescription": "監視此目標的健康狀況。如果需要,您可以監視一個不同的終點。", + "healthScheme": "方法", + "healthSelectScheme": "選擇方法", + "healthCheckPortInvalid": "健康檢查連接埠必須介於 1 到 65535 之間", + "healthCheckPath": "路徑", + "healthHostname": "IP / 主機", + "healthPort": "埠", + "healthCheckPathDescription": "用於檢查健康狀態的路徑。", + "healthyIntervalSeconds": "正常間隔", + "unhealthyIntervalSeconds": "不正常間隔", + "IntervalSeconds": "正常間隔", + "timeoutSeconds": "超時", + "timeIsInSeconds": "時間以秒為單位", + "retryAttempts": "重試次數", + "expectedResponseCodes": "期望響應代碼", + "expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。", + "customHeaders": "自訂 Headers", + "customHeadersDescription": "Header 斷行分隔:Header 名稱:值", + "headersValidationError": "Header 必須是格式:Header 名稱:值。", + "saveHealthCheck": "保存健康檢查", + "healthCheckSaved": "健康檢查已保存", + "healthCheckSavedDescription": "健康檢查配置已成功保存。", + "healthCheckError": "健康檢查錯誤", + "healthCheckErrorDescription": "保存健康檢查配置時出錯", + "healthCheckPathRequired": "健康檢查路徑為必填項", + "healthCheckMethodRequired": "HTTP 方法為必填項", + "healthCheckIntervalMin": "檢查間隔必須至少為 5 秒", + "healthCheckTimeoutMin": "超時必須至少為 1 秒", + "healthCheckRetryMin": "重試次數必須至少為 1 次", + "httpMethod": "HTTP 方法", + "selectHttpMethod": "選擇 HTTP 方法", + "domainPickerSubdomainLabel": "子域名", + "domainPickerBaseDomainLabel": "根域名", + "domainPickerSearchDomains": "搜索域名...", + "domainPickerNoDomainsFound": "未找到域名", + "domainPickerLoadingDomains": "載入域名...", + "domainPickerSelectBaseDomain": "選擇根域名...", + "domainPickerNotAvailableForCname": "不適用於 CNAME 域", + "domainPickerEnterSubdomainOrLeaveBlank": "輸入子域名或留空以使用根域名。", + "domainPickerEnterSubdomainToSearch": "輸入一個子域名以搜索並從可用免費域名中選擇。", + "domainPickerFreeDomains": "免費域名", + "domainPickerSearchForAvailableDomains": "搜索可用域名", + "domainPickerNotWorkSelfHosted": "注意:自託管實例當前不提供免費的域名。", + "resourceDomain": "域名", + "resourceEditDomain": "編輯域名", + "siteName": "站點名稱", + "proxyPort": "埠", + "resourcesTableProxyResources": "代理資源", + "resourcesTableClientResources": "用戶端資源", + "resourcesTableNoProxyResourcesFound": "未找到代理資源。", + "resourcesTableNoInternalResourcesFound": "未找到內部資源。", + "resourcesTableDestination": "目標", + "resourcesTableAlias": "別名", + "resourcesTableClients": "用戶端", + "resourcesTableAndOnlyAccessibleInternally": "且僅在與用戶端連接時可內部訪問。", + "resourcesTableNoTargets": "無目標", + "resourcesTableHealthy": "健康", + "resourcesTableDegraded": "降級", + "resourcesTableOffline": "離線", + "resourcesTableUnknown": "未知", + "resourcesTableNotMonitored": "未監控", + "editInternalResourceDialogEditClientResource": "編輯用戶端資源", + "editInternalResourceDialogUpdateResourceProperties": "更新 {resourceName} 的資源屬性和目標配置。", + "editInternalResourceDialogResourceProperties": "資源屬性", + "editInternalResourceDialogName": "名稱", + "editInternalResourceDialogProtocol": "協議", + "editInternalResourceDialogSitePort": "站點埠", + "editInternalResourceDialogTargetConfiguration": "目標配置", + "editInternalResourceDialogCancel": "取消", + "editInternalResourceDialogSaveResource": "保存資源", + "editInternalResourceDialogSuccess": "成功", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "內部資源更新成功", + "editInternalResourceDialogError": "錯誤", + "editInternalResourceDialogFailedToUpdateInternalResource": "更新內部資源失敗", + "editInternalResourceDialogNameRequired": "名稱為必填項", + "editInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", + "editInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", + "editInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", + "editInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", + "editInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", + "editInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", + "editInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠", + "editInternalResourceDialogMode": "模式", + "editInternalResourceDialogModePort": "連接埠", + "editInternalResourceDialogModeHost": "主機", + "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogDestination": "目的地", + "editInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", + "editInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名稱位址。", + "editInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", + "editInternalResourceDialogAlias": "別名", + "editInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", + "createInternalResourceDialogNoSitesAvailable": "暫無可用站點", + "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一個子網的 Newt 站點來創建內部資源。", + "createInternalResourceDialogClose": "關閉", + "createInternalResourceDialogCreateClientResource": "創建用戶端資源", + "createInternalResourceDialogCreateClientResourceDescription": "創建一個新資源,該資源將可供連接到所選站點的用戶端訪問。", + "createInternalResourceDialogResourceProperties": "資源屬性", + "createInternalResourceDialogName": "名稱", + "createInternalResourceDialogSite": "站點", + "selectSite": "選擇站點...", + "noSitesFound": "找不到站點。", + "createInternalResourceDialogProtocol": "協議", + "createInternalResourceDialogTcp": "TCP", + "createInternalResourceDialogUdp": "UDP", + "createInternalResourceDialogSitePort": "站點埠", + "createInternalResourceDialogSitePortDescription": "使用此埠在連接到用戶端時訪問站點上的資源。", + "createInternalResourceDialogTargetConfiguration": "目標配置", + "createInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名地址。", + "createInternalResourceDialogDestinationPortDescription": "資源在目標 IP 上可訪問的埠。", + "createInternalResourceDialogCancel": "取消", + "createInternalResourceDialogCreateResource": "創建資源", + "createInternalResourceDialogSuccess": "成功", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "內部資源創建成功", + "createInternalResourceDialogError": "錯誤", + "createInternalResourceDialogFailedToCreateInternalResource": "創建內部資源失敗", + "createInternalResourceDialogNameRequired": "名稱為必填項", + "createInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", + "createInternalResourceDialogPleaseSelectSite": "請選擇一個站點", + "createInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", + "createInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", + "createInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", + "createInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", + "createInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", + "createInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠", + "createInternalResourceDialogMode": "模式", + "createInternalResourceDialogModePort": "連接埠", + "createInternalResourceDialogModeHost": "主機", + "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogDestination": "目的地", + "createInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", + "createInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", + "createInternalResourceDialogAlias": "別名", + "createInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", + "siteConfiguration": "配置", + "siteAcceptClientConnections": "接受用戶端連接", + "siteAcceptClientConnectionsDescription": "允許其他設備透過此 Newt 實例使用用戶端作為閘道器連接。", + "siteAddress": "站點地址", + "siteAddressDescription": "指定主機的 IP 位址以供用戶端連接。這是 Pangolin 網路中站點的內部地址,供用戶端訪問。必須在 Org 子網內。", + "siteNameDescription": "站點的顯示名稱,可以稍後更改。", + "autoLoginExternalIdp": "自動使用外部 IDP 登錄", + "autoLoginExternalIdpDescription": "立即將用戶重定向到外部 IDP 進行身份驗證。", + "selectIdp": "選擇 IDP", + "selectIdpPlaceholder": "選擇一個 IDP...", + "selectIdpRequired": "在啟用自動登錄時,請選擇一個 IDP。", + "autoLoginTitle": "重定向中", + "autoLoginDescription": "正在將您重定向到外部身份提供商進行身份驗證。", + "autoLoginProcessing": "準備身份驗證...", + "autoLoginRedirecting": "重定向到登錄...", + "autoLoginError": "自動登錄錯誤", + "autoLoginErrorNoRedirectUrl": "未從身份提供商收到重定向 URL。", + "autoLoginErrorGeneratingUrl": "生成身份驗證 URL 失敗。", + "remoteExitNodeManageRemoteExitNodes": "遠程節點", + "remoteExitNodeDescription": "自我主機一個或多個遠程節點來擴展您的網路連接並減少對雲的依賴性", + "remoteExitNodes": "節點", + "searchRemoteExitNodes": "搜索節點...", + "remoteExitNodeAdd": "添加節點", + "remoteExitNodeErrorDelete": "刪除節點時出錯", + "remoteExitNodeQuestionRemove": "您確定要從組織中刪除該節點嗎?", + "remoteExitNodeMessageRemove": "一旦刪除,該節點將不再能夠訪問。", + "remoteExitNodeConfirmDelete": "確認刪除節點", + "remoteExitNodeDelete": "刪除節點", + "sidebarRemoteExitNodes": "遠程節點", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "密鑰", + "remoteExitNodeCreate": { + "title": "創建節點", + "description": "創建一個新節點來擴展您的網路連接", + "viewAllButton": "查看所有節點", + "strategy": { + "title": "創建策略", + "description": "選擇此選項以手動配置您的節點或生成新憑據。", + "adopt": { + "title": "採納節點", + "description": "如果您已經擁有該節點的憑據,請選擇此項。" + }, + "generate": { + "title": "生成金鑰", + "description": "如果您想為節點生成新金鑰,請選擇此選項" + } + }, + "adopt": { + "title": "採納現有節點", + "description": "輸入您想要採用的現有節點的憑據", + "nodeIdLabel": "節點 ID", + "nodeIdDescription": "您想要採用的現有節點的 ID", + "secretLabel": "金鑰", + "secretDescription": "現有節點的秘密金鑰", + "submitButton": "採用節點" + }, + "generate": { + "title": "生成的憑據", + "description": "使用這些生成的憑據來配置您的節點", + "nodeIdTitle": "節點 ID", + "secretTitle": "金鑰", + "saveCredentialsTitle": "將憑據添加到配置中", + "saveCredentialsDescription": "將這些憑據添加到您的自託管 Pangolin 節點設定檔中以完成連接。", + "submitButton": "創建節點" + }, + "validation": { + "adoptRequired": "在通過現有節點時需要節點ID和金鑰" + }, + "errors": { + "loadDefaultsFailed": "無法載入預設值", + "defaultsNotLoaded": "預設值未載入", + "createFailed": "創建節點失敗" + }, + "success": { + "created": "節點創建成功" + } }, - "validation": { - "adoptRequired": "在通過現有節點時需要節點ID和金鑰" + "remoteExitNodeSelection": "節點選擇", + "remoteExitNodeSelectionDescription": "為此本地站點選擇要路由流量的節點", + "remoteExitNodeRequired": "必須為本地站點選擇節點", + "noRemoteExitNodesAvailable": "無可用節點", + "noRemoteExitNodesAvailableDescription": "此組織沒有可用的節點。首先創建一個節點來使用本地站點。", + "exitNode": "出口節點", + "country": "國家", + "rulesMatchCountry": "當前基於源 IP", + "managedSelfHosted": { + "title": "託管自託管", + "description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能", + "introTitle": "託管式自架 Pangolin", + "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。", + "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", + "benefitSimplerOperations": { + "title": "簡單的操作", + "description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。" + }, + "benefitAutomaticUpdates": { + "title": "自動更新", + "description": "雲儀錶板快速演化,所以您可以獲得新的功能和錯誤修復,而不必每次手動拉取新的容器。" + }, + "benefitLessMaintenance": { + "title": "減少維護時間", + "description": "沒有要管理的資料庫遷移、備份或額外的基礎設施。我們在雲端處理這個問題。" + }, + "benefitCloudFailover": { + "title": "雲端故障轉移", + "description": "如果您的節點發生故障,您的隧道可以暫時故障轉移到我們的雲端存取點,直到您將節點恢復線上狀態。" + }, + "benefitHighAvailability": { + "title": "高可用率(PoPs)", + "description": "您還可以將多個節點添加到您的帳戶中以獲取冗餘和更好的性能。" + }, + "benefitFutureEnhancements": { + "title": "將來的改進", + "description": "我們正在計劃添加更多的分析、警報和管理工具,使你的部署更加有力。" + }, + "docsAlert": { + "text": "在我們中更多地了解管理下的自託管選項", + "documentation": "文件" + }, + "convertButton": "將此節點轉換為管理自託管的" }, - "errors": { - "loadDefaultsFailed": "無法載入預設值", - "defaultsNotLoaded": "預設值未載入", - "createFailed": "創建節點失敗" + "internationaldomaindetected": "檢測到國際域", + "willbestoredas": "儲存為:", + "roleMappingDescription": "確定當用戶啟用自動配送時如何分配他們的角色。", + "selectRole": "選擇角色", + "roleMappingExpression": "表達式", + "selectRolePlaceholder": "選擇角色", + "selectRoleDescription": "選擇一個角色,從此身份提供商分配給所有用戶", + "roleMappingExpressionDescription": "輸入一個 JMESPath 表達式來從 ID 令牌提取角色資訊", + "idpTenantIdRequired": "租戶 ID 是必需的", + "invalidValue": "無效的值", + "idpTypeLabel": "身份提供者類型", + "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", + "idpGoogleConfiguration": "Google 配置", + "idpGoogleConfigurationDescription": "配置您的 Google OAuth2 憑據", + "idpGoogleClientIdDescription": "您的 Google OAuth2 用戶端 ID", + "idpGoogleClientSecretDescription": "您的 Google OAuth2 用戶端金鑰", + "idpAzureConfiguration": "Azure Entra ID 配置", + "idpAzureConfigurationDescription": "配置您的 Azure Entra ID OAuth2 憑據", + "idpTenantId": "租戶 ID", + "idpTenantIdPlaceholder": "您的租戶 ID", + "idpAzureTenantIdDescription": "您的 Azure 租戶ID (在 Azure Active Directory 概覽中發現)", + "idpAzureClientIdDescription": "您的 Azure 應用程式註冊用戶端 ID", + "idpAzureClientSecretDescription": "您的 Azure 應用程式註冊用戶端金鑰", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google 配置", + "idpAzureConfigurationTitle": "Azure Entra ID 配置", + "idpTenantIdLabel": "租戶 ID", + "idpAzureClientIdDescription2": "您的 Azure 應用程式註冊用戶端 ID", + "idpAzureClientSecretDescription2": "您的 Azure 應用程式註冊用戶端金鑰", + "idpGoogleDescription": "Google OAuth2/OIDC 提供商", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 提供者", + "subnet": "子網", + "subnetDescription": "此組織網路配置的子網。", + "customDomain": "自訂網域", + "authPage": "認證頁面", + "authPageDescription": "配置您的組織認證頁面", + "authPageDomain": "認證頁面域", + "authPageBranding": "自訂品牌", + "authPageBrandingDescription": "設定此組織驗證頁面上顯示的品牌", + "authPageBrandingUpdated": "驗證頁面品牌更新成功", + "authPageBrandingRemoved": "驗證頁面品牌移除成功", + "authPageBrandingRemoveTitle": "移除驗證頁面品牌", + "authPageBrandingQuestionRemove": "您確定要移除驗證頁面的品牌嗎?", + "authPageBrandingDeleteConfirm": "確認刪除品牌", + "brandingLogoURL": "Logo 網址", + "brandingPrimaryColor": "主要顏色", + "brandingLogoWidth": "寬度 (px)", + "brandingLogoHeight": "高度 (px)", + "brandingOrgTitle": "組織驗證頁面標題", + "brandingOrgDescription": "{orgName} 將被替換為組織名稱", + "brandingOrgSubtitle": "組織驗證頁面副標題", + "brandingResourceTitle": "資源驗證頁面標題", + "brandingResourceSubtitle": "資源驗證頁面副標題", + "brandingResourceDescription": "{resourceName} 將被替換為組織名稱", + "saveAuthPageDomain": "儲存網域", + "saveAuthPageBranding": "儲存品牌", + "removeAuthPageBranding": "移除品牌", + "noDomainSet": "沒有域設置", + "changeDomain": "更改域", + "selectDomain": "選擇域", + "restartCertificate": "重新啟動證書", + "editAuthPageDomain": "編輯認證頁面域", + "setAuthPageDomain": "設置認證頁面域", + "failedToFetchCertificate": "獲取證書失敗", + "failedToRestartCertificate": "重新啟動證書失敗", + "addDomainToEnableCustomAuthPages": "為您的組織添加域名以啟用自訂認證頁面", + "selectDomainForOrgAuthPage": "選擇組織認證頁面的域", + "domainPickerProvidedDomain": "提供的域", + "domainPickerFreeProvidedDomain": "免費提供的域", + "domainPickerVerified": "已驗證", + "domainPickerUnverified": "未驗證", + "domainPickerInvalidSubdomainStructure": "此子域包含無效的字元或結構。當您保存時,它將被自動清除。", + "domainPickerError": "錯誤", + "domainPickerErrorLoadDomains": "載入組織域名失敗", + "domainPickerErrorCheckAvailability": "檢查域可用性失敗", + "domainPickerInvalidSubdomain": "無效的子域", + "domainPickerInvalidSubdomainRemoved": "輸入 \"{sub}\" 已被移除,因為其無效。", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 無法為 {domain} 變為有效。", + "domainPickerSubdomainSanitized": "子域已淨化", + "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正為 \"{sanitized}\"", + "orgAuthSignInTitle": "登錄到您的組織", + "orgAuthChooseIdpDescription": "選擇您的身份提供商以繼續", + "orgAuthNoIdpConfigured": "此機構沒有配置任何身份提供者。您可以使用您的 Pangolin 身份登錄。", + "orgAuthSignInWithPangolin": "使用 Pangolin 登錄", + "orgAuthSignInToOrg": "登入組織", + "orgAuthSelectOrgTitle": "組織登入", + "orgAuthSelectOrgDescription": "輸入您的組織 ID 以繼續", + "orgAuthOrgIdPlaceholder": "your-organization", + "orgAuthOrgIdHelp": "輸入您組織的唯一識別碼", + "orgAuthSelectOrgHelp": "輸入組織 ID 後,您將被導向到組織的登入頁面,在那裡您可以使用 SSO 或組織憑證。", + "orgAuthRememberOrgId": "記住此組織 ID", + "orgAuthBackToSignIn": "返回標準登入", + "orgAuthNoAccount": "沒有帳戶?", + "subscriptionRequiredToUse": "需要訂閱才能使用此功能。", + "idpDisabled": "身份提供者已禁用。", + "orgAuthPageDisabled": "組織認證頁面已禁用。", + "domainRestartedDescription": "域驗證重新啟動成功", + "resourceAddEntrypointsEditFile": "編輯文件:config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "編輯文件:docker-compose.yml", + "emailVerificationRequired": "需要電子郵件驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", + "twoFactorSetupRequired": "需要設置雙因素身份驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", + "additionalSecurityRequired": "需要額外的安全", + "organizationRequiresAdditionalSteps": "這個組織需要額外的安全步驟才能訪問資源。", + "completeTheseSteps": "完成這些步驟", + "enableTwoFactorAuthentication": "啟用兩步驗證", + "completeSecuritySteps": "完成安全步驟", + "securitySettings": "安全設定", + "dangerSection": "危險區域", + "dangerSectionDescription": "永久刪除與此組織相關的所有資料", + "securitySettingsDescription": "配置您組織的安全策略", + "requireTwoFactorForAllUsers": "所有用戶需要兩步驗證", + "requireTwoFactorDescription": "如果啟用,此組織的所有內部用戶必須啟用雙重身份驗證才能訪問組織。", + "requireTwoFactorDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", + "requireTwoFactorCannotEnableDescription": "您必須為您的帳戶啟用雙重身份驗證才能對所有用戶", + "maxSessionLength": "最大會話長度", + "maxSessionLengthDescription": "設置用戶會話的最長時間。此後用戶需要重新驗證。", + "maxSessionLengthDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", + "selectSessionLength": "選擇會話長度", + "unenforced": "未執行", + "1Hour": "1 小時", + "3Hours": "3 小時", + "6Hours": "6 小時", + "12Hours": "12 小時", + "1DaySession": "1天", + "3Days": "3 天", + "7Days": "7 天", + "14Days": "14 天", + "30DaysSession": "30 天", + "90DaysSession": "90 天", + "180DaysSession": "180天", + "passwordExpiryDays": "密碼過期", + "editPasswordExpiryDescription": "設置用戶需要更改密碼之前的天數。", + "selectPasswordExpiry": "選擇密碼過期", + "30Days": "30 天", + "1Day": "1天", + "60Days": "60天", + "90Days": "90 天", + "180Days": "180天", + "1Year": "1 年", + "subscriptionBadge": "需要訂閱", + "securityPolicyChangeWarning": "安全政策更改警告", + "securityPolicyChangeDescription": "您即將更改安全政策設置。保存後,您可能需要重新認證以遵守這些政策更新。 所有不符合要求的用戶也需要重新認證。", + "securityPolicyChangeConfirmMessage": "我確認", + "securityPolicyChangeWarningText": "這將影響組織中的所有用戶", + "authPageErrorUpdateMessage": "更新身份驗證頁面設置時出錯", + "authPageErrorUpdate": "無法更新認證頁面", + "authPageDomainUpdated": "驗證頁面網域更新成功", + "healthCheckNotAvailable": "本地的", + "rewritePath": "重寫路徑", + "rewritePathDescription": "在轉發到目標之前,可以選擇重寫路徑。", + "continueToApplication": "繼續應用", + "checkingInvite": "正在檢查邀請", + "setResourceHeaderAuth": "設置 ResourceHeaderAuth", + "resourceHeaderAuthRemove": "移除 Header 身份驗證", + "resourceHeaderAuthRemoveDescription": "已成功刪除 Header 身份驗證。", + "resourceErrorHeaderAuthRemove": "刪除 Header 身份驗證失敗", + "resourceErrorHeaderAuthRemoveDescription": "無法刪除資源的 Header 身份驗證。", + "resourceHeaderAuthProtectionEnabled": "Header 認證已啟用", + "resourceHeaderAuthProtectionDisabled": "Header 身份驗證已禁用", + "headerAuthRemove": "刪除 Header 認證", + "headerAuthAdd": "添加頁首認證", + "resourceErrorHeaderAuthSetup": "設置頁首認證失敗", + "resourceErrorHeaderAuthSetupDescription": "無法設置資源的 Header 身份驗證。", + "resourceHeaderAuthSetup": "Header 認證設置成功", + "resourceHeaderAuthSetupDescription": "Header 認證已成功設置。", + "resourceHeaderAuthSetupTitle": "設置 Header 身份驗證", + "resourceHeaderAuthSetupTitleDescription": "使用 HTTP 頭身份驗證來設置基本身份驗證資訊(使用者名稱和密碼)。使用 https://username:password@resource.example.com 訪問它", + "resourceHeaderAuthSubmit": "設置 Header 身份驗證", + "actionSetResourceHeaderAuth": "設置 Header 身份驗證", + "enterpriseEdition": "企業版", + "unlicensed": "未授權", + "beta": "測試版", + "manageUserDevices": "使用者裝置", + "manageUserDevicesDescription": "查看和管理使用者用於私密連接資源的裝置", + "downloadClientBannerTitle": "下載 Pangolin 客戶端", + "downloadClientBannerDescription": "下載適用於您系統的 Pangolin 客戶端,以連接到 Pangolin 網路並私密存取資源。", + "manageMachineClients": "管理機器客戶端", + "manageMachineClientsDescription": "建立和管理伺服器和系統用於私密連接資源的客戶端", + "machineClientsBannerTitle": "伺服器與自動化系統", + "machineClientsBannerDescription": "機器客戶端適用於與特定使用者無關的伺服器和自動化系統。它們使用 ID 和密鑰進行驗證,可以透過 Pangolin CLI、Olm CLI 或 Olm 容器執行。", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm 容器", + "clientsTableUserClients": "使用者", + "clientsTableMachineClients": "機器", + "licenseTableValidUntil": "有效期至", + "saasLicenseKeysSettingsTitle": "企業許可證", + "saasLicenseKeysSettingsDescription": "為自我託管的 Pangolin 實例生成和管理企業許可證金鑰", + "sidebarEnterpriseLicenses": "許可協議", + "generateLicenseKey": "生成許可證金鑰", + "generateLicenseKeyForm": { + "validation": { + "emailRequired": "請輸入一個有效的電子郵件地址", + "useCaseTypeRequired": "請選擇一個使用的案例類型", + "firstNameRequired": "必填名", + "lastNameRequired": "姓氏是必填項", + "primaryUseRequired": "請描述您的主要使用", + "jobTitleRequiredBusiness": "企業使用必須有職位頭銜。", + "industryRequiredBusiness": "商業使用需要工業", + "stateProvinceRegionRequired": "州/省/地區是必填項", + "postalZipCodeRequired": "郵政編碼是必需的", + "companyNameRequiredBusiness": "企業使用需要公司名稱", + "countryOfResidenceRequiredBusiness": "商業使用必須是居住國", + "countryRequiredPersonal": "國家需要個人使用", + "agreeToTermsRequired": "您必須同意條款", + "complianceConfirmationRequired": "您必須確認遵守 Fossorial Commercial License" + }, + "useCaseOptions": { + "personal": { + "title": "個人使用", + "description": "個人非商業用途,如學習、個人項目或實驗。" + }, + "business": { + "title": "商業使用", + "description": "供組織、公司或商業或創收活動使用。" + } + }, + "steps": { + "emailLicenseType": { + "title": "電子郵件和許可證類型", + "description": "輸入您的電子郵件並選擇您的許可證類型" + }, + "personalInformation": { + "title": "個人資訊", + "description": "告訴我們自己的資訊" + }, + "contactInformation": { + "title": "聯繫資訊", + "description": "您的聯繫資訊" + }, + "termsGenerate": { + "title": "條款並生成", + "description": "審閱並接受條款生成您的許可證" + } + }, + "alerts": { + "commercialUseDisclosure": { + "title": "使用情況披露", + "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境--需要有效的企業許可證和支付適用的許可證費用。 所有用戶,不論是個人還是企業,都必須遵守寄養商業許可證條款。" + }, + "trialPeriodInformation": { + "title": "試用期資訊", + "description": "此許可證金鑰使企業特性能夠持續 7 天的評價。 在評估期過後繼續訪問付費功能需要在有效的個人或企業許可證下啟用。對於企業許可證,請聯絡 Sales@pangolin.net。" + } + }, + "form": { + "useCaseQuestion": "您是否正在使用 Pangolin 進行個人或商業使用?", + "firstName": "名字", + "lastName": "名字", + "jobTitle": "工作頭銜:", + "primaryUseQuestion": "您主要計劃使用 Pangolin 嗎?", + "industryQuestion": "您的行業是什麼?", + "prospectiveUsersQuestion": "您期望有多少預期用戶?", + "prospectiveSitesQuestion": "您期望有多少站點(隧道)?", + "companyName": "公司名稱", + "countryOfResidence": "居住國", + "stateProvinceRegion": "州/省/地區", + "postalZipCode": "郵政編碼", + "companyWebsite": "公司網站", + "companyPhoneNumber": "公司電話號碼", + "country": "國家", + "phoneNumberOptional": "電話號碼 (可選)", + "complianceConfirmation": "我確認我提供的資料是準確的,我遵守了寄養商業許可證。 報告不準確的資訊或錯誤的產品使用是違反許可證的行為,可能導致您的金鑰被撤銷。" + }, + "buttons": { + "close": "關閉", + "previous": "上一個", + "next": "下一個", + "generateLicenseKey": "生成許可證金鑰" + }, + "toasts": { + "success": { + "title": "許可證金鑰生成成功", + "description": "您的許可證金鑰已經生成並準備使用。" + }, + "error": { + "title": "生成許可證金鑰失敗", + "description": "生成許可證金鑰時出錯。" + } + } }, - "success": { - "created": "節點創建成功" - } - }, - "remoteExitNodeSelection": "節點選擇", - "remoteExitNodeSelectionDescription": "為此本地站點選擇要路由流量的節點", - "remoteExitNodeRequired": "必須為本地站點選擇節點", - "noRemoteExitNodesAvailable": "無可用節點", - "noRemoteExitNodesAvailableDescription": "此組織沒有可用的節點。首先創建一個節點來使用本地站點。", - "exitNode": "出口節點", - "country": "國家", - "rulesMatchCountry": "當前基於源 IP", - "managedSelfHosted": { - "title": "託管自託管", - "description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能", - "introTitle": "託管式自架 Pangolin", - "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。", - "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 — — 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", - "benefitSimplerOperations": { - "title": "簡單的操作", - "description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。" - }, - "benefitAutomaticUpdates": { - "title": "自動更新", - "description": "雲儀錶板快速演化,所以您可以獲得新的功能和錯誤修復,而不必每次手動拉取新的容器。" - }, - "benefitLessMaintenance": { - "title": "減少維護時間", - "description": "沒有要管理的資料庫遷移、備份或額外的基礎設施。我們在雲端處理這個問題。" - }, - "benefitCloudFailover": { - "title": "雲端故障轉移", - "description": "如果您的節點發生故障,您的隧道可以暫時故障轉移到我們的雲端存取點,直到您將節點恢復線上狀態。" - }, - "benefitHighAvailability": { - "title": "高可用率(PoPs)", - "description": "您還可以將多個節點添加到您的帳戶中以獲取冗餘和更好的性能。" - }, - "benefitFutureEnhancements": { - "title": "將來的改進", - "description": "我們正在計劃添加更多的分析、警報和管理工具,使你的部署更加有力。" - }, - "docsAlert": { - "text": "在我們中更多地了解管理下的自託管選項", - "documentation": "文件" - }, - "convertButton": "將此節點轉換為管理自託管的" - }, - "internationaldomaindetected": "檢測到國際域", - "willbestoredas": "儲存為:", - "roleMappingDescription": "確定當用戶啟用自動配送時如何分配他們的角色。", - "selectRole": "選擇角色", - "roleMappingExpression": "表達式", - "selectRolePlaceholder": "選擇角色", - "selectRoleDescription": "選擇一個角色,從此身份提供商分配給所有用戶", - "roleMappingExpressionDescription": "輸入一個 JMESPath 表達式來從 ID 令牌提取角色資訊", - "idpTenantIdRequired": "租戶 ID 是必需的", - "invalidValue": "無效的值", - "idpTypeLabel": "身份提供者類型", - "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", - "idpGoogleConfiguration": "Google 配置", - "idpGoogleConfigurationDescription": "配置您的 Google OAuth2 憑據", - "idpGoogleClientIdDescription": "您的 Google OAuth2 用戶端 ID", - "idpGoogleClientSecretDescription": "您的 Google OAuth2 用戶端金鑰", - "idpAzureConfiguration": "Azure Entra ID 配置", - "idpAzureConfigurationDescription": "配置您的 Azure Entra ID OAuth2 憑據", - "idpTenantId": "租戶 ID", - "idpTenantIdPlaceholder": "您的租戶 ID", - "idpAzureTenantIdDescription": "您的 Azure 租戶ID (在 Azure Active Directory 概覽中發現)", - "idpAzureClientIdDescription": "您的 Azure 應用程式註冊用戶端 ID", - "idpAzureClientSecretDescription": "您的 Azure 應用程式註冊用戶端金鑰", - "idpGoogleTitle": "Google", - "idpGoogleAlt": "Google", - "idpAzureTitle": "Azure Entra ID", - "idpAzureAlt": "Azure", - "idpGoogleConfigurationTitle": "Google 配置", - "idpAzureConfigurationTitle": "Azure Entra ID 配置", - "idpTenantIdLabel": "租戶 ID", - "idpAzureClientIdDescription2": "您的 Azure 應用程式註冊用戶端 ID", - "idpAzureClientSecretDescription2": "您的 Azure 應用程式註冊用戶端金鑰", - "idpGoogleDescription": "Google OAuth2/OIDC 提供商", - "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 提供者", - "subnet": "子網", - "subnetDescription": "此組織網路配置的子網。", - "customDomain": "自訂網域", - "authPage": "認證頁面", - "authPageDescription": "配置您的組織認證頁面", - "authPageDomain": "認證頁面域", - "authPageBranding": "自訂品牌", - "authPageBrandingDescription": "設定此組織驗證頁面上顯示的品牌", - "authPageBrandingUpdated": "驗證頁面品牌更新成功", - "authPageBrandingRemoved": "驗證頁面品牌移除成功", - "authPageBrandingRemoveTitle": "移除驗證頁面品牌", - "authPageBrandingQuestionRemove": "您確定要移除驗證頁面的品牌嗎?", - "authPageBrandingDeleteConfirm": "確認刪除品牌", - "brandingLogoURL": "Logo 網址", - "brandingPrimaryColor": "主要顏色", - "brandingLogoWidth": "寬度 (px)", - "brandingLogoHeight": "高度 (px)", - "brandingOrgTitle": "組織驗證頁面標題", - "brandingOrgDescription": "{orgName} 將被替換為組織名稱", - "brandingOrgSubtitle": "組織驗證頁面副標題", - "brandingResourceTitle": "資源驗證頁面標題", - "brandingResourceSubtitle": "資源驗證頁面副標題", - "brandingResourceDescription": "{resourceName} 將被替換為組織名稱", - "saveAuthPageDomain": "儲存網域", - "saveAuthPageBranding": "儲存品牌", - "removeAuthPageBranding": "移除品牌", - "noDomainSet": "沒有域設置", - "changeDomain": "更改域", - "selectDomain": "選擇域", - "restartCertificate": "重新啟動證書", - "editAuthPageDomain": "編輯認證頁面域", - "setAuthPageDomain": "設置認證頁面域", - "failedToFetchCertificate": "獲取證書失敗", - "failedToRestartCertificate": "重新啟動證書失敗", - "addDomainToEnableCustomAuthPages": "為您的組織添加域名以啟用自訂認證頁面", - "selectDomainForOrgAuthPage": "選擇組織認證頁面的域", - "domainPickerProvidedDomain": "提供的域", - "domainPickerFreeProvidedDomain": "免費提供的域", - "domainPickerVerified": "已驗證", - "domainPickerUnverified": "未驗證", - "domainPickerInvalidSubdomainStructure": "此子域包含無效的字元或結構。當您保存時,它將被自動清除。", - "domainPickerError": "錯誤", - "domainPickerErrorLoadDomains": "載入組織域名失敗", - "domainPickerErrorCheckAvailability": "檢查域可用性失敗", - "domainPickerInvalidSubdomain": "無效的子域", - "domainPickerInvalidSubdomainRemoved": "輸入 \"{sub}\" 已被移除,因為其無效。", - "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 無法為 {domain} 變為有效。", - "domainPickerSubdomainSanitized": "子域已淨化", - "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正為 \"{sanitized}\"", - "orgAuthSignInTitle": "登錄到您的組織", - "orgAuthChooseIdpDescription": "選擇您的身份提供商以繼續", - "orgAuthNoIdpConfigured": "此機構沒有配置任何身份提供者。您可以使用您的 Pangolin 身份登錄。", - "orgAuthSignInWithPangolin": "使用 Pangolin 登錄", - "orgAuthSignInToOrg": "登入組織", - "orgAuthSelectOrgTitle": "組織登入", - "orgAuthSelectOrgDescription": "輸入您的組織 ID 以繼續", - "orgAuthOrgIdPlaceholder": "your-organization", - "orgAuthOrgIdHelp": "輸入您組織的唯一識別碼", - "orgAuthSelectOrgHelp": "輸入組織 ID 後,您將被導向到組織的登入頁面,在那裡您可以使用 SSO 或組織憑證。", - "orgAuthRememberOrgId": "記住此組織 ID", - "orgAuthBackToSignIn": "返回標準登入", - "orgAuthNoAccount": "沒有帳戶?", - "subscriptionRequiredToUse": "需要訂閱才能使用此功能。", - "idpDisabled": "身份提供者已禁用。", - "orgAuthPageDisabled": "組織認證頁面已禁用。", - "domainRestartedDescription": "域驗證重新啟動成功", - "resourceAddEntrypointsEditFile": "編輯文件:config/traefik/traefik_config.yml", - "resourceExposePortsEditFile": "編輯文件:docker-compose.yml", - "emailVerificationRequired": "需要電子郵件驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", - "twoFactorSetupRequired": "需要設置雙因素身份驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", - "additionalSecurityRequired": "需要額外的安全", - "organizationRequiresAdditionalSteps": "這個組織需要額外的安全步驟才能訪問資源。", - "completeTheseSteps": "完成這些步驟", - "enableTwoFactorAuthentication": "啟用兩步驗證", - "completeSecuritySteps": "完成安全步驟", - "securitySettings": "安全設定", - "dangerSection": "危險區域", - "dangerSectionDescription": "永久刪除與此組織相關的所有資料", - "securitySettingsDescription": "配置您組織的安全策略", - "requireTwoFactorForAllUsers": "所有用戶需要兩步驗證", - "requireTwoFactorDescription": "如果啟用,此組織的所有內部用戶必須啟用雙重身份驗證才能訪問組織。", - "requireTwoFactorDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", - "requireTwoFactorCannotEnableDescription": "您必須為您的帳戶啟用雙重身份驗證才能對所有用戶", - "maxSessionLength": "最大會話長度", - "maxSessionLengthDescription": "設置用戶會話的最長時間。此後用戶需要重新驗證。", - "maxSessionLengthDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", - "selectSessionLength": "選擇會話長度", - "unenforced": "未執行", - "1Hour": "1 小時", - "3Hours": "3 小時", - "6Hours": "6 小時", - "12Hours": "12 小時", - "1DaySession": "1天", - "3Days": "3 天", - "7Days": "7 天", - "14Days": "14 天", - "30DaysSession": "30 天", - "90DaysSession": "90 天", - "180DaysSession": "180天", - "passwordExpiryDays": "密碼過期", - "editPasswordExpiryDescription": "設置用戶需要更改密碼之前的天數。", - "selectPasswordExpiry": "選擇密碼過期", - "30Days": "30 天", - "1Day": "1天", - "60Days": "60天", - "90Days": "90 天", - "180Days": "180天", - "1Year": "1 年", - "subscriptionBadge": "需要訂閱", - "securityPolicyChangeWarning": "安全政策更改警告", - "securityPolicyChangeDescription": "您即將更改安全政策設置。保存後,您可能需要重新認證以遵守這些政策更新。 所有不符合要求的用戶也需要重新認證。", - "securityPolicyChangeConfirmMessage": "我確認", - "securityPolicyChangeWarningText": "這將影響組織中的所有用戶", - "authPageErrorUpdateMessage": "更新身份驗證頁面設置時出錯", - "authPageErrorUpdate": "無法更新認證頁面", - "authPageDomainUpdated": "驗證頁面網域更新成功", - "healthCheckNotAvailable": "本地的", - "rewritePath": "重寫路徑", - "rewritePathDescription": "在轉發到目標之前,可以選擇重寫路徑。", - "continueToApplication": "繼續應用", - "checkingInvite": "正在檢查邀請", - "setResourceHeaderAuth": "設置 ResourceHeaderAuth", - "resourceHeaderAuthRemove": "移除 Header 身份驗證", - "resourceHeaderAuthRemoveDescription": "已成功刪除 Header 身份驗證。", - "resourceErrorHeaderAuthRemove": "刪除 Header 身份驗證失敗", - "resourceErrorHeaderAuthRemoveDescription": "無法刪除資源的 Header 身份驗證。", - "resourceHeaderAuthProtectionEnabled": "Header 認證已啟用", - "resourceHeaderAuthProtectionDisabled": "Header 身份驗證已禁用", - "headerAuthRemove": "刪除 Header 認證", - "headerAuthAdd": "添加頁首認證", - "resourceErrorHeaderAuthSetup": "設置頁首認證失敗", - "resourceErrorHeaderAuthSetupDescription": "無法設置資源的 Header 身份驗證。", - "resourceHeaderAuthSetup": "Header 認證設置成功", - "resourceHeaderAuthSetupDescription": "Header 認證已成功設置。", - "resourceHeaderAuthSetupTitle": "設置 Header 身份驗證", - "resourceHeaderAuthSetupTitleDescription": "使用 HTTP 頭身份驗證來設置基本身份驗證資訊(使用者名稱和密碼)。使用 https://username:password@resource.example.com 訪問它", - "resourceHeaderAuthSubmit": "設置 Header 身份驗證", - "actionSetResourceHeaderAuth": "設置 Header 身份驗證", - "enterpriseEdition": "企業版", - "unlicensed": "未授權", - "beta": "測試版", - "manageUserDevices": "使用者裝置", - "manageUserDevicesDescription": "查看和管理使用者用於私密連接資源的裝置", - "downloadClientBannerTitle": "下載 Pangolin 客戶端", - "downloadClientBannerDescription": "下載適用於您系統的 Pangolin 客戶端,以連接到 Pangolin 網路並私密存取資源。", - "manageMachineClients": "管理機器客戶端", - "manageMachineClientsDescription": "建立和管理伺服器和系統用於私密連接資源的客戶端", - "machineClientsBannerTitle": "伺服器與自動化系統", - "machineClientsBannerDescription": "機器客戶端適用於與特定使用者無關的伺服器和自動化系統。它們使用 ID 和密鑰進行驗證,可以透過 Pangolin CLI、Olm CLI 或 Olm 容器執行。", - "machineClientsBannerPangolinCLI": "Pangolin CLI", - "machineClientsBannerOlmCLI": "Olm CLI", - "machineClientsBannerOlmContainer": "Olm 容器", - "clientsTableUserClients": "使用者", - "clientsTableMachineClients": "機器", - "licenseTableValidUntil": "有效期至", - "saasLicenseKeysSettingsTitle": "企業許可證", - "saasLicenseKeysSettingsDescription": "為自我託管的 Pangolin 實例生成和管理企業許可證金鑰", - "sidebarEnterpriseLicenses": "許可協議", - "generateLicenseKey": "生成許可證金鑰", - "generateLicenseKeyForm": { - "validation": { - "emailRequired": "請輸入一個有效的電子郵件地址", - "useCaseTypeRequired": "請選擇一個使用的案例類型", - "firstNameRequired": "必填名", - "lastNameRequired": "姓氏是必填項", - "primaryUseRequired": "請描述您的主要使用", - "jobTitleRequiredBusiness": "企業使用必須有職位頭銜。", - "industryRequiredBusiness": "商業使用需要工業", - "stateProvinceRegionRequired": "州/省/地區是必填項", - "postalZipCodeRequired": "郵政編碼是必需的", - "companyNameRequiredBusiness": "企業使用需要公司名稱", - "countryOfResidenceRequiredBusiness": "商業使用必須是居住國", - "countryRequiredPersonal": "國家需要個人使用", - "agreeToTermsRequired": "您必須同意條款", - "complianceConfirmationRequired": "您必須確認遵守 Fossorial Commercial License" - }, - "useCaseOptions": { - "personal": { - "title": "個人使用", - "description": "個人非商業用途,如學習、個人項目或實驗。" - }, - "business": { - "title": "商業使用", - "description": "供組織、公司或商業或創收活動使用。" - } - }, - "steps": { - "emailLicenseType": { - "title": "電子郵件和許可證類型", - "description": "輸入您的電子郵件並選擇您的許可證類型" - }, - "personalInformation": { - "title": "個人資訊", - "description": "告訴我們自己的資訊" - }, - "contactInformation": { - "title": "聯繫資訊", - "description": "您的聯繫資訊" - }, - "termsGenerate": { - "title": "條款並生成", - "description": "審閱並接受條款生成您的許可證" - } - }, - "alerts": { - "commercialUseDisclosure": { - "title": "使用情況披露", - "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境——需要有效的企業許可證和支付適用的許可證費用。 所有用戶,不論是個人還是企業,都必須遵守寄養商業許可證條款。" - }, - "trialPeriodInformation": { - "title": "試用期資訊", - "description": "此許可證金鑰使企業特性能夠持續 7 天的評價。 在評估期過後繼續訪問付費功能需要在有效的個人或企業許可證下啟用。對於企業許可證,請聯絡 Sales@pangolin.net。" - } - }, - "form": { - "useCaseQuestion": "您是否正在使用 Pangolin 進行個人或商業使用?", - "firstName": "名字", - "lastName": "名字", - "jobTitle": "工作頭銜:", - "primaryUseQuestion": "您主要計劃使用 Pangolin 嗎?", - "industryQuestion": "您的行業是什麼?", - "prospectiveUsersQuestion": "您期望有多少預期用戶?", - "prospectiveSitesQuestion": "您期望有多少站點(隧道)?", - "companyName": "公司名稱", - "countryOfResidence": "居住國", - "stateProvinceRegion": "州/省/地區", - "postalZipCode": "郵政編碼", - "companyWebsite": "公司網站", - "companyPhoneNumber": "公司電話號碼", - "country": "國家", - "phoneNumberOptional": "電話號碼 (可選)", - "complianceConfirmation": "我確認我提供的資料是準確的,我遵守了寄養商業許可證。 報告不準確的資訊或錯誤的產品使用是違反許可證的行為,可能導致您的金鑰被撤銷。" - }, - "buttons": { - "close": "關閉", - "previous": "上一個", - "next": "下一個", - "generateLicenseKey": "生成許可證金鑰" - }, - "toasts": { - "success": { - "title": "許可證金鑰生成成功", - "description": "您的許可證金鑰已經生成並準備使用。" - }, - "error": { - "title": "生成許可證金鑰失敗", - "description": "生成許可證金鑰時出錯。" - } - } - }, - "priority": "優先權", - "priorityDescription": "先評估更高優先度線路。優先度 = 100 意味著自動排序(系統決定). 使用另一個數字強制執行手動優先度。", - "instanceName": "實例名稱", - "pathMatchModalTitle": "配置路徑匹配", - "pathMatchModalDescription": "根據傳入請求的路徑設置匹配方式。", - "pathMatchType": "匹配類型", - "pathMatchPrefix": "前綴", - "pathMatchExact": "精準的", - "pathMatchRegex": "正則表達式", - "pathMatchValue": "路徑值", - "clear": "清空", - "saveChanges": "保存更改", - "pathMatchRegexPlaceholder": "^/api/.*", - "pathMatchDefaultPlaceholder": "/路徑", - "pathMatchPrefixHelp": "範例: /api 匹配/api, /api/users 等。", - "pathMatchExactHelp": "範例:/api 匹配僅限/api", - "pathMatchRegexHelp": "例如:^/api/.* 匹配/api/why", - "pathRewriteModalTitle": "配置路徑重寫", - "pathRewriteModalDescription": "在轉發到目標之前變換匹配的路徑。", - "pathRewriteType": "重寫類型", - "pathRewritePrefixOption": "前綴 - 替換前綴", - "pathRewriteExactOption": "精確-替換整個路徑", - "pathRewriteRegexOption": "正則表達式 - 替換模式", - "pathRewriteStripPrefixOption": "刪除前綴 - 刪除前綴", - "pathRewriteValue": "重寫值", - "pathRewriteRegexPlaceholder": "/new/$1", - "pathRewriteDefaultPlaceholder": "/new-path", - "pathRewritePrefixHelp": "用此值替換匹配的前綴", - "pathRewriteExactHelp": "當路徑匹配時用此值替換整個路徑", - "pathRewriteRegexHelp": "使用抓取組,如$1,$2來替換", - "pathRewriteStripPrefixHelp": "留空以脫離前綴或提供新的前綴", - "pathRewritePrefix": "前綴", - "pathRewriteExact": "精準的", - "pathRewriteRegex": "正則表達式", - "pathRewriteStrip": "帶狀圖", - "pathRewriteStripLabel": "條形圖", - "sidebarEnableEnterpriseLicense": "啟用企業許可證", - "cannotbeUndone": "無法撤消。", - "toConfirm": "確認", - "deleteClientQuestion": "您確定要從站點和組織中刪除客戶嗎?", - "clientMessageRemove": "一旦刪除,用戶端將無法連接到站點。", - "sidebarLogs": "日誌", - "request": "請求", - "requests": "請求", - "logs": "日誌", - "logsSettingsDescription": "監視從此 orginization 中收集的日誌", - "searchLogs": "搜索日誌...", - "action": "行動", - "actor": "執行者", - "timestamp": "時間戳", - "accessLogs": "訪問日誌", - "exportCsv": "導出 CSV", - "exportError": "匯出 CSV 時發生未知錯誤", - "exportCsvTooltip": "在時間範圍內", - "actorId": "執行者 ID", - "allowedByRule": "根據規則允許", - "allowedNoAuth": "無認證", - "validAccessToken": "有效訪問令牌", - "validHeaderAuth": "有效的 Header 身份驗證", - "validPincode": "有效的 Pincode", - "validPassword": "有效密碼", - "validEmail": "有效的 email", - "validSSO": "有效的 SSO", - "resourceBlocked": "資源被阻止", - "droppedByRule": "被規則刪除", - "noSessions": "無會話", - "temporaryRequestToken": "臨時請求令牌", - "noMoreAuthMethods": "無有效授權", - "ip": "IP", - "reason": "原因", - "requestLogs": "請求日誌", - "requestAnalytics": "請求分析", - "host": "主機", - "location": "地點", - "actionLogs": "操作日誌", - "sidebarLogsRequest": "請求日誌", - "sidebarLogsAccess": "訪問日誌", - "sidebarLogsAction": "操作日誌", - "logRetention": "日誌保留", - "logRetentionDescription": "管理不同類型的日誌為這個機構保留多長時間或禁用這些日誌", - "requestLogsDescription": "查看此機構資源的詳細請求日誌", - "requestAnalyticsDescription": "查看此組織資源的詳細請求分析", - "logRetentionRequestLabel": "請求日誌保留", - "logRetentionRequestDescription": "保留請求日誌的時間", - "logRetentionAccessLabel": "訪問日誌保留", - "logRetentionAccessDescription": "保留訪問日誌的時間", - "logRetentionActionLabel": "動作日誌保留", - "logRetentionActionDescription": "保留操作日誌的時間", - "logRetentionDisabled": "已禁用", - "logRetention3Days": "3 天", - "logRetention7Days": "7 天", - "logRetention14Days": "14 天", - "logRetention30Days": "30 天", - "logRetention90Days": "90 天", - "logRetentionForever": "永遠的", - "logRetentionEndOfFollowingYear": "次年年底", - "actionLogsDescription": "查看此機構執行的操作歷史", - "accessLogsDescription": "查看此機構資源的訪問認證請求", - "licenseRequiredToUse": "需要企業許可證才能使用此功能。", - "certResolver": "證書解決器", - "certResolverDescription": "選擇用於此資源的證書解析器。", - "selectCertResolver": "選擇證書解析", - "enterCustomResolver": "輸入自訂解析器", - "preferWildcardCert": "喜歡通配符證書", - "unverified": "未驗證", - "domainSetting": "域設置", - "domainSettingDescription": "配置您的域的設置", - "preferWildcardCertDescription": "嘗試生成通配符證書(需要正確配置的證書解析器)。", - "recordName": "記錄名稱", - "auto": "自動操作", - "TTL": "TTL", - "howToAddRecords": "如何添加記錄", - "dnsRecord": "DNS 記錄", - "required": "必填", - "domainSettingsUpdated": "域設置更新成功", - "orgOrDomainIdMissing": "缺少機構或域 ID", - "loadingDNSRecords": "正在載入 DNS 記錄...", - "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。請更新到最新版本以獲取最佳體驗。", - "client": "用戶端:", - "proxyProtocol": "代理協議設置", - "proxyProtocolDescription": "配置代理協議以保留 TCP/UDP 服務的用戶端 IP 位址。", - "enableProxyProtocol": "啟用代理協議", - "proxyProtocolInfo": "為 TCP/UDP 後端保留用戶端 IP 位址", - "proxyProtocolVersion": "代理協議版本", - "version1": " 版本 1 (推薦)", - "version2": "版本 2", - "versionDescription": "版本 1 是基於文本和廣泛支持的版本。版本 2 是二進制和更有效率但不那麼相容。", - "warning": "警告", - "proxyProtocolWarning": "您的後端應用程式必須配置為接受代理協議連接。如果您的後端不支持代理協議,啟用這將會中斷所有連接。 請務必從 Traefik 配置您的後端到信任代理協議標題。", - "restarting": "正在重啟...", - "manual": "手動模式", - "messageSupport": "消息支持", - "supportNotAvailableTitle": "支持不可用", - "supportNotAvailableDescription": "支持現在不可用。您可以發送電子郵件到 support@pangolin.net。", - "supportRequestSentTitle": "支持請求已發送", - "supportRequestSentDescription": "您的消息已成功發送。", - "supportRequestFailedTitle": "發送請求失敗", - "supportRequestFailedDescription": "發送您的支持請求時出錯。", - "supportSubjectRequired": "主題是必填項", - "supportSubjectMaxLength": "主題必須是 255 個或更少的字元", - "supportMessageRequired": "消息是必填項", - "supportReplyTo": "回復給", - "supportSubject": "議題", - "supportSubjectPlaceholder": "輸入主題", - "supportMessage": "留言", - "supportMessagePlaceholder": "輸入您的消息", - "supportSending": "正在發送...", - "supportSend": "發送", - "supportMessageSent": "消息已發送!", - "supportWillContact": "我們很快就會聯繫起來!", - "selectLogRetention": "選擇保留日誌", - "terms": "條款", - "privacy": "隱私權", - "security": "安全性", - "docs": "文件", - "deviceActivation": "裝置啟用", - "deviceCodeInvalidFormat": "代碼必須為 9 個字元(例如:A1AJ-N5JD)", - "deviceCodeInvalidOrExpired": "代碼無效或已過期", - "deviceCodeVerifyFailed": "驗證裝置代碼失敗", - "signedInAs": "已登入為", - "deviceCodeEnterPrompt": "輸入裝置上顯示的代碼", - "continue": "繼續", - "deviceUnknownLocation": "未知位置", - "deviceAuthorizationRequested": "此授權請求來自 {location},時間為 {date}。請確保您信任此裝置,因為它將獲得帳戶存取權限。", - "deviceLabel": "裝置:{deviceName}", - "deviceWantsAccess": "想要存取您的帳戶", - "deviceExistingAccess": "現有存取權限:", - "deviceFullAccess": "完整帳戶存取權限", - "deviceOrganizationsAccess": "存取您帳戶有權限的所有組織", - "deviceAuthorize": "授權 {applicationName}", - "deviceConnected": "裝置已連接!", - "deviceAuthorizedMessage": "裝置已獲授權存取您的帳戶。請返回客戶端應用程式。", - "pangolinCloud": "Pangolin 雲端", - "viewDevices": "查看裝置", - "viewDevicesDescription": "管理您已連接的裝置", - "noDevices": "找不到裝置", - "dateCreated": "建立日期", - "unnamedDevice": "未命名裝置", - "deviceQuestionRemove": "您確定要刪除此裝置嗎?", - "deviceMessageRemove": "此操作無法復原。", - "deviceDeleteConfirm": "刪除裝置", - "deleteDevice": "刪除裝置", - "errorLoadingDevices": "載入裝置時發生錯誤", - "failedToLoadDevices": "載入裝置失敗", - "deviceDeleted": "裝置已刪除", - "deviceDeletedDescription": "裝置已成功刪除。", - "errorDeletingDevice": "刪除裝置時發生錯誤", - "failedToDeleteDevice": "刪除裝置失敗", - "showColumns": "顯示列", - "hideColumns": "隱藏列", - "columnVisibility": "列可見性", - "toggleColumn": "切換 {columnName} 列", - "allColumns": "全部列", - "defaultColumns": "默認列", - "customizeView": "自訂視圖", - "viewOptions": "查看選項", - "selectAll": "選擇所有", - "selectNone": "沒有選擇", - "selectedResources": "選定的資源", - "enableSelected": "啟用選中的", - "disableSelected": "禁用選中的", - "checkSelectedStatus": "檢查選中的狀態", - "clients": "客戶端", - "accessClientSelect": "選擇機器客戶端", - "resourceClientDescription": "可以存取此資源的機器客戶端", - "regenerate": "重新產生", - "credentials": "憑證", - "savecredentials": "儲存憑證", - "regenerateCredentialsButton": "重新產生憑證", - "regenerateCredentials": "重新產生憑證", - "generatedcredentials": "已產生的憑證", - "copyandsavethesecredentials": "複製並儲存這些憑證", - "copyandsavethesecredentialsdescription": "離開此頁面後將不會再顯示這些憑證。請立即安全儲存。", - "credentialsSaved": "憑證已儲存", - "credentialsSavedDescription": "憑證已成功重新產生並儲存。", - "credentialsSaveError": "憑證儲存錯誤", - "credentialsSaveErrorDescription": "重新產生和儲存憑證時發生錯誤。", - "regenerateCredentialsWarning": "重新產生憑證將使先前的憑證失效並導致斷線。請確保更新任何使用這些憑證的設定。", - "confirm": "確認", - "regenerateCredentialsConfirmation": "您確定要重新產生憑證嗎?", - "endpoint": "端點", - "Id": "ID", - "SecretKey": "密鑰", - "niceId": "友善 ID", - "niceIdUpdated": "友善 ID 已更新", - "niceIdUpdatedSuccessfully": "友善 ID 更新成功", - "niceIdUpdateError": "更新友善 ID 時發生錯誤", - "niceIdUpdateErrorDescription": "更新友善 ID 時發生錯誤。", - "niceIdCannotBeEmpty": "友善 ID 不能為空", - "enterIdentifier": "輸入識別碼", - "identifier": "識別碼", - "deviceLoginUseDifferentAccount": "不是您嗎?使用其他帳戶。", - "deviceLoginDeviceRequestingAccessToAccount": "有裝置正在請求存取此帳戶。", - "noData": "無資料", - "machineClients": "機器客戶端", - "install": "安裝", - "run": "執行", - "clientNameDescription": "客戶端的顯示名稱,可以稍後更改。", - "clientAddress": "客戶端位址(進階)", - "setupFailedToFetchSubnet": "取得預設子網路失敗", - "setupSubnetAdvanced": "子網路(進階)", - "setupSubnetDescription": "此組織內部網路的子網路。", - "setupUtilitySubnet": "工具子網路(進階)", - "setupUtilitySubnetDescription": "此組織別名位址和 DNS 伺服器的子網路。", - "siteRegenerateAndDisconnect": "重新產生並斷開連接", - "siteRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此站點的連接嗎?", - "siteRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開站點連接。站點需要使用新憑證重新啟動。", - "siteRegenerateCredentialsConfirmation": "您確定要重新產生此站點的憑證嗎?", - "siteRegenerateCredentialsWarning": "這將重新產生憑證。站點將保持連接,直到您手動重新啟動並使用新憑證。", - "clientRegenerateAndDisconnect": "重新產生並斷開連接", - "clientRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此客戶端的連接嗎?", - "clientRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開客戶端連接。客戶端需要使用新憑證重新啟動。", - "clientRegenerateCredentialsConfirmation": "您確定要重新產生此客戶端的憑證嗎?", - "clientRegenerateCredentialsWarning": "這將重新產生憑證。客戶端將保持連接,直到您手動重新啟動並使用新憑證。", - "remoteExitNodeRegenerateAndDisconnect": "重新產生並斷開連接", - "remoteExitNodeRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此遠端出口節點的連接嗎?", - "remoteExitNodeRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開遠端出口節點連接。遠端出口節點需要使用新憑證重新啟動。", - "remoteExitNodeRegenerateCredentialsConfirmation": "您確定要重新產生此遠端出口節點的憑證嗎?", - "remoteExitNodeRegenerateCredentialsWarning": "這將重新產生憑證。遠端出口節點將保持連接,直到您手動重新啟動並使用新憑證。", - "agent": "代理", - "personalUseOnly": "僅限個人使用", - "loginPageLicenseWatermark": "此實例僅授權個人使用。", - "instanceIsUnlicensed": "此實例未授權。", - "portRestrictions": "連接埠限制", - "allPorts": "全部", - "custom": "自訂", - "allPortsAllowed": "允許所有連接埠", - "allPortsBlocked": "阻擋所有連接埠", - "tcpPortsDescription": "指定此資源允許的 TCP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:80,443,8000-9000)。", - "udpPortsDescription": "指定此資源允許的 UDP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:53,123,500-600)。", - "organizationLoginPageTitle": "組織登入頁面", - "organizationLoginPageDescription": "自訂此組織的登入頁面", - "resourceLoginPageTitle": "資源登入頁面", - "resourceLoginPageDescription": "自訂個別資源的登入頁面", - "enterConfirmation": "輸入確認", - "blueprintViewDetails": "詳細資訊", - "defaultIdentityProvider": "預設身份提供者", - "defaultIdentityProviderDescription": "當選擇預設身份提供者時,使用者將自動被重新導向到該提供者進行驗證。", - "editInternalResourceDialogNetworkSettings": "網路設定", - "editInternalResourceDialogAccessPolicy": "存取策略", - "editInternalResourceDialogAddRoles": "新增角色", - "editInternalResourceDialogAddUsers": "新增使用者", - "editInternalResourceDialogAddClients": "新增客戶端", - "editInternalResourceDialogDestinationLabel": "目的地", - "editInternalResourceDialogDestinationDescription": "指定內部資源的目的地位址。根據所選模式,這可以是主機名稱、IP 位址或 CIDR 範圍。可選擇設定內部 DNS 別名以便識別。", - "editInternalResourceDialogPortRestrictionsDescription": "限制對特定 TCP/UDP 連接埠的存取,或允許/阻擋所有連接埠。", - "editInternalResourceDialogTcp": "TCP", - "editInternalResourceDialogUdp": "UDP", - "editInternalResourceDialogIcmp": "ICMP", - "editInternalResourceDialogAccessControl": "存取控制", - "editInternalResourceDialogAccessControlDescription": "控制哪些角色、使用者和機器客戶端在連接時可以存取此資源。管理員始終擁有存取權限。", - "editInternalResourceDialogPortRangeValidationError": "連接埠範圍必須是「*」表示所有連接埠,或以逗號分隔的連接埠和範圍列表(例如:「80,443,8000-9000」)。連接埠必須介於 1 到 65535 之間。", - "orgAuthWhatsThis": "我在哪裡可以找到我的組織 ID?", - "learnMore": "了解更多", - "backToHome": "返回首頁", - "needToSignInToOrg": "需要使用您組織的身份提供者嗎?", - "maintenanceMode": "維護模式", - "maintenanceModeDescription": "向訪客顯示維護頁面", - "maintenanceModeType": "維護模式類型", - "showMaintenancePage": "向訪客顯示維護頁面", - "enableMaintenanceMode": "啟用維護模式", - "automatic": "自動", - "automaticModeDescription": "僅在所有後端目標都關閉或不健康時顯示維護頁面。只要至少有一個目標健康,您的資源就會正常運作。", - "forced": "強制", - "forcedModeDescription": "無論後端健康狀況如何,始終顯示維護頁面。當您想要阻止所有存取時,用於計劃維護。", - "warning:": "警告:", - "forcedeModeWarning": "所有流量將被導向維護頁面。您的後端資源將不會收到任何請求。", - "pageTitle": "頁面標題", - "pageTitleDescription": "維護頁面上顯示的主標題", - "maintenancePageMessage": "維護訊息", - "maintenancePageMessagePlaceholder": "我們很快就會回來!我們的網站目前正在進行預定維護。", - "maintenancePageMessageDescription": "說明維護的詳細訊息", - "maintenancePageTimeTitle": "預計完成時間(可選)", - "maintenanceTime": "例如:2 小時、11 月 1 日下午 5:00", - "maintenanceEstimatedTimeDescription": "您預計何時完成維護", - "editDomain": "編輯網域", - "editDomainDescription": "為您的資源選擇網域", - "maintenanceModeDisabledTooltip": "此功能需要有效的授權才能啟用。", - "maintenanceScreenTitle": "服務暫時無法使用", - "maintenanceScreenMessage": "我們目前遇到技術問題。請稍後再試。", - "maintenanceScreenEstimatedCompletion": "預計完成時間:", - "createInternalResourceDialogDestinationRequired": "目的地為必填欄位" -} \ No newline at end of file + "priority": "優先權", + "priorityDescription": "先評估更高優先度線路。優先度 = 100 意味著自動排序(系統決定). 使用另一個數字強制執行手動優先度。", + "instanceName": "實例名稱", + "pathMatchModalTitle": "配置路徑匹配", + "pathMatchModalDescription": "根據傳入請求的路徑設置匹配方式。", + "pathMatchType": "匹配類型", + "pathMatchPrefix": "前綴", + "pathMatchExact": "精準的", + "pathMatchRegex": "正則表達式", + "pathMatchValue": "路徑值", + "clear": "清空", + "saveChanges": "保存更改", + "pathMatchRegexPlaceholder": "^/api/.*", + "pathMatchDefaultPlaceholder": "/路徑", + "pathMatchPrefixHelp": "範例: /api 匹配/api, /api/users 等。", + "pathMatchExactHelp": "範例:/api 匹配僅限/api", + "pathMatchRegexHelp": "例如:^/api/.* 匹配/api/why", + "pathRewriteModalTitle": "配置路徑重寫", + "pathRewriteModalDescription": "在轉發到目標之前變換匹配的路徑。", + "pathRewriteType": "重寫類型", + "pathRewritePrefixOption": "前綴 - 替換前綴", + "pathRewriteExactOption": "精確-替換整個路徑", + "pathRewriteRegexOption": "正則表達式 - 替換模式", + "pathRewriteStripPrefixOption": "刪除前綴 - 刪除前綴", + "pathRewriteValue": "重寫值", + "pathRewriteRegexPlaceholder": "/new/$1", + "pathRewriteDefaultPlaceholder": "/new-path", + "pathRewritePrefixHelp": "用此值替換匹配的前綴", + "pathRewriteExactHelp": "當路徑匹配時用此值替換整個路徑", + "pathRewriteRegexHelp": "使用抓取組,如$1,$2來替換", + "pathRewriteStripPrefixHelp": "留空以脫離前綴或提供新的前綴", + "pathRewritePrefix": "前綴", + "pathRewriteExact": "精準的", + "pathRewriteRegex": "正則表達式", + "pathRewriteStrip": "帶狀圖", + "pathRewriteStripLabel": "條形圖", + "sidebarEnableEnterpriseLicense": "啟用企業許可證", + "cannotbeUndone": "無法撤消。", + "toConfirm": "確認", + "deleteClientQuestion": "您確定要從站點和組織中刪除客戶嗎?", + "clientMessageRemove": "一旦刪除,用戶端將無法連接到站點。", + "sidebarLogs": "日誌", + "request": "請求", + "requests": "請求", + "logs": "日誌", + "logsSettingsDescription": "監視從此 orginization 中收集的日誌", + "searchLogs": "搜索日誌...", + "action": "行動", + "actor": "執行者", + "timestamp": "時間戳", + "accessLogs": "訪問日誌", + "exportCsv": "導出 CSV", + "exportError": "匯出 CSV 時發生未知錯誤", + "exportCsvTooltip": "在時間範圍內", + "actorId": "執行者 ID", + "allowedByRule": "根據規則允許", + "allowedNoAuth": "無認證", + "validAccessToken": "有效訪問令牌", + "validHeaderAuth": "有效的 Header 身份驗證", + "validPincode": "有效的 Pincode", + "validPassword": "有效密碼", + "validEmail": "有效的 email", + "validSSO": "有效的 SSO", + "resourceBlocked": "資源被阻止", + "droppedByRule": "被規則刪除", + "noSessions": "無會話", + "temporaryRequestToken": "臨時請求令牌", + "noMoreAuthMethods": "無有效授權", + "ip": "IP", + "reason": "原因", + "requestLogs": "請求日誌", + "requestAnalytics": "請求分析", + "host": "主機", + "location": "地點", + "actionLogs": "操作日誌", + "sidebarLogsRequest": "請求日誌", + "sidebarLogsAccess": "訪問日誌", + "sidebarLogsAction": "操作日誌", + "logRetention": "日誌保留", + "logRetentionDescription": "管理不同類型的日誌為這個機構保留多長時間或禁用這些日誌", + "requestLogsDescription": "查看此機構資源的詳細請求日誌", + "requestAnalyticsDescription": "查看此組織資源的詳細請求分析", + "logRetentionRequestLabel": "請求日誌保留", + "logRetentionRequestDescription": "保留請求日誌的時間", + "logRetentionAccessLabel": "訪問日誌保留", + "logRetentionAccessDescription": "保留訪問日誌的時間", + "logRetentionActionLabel": "動作日誌保留", + "logRetentionActionDescription": "保留操作日誌的時間", + "logRetentionDisabled": "已禁用", + "logRetention3Days": "3 天", + "logRetention7Days": "7 天", + "logRetention14Days": "14 天", + "logRetention30Days": "30 天", + "logRetention90Days": "90 天", + "logRetentionForever": "永遠的", + "logRetentionEndOfFollowingYear": "次年年底", + "actionLogsDescription": "查看此機構執行的操作歷史", + "accessLogsDescription": "查看此機構資源的訪問認證請求", + "licenseRequiredToUse": "需要企業許可證才能使用此功能。", + "certResolver": "證書解決器", + "certResolverDescription": "選擇用於此資源的證書解析器。", + "selectCertResolver": "選擇證書解析", + "enterCustomResolver": "輸入自訂解析器", + "preferWildcardCert": "喜歡通配符證書", + "unverified": "未驗證", + "domainSetting": "域設置", + "domainSettingDescription": "配置您的域的設置", + "preferWildcardCertDescription": "嘗試生成通配符證書(需要正確配置的證書解析器)。", + "recordName": "記錄名稱", + "auto": "自動操作", + "TTL": "TTL", + "howToAddRecords": "如何添加記錄", + "dnsRecord": "DNS 記錄", + "required": "必填", + "domainSettingsUpdated": "域設置更新成功", + "orgOrDomainIdMissing": "缺少機構或域 ID", + "loadingDNSRecords": "正在載入 DNS 記錄...", + "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。請更新到最新版本以獲取最佳體驗。", + "client": "用戶端:", + "proxyProtocol": "代理協議設置", + "proxyProtocolDescription": "配置代理協議以保留 TCP/UDP 服務的用戶端 IP 位址。", + "enableProxyProtocol": "啟用代理協議", + "proxyProtocolInfo": "為 TCP/UDP 後端保留用戶端 IP 位址", + "proxyProtocolVersion": "代理協議版本", + "version1": " 版本 1 (推薦)", + "version2": "版本 2", + "versionDescription": "版本 1 是基於文本和廣泛支持的版本。版本 2 是二進制和更有效率但不那麼相容。", + "warning": "警告", + "proxyProtocolWarning": "您的後端應用程式必須配置為接受代理協議連接。如果您的後端不支持代理協議,啟用這將會中斷所有連接。 請務必從 Traefik 配置您的後端到信任代理協議標題。", + "restarting": "正在重啟...", + "manual": "手動模式", + "messageSupport": "消息支持", + "supportNotAvailableTitle": "支持不可用", + "supportNotAvailableDescription": "支持現在不可用。您可以發送電子郵件到 support@pangolin.net。", + "supportRequestSentTitle": "支持請求已發送", + "supportRequestSentDescription": "您的消息已成功發送。", + "supportRequestFailedTitle": "發送請求失敗", + "supportRequestFailedDescription": "發送您的支持請求時出錯。", + "supportSubjectRequired": "主題是必填項", + "supportSubjectMaxLength": "主題必須是 255 個或更少的字元", + "supportMessageRequired": "消息是必填項", + "supportReplyTo": "回復給", + "supportSubject": "議題", + "supportSubjectPlaceholder": "輸入主題", + "supportMessage": "留言", + "supportMessagePlaceholder": "輸入您的消息", + "supportSending": "正在發送...", + "supportSend": "發送", + "supportMessageSent": "消息已發送!", + "supportWillContact": "我們很快就會聯繫起來!", + "selectLogRetention": "選擇保留日誌", + "terms": "條款", + "privacy": "隱私權", + "security": "安全性", + "docs": "文件", + "deviceActivation": "裝置啟用", + "deviceCodeInvalidFormat": "代碼必須為 9 個字元(例如:A1AJ-N5JD)", + "deviceCodeInvalidOrExpired": "代碼無效或已過期", + "deviceCodeVerifyFailed": "驗證裝置代碼失敗", + "signedInAs": "已登入為", + "deviceCodeEnterPrompt": "輸入裝置上顯示的代碼", + "continue": "繼續", + "deviceUnknownLocation": "未知位置", + "deviceAuthorizationRequested": "此授權請求來自 {location},時間為 {date}。請確保您信任此裝置,因為它將獲得帳戶存取權限。", + "deviceLabel": "裝置:{deviceName}", + "deviceWantsAccess": "想要存取您的帳戶", + "deviceExistingAccess": "現有存取權限:", + "deviceFullAccess": "完整帳戶存取權限", + "deviceOrganizationsAccess": "存取您帳戶有權限的所有組織", + "deviceAuthorize": "授權 {applicationName}", + "deviceConnected": "裝置已連接!", + "deviceAuthorizedMessage": "裝置已獲授權存取您的帳戶。請返回客戶端應用程式。", + "pangolinCloud": "Pangolin 雲端", + "viewDevices": "查看裝置", + "viewDevicesDescription": "管理您已連接的裝置", + "noDevices": "找不到裝置", + "dateCreated": "建立日期", + "unnamedDevice": "未命名裝置", + "deviceQuestionRemove": "您確定要刪除此裝置嗎?", + "deviceMessageRemove": "此操作無法復原。", + "deviceDeleteConfirm": "刪除裝置", + "deleteDevice": "刪除裝置", + "errorLoadingDevices": "載入裝置時發生錯誤", + "failedToLoadDevices": "載入裝置失敗", + "deviceDeleted": "裝置已刪除", + "deviceDeletedDescription": "裝置已成功刪除。", + "errorDeletingDevice": "刪除裝置時發生錯誤", + "failedToDeleteDevice": "刪除裝置失敗", + "showColumns": "顯示列", + "hideColumns": "隱藏列", + "columnVisibility": "列可見性", + "toggleColumn": "切換 {columnName} 列", + "allColumns": "全部列", + "defaultColumns": "默認列", + "customizeView": "自訂視圖", + "viewOptions": "查看選項", + "selectAll": "選擇所有", + "selectNone": "沒有選擇", + "selectedResources": "選定的資源", + "enableSelected": "啟用選中的", + "disableSelected": "禁用選中的", + "checkSelectedStatus": "檢查選中的狀態", + "clients": "客戶端", + "accessClientSelect": "選擇機器客戶端", + "resourceClientDescription": "可以存取此資源的機器客戶端", + "regenerate": "重新產生", + "credentials": "憑證", + "savecredentials": "儲存憑證", + "regenerateCredentialsButton": "重新產生憑證", + "regenerateCredentials": "重新產生憑證", + "generatedcredentials": "已產生的憑證", + "copyandsavethesecredentials": "複製並儲存這些憑證", + "copyandsavethesecredentialsdescription": "離開此頁面後將不會再顯示這些憑證。請立即安全儲存。", + "credentialsSaved": "憑證已儲存", + "credentialsSavedDescription": "憑證已成功重新產生並儲存。", + "credentialsSaveError": "憑證儲存錯誤", + "credentialsSaveErrorDescription": "重新產生和儲存憑證時發生錯誤。", + "regenerateCredentialsWarning": "重新產生憑證將使先前的憑證失效並導致斷線。請確保更新任何使用這些憑證的設定。", + "confirm": "確認", + "regenerateCredentialsConfirmation": "您確定要重新產生憑證嗎?", + "endpoint": "端點", + "Id": "ID", + "SecretKey": "密鑰", + "niceId": "友善 ID", + "niceIdUpdated": "友善 ID 已更新", + "niceIdUpdatedSuccessfully": "友善 ID 更新成功", + "niceIdUpdateError": "更新友善 ID 時發生錯誤", + "niceIdUpdateErrorDescription": "更新友善 ID 時發生錯誤。", + "niceIdCannotBeEmpty": "友善 ID 不能為空", + "enterIdentifier": "輸入識別碼", + "identifier": "識別碼", + "deviceLoginUseDifferentAccount": "不是您嗎?使用其他帳戶。", + "deviceLoginDeviceRequestingAccessToAccount": "有裝置正在請求存取此帳戶。", + "noData": "無資料", + "machineClients": "機器客戶端", + "install": "安裝", + "run": "執行", + "clientNameDescription": "客戶端的顯示名稱,可以稍後更改。", + "clientAddress": "客戶端位址(進階)", + "setupFailedToFetchSubnet": "取得預設子網路失敗", + "setupSubnetAdvanced": "子網路(進階)", + "setupSubnetDescription": "此組織內部網路的子網路。", + "setupUtilitySubnet": "工具子網路(進階)", + "setupUtilitySubnetDescription": "此組織別名位址和 DNS 伺服器的子網路。", + "siteRegenerateAndDisconnect": "重新產生並斷開連接", + "siteRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此站點的連接嗎?", + "siteRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開站點連接。站點需要使用新憑證重新啟動。", + "siteRegenerateCredentialsConfirmation": "您確定要重新產生此站點的憑證嗎?", + "siteRegenerateCredentialsWarning": "這將重新產生憑證。站點將保持連接,直到您手動重新啟動並使用新憑證。", + "clientRegenerateAndDisconnect": "重新產生並斷開連接", + "clientRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此客戶端的連接嗎?", + "clientRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開客戶端連接。客戶端需要使用新憑證重新啟動。", + "clientRegenerateCredentialsConfirmation": "您確定要重新產生此客戶端的憑證嗎?", + "clientRegenerateCredentialsWarning": "這將重新產生憑證。客戶端將保持連接,直到您手動重新啟動並使用新憑證。", + "remoteExitNodeRegenerateAndDisconnect": "重新產生並斷開連接", + "remoteExitNodeRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此遠端出口節點的連接嗎?", + "remoteExitNodeRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開遠端出口節點連接。遠端出口節點需要使用新憑證重新啟動。", + "remoteExitNodeRegenerateCredentialsConfirmation": "您確定要重新產生此遠端出口節點的憑證嗎?", + "remoteExitNodeRegenerateCredentialsWarning": "這將重新產生憑證。遠端出口節點將保持連接,直到您手動重新啟動並使用新憑證。", + "agent": "代理", + "personalUseOnly": "僅限個人使用", + "loginPageLicenseWatermark": "此實例僅授權個人使用。", + "instanceIsUnlicensed": "此實例未授權。", + "portRestrictions": "連接埠限制", + "allPorts": "全部", + "custom": "自訂", + "allPortsAllowed": "允許所有連接埠", + "allPortsBlocked": "阻擋所有連接埠", + "tcpPortsDescription": "指定此資源允許的 TCP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:80,443,8000-9000)。", + "udpPortsDescription": "指定此資源允許的 UDP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:53,123,500-600)。", + "organizationLoginPageTitle": "組織登入頁面", + "organizationLoginPageDescription": "自訂此組織的登入頁面", + "resourceLoginPageTitle": "資源登入頁面", + "resourceLoginPageDescription": "自訂個別資源的登入頁面", + "enterConfirmation": "輸入確認", + "blueprintViewDetails": "詳細資訊", + "defaultIdentityProvider": "預設身份提供者", + "defaultIdentityProviderDescription": "當選擇預設身份提供者時,使用者將自動被重新導向到該提供者進行驗證。", + "editInternalResourceDialogNetworkSettings": "網路設定", + "editInternalResourceDialogAccessPolicy": "存取策略", + "editInternalResourceDialogAddRoles": "新增角色", + "editInternalResourceDialogAddUsers": "新增使用者", + "editInternalResourceDialogAddClients": "新增客戶端", + "editInternalResourceDialogDestinationLabel": "目的地", + "editInternalResourceDialogDestinationDescription": "指定內部資源的目的地位址。根據所選模式,這可以是主機名稱、IP 位址或 CIDR 範圍。可選擇設定內部 DNS 別名以便識別。", + "editInternalResourceDialogPortRestrictionsDescription": "限制對特定 TCP/UDP 連接埠的存取,或允許/阻擋所有連接埠。", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "存取控制", + "editInternalResourceDialogAccessControlDescription": "控制哪些角色、使用者和機器客戶端在連接時可以存取此資源。管理員始終擁有存取權限。", + "editInternalResourceDialogPortRangeValidationError": "連接埠範圍必須是「*」表示所有連接埠,或以逗號分隔的連接埠和範圍列表(例如:「80,443,8000-9000」)。連接埠必須介於 1 到 65535 之間。", + "orgAuthWhatsThis": "我在哪裡可以找到我的組織 ID?", + "learnMore": "了解更多", + "backToHome": "返回首頁", + "needToSignInToOrg": "需要使用您組織的身份提供者嗎?", + "maintenanceMode": "維護模式", + "maintenanceModeDescription": "向訪客顯示維護頁面", + "maintenanceModeType": "維護模式類型", + "showMaintenancePage": "向訪客顯示維護頁面", + "enableMaintenanceMode": "啟用維護模式", + "automatic": "自動", + "automaticModeDescription": "僅在所有後端目標都關閉或不健康時顯示維護頁面。只要至少有一個目標健康,您的資源就會正常運作。", + "forced": "強制", + "forcedModeDescription": "無論後端健康狀況如何,始終顯示維護頁面。當您想要阻止所有存取時,用於計劃維護。", + "warning:": "警告:", + "forcedeModeWarning": "所有流量將被導向維護頁面。您的後端資源將不會收到任何請求。", + "pageTitle": "頁面標題", + "pageTitleDescription": "維護頁面上顯示的主標題", + "maintenancePageMessage": "維護訊息", + "maintenancePageMessagePlaceholder": "我們很快就會回來!我們的網站目前正在進行預定維護。", + "maintenancePageMessageDescription": "說明維護的詳細訊息", + "maintenancePageTimeTitle": "預計完成時間(可選)", + "maintenanceTime": "例如:2 小時、11 月 1 日下午 5:00", + "maintenanceEstimatedTimeDescription": "您預計何時完成維護", + "editDomain": "編輯網域", + "editDomainDescription": "為您的資源選擇網域", + "maintenanceModeDisabledTooltip": "此功能需要有效的授權才能啟用。", + "maintenanceScreenTitle": "服務暫時無法使用", + "maintenanceScreenMessage": "我們目前遇到技術問題。請稍後再試。", + "maintenanceScreenEstimatedCompletion": "預計完成時間:", + "createInternalResourceDialogDestinationRequired": "目的地為必填欄位" +} diff --git a/package-lock.json b/package-lock.json index a6ce609ae..8c241554a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", "arctic": "3.7.0", - "axios": "1.13.5", + "axios": "1.15.0", "better-sqlite3": "11.9.1", "canvas-confetti": "1.9.4", "class-variance-authority": "0.7.1", @@ -55,7 +55,7 @@ "cors": "2.8.6", "crypto-js": "4.2.0", "d3": "7.9.0", - "drizzle-orm": "0.45.1", + "drizzle-orm": "0.45.2", "express": "5.2.1", "express-rate-limit": "8.3.0", "glob": "13.0.6", @@ -69,12 +69,12 @@ "lucide-react": "0.577.0", "maxmind": "5.0.5", "moment": "2.30.1", - "next": "15.5.14", + "next": "15.5.15", "next-intl": "4.8.3", "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", "node-cache": "5.1.2", - "nodemailer": "8.0.4", + "nodemailer": "8.0.5", "oslo": "1.2.1", "pg": "8.20.0", "posthog-node": "5.28.0", @@ -89,13 +89,13 @@ "reodotdev": "1.1.0", "resend": "6.9.2", "semver": "7.7.4", - "sshpk": "^1.18.0", + "sshpk": "1.18.0", "stripe": "20.4.1", "swagger-ui-express": "5.0.1", "tailwind-merge": "3.5.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", - "use-debounce": "^10.1.0", + "use-debounce": "10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "visionscarto-world-atlas": "1.0.0", @@ -130,7 +130,7 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", - "@types/sshpk": "^1.17.4", + "@types/sshpk": "1.17.4", "@types/swagger-ui-express": "4.1.8", "@types/topojson-client": "3.1.5", "@types/ws": "8.18.1", @@ -1058,6 +1058,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2353,6 +2354,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2375,6 +2377,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2397,6 +2400,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2413,6 +2417,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2429,6 +2434,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2445,6 +2451,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2461,6 +2468,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2477,6 +2485,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2493,6 +2502,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2509,6 +2519,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2525,6 +2536,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2541,6 +2553,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2563,6 +2576,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2585,6 +2599,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2607,6 +2622,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2629,6 +2645,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2651,6 +2668,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2673,6 +2691,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2695,6 +2714,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2714,6 +2734,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2733,6 +2754,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2752,6 +2774,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2862,9 +2885,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.14.tgz", - "integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz", + "integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2878,9 +2901,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.14.tgz", - "integrity": "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz", + "integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==", "cpu": [ "arm64" ], @@ -2894,9 +2917,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz", - "integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz", + "integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==", "cpu": [ "x64" ], @@ -2910,9 +2933,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz", - "integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz", + "integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==", "cpu": [ "arm64" ], @@ -2926,9 +2949,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz", - "integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz", + "integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==", "cpu": [ "arm64" ], @@ -2942,9 +2965,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz", - "integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz", + "integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==", "cpu": [ "x64" ], @@ -2958,9 +2981,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz", - "integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz", + "integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==", "cpu": [ "x64" ], @@ -2974,9 +2997,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz", - "integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz", + "integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==", "cpu": [ "arm64" ], @@ -2990,9 +3013,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz", - "integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz", + "integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==", "cpu": [ "x64" ], @@ -3011,6 +3034,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -6957,6 +6981,7 @@ "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" }, @@ -8417,6 +8442,7 @@ "version": "5.90.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -8532,6 +8558,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -8879,6 +8906,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -8974,6 +9002,7 @@ "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -9001,6 +9030,7 @@ "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9026,6 +9056,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9036,6 +9067,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9122,8 +9154,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -9197,6 +9228,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -9670,6 +9702,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10093,13 +10126,14 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axobject-query": { @@ -10118,6 +10152,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -10189,6 +10224,7 @@ "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10317,6 +10353,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11223,6 +11260,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -11663,7 +11701,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "engines": { "node": ">=20" }, @@ -11731,9 +11768,9 @@ } }, "node_modules/drizzle-orm": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", - "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", @@ -12298,6 +12335,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12383,6 +12421,7 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -12519,6 +12558,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12912,6 +12952,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -13251,9 +13292,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -14959,9 +15000,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.defaults": { @@ -15329,7 +15370,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -15340,7 +15380,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -15425,12 +15464,13 @@ } }, "node_modules/next": { - "version": "15.5.14", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz", - "integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz", + "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==", "license": "MIT", + "peer": true, "dependencies": { - "@next/env": "15.5.14", + "@next/env": "15.5.15", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -15443,14 +15483,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.14", - "@next/swc-darwin-x64": "15.5.14", - "@next/swc-linux-arm64-gnu": "15.5.14", - "@next/swc-linux-arm64-musl": "15.5.14", - "@next/swc-linux-x64-gnu": "15.5.14", - "@next/swc-linux-x64-musl": "15.5.14", - "@next/swc-win32-arm64-msvc": "15.5.14", - "@next/swc-win32-x64-msvc": "15.5.14", + "@next/swc-darwin-arm64": "15.5.15", + "@next/swc-darwin-x64": "15.5.15", + "@next/swc-linux-arm64-gnu": "15.5.15", + "@next/swc-linux-arm64-musl": "15.5.15", + "@next/swc-linux-x64-gnu": "15.5.15", + "@next/swc-linux-x64-musl": "15.5.15", + "@next/swc-win32-arm64-msvc": "15.5.15", + "@next/swc-win32-x64-msvc": "15.5.15", "sharp": "^0.34.3" }, "peerDependencies": { @@ -15646,9 +15686,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", - "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -16388,6 +16428,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -16725,10 +16766,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pump": { "version": "3.0.3", @@ -16892,6 +16936,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16923,6 +16968,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17215,6 +17261,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18676,7 +18723,8 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.2", @@ -19151,6 +19199,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19578,6 +19627,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -19784,6 +19834,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 7d7b3df69..1da507c0d 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", "arctic": "3.7.0", - "axios": "1.13.5", + "axios": "1.15.0", "better-sqlite3": "11.9.1", "canvas-confetti": "1.9.4", "class-variance-authority": "0.7.1", @@ -78,7 +78,7 @@ "cors": "2.8.6", "crypto-js": "4.2.0", "d3": "7.9.0", - "drizzle-orm": "0.45.1", + "drizzle-orm": "0.45.2", "express": "5.2.1", "express-rate-limit": "8.3.0", "glob": "13.0.6", @@ -92,12 +92,12 @@ "lucide-react": "0.577.0", "maxmind": "5.0.5", "moment": "2.30.1", - "next": "15.5.14", + "next": "15.5.15", "next-intl": "4.8.3", "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", "node-cache": "5.1.2", - "nodemailer": "8.0.4", + "nodemailer": "8.0.5", "oslo": "1.2.1", "pg": "8.20.0", "posthog-node": "5.28.0", diff --git a/public/idp/openid.png b/public/idp/openid.png new file mode 100644 index 000000000..d4422c872 Binary files /dev/null and b/public/idp/openid.png differ diff --git a/public/screenshots/hero.png b/public/screenshots/hero.png index f42a830e6..8d758b260 100644 Binary files a/public/screenshots/hero.png and b/public/screenshots/hero.png differ diff --git a/public/screenshots/private-resources.png b/public/screenshots/private-resources.png index f48d9279c..55bf97d3b 100644 Binary files a/public/screenshots/private-resources.png and b/public/screenshots/private-resources.png differ diff --git a/public/screenshots/public-resources.png b/public/screenshots/public-resources.png index f42a830e6..8d758b260 100644 Binary files a/public/screenshots/public-resources.png and b/public/screenshots/public-resources.png differ diff --git a/public/screenshots/sites.png b/public/screenshots/sites.png index 86b32b81b..ea8edf74f 100644 Binary files a/public/screenshots/sites.png and b/public/screenshots/sites.png differ diff --git a/public/screenshots/user-devices.png b/public/screenshots/user-devices.png index 7b407cd64..768a3bffe 100644 Binary files a/public/screenshots/user-devices.png and b/public/screenshots/user-devices.png differ diff --git a/public/screenshots/users.png b/public/screenshots/users.png index 91286e02a..d9b2b2987 100644 Binary files a/public/screenshots/users.png and b/public/screenshots/users.png differ diff --git a/public/third-party/incidentio.png b/public/third-party/incidentio.png new file mode 100644 index 000000000..e567d31fb Binary files /dev/null and b/public/third-party/incidentio.png differ diff --git a/public/third-party/opsgenie.png b/public/third-party/opsgenie.png new file mode 100644 index 000000000..3a1f5a849 Binary files /dev/null and b/public/third-party/opsgenie.png differ diff --git a/public/third-party/pgd.png b/public/third-party/pgd.png new file mode 100644 index 000000000..b084406a0 Binary files /dev/null and b/public/third-party/pgd.png differ diff --git a/public/third-party/servicenow.png b/public/third-party/servicenow.png new file mode 100644 index 000000000..b3fcca4dc Binary files /dev/null and b/public/third-party/servicenow.png differ diff --git a/server/apiServer.ts b/server/apiServer.ts index dafec1b85..9a91d473e 100644 --- a/server/apiServer.ts +++ b/server/apiServer.ts @@ -121,7 +121,7 @@ export function createApiServer() { const httpServer = apiServer.listen(externalPort, (err?: any) => { if (err) throw err; logger.info( - `API server is running on http://localhost:${externalPort}` + `Dashboard API server is running on http://localhost:${externalPort}` ); }); diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 213dab9d3..9ba1b5bce 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -122,7 +122,6 @@ export enum ActionsEnum { createOrgDomain = "createOrgDomain", deleteOrgDomain = "deleteOrgDomain", restartOrgDomain = "restartOrgDomain", - sendUsageNotification = "sendUsageNotification", createRemoteExitNode = "createRemoteExitNode", updateRemoteExitNode = "updateRemoteExitNode", getRemoteExitNode = "getRemoteExitNode", @@ -144,7 +143,16 @@ export enum ActionsEnum { createEventStreamingDestination = "createEventStreamingDestination", updateEventStreamingDestination = "updateEventStreamingDestination", deleteEventStreamingDestination = "deleteEventStreamingDestination", - listEventStreamingDestinations = "listEventStreamingDestinations" + listEventStreamingDestinations = "listEventStreamingDestinations", + createAlertRule = "createAlertRule", + updateAlertRule = "updateAlertRule", + deleteAlertRule = "deleteAlertRule", + listAlertRules = "listAlertRules", + getAlertRule = "getAlertRule", + createHealthCheck = "createHealthCheck", + updateHealthCheck = "updateHealthCheck", + deleteHealthCheck = "deleteHealthCheck", + listHealthChecks = "listHealthChecks" } export async function checkUserActionPermission( diff --git a/server/db/mac_models.json b/server/db/mac_models.json index db473f3ae..6d9b837d5 100644 --- a/server/db/mac_models.json +++ b/server/db/mac_models.json @@ -1,94 +1,53 @@ { - "PowerMac4,4": "eMac", - "PowerMac6,4": "eMac", - "PowerBook2,1": "iBook", - "PowerBook2,2": "iBook", - "PowerBook4,1": "iBook", - "PowerBook4,2": "iBook", - "PowerBook4,3": "iBook", - "PowerBook6,3": "iBook", - "PowerBook6,5": "iBook", - "PowerBook6,7": "iBook", - "iMac,1": "iMac", - "PowerMac2,1": "iMac", - "PowerMac2,2": "iMac", - "PowerMac4,1": "iMac", - "PowerMac4,2": "iMac", - "PowerMac4,5": "iMac", - "PowerMac6,1": "iMac", - "PowerMac6,3*": "iMac", - "PowerMac6,3": "iMac", - "PowerMac8,1": "iMac", - "PowerMac8,2": "iMac", - "PowerMac12,1": "iMac", - "iMac4,1": "iMac", - "iMac4,2": "iMac", - "iMac5,2": "iMac", - "iMac5,1": "iMac", - "iMac6,1": "iMac", - "iMac7,1": "iMac", - "iMac8,1": "iMac", - "iMac9,1": "iMac", - "iMac10,1": "iMac", - "iMac11,1": "iMac", - "iMac11,2": "iMac", - "iMac11,3": "iMac", - "iMac12,1": "iMac", - "iMac12,2": "iMac", - "iMac13,1": "iMac", - "iMac13,2": "iMac", - "iMac14,1": "iMac", - "iMac14,3": "iMac", - "iMac14,2": "iMac", - "iMac14,4": "iMac", - "iMac15,1": "iMac", - "iMac16,1": "iMac", - "iMac16,2": "iMac", - "iMac17,1": "iMac", - "iMac18,1": "iMac", - "iMac18,2": "iMac", - "iMac18,3": "iMac", - "iMac19,2": "iMac", - "iMac19,1": "iMac", - "iMac20,1": "iMac", - "iMac20,2": "iMac", - "iMac21,2": "iMac", - "iMac21,1": "iMac", - "iMacPro1,1": "iMac Pro", - "PowerMac10,1": "Mac mini", - "PowerMac10,2": "Mac mini", - "Macmini1,1": "Mac mini", - "Macmini2,1": "Mac mini", - "Macmini3,1": "Mac mini", - "Macmini4,1": "Mac mini", - "Macmini5,1": "Mac mini", - "Macmini5,2": "Mac mini", - "Macmini5,3": "Mac mini", - "Macmini6,1": "Mac mini", - "Macmini6,2": "Mac mini", - "Macmini7,1": "Mac mini", - "Macmini8,1": "Mac mini", "ADP3,2": "Mac mini", - "Macmini9,1": "Mac mini", - "Mac14,3": "Mac mini", - "Mac14,12": "Mac mini", - "MacPro1,1*": "Mac Pro", - "MacPro2,1": "Mac Pro", - "MacPro3,1": "Mac Pro", - "MacPro4,1": "Mac Pro", - "MacPro5,1": "Mac Pro", - "MacPro6,1": "Mac Pro", - "MacPro7,1": "Mac Pro", - "N/A*": "Power Macintosh", - "PowerMac1,1": "Power Macintosh", - "PowerMac3,1": "Power Macintosh", - "PowerMac3,3": "Power Macintosh", - "PowerMac3,4": "Power Macintosh", - "PowerMac3,5": "Power Macintosh", - "PowerMac3,6": "Power Macintosh", "Mac13,1": "Mac Studio", "Mac13,2": "Mac Studio", + "Mac14,10": "MacBook Pro", + "Mac14,12": "Mac mini", + "Mac14,13": "Mac Studio", + "Mac14,14": "Mac Studio", + "Mac14,15": "MacBook Air", + "Mac14,2": "MacBook Air", + "Mac14,3": "Mac mini", + "Mac14,5": "MacBook Pro", + "Mac14,6": "MacBook Pro", + "Mac14,7": "MacBook Pro", + "Mac14,8": "Mac Pro", + "Mac14,9": "MacBook Pro", + "Mac15,10": "MacBook Pro", + "Mac15,11": "MacBook Pro", + "Mac15,12": "MacBook Air", + "Mac15,13": "MacBook Air", + "Mac15,14": "Mac Studio", + "Mac15,3": "MacBook Pro", + "Mac15,4": "iMac", + "Mac15,5": "iMac", + "Mac15,6": "MacBook Pro", + "Mac15,7": "MacBook Pro", + "Mac15,8": "MacBook Pro", + "Mac15,9": "MacBook Pro", + "Mac16,1": "MacBook Pro", + "Mac16,10": "Mac mini", + "Mac16,11": "Mac mini", + "Mac16,12": "MacBook Air", + "Mac16,13": "MacBook Air", + "Mac16,2": "iMac", + "Mac16,3": "iMac", + "Mac16,5": "MacBook Pro", + "Mac16,6": "MacBook Pro", + "Mac16,7": "MacBook Pro", + "Mac16,8": "MacBook Pro", + "Mac16,9": "Mac Studio", + "Mac17,2": "MacBook Pro", + "Mac17,3": "MacBook Air", + "Mac17,4": "MacBook Air", + "Mac17,5": "MacBook Neo", + "Mac17,6": "MacBook Pro", + "Mac17,7": "MacBook Pro", + "Mac17,8": "MacBook Pro", + "Mac17,9": "MacBook Pro", "MacBook1,1": "MacBook", + "MacBook10,1": "MacBook", "MacBook2,1": "MacBook", "MacBook3,1": "MacBook", "MacBook4,1": "MacBook", @@ -98,8 +57,8 @@ "MacBook7,1": "MacBook", "MacBook8,1": "MacBook", "MacBook9,1": "MacBook", - "MacBook10,1": "MacBook", "MacBookAir1,1": "MacBook Air", + "MacBookAir10,1": "MacBook Air", "MacBookAir2,1": "MacBook Air", "MacBookAir3,1": "MacBook Air", "MacBookAir3,2": "MacBook Air", @@ -114,88 +73,163 @@ "MacBookAir8,1": "MacBook Air", "MacBookAir8,2": "MacBook Air", "MacBookAir9,1": "MacBook Air", - "MacBookAir10,1": "MacBook Air", - "Mac14,2": "MacBook Air", "MacBookPro1,1": "MacBook Pro", "MacBookPro1,2": "MacBook Pro", - "MacBookPro2,2": "MacBook Pro", - "MacBookPro2,1": "MacBook Pro", - "MacBookPro3,1": "MacBook Pro", - "MacBookPro4,1": "MacBook Pro", - "MacBookPro5,1": "MacBook Pro", - "MacBookPro5,2": "MacBook Pro", - "MacBookPro5,5": "MacBook Pro", - "MacBookPro5,4": "MacBook Pro", - "MacBookPro5,3": "MacBook Pro", - "MacBookPro7,1": "MacBook Pro", - "MacBookPro6,2": "MacBook Pro", - "MacBookPro6,1": "MacBook Pro", - "MacBookPro8,1": "MacBook Pro", - "MacBookPro8,2": "MacBook Pro", - "MacBookPro8,3": "MacBook Pro", - "MacBookPro9,2": "MacBook Pro", - "MacBookPro9,1": "MacBook Pro", "MacBookPro10,1": "MacBook Pro", "MacBookPro10,2": "MacBook Pro", "MacBookPro11,1": "MacBook Pro", "MacBookPro11,2": "MacBook Pro", "MacBookPro11,3": "MacBook Pro", - "MacBookPro12,1": "MacBook Pro", "MacBookPro11,4": "MacBook Pro", "MacBookPro11,5": "MacBook Pro", + "MacBookPro12,1": "MacBook Pro", "MacBookPro13,1": "MacBook Pro", "MacBookPro13,2": "MacBook Pro", "MacBookPro13,3": "MacBook Pro", "MacBookPro14,1": "MacBook Pro", "MacBookPro14,2": "MacBook Pro", "MacBookPro14,3": "MacBook Pro", - "MacBookPro15,2": "MacBook Pro", "MacBookPro15,1": "MacBook Pro", + "MacBookPro15,2": "MacBook Pro", "MacBookPro15,3": "MacBook Pro", "MacBookPro15,4": "MacBook Pro", "MacBookPro16,1": "MacBook Pro", - "MacBookPro16,3": "MacBook Pro", "MacBookPro16,2": "MacBook Pro", + "MacBookPro16,3": "MacBook Pro", "MacBookPro16,4": "MacBook Pro", "MacBookPro17,1": "MacBook Pro", - "MacBookPro18,3": "MacBook Pro", - "MacBookPro18,4": "MacBook Pro", "MacBookPro18,1": "MacBook Pro", "MacBookPro18,2": "MacBook Pro", - "Mac14,7": "MacBook Pro", - "Mac14,9": "MacBook Pro", - "Mac14,5": "MacBook Pro", - "Mac14,10": "MacBook Pro", - "Mac14,6": "MacBook Pro", - "PowerMac1,2": "Power Macintosh", - "PowerMac5,1": "Power Macintosh", - "PowerMac7,2": "Power Macintosh", - "PowerMac7,3": "Power Macintosh", - "PowerMac9,1": "Power Macintosh", - "PowerMac11,2": "Power Macintosh", + "MacBookPro18,3": "MacBook Pro", + "MacBookPro18,4": "MacBook Pro", + "MacBookPro2,1": "MacBook Pro", + "MacBookPro2,2": "MacBook Pro", + "MacBookPro3,1": "MacBook Pro", + "MacBookPro4,1": "MacBook Pro", + "MacBookPro5,1": "MacBook Pro", + "MacBookPro5,2": "MacBook Pro", + "MacBookPro5,3": "MacBook Pro", + "MacBookPro5,4": "MacBook Pro", + "MacBookPro5,5": "MacBook Pro", + "MacBookPro6,1": "MacBook Pro", + "MacBookPro6,2": "MacBook Pro", + "MacBookPro7,1": "MacBook Pro", + "MacBookPro8,1": "MacBook Pro", + "MacBookPro8,2": "MacBook Pro", + "MacBookPro8,3": "MacBook Pro", + "MacBookPro9,1": "MacBook Pro", + "MacBookPro9,2": "MacBook Pro", + "MacPro1,1": "Mac Pro", + "MacPro2,1": "Mac Pro", + "MacPro3,1": "Mac Pro", + "MacPro4,1": "Mac Pro", + "MacPro5,1": "Mac Pro", + "MacPro6,1": "Mac Pro", + "MacPro7,1": "Mac Pro", + "Macmini1,1": "Mac mini", + "Macmini2,1": "Mac mini", + "Macmini3,1": "Mac mini", + "Macmini4,1": "Mac mini", + "Macmini5,1": "Mac mini", + "Macmini5,2": "Mac mini", + "Macmini5,3": "Mac mini", + "Macmini6,1": "Mac mini", + "Macmini6,2": "Mac mini", + "Macmini7,1": "Mac mini", + "Macmini8,1": "Mac mini", + "Macmini9,1": "Mac mini", "PowerBook1,1": "PowerBook", + "PowerBook2,1": "iBook", + "PowerBook2,2": "iBook", "PowerBook3,1": "PowerBook", "PowerBook3,2": "PowerBook", "PowerBook3,3": "PowerBook", "PowerBook3,4": "PowerBook", "PowerBook3,5": "PowerBook", - "PowerBook6,1": "PowerBook", + "PowerBook4,1": "iBook", + "PowerBook4,2": "iBook", + "PowerBook4,3": "iBook", "PowerBook5,1": "PowerBook", - "PowerBook6,2": "PowerBook", "PowerBook5,2": "PowerBook", "PowerBook5,3": "PowerBook", - "PowerBook6,4": "PowerBook", "PowerBook5,4": "PowerBook", "PowerBook5,5": "PowerBook", - "PowerBook6,8": "PowerBook", "PowerBook5,6": "PowerBook", "PowerBook5,7": "PowerBook", "PowerBook5,8": "PowerBook", "PowerBook5,9": "PowerBook", + "PowerBook6,1": "PowerBook", + "PowerBook6,2": "PowerBook", + "PowerBook6,3": "iBook", + "PowerBook6,4": "PowerBook", + "PowerBook6,5": "iBook", + "PowerBook6,7": "iBook", + "PowerBook6,8": "PowerBook", + "PowerMac1,1": "Power Macintosh", + "PowerMac1,2": "Power Macintosh", + "PowerMac10,1": "Mac mini", + "PowerMac10,2": "Mac mini", + "PowerMac11,2": "Power Macintosh", + "PowerMac12,1": "iMac", + "PowerMac2,1": "iMac", + "PowerMac2,2": "iMac", + "PowerMac3,1": "Mac Server", + "PowerMac3,3": "Power Macintosh", + "PowerMac3,4": "Power Macintosh", + "PowerMac3,5": "Power Macintosh", + "PowerMac3,6": "Power Macintosh", + "PowerMac4,1": "iMac", + "PowerMac4,2": "iMac", + "PowerMac4,4": "eMac", + "PowerMac4,5": "iMac", + "PowerMac5,1": "Power Macintosh", + "PowerMac6,1": "iMac", + "PowerMac6,3": "iMac", + "PowerMac6,4": "eMac", + "PowerMac7,2": "Power Macintosh", + "PowerMac7,3": "Power Macintosh", + "PowerMac8,1": "iMac", + "PowerMac8,2": "iMac", + "PowerMac9,1": "Power Macintosh", "RackMac1,1": "Xserve", "RackMac1,2": "Xserve", "RackMac3,1": "Xserve", "Xserve1,1": "Xserve", "Xserve2,1": "Xserve", - "Xserve3,1": "Xserve" -} \ No newline at end of file + "Xserve3,1": "Xserve", + "iMac,1": "iMac", + "iMac10,1": "iMac", + "iMac11,1": "iMac", + "iMac11,2": "iMac", + "iMac11,3": "iMac", + "iMac12,1": "iMac", + "iMac12,2": "iMac", + "iMac13,1": "iMac", + "iMac13,2": "iMac", + "iMac14,1": "iMac", + "iMac14,2": "iMac", + "iMac14,3": "iMac", + "iMac14,4": "iMac", + "iMac15,1": "iMac", + "iMac16,1": "iMac", + "iMac16,2": "iMac", + "iMac17,1": "iMac", + "iMac18,1": "iMac", + "iMac18,2": "iMac", + "iMac18,3": "iMac", + "iMac19,1": "iMac", + "iMac19,2": "iMac", + "iMac20,1": "iMac", + "iMac20,2": "iMac", + "iMac21,1": "iMac", + "iMac21,2": "iMac", + "iMac4,1": "iMac", + "iMac4,2": "iMac", + "iMac5,1": "iMac", + "iMac5,2": "iMac", + "iMac6,1": "iMac", + "iMac7,1": "iMac", + "iMac8,1": "iMac", + "iMac9,1": "iMac", + "iMacPro1,1": "iMac Pro" +} diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 4122fb5b5..0f1914fad 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -16,11 +16,14 @@ import { domains, orgs, targets, + roles, users, exitNodes, sessions, clients, + resources, siteResources, + targetHealthCheck, sites } from "./schema"; @@ -88,6 +91,8 @@ export const subscriptions = pgTable("subscriptions", { updatedAt: bigint("updatedAt", { mode: "number" }), version: integer("version"), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), + expiresAt: bigint("expiresAt", { mode: "number" }), + trial: boolean("trial").default(false), type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license }); @@ -425,7 +430,9 @@ export const eventStreamingDestinations = pgTable( orgId: varchar("orgId", { length: 255 }) .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false), + sendConnectionLogs: boolean("sendConnectionLogs") + .notNull() + .default(false), sendRequestLogs: boolean("sendRequestLogs").notNull().default(false), sendActionLogs: boolean("sendActionLogs").notNull().default(false), sendAccessLogs: boolean("sendAccessLogs").notNull().default(false), @@ -447,7 +454,9 @@ export const eventStreamingCursors = pgTable( onDelete: "cascade" }), logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection" - lastSentId: bigint("lastSentId", { mode: "number" }).notNull().default(0), + lastSentId: bigint("lastSentId", { mode: "number" }) + .notNull() + .default(0), lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent }, (table) => [ @@ -458,6 +467,116 @@ export const eventStreamingCursors = pgTable( ] ); +export const alertRules = pgTable("alertRules", { + alertRuleId: serial("alertRuleId").primaryKey(), + orgId: varchar("orgId", { length: 255 }) + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + // Single field encodes both source and trigger - no redundancy + eventType: varchar("eventType", { length: 100 }) + .$type< + | "site_online" + | "site_offline" + | "site_toggle" + | "health_check_healthy" + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_degraded" + | "resource_toggle" + >() + .notNull(), + // Nullable depending on eventType + enabled: boolean("enabled").notNull().default(true), + cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + allSites: boolean("allSites").notNull().default(false), + allHealthChecks: boolean("allHealthChecks").notNull().default(false), + allResources: boolean("allResources").notNull().default(false), + lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() +}); + +export const alertSites = pgTable("alertSites", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }) +}); + +export const alertHealthChecks = pgTable("alertHealthChecks", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId") + .notNull() + .references(() => targetHealthCheck.targetHealthCheckId, { + onDelete: "cascade" + }) +}); + +export const alertResources = pgTable("alertResources", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) +}); + +// Separating channels by type avoids the mixed-shape problem entirely +export const alertEmailActions = pgTable("alertEmailActions", { + emailActionId: serial("emailActionId").primaryKey(), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + enabled: boolean("enabled").notNull().default(true), + lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable +}); + +export const alertEmailRecipients = pgTable("alertEmailRecipients", { + recipientId: serial("recipientId").primaryKey(), + emailActionId: integer("emailActionId") + .notNull() + .references(() => alertEmailActions.emailActionId, { + onDelete: "cascade" + }), + // At least one of these should be set - enforced at app level + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + roleId: integer("roleId").references(() => roles.roleId, { + onDelete: "cascade" + }), + email: varchar("email", { length: 255 }) // external emails not tied to a user +}); + +export const alertWebhookActions = pgTable("alertWebhookActions", { + webhookActionId: serial("webhookActionId").primaryKey(), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + webhookUrl: text("webhookUrl").notNull(), + config: text("config"), // encrypted JSON with auth config (authType, credentials) + enabled: boolean("enabled").notNull().default(true), + lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable +}); + +export const trialNotifications = pgTable("trialNotifications", { + notificationId: serial("notificationId").primaryKey(), + subscriptionId: varchar("subscriptionId", { length: 255 }) + .notNull() + .references(() => subscriptions.subscriptionId, { + onDelete: "cascade" + }), + notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended + sentAt: bigint("sentAt", { mode: "number" }).notNull() +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -495,3 +614,13 @@ export type EventStreamingDestination = InferSelectModel< export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; +export type AlertResources = InferSelectModel; +export type AlertHealthChecks = InferSelectModel; +export type AlertSites = InferSelectModel; +export type AlertRules = InferSelectModel; +export type AlertEmailActions = InferSelectModel; +export type AlertEmailRecipients = InferSelectModel< + typeof alertEmailRecipients +>; +export type AlertWebhookActions = InferSelectModel; +export type TrialNotification = InferSelectModel; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index bde3e9aec..7fbcef621 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -57,7 +57,9 @@ export const orgs = pgTable("orgs", { settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), - settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + settingsLogRetentionDaysConnection: integer( + "settingsLogRetentionDaysConnection" + ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) @@ -101,7 +103,9 @@ export const sites = pgTable("sites", { lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), - status: varchar("status").$type<"pending" | "approved">().default("approved") + status: varchar("status") + .$type<"pending" | "approved">() + .default("approved") }); export const resources = pgTable("resources", { @@ -153,7 +157,9 @@ export const resources = pgTable("resources", { maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), - postAuthPath: text("postAuthPath") + postAuthPath: text("postAuthPath"), + health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" + wildcard: boolean("wildcard").notNull().default(false) }); export const targets = pgTable("targets", { @@ -182,9 +188,18 @@ export const targets = pgTable("targets", { export const targetHealthCheck = pgTable("targetHealthCheck", { targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), - targetId: integer("targetId") - .notNull() - .references(() => targets.targetId, { onDelete: "cascade" }), + targetId: integer("targetId").references(() => targets.targetId, { + onDelete: "cascade" + }), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }).notNull(), + name: varchar("name"), hcEnabled: boolean("hcEnabled").notNull().default(false), hcPath: varchar("hcPath"), hcScheme: varchar("hcScheme"), @@ -201,7 +216,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { hcHealth: text("hcHealth") .$type<"unknown" | "healthy" | "unhealthy">() .default("unknown"), // "unknown", "healthy", "unhealthy" - hcTlsServerName: text("hcTlsServerName") + hcTlsServerName: text("hcTlsServerName"), + hcHealthyThreshold: integer("hcHealthyThreshold").default(1), + hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1) }); export const exitNodes = pgTable("exitNodes", { @@ -222,16 +239,23 @@ export const exitNodes = pgTable("exitNodes", { export const siteResources = pgTable("siteResources", { // this is for the clients siteResourceId: serial("siteResourceId").primaryKey(), - siteId: integer("siteId") - .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), + defaultNetworkId: integer("defaultNetworkId").references( + () => networks.networkId, + { + onDelete: "restrict" + } + ), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" - protocol: varchar("protocol"), // only for port mode + ssl: boolean("ssl").notNull().default(false), + mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" + scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode @@ -244,7 +268,38 @@ export const siteResources = pgTable("siteResources", { authDaemonPort: integer("authDaemonPort").default(22123), authDaemonMode: varchar("authDaemonMode", { length: 32 }) .$type<"site" | "remote">() - .default("site") + .default("site"), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain") +}); + +export const networks = pgTable("networks", { + networkId: serial("networkId").primaryKey(), + niceId: text("niceId"), + name: text("name"), + scope: varchar("scope") + .$type<"global" | "resource">() + .notNull() + .default("global"), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() +}); + +export const siteNetworks = pgTable("siteNetworks", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { + onDelete: "cascade" + }), + networkId: integer("networkId") + .notNull() + .references(() => networks.networkId, { onDelete: "cascade" }) }); export const clientSiteResources = pgTable("clientSiteResources", { @@ -994,6 +1049,7 @@ export const requestAuditLog = pgTable( actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: text("ip"), location: text("location"), userAgent: text("userAgent"), @@ -1041,6 +1097,20 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", { complete: boolean("complete").notNull().default(false) }); +export const statusHistory = pgTable("statusHistory", { + id: serial("id").primaryKey(), + entityType: varchar("entityType").notNull(), + entityId: integer("entityId").notNull(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: varchar("status").notNull(), + timestamp: integer("timestamp").notNull(), +}, (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; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1080,6 +1150,7 @@ export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; +export type DnsRecord = InferSelectModel; export type SupporterKey = InferSelectModel; export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; @@ -1106,3 +1177,5 @@ export type RequestAuditLog = InferSelectModel; export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; +export type Network = InferSelectModel; +export type StatusHistory = InferSelectModel; diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 989e111a7..46b45b1a0 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -25,7 +25,7 @@ import { ResourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility } from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, or, sql } from "drizzle-orm"; export type ResourceWithAuth = { resource: Resource | null; @@ -47,7 +47,17 @@ export type UserSessionWithUser = { export async function getResourceByDomain( domain: string ): Promise { - const [result] = await db + // Build wildcard domain variants to match against. + // For a domain like "me.example.test.com", we want to match: + // - "*.example.test.com" (subdomain wildcard) + // - "*.test.com" (parent wildcard, i.e. just "*" subdomain on parent) + const parts = domain.split("."); + const wildcardCandidates: string[] = []; + for (let i = 1; i < parts.length; i++) { + wildcardCandidates.push(`*.${parts.slice(i).join(".")}`); + } + + const potentialResults = await db .select() .from(resources) .leftJoin( @@ -70,8 +80,29 @@ export async function getResourceByDomain( ) ) .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) - .where(eq(resources.fullDomain, domain)) - .limit(1); + .where( + or( + // Exact match + eq(resources.fullDomain, domain), + // Wildcard match: resource fullDomain is one of the wildcard candidates + wildcardCandidates.length > 0 + ? and( + eq(resources.wildcard, true), + inArray(resources.fullDomain, wildcardCandidates) + ) + : sql`false` + ) + ); + + if (!potentialResults.length) { + return null; + } + + // Prefer exact match over wildcard match + const exactMatch = potentialResults.find( + (r) => r.resources?.fullDomain === domain + ); + const result = exactMatch ?? potentialResults[0]; if (!result) { return null; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index c1aa084a2..05c917887 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -13,11 +13,17 @@ import { domains, exitNodes, orgs, + resources, + roles, sessions, siteResources, sites, + targetHealthCheck, users } from "./schema"; +import { serial, varchar } from "drizzle-orm/mysql-core"; +import { pgTable } from "drizzle-orm/pg-core"; +import { bigint } from "zod"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -82,6 +88,8 @@ export const subscriptions = sqliteTable("subscriptions", { createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt"), version: integer("version"), + expiresAt: integer("expiresAt"), + trial: integer("trial", { mode: "boolean" }).default(false), billingCycleAnchor: integer("billingCycleAnchor"), type: text("type") // tier1, tier2, tier3, or license }); @@ -420,10 +428,18 @@ export const eventStreamingDestinations = sqliteTable( orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false), - sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false), - sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false), - sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false), + sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }) + .notNull() + .default(false), + sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }) + .notNull() + .default(false), + sendActionLogs: integer("sendActionLogs", { mode: "boolean" }) + .notNull() + .default(false), + sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }) + .notNull() + .default(false), type: text("type").notNull(), // e.g. "http", "kafka", etc. config: text("config").notNull(), // JSON string with the configuration for the destination enabled: integer("enabled", { mode: "boolean" }) @@ -455,6 +471,120 @@ export const eventStreamingCursors = sqliteTable( ] ); +export const alertRules = sqliteTable("alertRules", { + alertRuleId: integer("alertRuleId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: text("name").notNull(), + eventType: text("eventType") + .$type< + | "site_online" + | "site_offline" + | "site_toggle" + | "health_check_healthy" + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_degraded" + | "resource_toggle" + >() + .notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + allSites: integer("allSites", { mode: "boolean" }).notNull().default(false), + allHealthChecks: integer("allHealthChecks", { mode: "boolean" }) + .notNull() + .default(false), + allResources: integer("allResources", { mode: "boolean" }) + .notNull() + .default(false), + lastTriggeredAt: integer("lastTriggeredAt"), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() +}); + +export const alertSites = sqliteTable("alertSites", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }) +}); + +export const alertHealthChecks = sqliteTable("alertHealthChecks", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId") + .notNull() + .references(() => targetHealthCheck.targetHealthCheckId, { + onDelete: "cascade" + }) +}); + +export const alertResources = sqliteTable("alertResources", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) +}); + +export const alertEmailActions = sqliteTable("alertEmailActions", { + emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + lastSentAt: integer("lastSentAt") +}); + +export const alertEmailRecipients = sqliteTable("alertEmailRecipients", { + recipientId: integer("recipientId").primaryKey({ autoIncrement: true }), + emailActionId: integer("emailActionId") + .notNull() + .references(() => alertEmailActions.emailActionId, { + onDelete: "cascade" + }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + roleId: integer("roleId").references(() => roles.roleId, { + onDelete: "cascade" + }), + email: text("email") +}); + +export const alertWebhookActions = sqliteTable("alertWebhookActions", { + webhookActionId: integer("webhookActionId").primaryKey({ + autoIncrement: true + }), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + webhookUrl: text("webhookUrl").notNull(), + config: text("config"), // encrypted JSON with auth config (authType, credentials) + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + lastSentAt: integer("lastSentAt") +}); + +export const trialNotifications = sqliteTable("trialNotifications", { + notificationId: integer("notificationId").primaryKey({ + autoIncrement: true + }), + subscriptionId: text("subscriptionId") + .notNull() + .references(() => subscriptions.subscriptionId, { + onDelete: "cascade" + }), + notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended + sentAt: integer("sentAt").notNull() +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -486,3 +616,11 @@ export type EventStreamingDestination = InferSelectModel< export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; +export type AlertResources = InferSelectModel; +export type AlertHealthChecks = InferSelectModel; +export type AlertSites = InferSelectModel; +export type AlertRule = InferSelectModel; +export type AlertEmailAction = InferSelectModel; +export type AlertEmailRecipient = InferSelectModel; +export type AlertWebhookAction = InferSelectModel; +export type TrialNotification = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1fb04ef14..423190420 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -54,7 +54,9 @@ export const orgs = sqliteTable("orgs", { settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), - settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + settingsLogRetentionDaysConnection: integer( + "settingsLogRetentionDaysConnection" + ) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) @@ -92,6 +94,9 @@ export const sites = sqliteTable("sites", { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), name: text("name").notNull(), pubKey: text("pubKey"), subnet: text("subnet"), @@ -173,7 +178,9 @@ export const resources = sqliteTable("resources", { maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), - postAuthPath: text("postAuthPath") + postAuthPath: text("postAuthPath"), + health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" + wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false) }); export const targets = sqliteTable("targets", { @@ -204,9 +211,18 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ autoIncrement: true }), - targetId: integer("targetId") - .notNull() - .references(() => targets.targetId, { onDelete: "cascade" }), + targetId: integer("targetId").references(() => targets.targetId, { + onDelete: "cascade" + }), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }).notNull(), + name: text("name"), hcEnabled: integer("hcEnabled", { mode: "boolean" }) .notNull() .default(false), @@ -227,7 +243,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { hcHealth: text("hcHealth") .$type<"unknown" | "healthy" | "unhealthy">() .default("unknown"), // "unknown", "healthy", "unhealthy" - hcTlsServerName: text("hcTlsServerName") + hcTlsServerName: text("hcTlsServerName"), + hcHealthyThreshold: integer("hcHealthyThreshold").default(1), + hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1) }); export const exitNodes = sqliteTable("exitNodes", { @@ -250,16 +268,21 @@ export const siteResources = sqliteTable("siteResources", { siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }), - siteId: integer("siteId") - .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), + defaultNetworkId: integer("defaultNetworkId").references( + () => networks.networkId, + { onDelete: "restrict" } + ), niceId: text("niceId").notNull(), name: text("name").notNull(), - mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" - protocol: text("protocol"), // only for port mode + ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), + mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" + scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode destination: text("destination").notNull(), // ip, cidr, hostname @@ -274,7 +297,36 @@ export const siteResources = sqliteTable("siteResources", { authDaemonPort: integer("authDaemonPort").default(22123), authDaemonMode: text("authDaemonMode") .$type<"site" | "remote">() - .default("site") + .default("site"), + domainId: text("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + subdomain: text("subdomain"), + fullDomain: text("fullDomain") +}); + +export const networks = sqliteTable("networks", { + networkId: integer("networkId").primaryKey({ autoIncrement: true }), + niceId: text("niceId"), + name: text("name"), + scope: text("scope") + .$type<"global" | "resource">() + .notNull() + .default("global"), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) +}); + +export const siteNetworks = sqliteTable("siteNetworks", { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { + onDelete: "cascade" + }), + networkId: integer("networkId") + .notNull() + .references(() => networks.networkId, { onDelete: "cascade" }) }); export const clientSiteResources = sqliteTable("clientSiteResources", { @@ -1096,6 +1148,7 @@ export const requestAuditLog = sqliteTable( actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: text("ip"), location: text("location"), userAgent: text("userAgent"), @@ -1143,6 +1196,20 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", { complete: integer("complete", { mode: "boolean" }).notNull().default(false) }); +export const statusHistory = sqliteTable("statusHistory", { + id: integer("id").primaryKey({ autoIncrement: true }), + entityType: text("entityType").notNull(), // "site" | "healthCheck" + entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks + timestamp: integer("timestamp").notNull(), // unix epoch seconds +}, (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; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -1195,6 +1262,7 @@ export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; export type SiteResource = InferSelectModel; +export type Network = InferSelectModel; export type OrgDomains = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; @@ -1209,3 +1277,4 @@ export type DeviceWebAuthCode = InferSelectModel; export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; +export type StatusHistory = InferSelectModel; diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx new file mode 100644 index 000000000..ce30753da --- /dev/null +++ b/server/emails/templates/AlertNotification.tsx @@ -0,0 +1,262 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailInfoSection, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText +} from "./components/Email"; +import ButtonLink from "./components/ButtonLink"; + +export type AlertEventType = + | "site_online" + | "site_offline" + | "site_toggle" + | "health_check_healthy" + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_degraded" + | "resource_toggle"; + +export type AlertNotificationProps = { + eventType: AlertEventType; + orgId: string; + data: Record; + dashboardLink: string; +}; + +function getEventMeta(eventType: AlertEventType): { + heading: string; + previewText: string; + summary: string; + statusLabel: string | null; + statusColor: string | null; +} { + switch (eventType) { + case "site_online": + return { + heading: "Site Back Online", + previewText: "A site in your organization is back online.", + summary: + "Good news – a site in your organization has come back online and is now reachable.", + statusLabel: "Online", + statusColor: "#16a34a" + }; + case "site_offline": + return { + heading: "Site Offline", + previewText: "A site in your organization has gone offline.", + summary: + "A site in your organization has gone offline and is no longer reachable.", + statusLabel: "Offline", + statusColor: "#dc2626" + }; + case "site_toggle": + return { + heading: "Site Status Changed", + previewText: "A site in your organization has changed status.", + summary: "A site in your organization has changed status.", + statusLabel: null, + statusColor: null + }; + case "health_check_healthy": + return { + heading: "Health Check Recovered", + previewText: + "A health check in your organization is now healthy.", + summary: + "A health check in your organization has recovered and is now reporting a healthy status.", + statusLabel: "Healthy", + statusColor: "#16a34a" + }; + case "health_check_unhealthy": + return { + heading: "Health Check Failing", + previewText: + "A health check in your organization is not healthy.", + summary: + "A health check in your organization is currently failing.", + statusLabel: "Not Healthy", + statusColor: "#dc2626" + }; + case "health_check_toggle": + return { + heading: "Health Check Status Changed", + previewText: + "A health check in your organization has changed status.", + summary: + "A health check in your organization has changed status.", + statusLabel: null, + statusColor: null + }; + case "resource_healthy": + return { + heading: "Resource Healthy", + previewText: "A resource in your organization is now healthy.", + summary: + "A resource in your organization has recovered and is now reporting a healthy status.", + statusLabel: "Healthy", + statusColor: "#16a34a" + }; + case "resource_unhealthy": + return { + heading: "Resource Unhealthy", + previewText: "A resource in your organization is not healthy.", + summary: + "A resource in your organization is currently unhealthy.", + statusLabel: "Unhealthy", + statusColor: "#dc2626" + }; + case "resource_degraded": + return { + heading: "Resource Degraded", + previewText: "A resource in your organization is degraded.", + summary: + "A resource in your organization is currently degraded.", + statusLabel: "Degraded", + statusColor: "#dc2626" + }; + case "resource_toggle": + return { + heading: "Resource Status Changed", + previewText: + "A resource in your organization has changed status.", + summary: "A resource in your organization has changed status.", + statusLabel: null, + statusColor: null + }; + default: + return { + heading: "Alert Notification", + previewText: + "An alert event has occurred in your organization.", + summary: "An alert event has occurred in your organization.", + statusLabel: "Alert", + statusColor: "#f59e0b" + }; + } +} + +function resolveToggleStatus(status: unknown): { + label: string; + color: string; +} { + switch (String(status).toLowerCase()) { + case "online": + return { label: "Online", color: "#16a34a" }; + case "offline": + return { label: "Offline", color: "#dc2626" }; + case "healthy": + return { label: "Healthy", color: "#16a34a" }; + case "unhealthy": + return { label: "Unhealthy", color: "#dc2626" }; + case "degraded": + return { label: "Degraded", color: "#dc2626" }; + default: + return { label: String(status ?? "Unknown"), color: "#f59e0b" }; + } +} + +function formatDataItems( + data: Record +): { label: string; value: React.ReactNode }[] { + return Object.entries(data) + .filter(([key]) => key !== "orgId" && key !== "status") + .map(([key, value]) => ({ + label: key + .replace(/([A-Z])/g, " $1") + .replace(/^./, (s) => s.toUpperCase()) + .trim(), + value: String(value ?? "-") + })); +} + +export const AlertNotification = (props: AlertNotificationProps) => { + const { eventType, orgId, data, dashboardLink } = props; + const meta = getEventMeta(eventType); + const dataItems = formatDataItems(data); + + const isToggle = + eventType === "site_toggle" || + eventType === "health_check_toggle" || + eventType === "resource_toggle"; + + const resolvedStatus = isToggle + ? resolveToggleStatus(data.status) + : meta.statusLabel != null + ? { label: meta.statusLabel, color: meta.statusColor! } + : null; + + const allItems: { label: string; value: React.ReactNode }[] = [ + { label: "Organization", value: orgId }, + ...(resolvedStatus != null + ? [ + { + label: "Status", + value: ( + + {resolvedStatus.label} + + ) + } + ] + : []), + { label: "Time", value: new Date().toUTCString() }, + ...dataItems + ]; + + return ( + + + {meta.previewText} + + + + + + {meta.heading} + + Hi there, + + {meta.summary} + + + + + Open your dashboard to view more details and manage + your alert rules. + + + + + Open Dashboard + + + + + + + + + + + ); +}; + +export default AlertNotification; diff --git a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx index 44472c8a6..82154ab7d 100644 --- a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx +++ b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx @@ -32,7 +32,7 @@ export const EnterpriseEditionKeyGenerated = ({ }: EnterpriseEditionKeyGeneratedProps) => { const previewText = personalUseOnly ? "Your Enterprise Edition key for personal use is ready" - : "Thank you for your purchase — your Enterprise Edition key is ready"; + : "Thank you for your purchase - your Enterprise Edition key is ready"; return ( diff --git a/server/emails/templates/NotifyTrialExpiring.tsx b/server/emails/templates/NotifyTrialExpiring.tsx new file mode 100644 index 000000000..7c712e278 --- /dev/null +++ b/server/emails/templates/NotifyTrialExpiring.tsx @@ -0,0 +1,126 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSignature, + EmailText +} from "./components/Email"; + +interface Props { + email: string; + orgName: string; + trialEndsAt: string; + daysRemaining: number | null; + billingLink: string; +} + +export const NotifyTrialExpiring = ({ + email, + orgName, + trialEndsAt, + daysRemaining, + billingLink +}: Props) => { + const hasEnded = daysRemaining === null || daysRemaining === 0; + const isLastDay = daysRemaining === 1; + + const previewText = hasEnded + ? `Your trial for ${orgName} has ended.` + : isLastDay + ? `Your trial for ${orgName} ends tomorrow.` + : `Your trial for ${orgName} ends in ${daysRemaining} days.`; + + const heading = hasEnded + ? "Your Trial Ended" + : "Your Trial is Ending Soon"; + + return ( + + + {previewText} + + + + + + {heading} + + Hi there, + + {hasEnded ? ( + <> + + Your free trial for{" "} + {orgName} ended on{" "} + {trialEndsAt}. Your account + has been moved to the free plan, which + includes limited functionality. + + + + Some features and resources may now be + restricted. To restore full + access and continue using all the features + you had during your trial, please upgrade to + a paid plan. + + + + You can{" "} + + upgrade your plan here + {" "} + to get back up and running right away. + + + ) : ( + <> + + Just a reminder that your free trial for{" "} + {orgName} will end on{" "} + {trialEndsAt} + {isLastDay + ? " - that's tomorrow!" + : `, in ${daysRemaining} days`} + . + + + + After your trial ends, your account will be + moved to the free plan and some + functionality may be restricted. + + + + To avoid any interruption to your service, + we encourage you to upgrade before your + trial expires. You can{" "} + + upgrade your plan here + + . + + + )} + + + If you have any questions or need assistance, please + don't hesitate to reach out to our support team. + + + + + + + + + + ); +}; + +export default NotifyTrialExpiring; diff --git a/server/emails/templates/components/Email.tsx b/server/emails/templates/components/Email.tsx index 71d8b4671..f74046042 100644 --- a/server/emails/templates/components/Email.tsx +++ b/server/emails/templates/components/Email.tsx @@ -5,7 +5,7 @@ import { build } from "@server/build"; // EmailContainer: Wraps the entire email layout export function EmailContainer({ children }: { children: React.ReactNode }) { return ( - + {children} ); @@ -18,7 +18,7 @@ export function EmailLetterHead() { Pangolin Logo diff --git a/server/index.ts b/server/index.ts index 0fc44c279..e3a6ba049 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,6 +22,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; +import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { fetchServerIp } from "@server/lib/serverIpService"; async function startServers() { @@ -39,6 +40,7 @@ async function startServers() { initTelemetryClient(); initLogCleanupInterval(); + initAcmeCertSync(); // Start all servers const apiServer = createApiServer(); diff --git a/server/internalServer.ts b/server/internalServer.ts index 7ba046e4b..83872e7f9 100644 --- a/server/internalServer.ts +++ b/server/internalServer.ts @@ -36,7 +36,7 @@ export function createInternalServer() { internalServer.listen(internalPort, (err?: any) => { if (err) throw err; logger.info( - `Internal server is running on http://localhost:${internalPort}` + `Internal API server is running on http://localhost:${internalPort}` ); }); diff --git a/server/lib/acmeCertSync.ts b/server/lib/acmeCertSync.ts new file mode 100644 index 000000000..d8fbd6368 --- /dev/null +++ b/server/lib/acmeCertSync.ts @@ -0,0 +1,3 @@ +export function initAcmeCertSync(): void { + // stub +} \ No newline at end of file diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts new file mode 100644 index 000000000..1b9ff40ae --- /dev/null +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -0,0 +1,293 @@ +import logger from "@server/logger"; +import { processAlerts } from "#dynamic/lib/alerts"; +import { + db, + statusHistory, + targetHealthCheck, + targets, + resources, + Transaction, + logsDb +} from "@server/db"; +import { eq } from "drizzle-orm"; +import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; +import { + fireResourceDegradedAlert, + fireResourceHealthyAlert, + fireResourceUnhealthyAlert, + fireResourceUnknownAlert +} from "./resourceEvents"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `health_check_healthy` alert for the given health check. + * + * Call this after a previously-failing health check has recovered so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + healthCheckTargetId?: number | null, + extra?: Record, + send: boolean = true, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "healthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("health_check", healthCheckId); + + await handleResource(orgId, healthCheckTargetId, send, trx); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "health_check_healthy", + orgId, + healthCheckId, + data: { + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "health_check_toggle", + orgId, + healthCheckId, + data: { + healthCheckId, + status: "healthy", + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + +/** + * Fire a `health_check_unhealthy` alert for the given health check. + * + * Call this after a health check has been detected as failing so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckUnhealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + healthCheckTargetId?: number | null, + extra?: Record, + send: boolean = true, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("health_check", healthCheckId); + + await handleResource(orgId, healthCheckTargetId, send, trx); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "health_check_unhealthy", + orgId, + healthCheckId, + data: { + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "health_check_toggle", + orgId, + healthCheckId, + data: { + healthCheckId, + status: "unhealthy", + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + +export async function fireHealthCheckUnknownAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + healthCheckTargetId?: number | null, + extra?: Record, + send: boolean = true, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("health_check", healthCheckId); + + await handleResource(orgId, healthCheckTargetId, send, trx); + + if (!send) { + return; + } + } catch (err) { + logger.error( + `fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + +async function handleResource( + orgId: string, + healthCheckTargetId?: number | null, + send: boolean = true, + trx: Transaction | typeof db = db +) { + if (!healthCheckTargetId) { + return; + } + // we have targets lets get them + const [target] = await trx + .select() + .from(targets) + .where(eq(targets.targetId, healthCheckTargetId)) + .limit(1); + + if (!target) { + return; + } + + const [resource] = await trx + .select() + .from(resources) + .where(eq(resources.resourceId, target.resourceId)) + .limit(1); + + if (!resource) { + return; + } + + const otherTargets = await trx + .select({ hcHealth: targetHealthCheck.hcHealth }) + .from(targets) + .innerJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .where(eq(targets.resourceId, resource.resourceId)); + + let health = "healthy"; + const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown"); + const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); + const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); + + if (allUnknown) { + logger.debug( + `Marking resource ${resource.resourceId} as unknown because all health checks are disabled` + ); + health = "unknown"; + } else if (allHealthy) { + health = "healthy"; + } else if (allUnhealthy) { + logger.debug( + `Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy` + ); + health = "unhealthy"; + } else { + logger.debug( + `Marking resource ${resource.resourceId} as degraded because some targets are unhealthy` + ); + health = "degraded"; + } + + if (health != resource.health) { + // it changed + await trx + .update(resources) + .set({ health }) + .where(eq(resources.resourceId, resource.resourceId)); + + if (health === "unknown") { + await fireResourceUnknownAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + send, + trx + ); + } else if (health === "unhealthy") { + await fireResourceUnhealthyAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + send, + trx + ); + } else if (health === "healthy") { + await fireResourceHealthyAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + send, + trx + ); + } else if (health === "degraded") { + await fireResourceDegradedAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + send, + trx + ); + } + } +} diff --git a/server/lib/alerts/events/resourceEvents.ts b/server/lib/alerts/events/resourceEvents.ts new file mode 100644 index 000000000..0d84328d2 --- /dev/null +++ b/server/lib/alerts/events/resourceEvents.ts @@ -0,0 +1,243 @@ +import logger from "@server/logger"; +import { processAlerts } from "#dynamic/lib/alerts"; +import { db, logsDb, statusHistory, Transaction } from "@server/db"; +import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `resource_healthy` alert for the given resource. + * + * Call this after a previously-unhealthy resource has recovered so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceHealthyAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record, + send: boolean = true, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "healthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "resource_healthy", + orgId, + resourceId, + data: { + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "healthy", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} + +/** + * Fire a `resource_unhealthy` alert for the given resource. + * + * Call this after a resource has been detected as unhealthy so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceUnhealthyAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record, + send: boolean = true, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "resource_unhealthy", + orgId, + resourceId, + data: { + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "unhealthy", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} + +/** + * Fire a `resource_degraded` alert for the given resource. + * + * Call this after a resource has been detected as degraded so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceDegradedAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record, + send: boolean = true, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "degraded", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "resource_degraded", + orgId, + resourceId, + data: { + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "degraded", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} + +/** + * Fire a `resource_unknown` alert for the given resource. + * + * Call this when all health checks on a resource are disabled so that the + * resource status transitions to unknown. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceUnknownAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record, + send: boolean = true, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } + + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "unknown", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} diff --git a/server/lib/alerts/events/siteEvents.ts b/server/lib/alerts/events/siteEvents.ts new file mode 100644 index 000000000..e64ac72f7 --- /dev/null +++ b/server/lib/alerts/events/siteEvents.ts @@ -0,0 +1,156 @@ +import logger from "@server/logger"; +import { processAlerts } from "#dynamic/lib/alerts"; +import { + db, + logsDb, + statusHistory, + targetHealthCheck, + Transaction +} from "@server/db"; +import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; +import { and, eq, inArray } from "drizzle-orm"; +import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `site_online` alert for the given site. + * + * Call this after the site has been confirmed reachable / connected so that + * any matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the site. + * @param siteId - Numeric primary key of the site. + * @param siteName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireSiteOnlineAlert( + orgId: string, + siteId: number, + siteName?: string, + extra?: Record, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "site", + entityId: siteId, + orgId: orgId, + status: "online", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("site", siteId); + + await processAlerts({ + eventType: "site_online", + orgId, + siteId, + data: { + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "site_toggle", + orgId, + siteId, + data: { + siteId, + status: "online", + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireSiteOnlineAlert: unexpected error for siteId ${siteId}`, + err + ); + } +} + +/** + * Fire a `site_offline` alert for the given site. + * + * Call this after the site has been detected as unreachable / disconnected so + * that any matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the site. + * @param siteId - Numeric primary key of the site. + * @param siteName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireSiteOfflineAlert( + orgId: string, + siteId: number, + siteName?: string, + extra?: Record, + trx: Transaction | typeof db = db +): Promise { + try { + await logsDb.insert(statusHistory).values({ + entityType: "site", + entityId: siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); + await invalidateStatusHistoryCache("site", siteId); + + const unhealthyHealthChecks = await trx + .update(targetHealthCheck) + .set({ hcHealth: "unhealthy" }) + .where( + and( + eq(targetHealthCheck.orgId, orgId), + eq(targetHealthCheck.siteId, siteId), + eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled + ) + ) + .returning(); + + for (const healthCheck of unhealthyHealthChecks) { + logger.info( + `Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline` + ); + + await fireHealthCheckUnhealthyAlert( + healthCheck.orgId, + healthCheck.targetHealthCheckId, + healthCheck.name, + healthCheck.targetId, // for the resource if we have one + undefined, + true, + trx + ); + } + + await processAlerts({ + eventType: "site_offline", + orgId, + siteId, + data: { + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + await processAlerts({ + eventType: "site_toggle", + orgId, + siteId, + data: { + siteId, + status: "offline", + ...(siteName != null ? { siteName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireSiteOfflineAlert: unexpected error for siteId ${siteId}`, + err + ); + } +} diff --git a/server/lib/alerts/index.ts b/server/lib/alerts/index.ts new file mode 100644 index 000000000..324e4cf6a --- /dev/null +++ b/server/lib/alerts/index.ts @@ -0,0 +1,4 @@ +export * from "./events/siteEvents"; +export * from "./events/healthCheckEvents"; +export * from "./events/resourceEvents"; +export * from "./processAlerts"; diff --git a/server/lib/alerts/processAlerts.ts b/server/lib/alerts/processAlerts.ts new file mode 100644 index 000000000..2d0fb7bfd --- /dev/null +++ b/server/lib/alerts/processAlerts.ts @@ -0,0 +1,5 @@ +import { AlertContext } from "@server/routers/alertRule/types"; + +export async function processAlerts(context: AlertContext): Promise { + return; +} diff --git a/server/lib/billing/getOrgTierData.ts b/server/lib/billing/getOrgTierData.ts index 75f125594..afe45d961 100644 --- a/server/lib/billing/getOrgTierData.ts +++ b/server/lib/billing/getOrgTierData.ts @@ -1,8 +1,9 @@ export async function getOrgTierData( orgId: string -): Promise<{ tier: string | null; active: boolean }> { +): Promise<{ tier: string | null; active: boolean; isTrial: boolean }> { const tier = null; const active = false; + const isTrial = false; - return { tier, active }; + return { tier, active, isTrial }; } diff --git a/server/lib/billing/licenses.ts b/server/lib/billing/licenses.ts index 3fecb32b5..ff942d11b 100644 --- a/server/lib/billing/licenses.ts +++ b/server/lib/billing/licenses.ts @@ -9,8 +9,8 @@ export type LicensePriceSet = { export const licensePriceSet: LicensePriceSet = { // Free license matches the freeLimitSet - [LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8", - [LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y" + [LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT", + [LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS" }; export const licensePriceSetSandbox: LicensePriceSet = { diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index ae9a18ffe..e45ae637d 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -25,7 +25,7 @@ export const tier1LimitSet: LimitSet = { export const tier2LimitSet: LimitSet = { [FeatureId.USERS]: { - value: 100, + value: 50, description: "Team limit" }, [FeatureId.SITES]: { @@ -48,7 +48,7 @@ export const tier2LimitSet: LimitSet = { export const tier3LimitSet: LimitSet = { [FeatureId.USERS]: { - value: 500, + value: 250, description: "Business limit" }, [FeatureId.SITES]: { diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 0756ea665..f44cb8bf6 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -20,7 +20,11 @@ export enum TierFeature { FullRbac = "fullRbac", SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed SIEM = "siem", // handle downgrade by disabling SIEM integrations - DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces + HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources + DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces + StandaloneHealthChecks = "standaloneHealthChecks", + AlertingRules = "alertingRules", + WildcardSubdomain = "wildcardSubdomain" } export const tierMatrix: Record = { @@ -58,5 +62,9 @@ export const tierMatrix: Record = { [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], [TierFeature.SIEM]: ["enterprise"], - [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"] + [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"], + [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"], + [TierFeature.AlertingRules]: ["tier3", "enterprise"], + [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index a304bb392..8ca66971a 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -121,8 +121,8 @@ export async function applyBlueprint({ for (const result of clientResourcesResults) { if ( result.oldSiteResource && - result.oldSiteResource.siteId != - result.newSiteResource.siteId + JSON.stringify(result.newSites?.sort()) !== + JSON.stringify(result.oldSites?.sort()) ) { // query existing associations const existingRoleIds = await trx @@ -222,38 +222,46 @@ export async function applyBlueprint({ trx ); } else { - const [newSite] = await trx - .select() - .from(sites) - .innerJoin(newts, eq(sites.siteId, newts.siteId)) - .where( - and( - eq(sites.siteId, result.newSiteResource.siteId), - eq(sites.orgId, orgId), - eq(sites.type, "newt"), - isNotNull(sites.pubKey) + let good = true; + for (const newSite of result.newSites) { + const [site] = await trx + .select() + .from(sites) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + eq(sites.siteId, newSite.siteId), + eq(sites.orgId, orgId), + eq(sites.type, "newt"), + isNotNull(sites.pubKey) + ) ) - ) - .limit(1); + .limit(1); + + if (!site) { + logger.debug( + `No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` + ); + good = false; + break; + } - if (!newSite) { logger.debug( - `No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` + `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}` ); - continue; } - logger.debug( - `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}` - ); + if (!good) { + continue; + } await handleMessagingForUpdatedSiteResource( result.oldSiteResource, result.newSiteResource, - { - siteId: newSite.sites.siteId, - orgId: newSite.sites.orgId - }, + result.newSites.map((site) => ({ + siteId: site.siteId, + orgId: result.newSiteResource.orgId + })), trx ); } @@ -285,7 +293,7 @@ export async function applyBlueprint({ orgId, name: name ?? - `${faker.word.adjective()} ${faker.word.adjective()} ${faker.word.noun()}`, + `${faker.word.adjective()}-${faker.word.adjective()}-${faker.word.noun()}`, contents: stringifyYaml(configData), createdAt: Math.floor(Date.now() / 1000), succeeded: blueprintSucceeded, diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 80c691c63..21476b580 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -1,24 +1,104 @@ import { clients, clientSiteResources, + domains, + orgDomains, roles, roleSiteResources, + Site, SiteResource, + siteNetworks, siteResources, Transaction, userOrgs, users, - userSiteResources + userSiteResources, + networks } from "@server/db"; import { sites } from "@server/db"; -import { eq, and, ne, inArray, or } from "drizzle-orm"; +import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm"; import { Config } from "./types"; import logger from "@server/logger"; import { getNextAvailableAliasAddress } from "../ip"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; + +async function getDomainForSiteResource( + siteResourceId: number | undefined, + fullDomain: string, + orgId: string, + trx: Transaction +): Promise<{ subdomain: string | null; domainId: string }> { + const [fullDomainExists] = await trx + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .where( + and( + eq(siteResources.fullDomain, fullDomain), + eq(siteResources.orgId, orgId), + siteResourceId + ? ne(siteResources.siteResourceId, siteResourceId) + : isNotNull(siteResources.siteResourceId) + ) + ) + .limit(1); + + if (fullDomainExists) { + throw new Error( + `Site resource already exists with domain: ${fullDomain} in org ${orgId}` + ); + } + + const possibleDomains = await trx + .select() + .from(domains) + .innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId)) + .where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true))) + .execute(); + + if (possibleDomains.length === 0) { + throw new Error( + `Domain not found for full-domain: ${fullDomain} in org ${orgId}` + ); + } + + const validDomains = possibleDomains.filter((domain) => { + if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { + return ( + fullDomain === domain.domains.baseDomain || + fullDomain.endsWith(`.${domain.domains.baseDomain}`) + ); + } else if (domain.domains.type == "cname") { + return fullDomain === domain.domains.baseDomain; + } + }); + + if (validDomains.length === 0) { + throw new Error( + `Domain not found for full-domain: ${fullDomain} in org ${orgId}` + ); + } + + const domainSelection = validDomains[0].domains; + const baseDomain = domainSelection.baseDomain; + + let subdomain: string | null = null; + if (fullDomain !== baseDomain) { + subdomain = fullDomain.replace(`.${baseDomain}`, ""); + } + + await createCertificate(domainSelection.domainId, fullDomain, trx); + + return { + subdomain, + domainId: domainSelection.domainId + }; +} export type ClientResourcesResults = { newSiteResource: SiteResource; oldSiteResource?: SiteResource; + newSites: { siteId: number }[]; + oldSites: { siteId: number }[]; }[]; export async function updateClientResources( @@ -43,53 +123,109 @@ export async function updateClientResources( ) .limit(1); - const resourceSiteId = resourceData.site; - let site; + const existingSiteIds = existingResource?.networkId + ? await trx + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, existingResource.networkId)) + : []; - if (resourceSiteId) { + const allSites: { siteId: number }[] = []; + + if (resourceData.site) { // Look up site by niceId - [site] = await trx + const [siteSingle] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and( - eq(sites.niceId, resourceSiteId), + eq(sites.niceId, resourceData.site), eq(sites.orgId, orgId) ) ) .limit(1); - } else if (siteId) { + if (siteSingle) { + allSites.push(siteSingle); + } + } + + if (resourceData.sites) { + for (const siteNiceId of resourceData.sites) { + const [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, siteNiceId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + if (site) { + allSites.push(site); + } + } + } + + if (siteId && allSites.length === 0) { + // only add if there are not provided sites // Use the provided siteId directly, but verify it belongs to the org - [site] = await trx + const [siteSingle] = await trx .select({ siteId: sites.siteId }) .from(sites) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .limit(1); - } else { - throw new Error(`Target site is required`); + if (siteSingle) { + allSites.push(siteSingle); + } } - if (!site) { + if (allSites.length === 0) { throw new Error( - `Site not found: ${resourceSiteId} in org ${orgId}` + `No valid sites found for private private resource ${resourceNiceId} in org ${orgId}` ); } if (existingResource) { + let domainInfo: + | { subdomain: string | null; domainId: string } + | undefined; + if (resourceData["full-domain"] && resourceData.mode === "http") { + domainInfo = await getDomainForSiteResource( + existingResource.siteResourceId, + resourceData["full-domain"], + orgId, + trx + ); + } + // Update existing resource const [updatedResource] = await trx .update(siteResources) .set({ name: resourceData.name || resourceNiceId, - siteId: site.siteId, mode: resourceData.mode, + ssl: resourceData.ssl, + scheme: resourceData.scheme, destination: resourceData.destination, + destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, - disableIcmp: resourceData["disable-icmp"], - tcpPortRangeString: resourceData["tcp-ports"], - udpPortRangeString: resourceData["udp-ports"] + disableIcmp: + resourceData["disable-icmp"] || + (resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false + tcpPortRangeString: + resourceData.mode == "http" + ? "443,80" + : resourceData["tcp-ports"], + udpPortRangeString: + resourceData.mode == "http" + ? "" + : resourceData["udp-ports"], + fullDomain: resourceData["full-domain"] || null, + subdomain: domainInfo ? domainInfo.subdomain : null, + domainId: domainInfo ? domainInfo.domainId : null }) .where( eq( @@ -100,7 +236,21 @@ export async function updateClientResources( .returning(); const siteResourceId = existingResource.siteResourceId; - const orgId = existingResource.orgId; + + if (updatedResource.networkId) { + await trx + .delete(siteNetworks) + .where( + eq(siteNetworks.networkId, updatedResource.networkId) + ); + + for (const site of allSites) { + await trx.insert(siteNetworks).values({ + siteId: site.siteId, + networkId: updatedResource.networkId + }); + } + } await trx .delete(clientSiteResources) @@ -204,37 +354,80 @@ export async function updateClientResources( results.push({ newSiteResource: updatedResource, - oldSiteResource: existingResource + oldSiteResource: existingResource, + newSites: allSites, + oldSites: existingSiteIds }); } else { let aliasAddress: string | null = null; - if (resourceData.mode == "host") { - // we can only have an alias on a host + if (resourceData.mode === "host" || resourceData.mode === "http") { aliasAddress = await getNextAvailableAliasAddress(orgId); } + let domainInfo: + | { subdomain: string | null; domainId: string } + | undefined; + if (resourceData["full-domain"] && resourceData.mode === "http") { + domainInfo = await getDomainForSiteResource( + undefined, + resourceData["full-domain"], + orgId, + trx + ); + } + + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); + // Create new resource const [newResource] = await trx .insert(siteResources) .values({ orgId: orgId, - siteId: site.siteId, niceId: resourceNiceId, + networkId: network.networkId, + defaultNetworkId: network.networkId, name: resourceData.name || resourceNiceId, mode: resourceData.mode, + ssl: resourceData.ssl, + scheme: resourceData.scheme, destination: resourceData.destination, + destinationPort: resourceData["destination-port"], enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, aliasAddress: aliasAddress, - disableIcmp: resourceData["disable-icmp"], - tcpPortRangeString: resourceData["tcp-ports"], - udpPortRangeString: resourceData["udp-ports"] + disableIcmp: + resourceData["disable-icmp"] || + (resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false + tcpPortRangeString: + resourceData.mode == "http" + ? "443,80" + : resourceData["tcp-ports"], + udpPortRangeString: + resourceData.mode == "http" + ? "" + : resourceData["udp-ports"], + fullDomain: resourceData["full-domain"] || null, + subdomain: domainInfo ? domainInfo.subdomain : null, + domainId: domainInfo ? domainInfo.domainId : null }) .returning(); const siteResourceId = newResource.siteResourceId; + for (const site of allSites) { + await trx.insert(siteNetworks).values({ + siteId: site.siteId, + networkId: network.networkId + }); + } + const [adminRole] = await trx .select() .from(roles) @@ -324,7 +517,11 @@ export async function updateClientResources( `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` ); - results.push({ newSiteResource: newResource }); + results.push({ + newSiteResource: newResource, + newSites: allSites, + oldSites: existingSiteIds + }); } } diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index e16da2ea5..34b352a42 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -1,5 +1,6 @@ import { domains, + domainNamespaces, orgDomains, Resource, resourceHeaderAuth, @@ -33,6 +34,7 @@ import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidRegionId } from "@server/db/regions"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; export type ProxyResourcesResults = { @@ -140,7 +142,10 @@ export async function updateProxyResources( const [newHealthcheck] = await trx .insert(targetHealthCheck) .values({ + name: `${targetData.hostname}:${targetData.port}`, + siteId: site.siteId, targetId: newTarget.targetId, + orgId: orgId, hcEnabled: healthcheckData?.enabled || false, hcPath: healthcheckData?.path, hcScheme: healthcheckData?.scheme, @@ -158,11 +163,27 @@ export async function updateProxyResources( healthcheckData?.["follow-redirects"], hcMethod: healthcheckData?.method, hcStatus: healthcheckData?.status, - hcHealth: "unknown" + hcHealth: "unknown", + hcHealthyThreshold: healthcheckData?.["healthy-threshold"], + hcUnhealthyThreshold: + healthcheckData?.["unhealthy-threshold"] }) .returning(); healthchecksToUpdate.push(newHealthcheck); + + // Insert unknown status history when HC is created in disabled state + if (!healthcheckData?.enabled) { + await fireHealthCheckUnknownAlert( + orgId, + newHealthcheck.targetHealthCheckId, + newHealthcheck.name, + newHealthcheck.targetId, + undefined, + true, + trx + ); + } } // Find existing resource by niceId and orgId @@ -231,6 +252,7 @@ export async function updateProxyResources( fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, + wildcard: domain ? domain.wildcard : false, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, skipToIdpId: @@ -522,7 +544,11 @@ export async function updateProxyResources( healthcheckData?.followRedirects || healthcheckData?.["follow-redirects"], hcMethod: healthcheckData?.method, - hcStatus: healthcheckData?.status + hcStatus: healthcheckData?.status, + hcHealthyThreshold: + healthcheckData?.["healthy-threshold"], + hcUnhealthyThreshold: + healthcheckData?.["unhealthy-threshold"] }) .where( eq( @@ -548,6 +574,21 @@ export async function updateProxyResources( targetsToUpdate.push(updatedTarget); } } + + // Insert unknown status history when HC is disabled + const isDisablingHc = + !healthcheckData?.enabled && oldHealthcheck?.hcEnabled; + if (isDisablingHc) { + await fireHealthCheckUnknownAlert( + orgId, + newHealthcheck.targetHealthCheckId, + newHealthcheck.name, + newHealthcheck.targetId, + undefined, + true, + trx + ); + } } else { await createTarget(existingResource.resourceId, targetData); } @@ -676,6 +717,7 @@ export async function updateProxyResources( fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, + wildcard: domain ? domain.wildcard : false, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, skipToIdpId: resourceData.auth?.["auto-login-idp"] || null, @@ -1081,6 +1123,10 @@ function checkIfHealthcheckChanged( JSON.stringify(incoming.hcHeaders) ) return true; + if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold) + return true; + if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold) + return true; return false; } @@ -1100,7 +1146,7 @@ function checkIfTargetChanged( return false; } -async function getDomain( +export async function getDomain( resourceId: number | undefined, fullDomain: string, orgId: string, @@ -1143,7 +1189,13 @@ async function getDomainId( orgId: string, fullDomain: string, trx: Transaction -): Promise<{ subdomain: string | null; domainId: string } | null> { +): Promise<{ + subdomain: string | null; + domainId: string; + wildcard: boolean; +} | null> { + const isWildcardFullDomain = fullDomain.startsWith("*."); + const possibleDomains = await trx .select() .from(domains) @@ -1156,6 +1208,11 @@ async function getDomainId( } const validDomains = possibleDomains.filter((domain) => { + // Wildcard full-domains are not allowed on CNAME domains + if (isWildcardFullDomain && domain.domains.type === "cname") { + return false; + } + if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { return ( fullDomain === domain.domains.baseDomain || @@ -1173,6 +1230,21 @@ async function getDomainId( const domainSelection = validDomains[0].domains; const baseDomain = domainSelection.baseDomain; + // Wildcard full-domains are not allowed on namespace (provided/free) domains + if (isWildcardFullDomain) { + const [namespaceDomain] = await trx + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainSelection.domainId)) + .limit(1); + + if (namespaceDomain) { + throw new Error( + `Wildcard full-domains are not supported for provided or free domains: ${fullDomain}` + ); + } + } + // remove the base domain of the domain let subdomain = null; if (fullDomain != baseDomain) { @@ -1182,6 +1254,7 @@ async function getDomainId( // Return the first valid domain return { subdomain: subdomain, - domainId: domainSelection.domainId + domainId: domainSelection.domainId, + wildcard: isWildcardFullDomain }; } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 6ebc509b8..13f4caa8f 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { portRangeStringSchema } from "@server/lib/ip"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; import { isValidRegionId } from "@server/db/regions"; +import { wildcardSubdomainSchema } from "@server/lib/schemas"; export const SiteSchema = z.object({ name: z.string().min(1).max(100), @@ -12,7 +13,7 @@ export const TargetHealthCheckSchema = z.object({ hostname: z.string(), port: z.int().min(1).max(65535), enabled: z.boolean().optional().default(true), - path: z.string().optional().default("/"), + path: z.string().optional(), scheme: z.string().optional(), mode: z.string().default("http"), interval: z.int().default(30), @@ -26,8 +27,10 @@ export const TargetHealthCheckSchema = z.object({ .default(null), "follow-redirects": z.boolean().default(true), followRedirects: z.boolean().optional(), // deprecated alias - method: z.string().default("GET"), - status: z.int().optional() + method: z.string().optional(), + status: z.int().optional(), + "healthy-threshold": z.int().min(1).optional().default(1), + "unhealthy-threshold": z.int().min(1).optional().default(1) }); // Schema for individual target within a resource @@ -164,6 +167,7 @@ export const ResourceSchema = z name: z.string().optional(), protocol: z.enum(["http", "tcp", "udp"]).optional(), ssl: z.boolean().optional(), + scheme: z.enum(["http", "https"]).optional(), "full-domain": z.string().optional(), "proxy-port": z.int().min(1).max(65535).optional(), enabled: z.boolean().optional(), @@ -316,6 +320,34 @@ export const ResourceSchema = z message: "Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)" } + ) + .refine( + (resource) => { + const fullDomain = resource["full-domain"]; + if (!fullDomain || !fullDomain.includes("*")) return true; + + // A wildcard full-domain must be of the form *.labels.basedomain + // Extract the leftmost label(s) before the first non-wildcard segment. + // e.g. "*.level1.example.com" → subdomain candidate is "*.level1" + // We do this by finding the base domain: everything after the first + // real (non-wildcard) dot-separated segment pair. + // + // Simple rule: split on ".", first token must be "*", rest must be + // valid hostname labels, and there must be at least 2 remaining labels + // (so the full domain has a real base domain). + const parts = fullDomain.split("."); + if (parts[0] !== "*") return false; // * must be the very first label + if (parts.includes("*", 1)) return false; // no further wildcards + if (parts.length < 3) return false; // need at least *.label.tld + + const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/; + return parts.slice(1).every((label) => labelRegex.test(label)); + }, + { + path: ["full-domain"], + message: + 'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.' + } ); export function isTargetsOnlyResource(resource: any): boolean { @@ -325,16 +357,20 @@ export function isTargetsOnlyResource(resource: any): boolean { export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr"]), - site: z.string(), + mode: z.enum(["host", "cidr", "http"]), + site: z.string().optional(), // DEPRECATED IN FAVOR OF sites + sites: z.array(z.string()).optional().default([]), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), - // destinationPort: z.int().positive().optional(), + "destination-port": z.int().positive().optional(), destination: z.string().min(1), // enabled: z.boolean().default(true), "tcp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"), "disable-icmp": z.boolean().optional().default(false), + "full-domain": z.string().optional(), + ssl: z.boolean().optional(), + scheme: z.enum(["http", "https"]).optional().nullable(), alias: z .string() .regex( @@ -477,6 +513,39 @@ export const ConfigSchema = z }); } + // Enforce the full-domain uniqueness across client-resources in the same stack + const clientFullDomainMap = new Map(); + + Object.entries(config["client-resources"]).forEach( + ([resourceKey, resource]) => { + const fullDomain = resource["full-domain"]; + if (fullDomain) { + if (!clientFullDomainMap.has(fullDomain)) { + clientFullDomainMap.set(fullDomain, []); + } + clientFullDomainMap.get(fullDomain)!.push(resourceKey); + } + } + ); + + const clientFullDomainDuplicates = Array.from( + clientFullDomainMap.entries() + ) + .filter(([_, resourceKeys]) => resourceKeys.length > 1) + .map( + ([fullDomain, resourceKeys]) => + `'${fullDomain}' used by resources: ${resourceKeys.join(", ")}` + ) + .join("; "); + + if (clientFullDomainDuplicates.length !== 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["client-resources"], + message: `Duplicate 'full-domain' values found: ${clientFullDomainDuplicates}` + }); + } + // Enforce proxy-port uniqueness within proxy-resources per protocol const protocolPortMap = new Map(); diff --git a/server/lib/certificates.ts b/server/lib/certificates.ts index f5860ff3a..f03f37f25 100644 --- a/server/lib/certificates.ts +++ b/server/lib/certificates.ts @@ -1,5 +1,6 @@ export async function getValidCertificatesForDomains( - domains: Set + domains: Set, + useCache: boolean = true ): Promise< Array<{ id: number; diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 8ad4f48e9..3d290d93b 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.17.0"; +export const APP_VERSION = "1.18.2"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts index 3562df683..138ffc74f 100644 --- a/server/lib/domainUtils.ts +++ b/server/lib/domainUtils.ts @@ -1,14 +1,16 @@ import { db } from "@server/db"; -import { domains, orgDomains } from "@server/db"; -import { eq, and } from "drizzle-orm"; -import { subdomainSchema } from "@server/lib/schemas"; +import { domains, orgDomains, domainNamespaces, resources } from "@server/db"; +import { eq, and, like, not } from "drizzle-orm"; +import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import { fromError } from "zod-validation-error"; +import config from "./config"; export type DomainValidationResult = | { success: true; fullDomain: string; subdomain: string | null; + wildcard: boolean; } | { success: false; @@ -66,6 +68,62 @@ export async function validateAndConstructDomain( }; } + // Detect wildcard subdomain request + const isWildcard = + subdomain !== undefined && + subdomain !== null && + subdomain.includes("*") && + domainRes.domains.type !== "cname"; + + // Wildcard subdomains are not allowed on CNAME domains + if (isWildcard && domainRes.domains.type === "cname") { + return { + success: false, + error: "Wildcard subdomains are not supported for CNAME domains. CNAME domains must use a specific hostname." + }; + } + + // Wildcard subdomains are not allowed on namespace (provided/free) domains + if (isWildcard) { + const [namespaceDomain] = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); + + if (namespaceDomain) { + return { + success: false, + error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead." + }; + } + } + + if ( + isWildcard && + domainRes.domains.type == "wildcard" && + !( + domainRes.domains.preferWildcardCert || + config.getRawConfig().traefik.prefer_wildcard_cert + ) + ) { + return { + success: false, + error: "Wildcard domains are not supported without configuring certificate resolver for wildcard certs and marking it as prefered." + }; + } + + // Validate wildcard subdomain format + if (isWildcard) { + const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain); + if (!parsedWildcard.success) { + return { + success: false, + error: fromError(parsedWildcard.error).toString() + }; + } + } + // Construct full domain based on domain type let fullDomain = ""; let finalSubdomain = subdomain; @@ -81,13 +139,16 @@ export async function validateAndConstructDomain( finalSubdomain = null; // CNAME domains don't use subdomains } else if (domainRes.domains.type === "wildcard") { if (subdomain !== undefined && subdomain !== null) { - // Validate subdomain format for wildcard domains - const parsedSubdomain = subdomainSchema.safeParse(subdomain); - if (!parsedSubdomain.success) { - return { - success: false, - error: fromError(parsedSubdomain.error).toString() - }; + if (!isWildcard) { + // Validate regular subdomain format for wildcard domains + const parsedSubdomain = + subdomainSchema.safeParse(subdomain); + if (!parsedSubdomain.success) { + return { + success: false, + error: fromError(parsedSubdomain.error).toString() + }; + } } fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; } else { @@ -100,13 +161,14 @@ export async function validateAndConstructDomain( finalSubdomain = null; } - // Convert to lowercase + // Convert to lowercase (preserve * as-is) fullDomain = fullDomain.toLowerCase(); return { success: true, fullDomain, - subdomain: finalSubdomain ?? null + subdomain: finalSubdomain ?? null, + wildcard: isWildcard }; } catch (error) { return { @@ -115,3 +177,81 @@ export async function validateAndConstructDomain( }; } } + +/** + * Checks whether a given fullDomain conflicts with any existing wildcard resources, + * or (if the fullDomain is itself a wildcard) whether any existing resources would + * be matched by it. + * + * @param fullDomain - The fully-constructed domain to check (may contain a leading `*`) + * @param excludeResourceId - Optional resource ID to exclude from the check (for updates) + * @returns An object with `conflict: true` and a human-readable `message`, or `conflict: false` + */ +export async function checkWildcardDomainConflict( + fullDomain: string, + excludeResourceId?: number +): Promise<{ conflict: false } | { conflict: true; message: string }> { + const isWildcard = fullDomain.startsWith("*."); + + if (isWildcard) { + // e.g. fullDomain = "*.example.com" → suffix = ".example.com" + const suffix = fullDomain.slice(1); // ".example.com" + + // Find any existing non-wildcard resource whose fullDomain ends with this suffix + // e.g. "test.example.com" or "foo.example.com" + const conflicting = await db + .select({ + resourceId: resources.resourceId, + fullDomain: resources.fullDomain + }) + .from(resources) + .where(like(resources.fullDomain, `%${suffix}`)); + + const matches = conflicting.filter( + (r) => + !r.fullDomain!.startsWith("*.") && + r.fullDomain!.endsWith(suffix) && + (excludeResourceId === undefined || + r.resourceId !== excludeResourceId) + ); + + if (matches.length > 0) { + return { + conflict: true, + message: `Wildcard domain ${fullDomain} conflicts with existing resource(s): ${matches.map((r) => r.fullDomain).join(", ")}` + }; + } + } else { + // Specific domain — check if any existing wildcard would match it. + // e.g. fullDomain = "test.example.com" + // We look for a wildcard "*.example.com" which means fullDomain ends with ".example.com" + const dotIndex = fullDomain.indexOf("."); + if (dotIndex !== -1) { + const suffix = fullDomain.slice(dotIndex); // ".example.com" + const wildcardPattern = `*.${fullDomain.slice(dotIndex + 1)}`; // "*.example.com" + + const conflicting = await db + .select({ + resourceId: resources.resourceId, + fullDomain: resources.fullDomain + }) + .from(resources) + .where(eq(resources.fullDomain, wildcardPattern)); + + const matches = conflicting.filter( + (r) => + excludeResourceId === undefined || + r.resourceId !== excludeResourceId + ); + + if (matches.length > 0) { + return { + conflict: true, + message: `Domain ${fullDomain} conflicts with existing wildcard resource(s): ${matches.map((r) => r.fullDomain).join(", ")}` + }; + } + } + } + + return { conflict: false }; +} diff --git a/server/lib/encryption.ts b/server/lib/encryption.ts deleted file mode 100644 index 79caecd1a..000000000 --- a/server/lib/encryption.ts +++ /dev/null @@ -1,39 +0,0 @@ -import crypto from "crypto"; - -export function encryptData(data: string, key: Buffer): string { - const algorithm = "aes-256-gcm"; - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(algorithm, key, iv); - - let encrypted = cipher.update(data, "utf8", "hex"); - encrypted += cipher.final("hex"); - - const authTag = cipher.getAuthTag(); - - // Combine IV, auth tag, and encrypted data - return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted; -} - -// Helper function to decrypt data (you'll need this to read certificates) -export function decryptData(encryptedData: string, key: Buffer): string { - const algorithm = "aes-256-gcm"; - const parts = encryptedData.split(":"); - - if (parts.length !== 3) { - throw new Error("Invalid encrypted data format"); - } - - const iv = Buffer.from(parts[0], "hex"); - const authTag = Buffer.from(parts[1], "hex"); - const encrypted = parts[2]; - - const decipher = crypto.createDecipheriv(algorithm, key, iv); - decipher.setAuthTag(authTag); - - let decrypted = decipher.update(encrypted, "hex", "utf8"); - decrypted += decipher.final("utf8"); - - return decrypted; -} - -// openssl rand -hex 32 > config/encryption.key diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 7f829bcef..929399f7b 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -5,6 +5,7 @@ import config from "@server/lib/config"; import z from "zod"; import logger from "@server/logger"; import semver from "semver"; +import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; interface IPRange { start: bigint; @@ -477,9 +478,9 @@ export type Alias = { alias: string | null; aliasAddress: string | null }; export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { return allSiteResources - .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") + .filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http"))) .map((sr) => ({ - alias: sr.alias, + alias: sr.alias || sr.fullDomain, aliasAddress: sr.aliasAddress })); } @@ -582,16 +583,26 @@ export type SubnetProxyTargetV2 = { protocol: "tcp" | "udp"; }[]; resourceId?: number; + protocol?: "http" | "https"; // if set, this target only applies to the specified protocol + httpTargets?: HTTPTarget[]; + tlsCert?: string; + tlsKey?: string; }; -export function generateSubnetProxyTargetV2( +export type HTTPTarget = { + destAddr: string; // must be an IP or hostname + destPort: number; + scheme: "http" | "https"; +}; + +export async function generateSubnetProxyTargetV2( siteResource: SiteResource, clients: { clientId: number; pubKey: string | null; subnet: string | null; }[] -): SubnetProxyTargetV2 | undefined { +): Promise { if (clients.length === 0) { logger.debug( `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` @@ -599,7 +610,7 @@ export function generateSubnetProxyTargetV2( return; } - let target: SubnetProxyTargetV2 | null = null; + let targets: SubnetProxyTargetV2[] = []; const portRange = [ ...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"), @@ -614,52 +625,114 @@ export function generateSubnetProxyTargetV2( if (ipSchema.safeParse(destination).success) { destination = `${destination}/32`; - target = { + targets.push({ sourcePrefixes: [], destPrefix: destination, portRange, disableIcmp, - resourceId: siteResource.siteResourceId, - }; + resourceId: siteResource.siteResourceId + }); } if (siteResource.alias && siteResource.aliasAddress) { // also push a match for the alias address - target = { + targets.push({ sourcePrefixes: [], destPrefix: `${siteResource.aliasAddress}/32`, rewriteTo: destination, portRange, disableIcmp, - resourceId: siteResource.siteResourceId, - }; + resourceId: siteResource.siteResourceId + }); } } else if (siteResource.mode == "cidr") { - target = { + targets.push({ sourcePrefixes: [], destPrefix: siteResource.destination, portRange, disableIcmp, + resourceId: siteResource.siteResourceId + }); + } else if (siteResource.mode == "http") { + let destination = siteResource.destination; + // check if this is a valid ip + const ipSchema = z.union([z.ipv4(), z.ipv6()]); + if (ipSchema.safeParse(destination).success) { + destination = `${destination}/32`; + } + + if ( + !siteResource.aliasAddress || + !siteResource.destinationPort || + !siteResource.scheme || + !siteResource.fullDomain + ) { + logger.debug( + `Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.` + ); + return; + } + // also push a match for the alias address + let tlsCert: string | undefined; + let tlsKey: string | undefined; + + if (siteResource.ssl && siteResource.fullDomain) { + try { + const certs = await getValidCertificatesForDomains( + new Set([siteResource.fullDomain]), + true + ); + if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) { + tlsCert = certs[0].certFile; + tlsKey = certs[0].keyFile; + } else { + logger.warn( + `No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}` + ); + } + } catch (err) { + logger.error( + `Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}` + ); + } + } + + targets.push({ + sourcePrefixes: [], + destPrefix: `${siteResource.aliasAddress}/32`, + portRange, + disableIcmp, resourceId: siteResource.siteResourceId, - }; + protocol: siteResource.ssl ? "https" : "http", + httpTargets: [ + { + destAddr: siteResource.destination, + destPort: siteResource.destinationPort, + scheme: siteResource.scheme + } + ], + ...(tlsCert && tlsKey ? { tlsCert, tlsKey } : {}) + }); } - if (!target) { + if (targets.length == 0) { return; } - for (const clientSite of clients) { - if (!clientSite.subnet) { - logger.debug( - `Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.` - ); - continue; + for (const target of targets) { + for (const clientSite of clients) { + if (!clientSite.subnet) { + logger.debug( + `Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.` + ); + continue; + } + + const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`; + + // add client prefix to source prefixes + target.sourcePrefixes.push(clientPrefix); } - - const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`; - - // add client prefix to source prefixes - target.sourcePrefixes.push(clientPrefix); } // print a nice representation of the targets @@ -667,36 +740,34 @@ export function generateSubnetProxyTargetV2( // `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}` // ); - return target; + return targets; } - /** * Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1) * by expanding each source prefix into its own target entry. * @param targetV2 - The v2 target to convert * @returns Array of v1 SubnetProxyTarget objects */ - export function convertSubnetProxyTargetsV2ToV1( - targetsV2: SubnetProxyTargetV2[] - ): SubnetProxyTarget[] { - return targetsV2.flatMap((targetV2) => - targetV2.sourcePrefixes.map((sourcePrefix) => ({ - sourcePrefix, - destPrefix: targetV2.destPrefix, - ...(targetV2.disableIcmp !== undefined && { - disableIcmp: targetV2.disableIcmp - }), - ...(targetV2.rewriteTo !== undefined && { - rewriteTo: targetV2.rewriteTo - }), - ...(targetV2.portRange !== undefined && { - portRange: targetV2.portRange - }) - })) - ); - } - +export function convertSubnetProxyTargetsV2ToV1( + targetsV2: SubnetProxyTargetV2[] +): SubnetProxyTarget[] { + return targetsV2.flatMap((targetV2) => + targetV2.sourcePrefixes.map((sourcePrefix) => ({ + sourcePrefix, + destPrefix: targetV2.destPrefix, + ...(targetV2.disableIcmp !== undefined && { + disableIcmp: targetV2.disableIcmp + }), + ...(targetV2.rewriteTo !== undefined && { + rewriteTo: targetV2.rewriteTo + }), + ...(targetV2.portRange !== undefined && { + portRange: targetV2.portRange + }) + })) + ); +} // Custom schema for validating port range strings // Format: "80,443,8000-9000" or "*" for all ports, or empty string diff --git a/server/lib/normalizePostAuthPath.ts b/server/lib/normalizePostAuthPath.ts index 7291f1842..7b3f01570 100644 --- a/server/lib/normalizePostAuthPath.ts +++ b/server/lib/normalizePostAuthPath.ts @@ -1,3 +1,5 @@ +// Normalizes + /** * Normalizes a post-authentication path for safe use when building redirect URLs. * Returns a path that starts with / and does not allow open redirects (no //, no :). diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 8459ce249..a5c9a9321 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -11,17 +11,16 @@ import { roleSiteResources, Site, SiteResource, + siteNetworks, siteResources, sites, Transaction, userOrgRoles, - userOrgs, userSiteResources } from "@server/db"; import { and, eq, inArray, ne } from "drizzle-orm"; import { - addPeer as newtAddPeer, deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; import { @@ -35,7 +34,6 @@ import { generateRemoteSubnets, generateSubnetProxyTargetV2, parseEndpoint, - formatEndpoint } from "@server/lib/ip"; import { addPeerData, @@ -48,15 +46,27 @@ export async function getClientSiteResourceAccess( siteResource: SiteResource, trx: Transaction | typeof db = db ) { - // get the site - const [site] = await trx - .select() - .from(sites) - .where(eq(sites.siteId, siteResource.siteId)) - .limit(1); + // get all sites associated with this siteResource via its network + const sitesList = siteResource.networkId + ? await trx + .select() + .from(sites) + .innerJoin( + siteNetworks, + eq(siteNetworks.siteId, sites.siteId) + ) + .where(eq(siteNetworks.networkId, siteResource.networkId)) + .then((rows) => rows.map((row) => row.sites)) + : []; - if (!site) { - throw new Error(`Site with ID ${siteResource.siteId} not found`); + logger.debug( + `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]` + ); + + if (sitesList.length === 0) { + logger.warn( + `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}` + ); } const roleIds = await trx @@ -136,8 +146,12 @@ export async function getClientSiteResourceAccess( const mergedAllClients = Array.from(allClientsMap.values()); const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); + logger.debug( + `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} mergedClientCount=${mergedAllClientIds.length} clientIds=[${mergedAllClientIds.join(", ")}] (userBased=${newAllClients.length} direct=${directClients.length})` + ); + return { - site, + sitesList, mergedAllClients, mergedAllClientIds }; @@ -153,40 +167,64 @@ export async function rebuildClientAssociationsFromSiteResource( subnet: string | null; }[]; }> { - const siteId = siteResource.siteId; + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}` + ); - const { site, mergedAllClients, mergedAllClientIds } = + const { sitesList, mergedAllClients, mergedAllClientIds } = await getClientSiteResourceAccess(siteResource, trx); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] access resolved siteResourceId=${siteResource.siteResourceId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}] mergedClientCount=${mergedAllClients.length} clientIds=[${mergedAllClientIds.join(", ")}]` + ); + /////////// process the client-siteResource associations /////////// - // get all of the clients associated with other resources on this site - const allUpdatedClientsFromOtherResourcesOnThisSite = await trx - .select({ - clientId: clientSiteResourcesAssociationsCache.clientId - }) - .from(clientSiteResourcesAssociationsCache) - .innerJoin( - siteResources, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .where( - and( - eq(siteResources.siteId, siteId), - ne(siteResources.siteResourceId, siteResource.siteResourceId) - ) - ); + // get all of the clients associated with other site resources that share + // any of the same sites as this site resource (via siteNetworks). We can't + // simply filter by networkId since each site resource has its own network; + // two site resources serving the same site typically belong to different + // networks that both happen to include the site through siteNetworks. + const sitesListSiteIds = sitesList.map((s) => s.siteId); + const allUpdatedClientsFromOtherResourcesOnThisSite = + sitesListSiteIds.length > 0 + ? await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + siteId: siteNetworks.siteId + }) + .from(clientSiteResourcesAssociationsCache) + .innerJoin( + siteResources, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + inArray(siteNetworks.siteId, sitesListSiteIds), + ne( + siteResources.siteResourceId, + siteResource.siteResourceId + ) + ) + ) + : []; - const allClientIdsFromOtherResourcesOnThisSite = Array.from( - new Set( - allUpdatedClientsFromOtherResourcesOnThisSite.map( - (row) => row.clientId - ) - ) - ); + // Build a per-site map so the loop below can check by siteId rather than + // across the entire network. + const clientsFromOtherResourcesBySite = new Map>(); + for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) { + if (!clientsFromOtherResourcesBySite.has(row.siteId)) { + clientsFromOtherResourcesBySite.set(row.siteId, new Set()); + } + clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId); + } const existingClientSiteResources = await trx .select({ @@ -204,6 +242,10 @@ export async function rebuildClientAssociationsFromSiteResource( (row) => row.clientId ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]` + ); + // Get full client details for existing resource clients (needed for sending delete messages) const existingResourceClients = existingClientSiteResourceIds.length > 0 @@ -223,6 +265,10 @@ export async function rebuildClientAssociationsFromSiteResource( (clientId) => !existingClientSiteResourceIds.includes(clientId) ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toAdd=[${clientSiteResourcesToAdd.join(", ")}]` + ); + const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map( (clientId) => ({ clientId, @@ -231,17 +277,34 @@ export async function rebuildClientAssociationsFromSiteResource( ); if (clientSiteResourcesToInsert.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserting ${clientSiteResourcesToInsert.length} clientSiteResource association(s)` + ); await trx .insert(clientSiteResourcesAssociationsCache) .values(clientSiteResourcesToInsert) .returning(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations` + ); + } else { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} no clientSiteResource associations to insert` + ); } const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter( (clientId) => !mergedAllClientIds.includes(clientId) ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toRemove=[${clientSiteResourcesToRemove.join(", ")}]` + ); + if (clientSiteResourcesToRemove.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} deleting ${clientSiteResourcesToRemove.length} clientSiteResource association(s)` + ); await trx .delete(clientSiteResourcesAssociationsCache) .where( @@ -260,82 +323,127 @@ export async function rebuildClientAssociationsFromSiteResource( /////////// process the client-site associations /////////// - const existingClientSites = await trx - .select({ - clientId: clientSitesAssociationsCache.clientId - }) - .from(clientSitesAssociationsCache) - .where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId)); - - const existingClientSiteIds = existingClientSites.map( - (row) => row.clientId + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)` ); - // Get full client details for existing clients (needed for sending delete messages) - const existingClients = await trx - .select({ - clientId: clients.clientId, - pubKey: clients.pubKey, - subnet: clients.subnet - }) - .from(clients) - .where(inArray(clients.clientId, existingClientSiteIds)); + for (const site of sitesList) { + const siteId = site.siteId; - const clientSitesToAdd = mergedAllClientIds.filter( - (clientId) => - !existingClientSiteIds.includes(clientId) && - !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource - ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` + ); - const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ - clientId, - siteId - })); + const existingClientSites = await trx + .select({ + clientId: clientSitesAssociationsCache.clientId + }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, siteId)); - if (clientSitesToInsert.length > 0) { - await trx - .insert(clientSitesAssociationsCache) - .values(clientSitesToInsert) - .returning(); - } + const existingClientSiteIds = existingClientSites.map( + (row) => row.clientId + ); - // Now remove any client-site associations that should no longer exist - const clientSitesToRemove = existingClientSiteIds.filter( - (clientId) => - !mergedAllClientIds.includes(clientId) && - !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource - ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]` + ); - if (clientSitesToRemove.length > 0) { - await trx - .delete(clientSitesAssociationsCache) - .where( - and( - eq(clientSitesAssociationsCache.siteId, siteId), - inArray( - clientSitesAssociationsCache.clientId, - clientSitesToRemove - ) - ) + // Get full client details for existing clients (needed for sending delete messages) + const existingClients = + existingClientSiteIds.length > 0 + ? await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where(inArray(clients.clientId, existingClientSiteIds)) + : []; + + const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set(); + + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` + ); + + const clientSitesToAdd = mergedAllClientIds.filter( + (clientId) => + !existingClientSiteIds.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource + ); + + const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ + clientId, + siteId + })); + + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]` + ); + + if (clientSitesToInsert.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)` ); + await trx + .insert(clientSitesAssociationsCache) + .values(clientSitesToInsert) + .returning(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations` + ); + } else { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert` + ); + } + + // Now remove any client-site associations that should no longer exist + const clientSitesToRemove = existingClientSiteIds.filter( + (clientId) => + !mergedAllClientIds.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource + ); + + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]` + ); + + if (clientSitesToRemove.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)` + ); + await trx + .delete(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.siteId, siteId), + inArray( + clientSitesAssociationsCache.clientId, + clientSitesToRemove + ) + ) + ); + } + + // Now handle the messages to add/remove peers on both the newt and olm sides + await handleMessagesForSiteClients( + site, + siteId, + mergedAllClients, + existingClients, + clientSitesToAdd, + clientSitesToRemove, + trx + ); } - /////////// send the messages /////////// - - // Now handle the messages to add/remove peers on both the newt and olm sides - await handleMessagesForSiteClients( - site, - siteId, - mergedAllClients, - existingClients, - clientSitesToAdd, - clientSitesToRemove, - trx - ); - // Handle subnet proxy target updates for the resource associations await handleSubnetProxyTargetUpdates( siteResource, + sitesList, mergedAllClients, existingResourceClients, clientSiteResourcesToAdd, @@ -624,6 +732,7 @@ export async function updateClientSiteDestinations( async function handleSubnetProxyTargetUpdates( siteResource: SiteResource, + sitesList: Site[], allClients: { clientId: number; pubKey: string | null; @@ -638,125 +747,138 @@ async function handleSubnetProxyTargetUpdates( clientSiteResourcesToRemove: number[], trx: Transaction | typeof db = db ): Promise { - // Get the newt for this site - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, siteResource.siteId)) - .limit(1); + const proxyJobs: Promise[] = []; + const olmJobs: Promise[] = []; - if (!newt) { - logger.warn( - `Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates` - ); - return; - } + for (const siteData of sitesList) { + const siteId = siteData.siteId; - const proxyJobs = []; - const olmJobs = []; - // Generate targets for added associations - if (clientSiteResourcesToAdd.length > 0) { - const addedClients = allClients.filter((client) => - clientSiteResourcesToAdd.includes(client.clientId) - ); + // Get the newt for this site + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); - if (addedClients.length > 0) { - const targetToAdd = generateSubnetProxyTargetV2( - siteResource, - addedClients + if (!newt) { + logger.warn( + `Newt not found for site ${siteId}, skipping subnet proxy target updates` ); - - if (targetToAdd) { - proxyJobs.push( - addSubnetProxyTargets( - newt.newtId, - [targetToAdd], - newt.version - ) - ); - } - - for (const client of addedClients) { - olmJobs.push( - addPeerData( - client.clientId, - siteResource.siteId, - generateRemoteSubnets([siteResource]), - generateAliasConfig([siteResource]) - ) - ); - } + continue; } - } - // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here - - // Generate targets for removed associations - if (clientSiteResourcesToRemove.length > 0) { - const removedClients = existingClients.filter((client) => - clientSiteResourcesToRemove.includes(client.clientId) - ); - - if (removedClients.length > 0) { - const targetToRemove = generateSubnetProxyTargetV2( - siteResource, - removedClients + // Generate targets for added associations + if (clientSiteResourcesToAdd.length > 0) { + const addedClients = allClients.filter((client) => + clientSiteResourcesToAdd.includes(client.clientId) ); - if (targetToRemove) { - proxyJobs.push( - removeSubnetProxyTargets( - newt.newtId, - [targetToRemove], - newt.version - ) + if (addedClients.length > 0) { + const targetsToAdd = await generateSubnetProxyTargetV2( + siteResource, + addedClients ); - } - for (const client of removedClients) { - // Check if this client still has access to another resource on this site with the same destination - const destinationStillInUse = await trx - .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .where( - and( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ), - eq(siteResources.siteId, siteResource.siteId), - eq( - siteResources.destination, - siteResource.destination - ), - ne( - siteResources.siteResourceId, - siteResource.siteResourceId - ) + if (targetsToAdd) { + proxyJobs.push( + addSubnetProxyTargets( + newt.newtId, + targetsToAdd, + newt.version ) ); + } - // Only remove remote subnet if no other resource uses the same destination - const remoteSubnetsToRemove = - destinationStillInUse.length > 0 - ? [] - : generateRemoteSubnets([siteResource]); + for (const client of addedClients) { + olmJobs.push( + addPeerData( + client.clientId, + siteId, + generateRemoteSubnets([siteResource]), + generateAliasConfig([siteResource]) + ) + ); + } + } + } - olmJobs.push( - removePeerData( - client.clientId, - siteResource.siteId, - remoteSubnetsToRemove, - generateAliasConfig([siteResource]) - ) + // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here + + // Generate targets for removed associations + if (clientSiteResourcesToRemove.length > 0) { + const removedClients = existingClients.filter((client) => + clientSiteResourcesToRemove.includes(client.clientId) + ); + + if (removedClients.length > 0) { + const targetsToRemove = await generateSubnetProxyTargetV2( + siteResource, + removedClients ); + + if (targetsToRemove) { + proxyJobs.push( + removeSubnetProxyTargets( + newt.newtId, + targetsToRemove, + newt.version + ) + ); + } + + for (const client of removedClients) { + // Check if this client still has access to another resource + // on this specific site with the same destination. We scope + // by siteId (via siteNetworks) rather than networkId because + // removePeerData operates per-site - a resource on a different + // site sharing the same network should not block removal here. + const destinationStillInUse = await trx + .select() + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ), + eq(siteNetworks.siteId, siteId), + eq( + siteResources.destination, + siteResource.destination + ), + ne( + siteResources.siteResourceId, + siteResource.siteResourceId + ) + ) + ); + + // Only remove remote subnet if no other resource uses the same destination + const remoteSubnetsToRemove = + destinationStillInUse.length > 0 + ? [] + : generateRemoteSubnets([siteResource]); + + olmJobs.push( + removePeerData( + client.clientId, + siteId, + remoteSubnetsToRemove, + generateAliasConfig([siteResource]) + ) + ); + } } } } @@ -863,10 +985,25 @@ export async function rebuildClientAssociationsFromClient( ) : []; - // Group by siteId for site-level associations - const newSiteIds = Array.from( - new Set(newSiteResources.map((sr) => sr.siteId)) + // Group by siteId for site-level associations - look up via siteNetworks since + // siteResources no longer carries a direct siteId column. + 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))) + ) + : []; /////////// Process client-siteResource associations /////////// @@ -1139,13 +1276,45 @@ async function handleMessagesForClientResources( resourcesToAdd.includes(r.siteResourceId) ); + // Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId + const addedNetworkIds = Array.from( + new Set( + addedResources + .map((r) => r.networkId) + .filter((id): id is number => id !== null) + ) + ); + const addedSiteNetworkRows = + addedNetworkIds.length > 0 + ? await trx + .select({ + networkId: siteNetworks.networkId, + siteId: siteNetworks.siteId + }) + .from(siteNetworks) + .where(inArray(siteNetworks.networkId, addedNetworkIds)) + : []; + const addedNetworkToSites = new Map(); + for (const row of addedSiteNetworkRows) { + if (!addedNetworkToSites.has(row.networkId)) { + addedNetworkToSites.set(row.networkId, []); + } + addedNetworkToSites.get(row.networkId)!.push(row.siteId); + } + // Group by site for proxy updates const addedBySite = new Map(); for (const resource of addedResources) { - if (!addedBySite.has(resource.siteId)) { - addedBySite.set(resource.siteId, []); + const siteIds = + resource.networkId != null + ? (addedNetworkToSites.get(resource.networkId) ?? []) + : []; + for (const siteId of siteIds) { + if (!addedBySite.has(siteId)) { + addedBySite.set(siteId, []); + } + addedBySite.get(siteId)!.push(resource); } - addedBySite.get(resource.siteId)!.push(resource); } // Add subnet proxy targets for each site @@ -1164,7 +1333,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const target = generateSubnetProxyTargetV2(resource, [ + const targets = await generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1172,11 +1341,11 @@ async function handleMessagesForClientResources( } ]); - if (target) { + if (targets) { proxyJobs.push( addSubnetProxyTargets( newt.newtId, - [target], + targets, newt.version ) ); @@ -1187,7 +1356,7 @@ async function handleMessagesForClientResources( olmJobs.push( addPeerData( client.clientId, - resource.siteId, + siteId, generateRemoteSubnets([resource]), generateAliasConfig([resource]) ) @@ -1199,7 +1368,7 @@ async function handleMessagesForClientResources( error.message.includes("not found") ) { logger.debug( - `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` + `Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition` ); } else { throw error; @@ -1216,13 +1385,45 @@ async function handleMessagesForClientResources( .from(siteResources) .where(inArray(siteResources.siteResourceId, resourcesToRemove)); + // Build (resource, siteId) pairs via siteNetworks + const removedNetworkIds = Array.from( + new Set( + removedResources + .map((r) => r.networkId) + .filter((id): id is number => id !== null) + ) + ); + const removedSiteNetworkRows = + removedNetworkIds.length > 0 + ? await trx + .select({ + networkId: siteNetworks.networkId, + siteId: siteNetworks.siteId + }) + .from(siteNetworks) + .where(inArray(siteNetworks.networkId, removedNetworkIds)) + : []; + const removedNetworkToSites = new Map(); + for (const row of removedSiteNetworkRows) { + if (!removedNetworkToSites.has(row.networkId)) { + removedNetworkToSites.set(row.networkId, []); + } + removedNetworkToSites.get(row.networkId)!.push(row.siteId); + } + // Group by site for proxy updates const removedBySite = new Map(); for (const resource of removedResources) { - if (!removedBySite.has(resource.siteId)) { - removedBySite.set(resource.siteId, []); + const siteIds = + resource.networkId != null + ? (removedNetworkToSites.get(resource.networkId) ?? []) + : []; + for (const siteId of siteIds) { + if (!removedBySite.has(siteId)) { + removedBySite.set(siteId, []); + } + removedBySite.get(siteId)!.push(resource); } - removedBySite.get(resource.siteId)!.push(resource); } // Remove subnet proxy targets for each site @@ -1241,7 +1442,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const target = generateSubnetProxyTargetV2(resource, [ + const targets = await generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1249,18 +1450,22 @@ async function handleMessagesForClientResources( } ]); - if (target) { + if (targets) { proxyJobs.push( removeSubnetProxyTargets( newt.newtId, - [target], + targets, newt.version ) ); } try { - // Check if this client still has access to another resource on this site with the same destination + // Check if this client still has access to another resource + // on this specific site with the same destination. We scope + // by siteId (via siteNetworks) rather than networkId because + // removePeerData operates per-site - a resource on a different + // site sharing the same network should not block removal here. const destinationStillInUse = await trx .select() .from(siteResources) @@ -1271,13 +1476,17 @@ async function handleMessagesForClientResources( siteResources.siteResourceId ) ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) .where( and( eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ), - eq(siteResources.siteId, resource.siteId), + eq(siteNetworks.siteId, siteId), eq( siteResources.destination, resource.destination @@ -1299,7 +1508,7 @@ async function handleMessagesForClientResources( olmJobs.push( removePeerData( client.clientId, - resource.siteId, + siteId, remoteSubnetsToRemove, generateAliasConfig([resource]) ) @@ -1311,7 +1520,7 @@ async function handleMessagesForClientResources( error.message.includes("not found") ) { logger.debug( - `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` + `Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal` ); } else { throw error; diff --git a/server/lib/sanitize.ts b/server/lib/sanitize.ts index 9eba8a583..d82cae0aa 100644 --- a/server/lib/sanitize.ts +++ b/server/lib/sanitize.ts @@ -1,3 +1,5 @@ +// Sanitizes + /** * Sanitize a string field before inserting into a database TEXT column. * @@ -37,4 +39,4 @@ export function sanitizeString( // Strip null bytes, C0 control chars (except HT/LF/CR), and DEL. .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") ); -} \ No newline at end of file +} diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index 5e2bd400b..813849d52 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -1,5 +1,41 @@ import { z } from "zod"; +/** + * Validates a wildcard subdomain passed as the leftmost component of a full domain. + * + * The value represents everything to the left of the base domain, so when combined + * with e.g. "example.com" it must produce a valid SSL-style wildcard hostname. + * + * Valid: + * "*" → *.example.com + * "*.level1" → *.level1.example.com + * + * Invalid: + * "*example" → *example.com (no dot after *) + * "level2.*.level1" → wildcard not in leftmost position + * "*.level1.*" → multiple wildcards + */ +export const wildcardSubdomainSchema = z + .string() + .refine( + (val) => { + // Must start with "*."; the remainder (if any) must be valid hostname labels. + // A bare "*" is also valid (becomes *.baseDomain directly). + if (val === "*") return true; + if (!val.startsWith("*.")) return false; + const rest = val.slice(2); // everything after "*." + // rest must not be empty, must not contain another "*", + // and every label must be a valid hostname label. + if (!rest || rest.includes("*")) return false; + const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/; + return rest.split(".").every((label) => labelRegex.test(label)); + }, + { + message: + 'Invalid wildcard subdomain. The wildcard "*" must be the leftmost label followed by a dot and valid hostname labels (e.g. "*" or "*.level1"). Patterns like "*example", "level2.*.level1", or multiple wildcards are not supported.' + } + ); + export const subdomainSchema = z .string() .regex( diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts new file mode 100644 index 000000000..3a9b1f6ef --- /dev/null +++ b/server/lib/statusHistory.ts @@ -0,0 +1,239 @@ +import { z } from "zod"; +import { db, logsDb, statusHistory } from "@server/db"; +import { and, eq, gte, asc } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +const STATUS_HISTORY_CACHE_TTL = 60; // seconds + +function statusHistoryCacheKey( + entityType: string, + entityId: number, + days: number +): string { + return `statusHistory:${entityType}:${entityId}:${days}`; +} + +export async function getCachedStatusHistory( + entityType: string, + entityId: number, + days: number +): Promise { + const cacheKey = statusHistoryCacheKey(entityType, entityId, days); + const cached = await cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const nowSec = Math.floor(Date.now() / 1000); + const startSec = nowSec - days * 86400; + + const events = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + const { buckets, totalDowntime } = computeBuckets(events, days); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100) + : 100; + + const result: StatusHistoryResponse = { + entityType, + entityId, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }; + + await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); + return result; +} + +export async function invalidateStatusHistoryCache( + entityType: string, + entityId: number +): Promise { + const prefix = `statusHistory:${entityType}:${entityId}:`; + const keys = cache.keys().filter((k) => k.startsWith(prefix)); + if (keys.length > 0) { + await cache.del(keys); + } +} + +export const statusHistoryQuerySchema = z + .object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)) + }) + .pipe( + z.object({ + days: z.number().int().min(1).max(365) + }) + ); + +export interface StatusHistoryDayBucket { + date: string; // ISO date "YYYY-MM-DD" + uptimePercent: number; // 0-100 + totalDowntimeSeconds: number; + downtimeWindows: { start: number; end: number | null; status: string }[]; + status: "good" | "degraded" | "bad" | "no_data" | "unknown"; +} + +export interface StatusHistoryResponse { + entityType: string; + entityId: number; + days: StatusHistoryDayBucket[]; + overallUptimePercent: number; + totalDowntimeSeconds: number; +} + +export function computeBuckets( + events: { + entityType: string; + entityId: number; + orgId: string; + status: string; + timestamp: number; + id: number; + }[], + days: number +): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { + const nowSec = Math.floor(Date.now() / 1000); + const buckets: StatusHistoryDayBucket[] = []; + let totalDowntime = 0; + + for (let d = 0; d < days; d++) { + const dayStartSec = nowSec - (days - d) * 86400; + const dayEndSec = dayStartSec + 86400; + + const dayEvents = events.filter( + (e) => e.timestamp >= dayStartSec && e.timestamp < dayEndSec + ); + + // Determine the status at the start of this day (last event before dayStart) + const lastBeforeDay = [...events] + .filter((e) => e.timestamp < dayStartSec) + .at(-1); + + const currentStatus = lastBeforeDay?.status ?? null; + + const windows: { start: number; end: number | null; status: string }[] = + []; + let dayDowntime = 0; + let dayDegradedTime = 0; + + let windowStart = dayStartSec; + let windowStatus = currentStatus; + + for (const evt of dayEvents) { + if (windowStatus !== null && windowStatus !== evt.status) { + const windowEnd = evt.timestamp; + const isDown = + windowStatus === "offline" || windowStatus === "unhealthy"; + const isDegraded = windowStatus === "degraded"; + if (isDown) { + dayDowntime += windowEnd - windowStart; + windows.push({ + start: windowStart, + end: windowEnd, + status: windowStatus + }); + } else if (isDegraded) { + dayDegradedTime += windowEnd - windowStart; + windows.push({ + start: windowStart, + end: windowEnd, + status: windowStatus + }); + } + } + windowStart = evt.timestamp; + windowStatus = evt.status; + } + + // Close the final window at the end of the day (or now if day hasn't ended) + if (windowStatus !== null) { + const finalEnd = Math.min(dayEndSec, nowSec); + const isDown = + windowStatus === "offline" || windowStatus === "unhealthy"; + const isDegraded = windowStatus === "degraded"; + if (isDown && finalEnd > windowStart) { + dayDowntime += finalEnd - windowStart; + windows.push({ + start: windowStart, + end: finalEnd, + status: windowStatus + }); + } else if (isDegraded && finalEnd > windowStart) { + dayDegradedTime += finalEnd - windowStart; + windows.push({ + start: windowStart, + end: finalEnd, + status: windowStatus + }); + } + } + + totalDowntime += dayDowntime; + + const effectiveDayLength = Math.max( + 0, + Math.min(dayEndSec, nowSec) - dayStartSec + ); + const uptimePct = + effectiveDayLength > 0 + ? Math.max( + 0, + ((effectiveDayLength - dayDowntime - dayDegradedTime) / + effectiveDayLength) * + 100 + ) + : 100; + + const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); + + const hasAnyData = currentStatus !== null || dayEvents.length > 0; + + // The whole observable window is "unknown" if every status we have seen is unknown + const allStatuses = [ + ...(currentStatus !== null ? [currentStatus] : []), + ...dayEvents.map((e) => e.status) + ]; + const onlyUnknownData = + hasAnyData && allStatuses.every((s) => s === "unknown"); + + let status: StatusHistoryDayBucket["status"] = "no_data"; + if (hasAnyData) { + if (onlyUnknownData) { + status = "unknown"; + } else if (dayDowntime > 0 && uptimePct < 50) { + status = "bad"; + } else if (dayDowntime > 0 || dayDegradedTime > 0) { + status = "degraded"; + } else { + status = "good"; + } + } + + buckets.push({ + date: dateStr, + uptimePercent: Math.round(uptimePct * 100) / 100, + totalDowntimeSeconds: dayDowntime, + downtimeWindows: windows, + status + }); + } + + return { buckets, totalDowntime }; +} diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index fda59f394..55c01c6b1 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -2,7 +2,7 @@ import { PostHog } from "posthog-node"; import config from "./config"; import { getHostMeta } from "./hostMeta"; import logger from "@server/logger"; -import { apiKeys, db, roles, siteResources } from "@server/db"; +import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db"; import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm"; import { APP_VERSION } from "./consts"; @@ -15,6 +15,7 @@ class TelemetryClient { private client: PostHog | null = null; private enabled: boolean; private intervalId: NodeJS.Timeout | null = null; + private collectionIntervalDays = 14; constructor() { const enabled = config.getRawConfig().app.telemetry.anonymous_usage; @@ -33,7 +34,7 @@ class TelemetryClient { this.client = new PostHog( "phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX", { - host: "https://pangolin.net/relay-O7yI" + host: "https://telemetry.fossorial.io/relay-O7yI" } ); @@ -72,7 +73,7 @@ class TelemetryClient { logger.debug("Successfully sent analytics data"); }); }, - 48 * 60 * 60 * 1000 + this.collectionIntervalDays * 24 * 60 * 60 * 1000 // Convert days to milliseconds ); this.collectAndSendAnalytics().catch((err) => { @@ -157,6 +158,14 @@ class TelemetryClient { }) .from(sites); + const [numAlertRules] = await db + .select({ count: count() }) + .from(alertRules); + + const [blueprintsCount] = await db + .select({ count: count() }) + .from(blueprints); + const supporterKey = config.getSupporterData(); const allPrivateResources = await db.select().from(siteResources); @@ -165,11 +174,14 @@ class TelemetryClient { let numPrivResourceAliases = 0; let numPrivResourceHosts = 0; let numPrivResourceCidr = 0; + let numPrivResourceHttp = 0; for (const res of allPrivateResources) { if (res.mode === "host") { numPrivResourceHosts += 1; } else if (res.mode === "cidr") { numPrivResourceCidr += 1; + } else if (res.mode === "http") { + numPrivResourceHttp += 1; } if (res.alias) { @@ -187,6 +199,9 @@ class TelemetryClient { numPrivateResources: numPrivResources, numPrivateResourceAliases: numPrivResourceAliases, numPrivateResourceHosts: numPrivResourceHosts, + numPrivateResourceCidr: numPrivResourceCidr, + numPrivateResourceHttp: numPrivResourceHttp, + numAlertRules: numAlertRules.count, numUserDevices: userDevicesCount.count, numMachineClients: machineClients.count, numIdentityProviders: idpCount.count, @@ -197,6 +212,7 @@ class TelemetryClient { appVersion: APP_VERSION, numApiKeys: numApiKeys.count, numCustomRoles: customRoles.count, + numBlueprints: blueprintsCount.count, supporterStatus: { valid: supporterKey?.valid || false, tier: supporterKey?.tier || "None", @@ -285,10 +301,12 @@ class TelemetryClient { num_private_resource_aliases: stats.numPrivateResourceAliases, num_private_resource_hosts: stats.numPrivateResourceHosts, + num_private_resource_cidr: stats.numPrivateResourceCidr, num_user_devices: stats.numUserDevices, num_machine_clients: stats.numMachineClients, num_identity_providers: stats.numIdentityProviders, num_sites_online: stats.numSitesOnline, + num_blueprint_runs: stats.numBlueprints, num_resources_sso_enabled: stats.resources.filter( (r) => r.sso ).length, diff --git a/server/lib/tokenCache.ts b/server/lib/tokenCache.ts index 022f46c15..74d29c1af 100644 --- a/server/lib/tokenCache.ts +++ b/server/lib/tokenCache.ts @@ -1,3 +1,5 @@ +// tokenCache + /** * Returns a cached plaintext token from Redis if one exists and decrypts * cleanly, otherwise calls `createSession` to mint a fresh token, stores the diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 4aed80e45..64a263097 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -19,6 +19,7 @@ export class TraefikConfigManager { private timeoutId: NodeJS.Timeout | null = null; private lastCertificateFetch: Date | null = null; private lastKnownDomains = new Set(); + private pendingDeletion = new Map(); // domain -> cycles remaining before delete private lastLocalCertificateState = new Map< string, { @@ -415,7 +416,8 @@ export class TraefikConfigManager { // Get valid certificates for domains not covered by wildcards validCertificates = await getValidCertificatesForDomains( - domainsToFetch + domainsToFetch, + true ); this.lastCertificateFetch = new Date(); this.lastKnownDomains = new Set(domains); @@ -533,6 +535,24 @@ export class TraefikConfigManager { if (match && match[1]) { domains.add(match[1]); } + // Match HostRegexp(`^[^.]+\.parent.domain$`) generated for wildcard resources + const hostRegexpMatch = router.rule.match( + /HostRegexp\(`([^`]+)`\)/ + ); + if (hostRegexpMatch && hostRegexpMatch[1]) { + const innerRegex = hostRegexpMatch[1]; + // Pattern is always ^[^.]+\.PARENT_DOMAIN$ where dots are escaped as \. + const domainMatch = innerRegex.match( + /^\^\[\^\.\]\+\\\.(.+)\$$/ + ); + if (domainMatch && domainMatch[1]) { + const parentDomain = domainMatch[1].replace( + /\\\./g, + "." + ); + domains.add(`*.${parentDomain}`); + } + } } } } @@ -1004,33 +1024,62 @@ export class TraefikConfigManager { const dirName = dirent.name; // Only delete if NO current domain is exactly the same or ends with `.${dirName}` - const shouldDelete = !Array.from(currentActiveDomains).some( + const isUnused = !Array.from(currentActiveDomains).some( (domain) => domain === dirName || domain.endsWith(`.${dirName}`) ); - if (shouldDelete) { - const domainDir = path.join(certsPath, dirName); - logger.info( - `Cleaning up unused certificate directory: ${dirName}` - ); - fs.rmSync(domainDir, { recursive: true, force: true }); - - // Remove from local state tracking - this.lastLocalCertificateState.delete(dirName); - - // Remove from dynamic config - const certFilePath = path.join(domainDir, "cert.pem"); - const keyFilePath = path.join(domainDir, "key.pem"); - const before = dynamicConfig.tls.certificates.length; - dynamicConfig.tls.certificates = - dynamicConfig.tls.certificates.filter( - (entry: any) => - entry.certFile !== certFilePath && - entry.keyFile !== keyFilePath + if (!isUnused) { + // Domain is still active - remove from pending deletion if it was queued + if (this.pendingDeletion.has(dirName)) { + logger.info( + `Certificate ${dirName} is active again, cancelling pending deletion` ); - if (dynamicConfig.tls.certificates.length !== before) { - configChanged = true; + this.pendingDeletion.delete(dirName); + } + continue; + } + + // Domain is unused - add to pending deletion or decrement its counter + if (!this.pendingDeletion.has(dirName)) { + const graceCycles = 3; + logger.info( + `Certificate ${dirName} is no longer in use. Will delete after ${graceCycles} more cycles.` + ); + this.pendingDeletion.set(dirName, graceCycles); + } else { + const remaining = this.pendingDeletion.get(dirName)! - 1; + if (remaining > 0) { + logger.info( + `Certificate ${dirName} pending deletion: ${remaining} cycle(s) remaining` + ); + this.pendingDeletion.set(dirName, remaining); + } else { + // Grace period expired - actually delete now + this.pendingDeletion.delete(dirName); + + const domainDir = path.join(certsPath, dirName); + logger.info( + `Cleaning up unused certificate directory: ${dirName}` + ); + fs.rmSync(domainDir, { recursive: true, force: true }); + + // Remove from local state tracking + this.lastLocalCertificateState.delete(dirName); + + // Remove from dynamic config + const certFilePath = path.join(domainDir, "cert.pem"); + const keyFilePath = path.join(domainDir, "key.pem"); + const before = dynamicConfig.tls.certificates.length; + dynamicConfig.tls.certificates = + dynamicConfig.tls.certificates.filter( + (entry: any) => + entry.certFile !== certFilePath && + entry.keyFile !== keyFilePath + ); + if (dynamicConfig.tls.certificates.length !== before) { + configChanged = true; + } } } } diff --git a/server/lib/traefik/pathEncoding.test.ts b/server/lib/traefik/pathEncoding.test.ts index 83d53a039..f0318807a 100644 --- a/server/lib/traefik/pathEncoding.test.ts +++ b/server/lib/traefik/pathEncoding.test.ts @@ -24,7 +24,7 @@ function encodePath(path: string | null | undefined): string { /** * Exact replica of the OLD key computation from upstream main. - * Uses sanitize() for paths — this is what had the collision bug. + * Uses sanitize() for paths - this is what had the collision bug. */ function oldKeyComputation( resourceId: number, @@ -44,7 +44,7 @@ function oldKeyComputation( /** * Replica of the NEW key computation from our fix. - * Uses encodePath() for paths — collision-free. + * Uses encodePath() for paths - collision-free. */ function newKeyComputation( resourceId: number, @@ -195,11 +195,11 @@ function runTests() { true, "/a/b and /a-b MUST have different keys" ); - console.log(" PASS: collision fix — /a/b vs /a-b have different keys"); + console.log(" PASS: collision fix - /a/b vs /a-b have different keys"); passed++; } - // Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key + // Test 9: demonstrate the old bug - old code maps /a/b and /a-b to same key { const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null); const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null); @@ -208,11 +208,11 @@ function runTests() { oldKeyDash, "old code MUST have this collision (confirms the bug exists)" ); - console.log(" PASS: confirmed old code bug — /a/b and /a-b collided"); + console.log(" PASS: confirmed old code bug - /a/b and /a-b collided"); passed++; } - // Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it + // Test 10: /api/v1 and /api-v1 - old code collision, new code fixes it { const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null); const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null); @@ -229,11 +229,11 @@ function runTests() { true, "new code must separate /api/v1 and /api-v1" ); - console.log(" PASS: collision fix — /api/v1 vs /api-v1"); + console.log(" PASS: collision fix - /api/v1 vs /api-v1"); passed++; } - // Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed + // Test 11: /app.v2 and /app/v2 and /app-v2 - three-way collision fixed { const a = newKeyComputation(1, "/app.v2", "prefix", null, null); const b = newKeyComputation(1, "/app/v2", "prefix", null, null); @@ -245,14 +245,14 @@ function runTests() { "three paths must produce three unique keys" ); console.log( - " PASS: collision fix — three-way /app.v2, /app/v2, /app-v2" + " PASS: collision fix - three-way /app.v2, /app/v2, /app-v2" ); passed++; } // ── Edge cases ─────────────────────────────────────────────────── - // Test 12: same path in different resources — always separate + // Test 12: same path in different resources - always separate { const key1 = newKeyComputation(1, "/api", "prefix", null, null); const key2 = newKeyComputation(2, "/api", "prefix", null, null); @@ -261,11 +261,11 @@ function runTests() { true, "different resources with same path must have different keys" ); - console.log(" PASS: edge case — same path, different resources"); + console.log(" PASS: edge case - same path, different resources"); passed++; } - // Test 13: same resource, different pathMatchType — separate keys + // Test 13: same resource, different pathMatchType - separate keys { const exact = newKeyComputation(1, "/api", "exact", null, null); const prefix = newKeyComputation(1, "/api", "prefix", null, null); @@ -274,11 +274,11 @@ function runTests() { true, "exact vs prefix must have different keys" ); - console.log(" PASS: edge case — same path, different match types"); + console.log(" PASS: edge case - same path, different match types"); passed++; } - // Test 14: same resource and path, different rewrite config — separate keys + // Test 14: same resource and path, different rewrite config - separate keys { const noRewrite = newKeyComputation(1, "/api", "prefix", null, null); const withRewrite = newKeyComputation( @@ -293,7 +293,7 @@ function runTests() { true, "with vs without rewrite must have different keys" ); - console.log(" PASS: edge case — same path, different rewrite config"); + console.log(" PASS: edge case - same path, different rewrite config"); passed++; } @@ -308,7 +308,7 @@ function runTests() { paths.length, "special URL chars must produce unique keys" ); - console.log(" PASS: edge case — special URL characters in paths"); + console.log(" PASS: edge case - special URL characters in paths"); passed++; } diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index c9ecf42e0..d37f6725d 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -15,7 +15,7 @@ export async function verifyDomainAccess( try { const userId = req.user!.userId; const domainId = - req.params.domainId || req.body.apiKeyId || req.query.apiKeyId; + req.params.domainId; const orgId = req.params.orgId; if (!userId) { diff --git a/server/nextServer.ts b/server/nextServer.ts index b862a699c..18b14a397 100644 --- a/server/nextServer.ts +++ b/server/nextServer.ts @@ -11,7 +11,7 @@ export async function createNextServer() { // const app = next({ dev }); const app = next({ dev: process.env.ENVIRONMENT !== "prod", - turbopack: true + turbopack: false }); const handle = app.getRequestHandler(); @@ -29,7 +29,7 @@ export async function createNextServer() { nextServer.listen(nextPort, (err?: any) => { if (err) throw err; logger.info( - `Next.js server is running on http://localhost:${nextPort}` + `Dashboard Web UI server is running on http://localhost:${nextPort}` ); }); diff --git a/server/private/auth/sessions/remoteExitNode.ts b/server/private/auth/sessions/remoteExitNode.ts index da1fb1aa5..5a5fc7c66 100644 --- a/server/private/auth/sessions/remoteExitNode.ts +++ b/server/private/auth/sessions/remoteExitNode.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/cleanup.ts b/server/private/cleanup.ts index af4238721..6c934cb0b 100644 --- a/server/private/cleanup.ts +++ b/server/private/cleanup.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts new file mode 100644 index 000000000..adf87eed8 --- /dev/null +++ b/server/private/lib/acmeCertSync.ts @@ -0,0 +1,839 @@ +/* + * 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 fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import { + certificates, + clients, + clientSiteResourcesAssociationsCache, + db, + domains, + newts, + siteNetworks, + SiteResource, + siteResources +} from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { encrypt, decrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; +import privateConfig from "#private/lib/config"; +import config from "@server/lib/config"; +import { + generateSubnetProxyTargetV2, + SubnetProxyTargetV2 +} from "@server/lib/ip"; +import { updateTargets } from "@server/routers/client/targets"; +import cache from "#private/lib/cache"; +import { build } from "@server/build"; + +interface AcmeCert { + domain: { main: string; sans?: string[] }; + certificate: string; + key: string; + Store: string; +} + +interface AcmeJson { + [resolver: string]: { + Certificates: AcmeCert[]; + }; +} + +export async function pushCertUpdateToAffectedNewts( + domain: string, + domainId: string | null, + oldCertPem: string | null, + oldKeyPem: string | null +): Promise { + // Find all SSL-enabled HTTP site resources that use this cert's domain + let affectedResources: SiteResource[] = []; + + if (domainId) { + affectedResources = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.domainId, domainId), + eq(siteResources.ssl, true) + ) + ); + } else { + // Fallback: match by exact fullDomain when no domainId is available + affectedResources = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.fullDomain, domain), + eq(siteResources.ssl, true) + ) + ); + } + + if (affectedResources.length === 0) { + logger.debug( + `acmeCertSync: no affected site resources for cert domain "${domain}"` + ); + return; + } + + logger.debug( + `acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"` + ); + + for (const resource of affectedResources) { + try { + // Get all sites for this resource via siteNetworks + const resourceSiteRows = resource.networkId + ? await db + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, resource.networkId)) + : []; + + if (resourceSiteRows.length === 0) { + logger.debug( + `acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Get all clients with access to this resource + const resourceClients = await db + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clients.clientId, + clientSiteResourcesAssociationsCache.clientId + ) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + resource.siteResourceId + ) + ); + + if (resourceClients.length === 0) { + logger.debug( + `acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data + if (resource.fullDomain) { + await cache.del(`cert:${resource.fullDomain}`); + } + + // Generate target once - same cert applies to all sites for this resource + const newTargets = await generateSubnetProxyTargetV2( + resource, + resourceClients + ); + + if (!newTargets) { + logger.debug( + `acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping` + ); + continue; + } + + // Construct the old targets - same routing shape but with the previous cert/key. + // The newt only uses destPrefix/sourcePrefixes for removal, but we keep the + // semantics correct so the update message accurately reflects what changed. + const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({ + ...t, + tlsCert: oldCertPem ?? undefined, + tlsKey: oldKeyPem ?? undefined + })); + + // Push update to each site's newt + for (const { siteId } of resourceSiteRows) { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + if (!newt) { + logger.debug( + `acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}` + ); + continue; + } + + await updateTargets( + newt.newtId, + { oldTargets: oldTargets, newTargets: newTargets }, + newt.version + ); + + logger.debug( + `acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}` + ); + } + } catch (err) { + logger.error( + `acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}` + ); + } + } +} + +async function findDomainId(certDomain: string): Promise { + // Strip wildcard prefix before lookup (*.example.com -> example.com) + const lookupDomain = certDomain.startsWith("*.") + ? certDomain.slice(2) + : certDomain; + + // 1. Exact baseDomain match (any domain type) + const exactMatch = await db + .select({ domainId: domains.domainId }) + .from(domains) + .where(eq(domains.baseDomain, lookupDomain)) + .limit(1); + + if (exactMatch.length > 0) { + return exactMatch[0].domainId; + } + + // 2. Walk up the domain hierarchy looking for a wildcard-type domain whose + // baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com" + // matches a wildcard domain with baseDomain "example.com". + const parts = lookupDomain.split("."); + for (let i = 1; i < parts.length; i++) { + const candidate = parts.slice(i).join("."); + if (!candidate) continue; + + const wildcardMatch = await db + .select({ domainId: domains.domainId }) + .from(domains) + .where( + and( + eq(domains.baseDomain, candidate), + eq(domains.type, "wildcard") + ) + ) + .limit(1); + + if (wildcardMatch.length > 0) { + return wildcardMatch[0].domainId; + } + } + + return null; +} + +function extractFirstCert(pemBundle: string): string | null { + const match = pemBundle.match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/ + ); + return match ? match[0] : null; +} + +/** + * Determine whether an ACME cert entry represents a wildcard cert by checking + * both the primary domain (`main`) and the SANs. Some ACME clients (notably + * Traefik) store the bare apex in `main` and only put the wildcard form in + * `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]). + */ +function detectWildcard( + main: string, + sans: string[] | undefined +): { wildcard: boolean; wildcardSan: string | null } { + if (main.startsWith("*.")) { + return { wildcard: true, wildcardSan: null }; + } + if (Array.isArray(sans)) { + for (const san of sans) { + if (typeof san !== "string") continue; + if (san === `*.${main}` || san.startsWith("*.")) { + return { wildcard: true, wildcardSan: san }; + } + } + } + return { wildcard: false, wildcardSan: null }; +} + +interface HttpCert { + wildcard: boolean; + altName: string; + certName: string; + commonName: string; + certFile: string; + keyFile: string; +} + +async function syncAcmeCertsFromHttp(endpoint: string): Promise { + let response: Response; + try { + response = await fetch(endpoint); + } catch (err) { + logger.debug( + `acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}` + ); + return; + } + + if (!response.ok) { + logger.debug( + `acmeCertSync: HTTP endpoint returned status ${response.status}` + ); + return; + } + + let httpCerts: HttpCert[]; + try { + httpCerts = await response.json(); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse JSON from HTTP endpoint: ${err}` + ); + return; + } + + if (!Array.isArray(httpCerts) || httpCerts.length === 0) { + logger.debug( + `acmeCertSync: no certificates returned from HTTP endpoint` + ); + return; + } + + for (const cert of httpCerts) { + const domain = cert?.certName; + + if (!domain || typeof domain !== "string") { + logger.debug( + `acmeCertSync: skipping HTTP cert with missing certName` + ); + continue; + } + + const certPem = cert.certFile; + const keyPem = cert.keyFile; + + if (!certPem?.trim() || !keyPem?.trim()) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile` + ); + continue; + } + + const firstCertPemForValidation = extractFirstCert(certPem); + if (!firstCertPemForValidation) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found` + ); + continue; + } + + let validatedX509: crypto.X509Certificate; + try { + validatedX509 = new crypto.X509Certificate( + firstCertPemForValidation + ); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}` + ); + continue; + } + + try { + crypto.createPrivateKey(keyPem); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}` + ); + continue; + } + + const wildcard = cert.wildcard ?? false; + + const existing = await db + .select() + .from(certificates) + .where(eq(certificates.domain, domain)) + .limit(1); + + let oldCertPem: string | null = null; + let oldKeyPem: string | null = null; + + if (existing.length > 0 && existing[0].certFile) { + try { + const storedCertPem = decrypt( + existing[0].certFile, + config.getRawConfig().server.secret! + ); + const wildcardUnchanged = existing[0].wildcard === wildcard; + if (storedCertPem === certPem && wildcardUnchanged) { + continue; + } + oldCertPem = storedCertPem; + if (existing[0].keyFile) { + try { + oldKeyPem = decrypt( + existing[0].keyFile, + config.getRawConfig().server.secret! + ); + } catch (keyErr) { + logger.debug( + `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` + ); + } + } + } catch (err) { + logger.debug( + `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` + ); + } + } + + let expiresAt: number | null = null; + try { + expiresAt = Math.floor( + new Date(validatedX509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); + } + + const encryptedCert = encrypt( + certPem, + config.getRawConfig().server.secret! + ); + const encryptedKey = encrypt( + keyPem, + config.getRawConfig().server.secret! + ); + const now = Math.floor(Date.now() / 1000); + + const domainId = await findDomainId(domain); + if (domainId) { + logger.debug( + `acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"` + ); + } else { + logger.debug( + `acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"` + ); + } + + if (existing.length > 0) { + logger.debug( + `acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db + .update(certificates) + .set({ + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + updatedAt: now, + wildcard, + ...(domainId !== null && { domainId }) + }) + .where(eq(certificates.domain, domain)); + + await pushCertUpdateToAffectedNewts( + domain, + domainId, + oldCertPem, + oldKeyPem + ); + } else { + logger.debug( + `acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db.insert(certificates).values({ + domain, + domainId, + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + createdAt: now, + updatedAt: now, + wildcard + }); + + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); + } + } +} + +function findAcmeJsonFiles(dirPath: string): string[] { + const results: string[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch (err) { + logger.warn( + `acmeCertSync: could not read directory "${dirPath}": ${err}` + ); + return results; + } + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + results.push(...findAcmeJsonFiles(fullPath)); + } else if (entry.isFile() && entry.name === "acme.json") { + results.push(fullPath); + } + } + return results; +} + +async function syncAcmeCerts(acmeJsonPath: string): Promise { + let raw: string; + try { + raw = fs.readFileSync(acmeJsonPath, "utf8"); + } catch (err) { + logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`); + return; + } + + let acmeJson: AcmeJson; + try { + acmeJson = JSON.parse(raw); + } catch (err) { + logger.warn( + `acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}` + ); + return; + } + + const resolvers = Object.keys(acmeJson || {}); + if (resolvers.length === 0) { + logger.debug(`acmeCertSync: no resolvers found in acme.json`); + return; + } + + // Collect certificates from every resolver. If the same domain appears in + // multiple resolvers, the last one wins (resolvers iterated in object order). + const allCerts: AcmeCert[] = []; + for (const resolver of resolvers) { + const resolverData = acmeJson[resolver]; + if (!resolverData || !Array.isArray(resolverData.Certificates)) { + logger.debug( + `acmeCertSync: no certificates found for resolver "${resolver}"` + ); + continue; + } + logger.debug( + `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` + ); + for (const cert of resolverData.Certificates) { + allCerts.push(cert); + } + } + + for (const cert of allCerts) { + const domain = cert?.domain?.main; + + if (!domain || typeof domain !== "string") { + logger.debug(`acmeCertSync: skipping cert with missing domain`); + continue; + } + + const { wildcard } = detectWildcard(domain, cert.domain?.sans); + + if (!cert.certificate || !cert.key) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - empty certificate or key field` + ); + continue; + } + + let certPem: string; + let keyPem: string; + try { + certPem = Buffer.from(cert.certificate, "base64").toString("utf8"); + keyPem = Buffer.from(cert.key, "base64").toString("utf8"); + } catch (err) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}` + ); + continue; + } + + if (!certPem.trim() || !keyPem.trim()) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode` + ); + continue; + } + + // Validate that the decoded data actually parses as a real X.509 cert + // before we touch the database. This prevents importing partially-written + // or corrupted entries from acme.json. + const firstCertPemForValidation = extractFirstCert(certPem); + if (!firstCertPemForValidation) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - no PEM certificate block found` + ); + continue; + } + + let validatedX509: crypto.X509Certificate; + try { + validatedX509 = new crypto.X509Certificate( + firstCertPemForValidation + ); + } catch (err) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}` + ); + continue; + } + + // Sanity-check the private key parses too + try { + crypto.createPrivateKey(keyPem); + } catch (err) { + logger.debug( + `acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}` + ); + continue; + } + + // Check if cert already exists in DB + const existing = await db + .select() + .from(certificates) + .where(and(eq(certificates.domain, domain))) + .limit(1); + + let oldCertPem: string | null = null; + let oldKeyPem: string | null = null; + + if (existing.length > 0 && existing[0].certFile) { + try { + const storedCertPem = decrypt( + existing[0].certFile, + config.getRawConfig().server.secret! + ); + const wildcardUnchanged = existing[0].wildcard === wildcard; + if (storedCertPem === certPem && wildcardUnchanged) { + // logger.debug( + // `acmeCertSync: cert for ${domain} is unchanged, skipping` + // ); + continue; + } + // Cert has changed; capture old values so we can send a correct + // update message to the newt after the DB write. + oldCertPem = storedCertPem; + if (existing[0].keyFile) { + try { + oldKeyPem = decrypt( + existing[0].keyFile, + config.getRawConfig().server.secret! + ); + } catch (keyErr) { + logger.debug( + `acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}` + ); + } + } + } catch (err) { + // Decryption failure means we should proceed with the update + logger.debug( + `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` + ); + } + } + + // Parse cert expiry from the validated X.509 certificate + let expiresAt: number | null = null; + try { + expiresAt = Math.floor( + new Date(validatedX509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); + } + + const encryptedCert = encrypt( + certPem, + config.getRawConfig().server.secret! + ); + const encryptedKey = encrypt( + keyPem, + config.getRawConfig().server.secret! + ); + const now = Math.floor(Date.now() / 1000); + + const domainId = await findDomainId(domain); + if (domainId) { + logger.debug( + `acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"` + ); + } else { + logger.debug( + `acmeCertSync: no matching domain record found for cert domain "${domain}"` + ); + } + + if (existing.length > 0) { + logger.debug( + `acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db + .update(certificates) + .set({ + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + updatedAt: now, + wildcard, + ...(domainId !== null && { domainId }) + }) + .where(eq(certificates.domain, domain)); + + logger.debug( + `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + + await pushCertUpdateToAffectedNewts( + domain, + domainId, + oldCertPem, + oldKeyPem + ); + } else { + logger.debug( + `acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db.insert(certificates).values({ + domain, + domainId, + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + createdAt: now, + updatedAt: now, + wildcard + }); + + logger.debug( + `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + + // For a brand-new cert, push to any SSL resources that were waiting for it + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); + } + } +} + +export function initAcmeCertSync(): void { + if (build == "saas") { + logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`); + return; + } + + const privateConfigData = privateConfig.getRawPrivateConfig(); + + if (!privateConfigData.flags?.enable_acme_cert_sync) { + logger.debug( + `acmeCertSync: ACME cert sync is disabled by config flag, skipping` + ); + return; + } + + if (privateConfigData.flags.use_pangolin_dns) { + logger.debug( + `acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be disabled, skipping` + ); + return; + } + + const acmeJsonPath = + privateConfigData.acme?.acme_json_path ?? + "config/letsencrypt/acme.json"; + const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; + const httpEndpoint = privateConfigData.acme?.acme_http_endpoint; + + logger.debug( + `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms` + ); + if (httpEndpoint) { + logger.debug( + `acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms` + ); + } + + const runSync = () => { + if (httpEndpoint) { + syncAcmeCertsFromHttp(httpEndpoint).catch((err) => { + logger.error(`acmeCertSync: error during HTTP sync: ${err}`); + }); + } else { + // only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up + let stat: fs.Stats | null = null; + try { + stat = fs.statSync(acmeJsonPath); + } catch (err) { + logger.warn( + `acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}` + ); + return; + } + + if (stat.isDirectory()) { + const files = findAcmeJsonFiles(acmeJsonPath); + if (files.length === 0) { + logger.debug( + `acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"` + ); + return; + } + logger.debug( + `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"` + ); + for (const file of files) { + syncAcmeCerts(file).catch((err) => { + logger.error( + `acmeCertSync: error during sync of "${file}": ${err}` + ); + }); + } + } else { + syncAcmeCerts(acmeJsonPath).catch((err) => { + logger.error(`acmeCertSync: error during sync: ${err}`); + }); + } + } + }; + + // Run immediately on init, then on the configured interval + runSync(); + + setInterval(runSync, intervalMs); +} diff --git a/server/private/lib/alerts/index.ts b/server/private/lib/alerts/index.ts new file mode 100644 index 000000000..04b4763d0 --- /dev/null +++ b/server/private/lib/alerts/index.ts @@ -0,0 +1,16 @@ +/* + * 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 "./processAlerts"; +export * from "./sendAlertWebhook"; +export * from "./sendAlertEmail"; diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts new file mode 100644 index 000000000..a08a55494 --- /dev/null +++ b/server/private/lib/alerts/processAlerts.ts @@ -0,0 +1,333 @@ +/* + * 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 { and, eq, or } from "drizzle-orm"; +import { db } from "@server/db"; +import { + alertRules, + alertSites, + alertHealthChecks, + alertResources, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions, + userOrgRoles, + users +} from "@server/db"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; +import { sendAlertWebhook } from "./sendAlertWebhook"; +import { sendAlertEmail } from "./sendAlertEmail"; +import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types"; + +/** + * Core alert processing pipeline. + * + * Given an `AlertContext`, this function: + * 1. Finds all enabled `alertRules` whose `eventType` matches and whose + * `siteId` / `healthCheckId` is listed in the `alertSites` / + * `alertHealthChecks` junction tables (or has no junction entries, + * meaning "match all"). + * 2. Applies per-rule cooldown gating. + * 3. Dispatches emails and webhook POSTs for every attached action. + * 4. Updates `lastTriggeredAt` and `lastSentAt` timestamps. + */ +export async function processAlerts(context: AlertContext): Promise { + const now = Date.now(); + + // ------------------------------------------------------------------ + // 1. Find matching alert rules + // ------------------------------------------------------------------ + // Rules with allSites / allHealthChecks / allResources set to true match + // ANY event of that type. Rules without these flags set match only the + // specific IDs listed in the junction tables. + const baseConditions = and( + eq(alertRules.orgId, context.orgId), + eq(alertRules.eventType, context.eventType), + eq(alertRules.enabled, true) + ); + + let rules: (typeof alertRules.$inferSelect)[]; + + if (context.siteId != null) { + const rows = await db + .select() + .from(alertRules) + .leftJoin( + alertSites, + eq(alertSites.alertRuleId, alertRules.alertRuleId) + ) + .where( + and( + baseConditions, + or( + eq(alertRules.allSites, true), + eq(alertSites.siteId, context.siteId) + ) + ) + ); + // Deduplicate in case a rule matched on multiple junction rows + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); + } else if (context.healthCheckId != null) { + const rows = await db + .select() + .from(alertRules) + .leftJoin( + alertHealthChecks, + eq(alertHealthChecks.alertRuleId, alertRules.alertRuleId) + ) + .where( + and( + baseConditions, + or( + eq(alertRules.allHealthChecks, true), + eq(alertHealthChecks.healthCheckId, context.healthCheckId) + ) + ) + ); + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); + } else if (context.resourceId != null) { + const rows = await db + .select() + .from(alertRules) + .leftJoin( + alertResources, + eq(alertResources.alertRuleId, alertRules.alertRuleId) + ) + .where( + and( + baseConditions, + or( + eq(alertRules.allResources, true), + eq(alertResources.resourceId, context.resourceId) + ) + ) + ); + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); + } else { + rules = []; + } + + if (rules.length === 0) { + logger.debug( + `processAlerts: no matching rules for event "${context.eventType}" in org "${context.orgId}"` + ); + return; + } + + for (const rule of rules) { + try { + await processRule(rule, context, now); + } catch (err) { + logger.error( + `processAlerts: error processing rule ${rule.alertRuleId} for event "${context.eventType}"`, + err + ); + } + } +} + +// --------------------------------------------------------------------------- +// Per-rule processing +// --------------------------------------------------------------------------- + +async function processRule( + rule: typeof alertRules.$inferSelect, + context: AlertContext, + now: number +): Promise { + // ------------------------------------------------------------------ + // 2. Cooldown check + // ------------------------------------------------------------------ + if ( + rule.lastTriggeredAt != null && + now - rule.lastTriggeredAt < rule.cooldownSeconds * 1000 + ) { + const remainingSeconds = Math.ceil( + (rule.cooldownSeconds * 1000 - (now - rule.lastTriggeredAt)) / 1000 + ); + logger.debug( + `processAlerts: rule ${rule.alertRuleId} is in cooldown – ${remainingSeconds}s remaining` + ); + return; + } + + // ------------------------------------------------------------------ + // 3. Mark rule as triggered (optimistic update – before sending so we + // don't re-trigger if the send is slow) + // ------------------------------------------------------------------ + await db + .update(alertRules) + .set({ lastTriggeredAt: now }) + .where(eq(alertRules.alertRuleId, rule.alertRuleId)); + + // ------------------------------------------------------------------ + // 4. Process email actions + // ------------------------------------------------------------------ + const emailActions = await db + .select() + .from(alertEmailActions) + .where( + and( + eq(alertEmailActions.alertRuleId, rule.alertRuleId), + eq(alertEmailActions.enabled, true) + ) + ); + + for (const action of emailActions) { + try { + const recipients = await resolveEmailRecipients(action.emailActionId); + if (recipients.length > 0) { + await sendAlertEmail(recipients, context); + await db + .update(alertEmailActions) + .set({ lastSentAt: now }) + .where( + eq(alertEmailActions.emailActionId, action.emailActionId) + ); + } + } catch (err) { + logger.error( + `processAlerts: failed to send alert email for action ${action.emailActionId}`, + err + ); + } + } + + // ------------------------------------------------------------------ + // 5. Process webhook actions + // ------------------------------------------------------------------ + const webhookActions = await db + .select() + .from(alertWebhookActions) + .where( + and( + eq(alertWebhookActions.alertRuleId, rule.alertRuleId), + eq(alertWebhookActions.enabled, true) + ) + ); + + const serverSecret = config.getRawConfig().server.secret!; + + for (const action of webhookActions) { + try { + let webhookConfig: WebhookAlertConfig = { authType: "none" }; + + if (action.config) { + try { + const decrypted = decrypt(action.config, serverSecret); + webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig; + } catch (err) { + logger.error( + `processAlerts: failed to decrypt webhook config for action ${action.webhookActionId}`, + err + ); + continue; + } + } + + await sendAlertWebhook(action.webhookUrl, webhookConfig, context); + await db + .update(alertWebhookActions) + .set({ lastSentAt: now }) + .where( + eq( + alertWebhookActions.webhookActionId, + action.webhookActionId + ) + ); + } catch (err) { + logger.error( + `processAlerts: failed to send alert webhook for action ${action.webhookActionId}`, + err + ); + } + } +} + +// --------------------------------------------------------------------------- +// Email recipient resolution +// --------------------------------------------------------------------------- + +/** + * Resolves all email addresses for a given `emailActionId`. + * + * Recipients may be: + * - Direct users (by `userId`) + * - All users in a role (by `roleId`, resolved via `userOrgRoles`) + * - Direct external email addresses + */ +async function resolveEmailRecipients(emailActionId: number): Promise { + const rows = await db + .select() + .from(alertEmailRecipients) + .where(eq(alertEmailRecipients.emailActionId, emailActionId)); + + const emailSet = new Set(); + + for (const row of rows) { + if (row.email) { + emailSet.add(row.email); + } + + if (row.userId) { + const [user] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.userId, row.userId)) + .limit(1); + if (user?.email) { + emailSet.add(user.email); + } + } + + if (row.roleId) { + // Find all users with this role via userOrgRoles + const roleUsers = await db + .select({ email: users.email }) + .from(userOrgRoles) + .innerJoin(users, eq(userOrgRoles.userId, users.userId)) + .where(eq(userOrgRoles.roleId, Number(row.roleId))); + + for (const u of roleUsers) { + if (u.email) { + emailSet.add(u.email); + } + } + } + } + + return Array.from(emailSet); +} diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts new file mode 100644 index 000000000..6f99b102c --- /dev/null +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -0,0 +1,103 @@ +/* + * 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 { sendEmail } from "@server/emails"; +import AlertNotification from "@server/emails/templates/AlertNotification"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { AlertContext } from "@server/routers/alertRule/types"; + +/** + * Sends an alert notification email to every address in `recipients`. + * + * Each recipient receives an individual email (no BCC list) so that delivery + * failures for one address do not affect the others. Failures per recipient + * are logged and swallowed – the caller only sees an error if something goes + * wrong before the send loop. + */ +export async function sendAlertEmail( + recipients: string[], + context: AlertContext +): Promise { + if (recipients.length === 0) { + return; + } + + const from = config.getNoReplyEmail(); + const subject = buildSubject(context); + + const baseUrl = config.getRawConfig().app.dashboard_url!.replace(/\/$/, ""); + const dashboardLink = `${baseUrl}/${context.orgId}/settings`; + + for (const to of recipients) { + try { + await sendEmail( + AlertNotification({ + eventType: context.eventType, + orgId: context.orgId, + data: context.data, + dashboardLink + }), + { + from, + to, + subject + } + ); + logger.debug( + `Alert email sent to "${to}" for event "${context.eventType}"` + ); + } catch (err) { + logger.error( + `sendAlertEmail: failed to send alert email to "${to}" for event "${context.eventType}"`, + err + ); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildSubject(context: AlertContext): string { + switch (context.eventType) { + case "site_online": + return "[Alert] Site Back Online"; + case "site_offline": + return "[Alert] Site Offline"; + case "site_toggle": + return "[Alert] Site Status Changed"; + case "health_check_healthy": + return "[Alert] Health Check Recovered"; + case "health_check_unhealthy": + return "[Alert] Health Check Failing"; + case "health_check_toggle": + return "[Alert] Health Check Status Changed"; + case "resource_healthy": + return "[Alert] Resource Healthy"; + case "resource_unhealthy": + return "[Alert] Resource Unhealthy"; + case "resource_degraded": + return "[Alert] Resource Degraded"; + case "resource_toggle": + return "[Alert] Resource Status Changed"; + default: { + // Exhaustiveness fallback – should never be reached with a + // well-typed caller, but keeps runtime behaviour predictable. + const _exhaustive: never = context.eventType; + void _exhaustive; + return "[Alert] Event Notification"; + } + } +} diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts new file mode 100644 index 000000000..dd5088a6c --- /dev/null +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -0,0 +1,274 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import { + AlertContext, + WebhookAlertConfig +} from "@server/routers/alertRule/types"; + +const REQUEST_TIMEOUT_MS = 15_000; +const MAX_RETRIES = 3; +const RETRY_BASE_DELAY_MS = 500; + +/** + * Sends a single webhook POST for an alert event. + * + * The payload shape is: + * ```json + * { + * "event": "site_online", + * "timestamp": "2024-01-01T00:00:00.000Z", + * "data": { ... } + * } + * ``` + * + * Authentication headers are applied according to `config.authType`, + * mirroring the same strategies supported by HttpLogDestination: + * none | bearer | basic | custom. + */ +export async function sendAlertWebhook( + url: string, + webhookConfig: WebhookAlertConfig, + context: AlertContext +): Promise { + const eventType = context.eventType; + const timestamp = new Date().toISOString(); + const status = deriveStatus(eventType, context.data); + const data = { orgId: context.orgId, ...context.data }; + + let body: string; + if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) { + body = renderTemplate(webhookConfig.bodyTemplate, { + event: eventType, + timestamp, + status, + data + }); + } else { + body = JSON.stringify({ event: eventType, timestamp, status, data }); + } + + const headers = buildHeaders(webhookConfig); + + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS + ); + + let response: Response; + try { + response = await fetch(url, { + method: webhookConfig.method ?? "POST", + headers, + body, + signal: controller.signal + }); + } catch (err: unknown) { + clearTimeout(timeoutHandle); + const isAbort = err instanceof Error && err.name === "AbortError"; + if (isAbort) { + lastError = new Error( + `Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms` + ); + } else { + const msg = err instanceof Error ? err.message : String(err); + lastError = new Error( + `Alert webhook: request to "${url}" failed – ${msg}` + ); + } + if (attempt < MAX_RETRIES) { + const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); + logger.warn( + `Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed – retrying in ${delay} ms. ${lastError.message}` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + continue; + } finally { + clearTimeout(timeoutHandle); + } + + if (!response.ok) { + let snippet = ""; + try { + const text = await response.text(); + snippet = text.slice(0, 300); + } catch { + // best-effort + } + lastError = new Error( + `Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` + + (snippet ? ` – ${snippet}` : "") + ); + if (attempt < MAX_RETRIES) { + const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); + logger.warn( + `Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed – retrying in ${delay} ms. ${lastError.message}` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + continue; + } + + logger.debug( + `Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})` + ); + return; + } + + throw ( + lastError ?? + new Error( + `Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"` + ) + ); +} + +// --------------------------------------------------------------------------- +// Status derivation +// --------------------------------------------------------------------------- + +function deriveStatus( + eventType: AlertContext["eventType"], + data: Record +): string { + switch (eventType) { + case "site_online": + return "online"; + case "site_offline": + return "offline"; + case "site_toggle": + return String(data.status ?? "unknown"); + case "health_check_healthy": + case "resource_healthy": + return "healthy"; + case "health_check_unhealthy": + case "resource_unhealthy": + return "unhealthy"; + case "resource_degraded": + return "degraded"; + case "health_check_toggle": + case "resource_toggle": + return String(data.status ?? "unknown"); + default: { + const _exhaustive: never = eventType; + void _exhaustive; + return "unknown"; + } + } +} + +// --------------------------------------------------------------------------- +// Header construction (mirrors HttpLogDestination.buildHeaders) +// --------------------------------------------------------------------------- + +function buildHeaders( + webhookConfig: WebhookAlertConfig +): Record { + const headers: Record = { + "Content-Type": "application/json" + }; + + switch (webhookConfig.authType) { + case "bearer": { + const token = webhookConfig.bearerToken?.trim(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + break; + } + case "basic": { + const creds = webhookConfig.basicCredentials?.trim(); + if (creds) { + const encoded = Buffer.from(creds).toString("base64"); + headers["Authorization"] = `Basic ${encoded}`; + } + break; + } + case "custom": { + const name = webhookConfig.customHeaderName?.trim(); + const value = webhookConfig.customHeaderValue ?? ""; + if (name) { + headers[name] = value; + } + break; + } + case "none": + default: + break; + } + + if (webhookConfig.headers) { + for (const { key, value } of webhookConfig.headers) { + if (key.trim()) { + headers[key.trim()] = value; + } + } + } + + return headers; +} + +// --------------------------------------------------------------------------- +// Body template rendering +// --------------------------------------------------------------------------- + +interface TemplateContext { + event: string; + timestamp: string; + status: string; + data: Record; +} + +/** + * Render a body template with {{event}}, {{timestamp}}, {{status}}, and + * {{data}} placeholders, mirroring the logic in HttpLogDestination. + * + * {{data}} is replaced first (as raw JSON) so that any literal "{{…}}" + * strings inside data values are not re-expanded. + */ +function renderTemplate(template: string, ctx: TemplateContext): string { + const rendered = template + .replace(/\{\{data\}\}/g, JSON.stringify(ctx.data)) + .replace(/\{\{event\}\}/g, escapeJsonString(ctx.event)) + .replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp)) + .replace(/\{\{status\}\}/g, escapeJsonString(ctx.status)); + + // Validate the rendered result is valid JSON; if not, log a warning and + // fall back to the default payload so the webhook still fires. + try { + JSON.parse(rendered); + return rendered; + } catch { + logger.warn( + `sendAlertWebhook: body template produced invalid JSON for event ` + + `"${ctx.event}" destined for a webhook. Falling back to default ` + + `payload. Check that {{data}} is NOT wrapped in quotes in your template.` + ); + return JSON.stringify({ + event: ctx.event, + timestamp: ctx.timestamp, + status: ctx.status, + data: ctx.data + }); + } +} + +function escapeJsonString(value: string): string { + return JSON.stringify(value).slice(1, -1); +} diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts new file mode 100644 index 000000000..36a71026d --- /dev/null +++ b/server/private/lib/alerts/types.ts @@ -0,0 +1,67 @@ +/* + * 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. + */ + +// --------------------------------------------------------------------------- +// Alert event types +// --------------------------------------------------------------------------- + +export type AlertEventType = + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + +// --------------------------------------------------------------------------- +// Webhook authentication config (stored as encrypted JSON in the DB) +// --------------------------------------------------------------------------- + +export type WebhookAuthType = "none" | "bearer" | "basic" | "custom"; + +/** + * Stored as an encrypted JSON blob in `alertWebhookActions.config`. + */ +export interface WebhookAlertConfig { + /** Authentication strategy for the webhook endpoint */ + authType: WebhookAuthType; + /** Bearer token – used when authType === "bearer" */ + bearerToken?: string; + /** Basic credentials – "username:password" – used when authType === "basic" */ + basicCredentials?: string; + /** Custom header name – used when authType === "custom" */ + customHeaderName?: string; + /** Custom header value – used when authType === "custom" */ + customHeaderValue?: string; + /** Extra headers to send with every webhook request */ + headers?: Array<{ key: string; value: string }>; + /** HTTP method (default POST) */ + method?: string; + /** Whether to use a custom body template */ + useBodyTemplate?: boolean; + /** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */ + bodyTemplate?: string; +} + +// --------------------------------------------------------------------------- +// Internal alert event passed through the processing pipeline +// --------------------------------------------------------------------------- + +export interface AlertContext { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + siteId?: number; + /** Set for health_check_* events */ + healthCheckId?: number; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; +} diff --git a/server/private/lib/billing/createCustomer.ts b/server/private/lib/billing/createCustomer.ts index 52c72c53d..e1a543b3d 100644 --- a/server/private/lib/billing/createCustomer.ts +++ b/server/private/lib/billing/createCustomer.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index 9972dcfc5..9df9b3b74 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -19,12 +19,13 @@ import { eq, and, ne } from "drizzle-orm"; export async function getOrgTierData( orgId: string -): Promise<{ tier: Tier | null; active: boolean }> { +): Promise<{ tier: Tier | null; active: boolean; isTrial: boolean }> { let tier: Tier | null = null; let active = false; + let isTrial = false; if (build !== "saas") { - return { tier, active }; + return { tier, active, isTrial }; } try { @@ -35,7 +36,7 @@ export async function getOrgTierData( .limit(1); if (!org) { - return { tier, active }; + return { tier, active, isTrial }; } let orgIdToUse = org.orgId; @@ -44,7 +45,7 @@ export async function getOrgTierData( logger.warn( `Org ${orgId} is not a billing org and does not have a billingOrgId` ); - return { tier, active }; + return { tier, active, isTrial }; } orgIdToUse = org.billingOrgId; } @@ -57,7 +58,7 @@ export async function getOrgTierData( .limit(1); if (!customer) { - return { tier, active }; + return { tier, active, isTrial }; } // Query for active subscriptions that are not license type @@ -84,11 +85,13 @@ export async function getOrgTierData( tier = subscription.type; active = true; } + + isTrial = subscription.trial ?? false; } } catch (error) { // If org not found or error occurs, return null tier and inactive // This is acceptable behavior as per the function signature } - return { tier, active }; + return { tier, active, isTrial }; } diff --git a/server/private/lib/billing/index.ts b/server/private/lib/billing/index.ts index c2b77d5f6..4d52668c0 100644 --- a/server/private/lib/billing/index.ts +++ b/server/private/lib/billing/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/blueprints/MaintenanceSchema.ts b/server/private/lib/blueprints/MaintenanceSchema.ts index af6b525a3..90e919c83 100644 --- a/server/private/lib/blueprints/MaintenanceSchema.ts +++ b/server/private/lib/blueprints/MaintenanceSchema.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/cache.ts b/server/private/lib/cache.ts index 1a2006d46..2d49d2e40 100644 --- a/server/private/lib/cache.ts +++ b/server/private/lib/cache.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 1ec524bb0..8875addda 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -11,23 +11,14 @@ * This file is not licensed under the AGPLv3. */ -import config from "./config"; +import privateConfig from "./config"; +import config from "@server/lib/config"; import { certificates, db } from "@server/db"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; -import { decryptData } from "@server/lib/encryption"; +import { decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; import cache from "#private/lib/cache"; - -let encryptionKeyHex = ""; -let encryptionKey: Buffer; -function loadEncryptData() { - if (encryptionKey) { - return; // already loaded - } - - encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key; - encryptionKey = Buffer.from(encryptionKeyHex, "hex"); -} +import { build } from "@server/build"; // Define the return type for clarity and type safety export type CertificateResult = { @@ -45,7 +36,7 @@ export async function getValidCertificatesForDomains( domains: Set, useCache: boolean = true ): Promise> { - loadEncryptData(); // Ensure encryption key is loaded + const finalResults: CertificateResult[] = []; const domainsToQuery = new Set(); @@ -68,7 +59,7 @@ export async function getValidCertificatesForDomains( // 2. If all domains were resolved from the cache, return early if (domainsToQuery.size === 0) { - const decryptedResults = decryptFinalResults(finalResults); + const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!); return decryptedResults; } @@ -86,6 +77,9 @@ export async function getValidCertificatesForDomains( const parentDomainsArray = Array.from(parentDomainsToQuery); + // Build wildcard variants: for each parent domain "example.com", also query "*.example.com" + const wildcardPrefixedArray = build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : []; + // 4. Build and execute a single, efficient Drizzle query // This query fetches all potential exact and wildcard matches in one database round-trip. const potentialCerts = await db @@ -99,10 +93,13 @@ export async function getValidCertificatesForDomains( or( // Condition for exact matches on the requested domains inArray(certificates.domain, domainsToQueryArray), - // Condition for wildcard matches on the parent domains + // Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com") parentDomainsArray.length > 0 ? and( - inArray(certificates.domain, parentDomainsArray), + inArray(certificates.domain, [ + ...parentDomainsArray, + ...wildcardPrefixedArray + ]), eq(certificates.wildcard, true) ) : // If there are no possible parent domains, this condition is false @@ -111,13 +108,18 @@ export async function getValidCertificatesForDomains( ) ); + // Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.") + const normalizeWildcardDomain = (domain: string): string => + domain.startsWith("*.") ? domain.slice(2) : domain; + // 5. Process the database results, prioritizing exact matches over wildcards const exactMatches = new Map(); const wildcardMatches = new Map(); for (const cert of potentialCerts) { if (cert.wildcard) { - wildcardMatches.set(cert.domain, cert); + // Normalize to bare parent domain so lookups are consistent regardless of storage format + wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert); } else { exactMatches.set(cert.domain, cert); } @@ -130,14 +132,15 @@ export async function getValidCertificatesForDomains( if (exactMatches.has(domain)) { foundCert = exactMatches.get(domain); } - // Priority 2: Check for a wildcard certificate that matches the exact domain + // Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain else { - if (wildcardMatches.has(domain)) { - foundCert = wildcardMatches.get(domain); + const normalizedDomain = normalizeWildcardDomain(domain); + if (wildcardMatches.has(normalizedDomain)) { + foundCert = wildcardMatches.get(normalizedDomain); } // Priority 3: Check for a wildcard match on the parent domain else { - const parts = domain.split("."); + const parts = normalizedDomain.split("."); if (parts.length > 1) { const parentDomain = parts.slice(1).join("."); if (wildcardMatches.has(parentDomain)) { @@ -173,22 +176,23 @@ export async function getValidCertificatesForDomains( } } - const decryptedResults = decryptFinalResults(finalResults); + const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!); return decryptedResults; } function decryptFinalResults( - finalResults: CertificateResult[] + finalResults: CertificateResult[], + secret: string ): CertificateResult[] { const validCertsDecrypted = finalResults.map((cert) => { // Decrypt and save certificate file - const decryptedCert = decryptData( + const decryptedCert = decrypt( cert.certFile!, // is not null from query - encryptionKey + secret ); // Decrypt and save key file - const decryptedKey = decryptData(cert.keyFile!, encryptionKey); + const decryptedKey = decrypt(cert.keyFile!, secret); // Return only the certificate data without org information return { diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index fee07a62a..b861c1ae6 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/config.ts b/server/private/lib/config.ts index 8e635c93c..9884fe252 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/exitNodes/exitNodeComms.ts b/server/private/lib/exitNodes/exitNodeComms.ts index 2145f32ff..3adeadd2a 100644 --- a/server/private/lib/exitNodes/exitNodeComms.ts +++ b/server/private/lib/exitNodes/exitNodeComms.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index 97c896140..f6417dae2 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/exitNodes/index.ts b/server/private/lib/exitNodes/index.ts index 00113b64a..27f887cb6 100644 --- a/server/private/lib/exitNodes/index.ts +++ b/server/private/lib/exitNodes/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/isLicencedOrSubscribed.ts b/server/private/lib/isLicencedOrSubscribed.ts index d6063c6c0..4cc93968e 100644 --- a/server/private/lib/isLicencedOrSubscribed.ts +++ b/server/private/lib/isLicencedOrSubscribed.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/isSubscribed.ts b/server/private/lib/isSubscribed.ts index e6e4c877f..e9c6e3cad 100644 --- a/server/private/lib/isSubscribed.ts +++ b/server/private/lib/isSubscribed.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts index 4462a454b..a59bbc051 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts index e56490795..ff0c1d779 100644 --- a/server/private/lib/logAccessAudit.ts +++ b/server/private/lib/logAccessAudit.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/logConnectionAudit.ts b/server/private/lib/logConnectionAudit.ts index c7e786280..039b75ec9 100644 --- a/server/private/lib/logConnectionAudit.ts +++ b/server/private/lib/logConnectionAudit.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -153,7 +153,7 @@ export async function flushConnectionLogToDb(): Promise { ); } - // Stop processing further batches from this snapshot — they will + // Stop processing further batches from this snapshot - they will // be picked up via the re-queued records on the next flush. const remaining = snapshot.slice(i + INSERT_BATCH_SIZE); if (remaining.length > 0) { @@ -180,7 +180,7 @@ const flushTimer = setInterval(async () => { }, FLUSH_INTERVAL_MS); // Calling unref() means this timer will not keep the Node.js event loop alive -// on its own — the process can still exit normally when there is no other work +// on its own - the process can still exit normally when there is no other work // left. The graceful-shutdown path will call flushConnectionLogToDb() explicitly // before process.exit(), so no data is lost. flushTimer.unref(); @@ -223,7 +223,7 @@ export function logConnectionAudit(record: ConnectionLogRecord): void { buffer.push(record); if (buffer.length >= MAX_BUFFERED_RECORDS) { - // Fire and forget — errors are handled inside flushConnectionLogToDb + // Fire and forget - errors are handled inside flushConnectionLogToDb flushConnectionLogToDb().catch((error) => { logger.error( "Unexpected error during size-triggered connection log flush:", @@ -231,4 +231,4 @@ export function logConnectionAudit(record: ConnectionLogRecord): void { ); }); } -} \ No newline at end of file +} diff --git a/server/private/lib/logStreaming/LogStreamingManager.ts b/server/private/lib/logStreaming/LogStreamingManager.ts index 39eae031a..1df67c886 100644 --- a/server/private/lib/logStreaming/LogStreamingManager.ts +++ b/server/private/lib/logStreaming/LogStreamingManager.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/logStreaming/index.ts b/server/private/lib/logStreaming/index.ts index 619809771..18662a7c0 100644 --- a/server/private/lib/logStreaming/index.ts +++ b/server/private/lib/logStreaming/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/logStreaming/providers/HttpLogDestination.ts b/server/private/lib/logStreaming/providers/HttpLogDestination.ts index 5e149f814..337a58f1f 100644 --- a/server/private/lib/logStreaming/providers/HttpLogDestination.ts +++ b/server/private/lib/logStreaming/providers/HttpLogDestination.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -37,7 +37,7 @@ const DEFAULT_FORMAT: PayloadFormat = "json_array"; * * **Payload formats** (controlled by `config.format`): * - * - `json_array` (default) — one POST per batch, body is a JSON array: + * - `json_array` (default) - one POST per batch, body is a JSON array: * ```json * [ * { "event": "request", "timestamp": "2024-01-01T00:00:00.000Z", "data": { … } }, @@ -46,7 +46,7 @@ const DEFAULT_FORMAT: PayloadFormat = "json_array"; * ``` * `Content-Type: application/json` * - * - `ndjson` — one POST per batch, body is newline-delimited JSON (one object + * - `ndjson` - one POST per batch, body is newline-delimited JSON (one object * per line, no outer array). Required by Splunk HEC, Elastic/OpenSearch, * and Grafana Loki: * ``` @@ -55,7 +55,7 @@ const DEFAULT_FORMAT: PayloadFormat = "json_array"; * ``` * `Content-Type: application/x-ndjson` * - * - `json_single` — one POST **per event**, body is a plain JSON object. + * - `json_single` - one POST **per event**, body is a plain JSON object. * Use only for endpoints that cannot handle batches at all. * * With a body template each event is rendered through the template before @@ -319,4 +319,4 @@ function epochSecondsToIso(epochSeconds: number): string { function escapeJsonString(value: string): string { // JSON.stringify produces `""` – strip the outer quotes. return JSON.stringify(value).slice(1, -1); -} \ No newline at end of file +} diff --git a/server/private/lib/logStreaming/providers/LogDestinationProvider.ts b/server/private/lib/logStreaming/providers/LogDestinationProvider.ts index d09be320b..a362e3542 100644 --- a/server/private/lib/logStreaming/providers/LogDestinationProvider.ts +++ b/server/private/lib/logStreaming/providers/LogDestinationProvider.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/logStreaming/types.ts b/server/private/lib/logStreaming/types.ts index 03fe88cad..1bcd25a66 100644 --- a/server/private/lib/logStreaming/types.ts +++ b/server/private/lib/logStreaming/types.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -60,9 +60,9 @@ export type AuthType = "none" | "bearer" | "basic" | "custom"; /** * Controls how the batch of events is serialised into the HTTP request body. * - * - `json_array` – `[{…}, {…}]` — default; one POST per batch wrapped in a + * - `json_array` – `[{…}, {…}]` - default; one POST per batch wrapped in a * JSON array. Works with most generic webhooks and Datadog. - * - `ndjson` – `{…}\n{…}` — newline-delimited JSON, one object per + * - `ndjson` – `{…}\n{…}` - newline-delimited JSON, one object per * line. Required by Splunk HEC, Elastic/OpenSearch, Loki. * - `json_single` – one HTTP POST per event, body is a plain JSON object. * Use only for endpoints that cannot handle batches at all. @@ -131,4 +131,4 @@ export interface DestinationFailureState { nextRetryAt: number; /** Date.now() value of the very first failure in the current streak */ firstFailedAt: number; -} \ No newline at end of file +} diff --git a/server/private/lib/rateLimit.test.ts b/server/private/lib/rateLimit.test.ts index 96adf082f..5e545e0ea 100644 --- a/server/private/lib/rateLimit.test.ts +++ b/server/private/lib/rateLimit.test.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/rateLimit.ts b/server/private/lib/rateLimit.ts index 984d95c62..a8cf3c01c 100644 --- a/server/private/lib/rateLimit.ts +++ b/server/private/lib/rateLimit.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/rateLimitStore.ts b/server/private/lib/rateLimitStore.ts index 32495cd20..445f2cdfe 100644 --- a/server/private/lib/rateLimitStore.ts +++ b/server/private/lib/rateLimitStore.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 54260009b..63ca0b068 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -21,167 +21,172 @@ import { getEnvOrYaml } from "@server/lib/getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); -export const privateConfigSchema = z.object({ - app: z - .object({ - region: z.string().optional().default("default"), - base_domain: z.string().optional(), - identity_provider_mode: z.enum(["global", "org"]).optional() - }) - .optional() - .default({ - region: "default" - }), - server: z - .object({ - encryption_key: z - .string() - .optional() - .transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")), - reo_client_id: z - .string() - .optional() - .transform(getEnvOrYaml("REO_CLIENT_ID")), - fossorial_api: z - .string() - .optional() - .default("https://api.fossorial.io"), - fossorial_api_key: z - .string() - .optional() - .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) - }) - .optional() - .prefault({}), - redis: z - .object({ - host: z.string(), - port: portSchema, - password: z - .string() - .optional() - .transform(getEnvOrYaml("REDIS_PASSWORD")), - db: z.int().nonnegative().optional().default(0), - replicas: z - .array( - z.object({ - host: z.string(), - port: portSchema, - password: z.string().optional(), - db: z.int().nonnegative().optional().default(0) +export const privateConfigSchema = z + .object({ + app: z + .object({ + region: z.string().optional().default("default"), + base_domain: z.string().optional(), + identity_provider_mode: z.enum(["global", "org"]).optional() + }) + .optional() + .default({ + region: "default" + }), + server: z + .object({ + reo_client_id: z + .string() + .optional() + .transform(getEnvOrYaml("REO_CLIENT_ID")), + fossorial_api: z + .string() + .optional() + .default("https://api.fossorial.io"), + fossorial_api_key: z + .string() + .optional() + .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) + }) + .optional() + .prefault({}), + redis: z + .object({ + host: z.string(), + port: portSchema, + password: z + .string() + .optional() + .transform(getEnvOrYaml("REDIS_PASSWORD")), + db: z.int().nonnegative().optional().default(0), + replicas: z + .array( + z.object({ + host: z.string(), + port: portSchema, + password: z.string().optional(), + db: z.int().nonnegative().optional().default(0) + }) + ) + .optional(), + tls: z + .object({ + rejectUnauthorized: z.boolean().optional().default(true) }) - ) - .optional(), - tls: z - .object({ - rejectUnauthorized: z - .boolean() - .optional() - .default(true) - }) - .optional() - }) - .optional(), - gerbil: z - .object({ - local_exit_node_reachable_at: z - .string() - .optional() - .default("http://gerbil:3004") - }) - .optional() - .prefault({}), - flags: z - .object({ - enable_redis: z.boolean().optional().default(false), - use_pangolin_dns: z.boolean().optional().default(false), - use_org_only_idp: z.boolean().optional() - }) - .optional() - .prefault({}), - branding: z - .object({ - app_name: z.string().optional(), - background_image_path: z.string().optional(), - colors: z - .object({ - light: colorsSchema.optional(), - dark: colorsSchema.optional() - }) - .optional(), - logo: z - .object({ - light_path: z.string().optional(), - dark_path: z.string().optional(), - auth_page: z - .object({ - width: z.number().optional(), - height: z.number().optional() - }) - .optional(), - navbar: z - .object({ - width: z.number().optional(), - height: z.number().optional() - }) - .optional() - }) - .optional(), - footer: z - .array( - z.object({ - text: z.string(), - href: z.string().optional() + .optional() + }) + .optional(), + gerbil: z + .object({ + local_exit_node_reachable_at: z + .string() + .optional() + .default("http://gerbil:3004") + }) + .optional() + .prefault({}), + flags: z + .object({ + enable_redis: z.boolean().optional().default(false), + use_pangolin_dns: z.boolean().optional().default(false), + use_org_only_idp: z.boolean().optional(), + enable_acme_cert_sync: z.boolean().optional().default(true) + }) + .optional() + .prefault({}), + acme: z + .object({ + acme_json_path: z + .string() + .optional() + .default("config/letsencrypt/acme.json"), + acme_http_endpoint: z.string().optional(), + sync_interval_ms: z.number().optional().default(5000) + }) + .optional(), + branding: z + .object({ + app_name: z.string().optional(), + background_image_path: z.string().optional(), + colors: z + .object({ + light: colorsSchema.optional(), + dark: colorsSchema.optional() }) - ) - .optional(), - hide_auth_layout_footer: z.boolean().optional().default(false), - login_page: z - .object({ - subtitle_text: z.string().optional() - }) - .optional(), - signup_page: z - .object({ - subtitle_text: z.string().optional() - }) - .optional(), - resource_auth_page: z - .object({ - show_logo: z.boolean().optional(), - hide_powered_by: z.boolean().optional(), - title_text: z.string().optional(), - subtitle_text: z.string().optional() - }) - .optional(), - emails: z - .object({ - signature: z.string().optional(), - colors: z - .object({ - primary: z.string().optional() + .optional(), + logo: z + .object({ + light_path: z.string().optional(), + dark_path: z.string().optional(), + auth_page: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional(), + navbar: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional() + }) + .optional(), + footer: z + .array( + z.object({ + text: z.string(), + href: z.string().optional() }) - .optional() - }) - .optional() - }) - .optional(), - stripe: z - .object({ - secret_key: z - .string() - .optional() - .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), - webhook_secret: z - .string() - .optional() - .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), - // s3Bucket: z.string(), - // s3Region: z.string().default("us-east-1"), - // localFilePath: z.string().optional() - }) - .optional() -}) + ) + .optional(), + hide_auth_layout_footer: z.boolean().optional().default(false), + login_page: z + .object({ + subtitle_text: z.string().optional() + }) + .optional(), + signup_page: z + .object({ + subtitle_text: z.string().optional() + }) + .optional(), + resource_auth_page: z + .object({ + show_logo: z.boolean().optional(), + hide_powered_by: z.boolean().optional(), + title_text: z.string().optional(), + subtitle_text: z.string().optional() + }) + .optional(), + emails: z + .object({ + signature: z.string().optional(), + colors: z + .object({ + primary: z.string().optional() + }) + .optional() + }) + .optional() + }) + .optional(), + stripe: z + .object({ + secret_key: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), + webhook_secret: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")) + // s3Bucket: z.string(), + // s3Region: z.string().default("us-east-1"), + // localFilePath: z.string().optional() + }) + .optional() + }) .transform((data) => { // this to maintain backwards compatibility with the old config file const identityProviderMode = data.app?.identity_provider_mode; diff --git a/server/private/lib/redis.ts b/server/private/lib/redis.ts index 69f563b44..57e73474a 100644 --- a/server/private/lib/redis.ts +++ b/server/private/lib/redis.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/redisStore.ts b/server/private/lib/redisStore.ts index 2360e309f..f9aad7bce 100644 --- a/server/private/lib/redisStore.ts +++ b/server/private/lib/redisStore.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/stripe.ts b/server/private/lib/stripe.ts index 01aacb35f..d0e6966bf 100644 --- a/server/private/lib/stripe.ts +++ b/server/private/lib/stripe.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/tokenCache.ts b/server/private/lib/tokenCache.ts index 284f1d698..66d8a8db4 100644 --- a/server/private/lib/tokenCache.ts +++ b/server/private/lib/tokenCache.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index adc3d965b..481192fb5 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -33,7 +33,15 @@ import { } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { orgs, resources, sites, Target, targets } from "@server/db"; +import { + orgs, + resources, + sites, + siteNetworks, + siteResources, + Target, + targets +} from "@server/db"; import { sanitize, encodePath, @@ -100,6 +108,7 @@ export async function getTraefikConfig( headers: resources.headers, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, + wildcard: resources.wildcard, maintenanceModeEnabled: resources.maintenanceModeEnabled, maintenanceModeType: resources.maintenanceModeType, @@ -238,6 +247,7 @@ export async function getTraefikConfig( priority: priority, // may be null, we fallback later domainCertResolver: row.domainCertResolver, preferWildcardCert: row.preferWildcardCert, + wildcard: row.wildcard, maintenanceModeEnabled: row.maintenanceModeEnabled, maintenanceModeType: row.maintenanceModeType, @@ -267,6 +277,38 @@ export async function getTraefikConfig( }); }); + let siteResourcesWithFullDomain: { + siteResourceId: number; + fullDomain: string | null; + mode: "http" | "host" | "cidr"; + }[] = []; + if (build == "enterprise") { + // we dont want to do this on the cloud + // Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge + siteResourcesWithFullDomain = await db + .select({ + siteResourceId: siteResources.siteResourceId, + fullDomain: siteResources.fullDomain, + mode: siteResources.mode + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where( + and( + eq(siteResources.enabled, true), + isNotNull(siteResources.fullDomain), + eq(siteResources.mode, "http"), + eq(siteResources.ssl, true), + eq(sites.exitNodeId, exitNodeId), + inArray(sites.type, siteTypes) + ) + ); + } + let validCerts: CertificateResult[] = []; if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { // create a list of all domains to get certs for @@ -276,6 +318,12 @@ export async function getTraefikConfig( domains.add(resource.fullDomain); } } + // Include siteResource aliases so pangolin-dns also fetches certs for them + for (const sr of siteResourcesWithFullDomain) { + if (sr.fullDomain) { + domains.add(sr.fullDomain); + } + } // get the valid certs for these domains validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); @@ -341,7 +389,16 @@ export async function getTraefikConfig( ...additionalMiddlewares ]; - let rule = `Host(\`${fullDomain}\`)`; + let rule: string; + if (resource.wildcard && fullDomain.startsWith("*.")) { + // Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`) + const escaped = fullDomain + .slice(2) // remove leading "*." + .replace(/\./g, "\\."); + rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`; + } else { + rule = `Host(\`${fullDomain}\`)`; + } // priority logic let priority: number; @@ -384,7 +441,8 @@ export async function getTraefikConfig( config.getRawConfig().traefik.prefer_wildcard_cert; const domainCertResolver = resource.domainCertResolver; - const preferWildcardCert = resource.preferWildcardCert; + const preferWildcardCert = + resource.preferWildcardCert || resource.wildcard; let resolverName: string | undefined; let preferWildcard: boolean | undefined; @@ -531,7 +589,7 @@ export async function getTraefikConfig( resource.ssl ? entrypointHttps : entrypointHttp ], service: maintenanceServiceName, - rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, priority: 2001, ...(resource.ssl ? { tls } : {}) }; @@ -867,6 +925,128 @@ export async function getTraefikConfig( } } + // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that + // Traefik generates TLS certificates for those domains even when no + // matching resource exists yet. + if (siteResourcesWithFullDomain.length > 0) { + // Build a set of domains already covered by normal resources + const existingFullDomains = new Set(); + for (const resource of resourcesMap.values()) { + if (resource.fullDomain) { + existingFullDomains.add(resource.fullDomain); + } + } + + for (const sr of siteResourcesWithFullDomain) { + if (!sr.fullDomain) continue; + + // Skip if this alias is already handled by a resource router + if (existingFullDomains.has(sr.fullDomain)) continue; + + const fullDomain = sr.fullDomain; + const srKey = `site-resource-cert-${sr.siteResourceId}`; + const siteResourceServiceName = `${srKey}-service`; + const siteResourceRouterName = `${srKey}-router`; + const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`; + + const maintenancePort = config.getRawConfig().server.next_port; + const maintenanceHost = + config.getRawConfig().server.internal_hostname; + + if (!config_output.http.routers) { + config_output.http.routers = {}; + } + if (!config_output.http.services) { + config_output.http.services = {}; + } + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + // Service pointing at the internal maintenance/Next.js page + config_output.http.services[siteResourceServiceName] = { + loadBalancer: { + servers: [ + { + url: `http://${maintenanceHost}:${maintenancePort}` + } + ], + passHostHeader: true + } + }; + + // Middleware that rewrites any path to /maintenance-screen + config_output.http.middlewares[siteResourceRewriteMiddlewareName] = + { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/private-maintenance-screen" + } + }; + + // HTTP -> HTTPS redirect so the ACME challenge can be served + config_output.http.routers[`${siteResourceRouterName}-redirect`] = { + entryPoints: [config.getRawConfig().traefik.http_entrypoint], + middlewares: [redirectHttpsMiddlewareName], + service: siteResourceServiceName, + rule: `Host(\`${fullDomain}\`)`, + priority: 100 + }; + + // Determine TLS / cert-resolver configuration + let tls: any = {}; + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { + const domainParts = fullDomain.split("."); + const wildCard = + domainParts.length <= 2 + ? `*.${domainParts.join(".")}` + : `*.${domainParts.slice(1).join(".")}`; + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + + tls = { + certResolver: globalDefaultResolver, + ...(globalDefaultPreferWildcard + ? { domains: [{ main: wildCard }] } + : {}) + }; + } else { + // pangolin-dns: only add route if we already have a valid cert + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === fullDomain + ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for siteResource alias: ${fullDomain}` + ); + continue; + } + } + + // HTTPS router - presence of this entry triggers cert generation + config_output.http.routers[siteResourceRouterName] = { + entryPoints: [config.getRawConfig().traefik.https_entrypoint], + service: siteResourceServiceName, + middlewares: [siteResourceRewriteMiddlewareName], + rule: `Host(\`${fullDomain}\`)`, + priority: 100, + tls + }; + + // Assets bypass router - lets Next.js static files load without rewrite + config_output.http.routers[`${siteResourceRouterName}-assets`] = { + entryPoints: [config.getRawConfig().traefik.https_entrypoint], + service: siteResourceServiceName, + rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + priority: 101, + tls + }; + } + } + if (generateLoginPageRouters) { const exitNodeLoginPages = await db .select({ diff --git a/server/private/lib/traefik/index.ts b/server/private/lib/traefik/index.ts index 5f2c2635e..a004713cb 100644 --- a/server/private/lib/traefik/index.ts +++ b/server/private/lib/traefik/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/license/license.ts b/server/private/license/license.ts index 972dbc82f..81aae1439 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/license/licenseJwt.ts b/server/private/license/licenseJwt.ts index eb27b78f9..36603cba5 100644 --- a/server/private/license/licenseJwt.ts +++ b/server/private/license/licenseJwt.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/middlewares/index.ts b/server/private/middlewares/index.ts index d6083f74d..4b598b4bf 100644 --- a/server/private/middlewares/index.ts +++ b/server/private/middlewares/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/middlewares/logActionAudit.ts b/server/private/middlewares/logActionAudit.ts index f62f43d3a..97c48f8e8 100644 --- a/server/private/middlewares/logActionAudit.ts +++ b/server/private/middlewares/logActionAudit.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/middlewares/verifyCertificateAccess.ts b/server/private/middlewares/verifyCertificateAccess.ts index dcc57dcae..3cd2e03be 100644 --- a/server/private/middlewares/verifyCertificateAccess.ts +++ b/server/private/middlewares/verifyCertificateAccess.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/middlewares/verifyIdpAccess.ts b/server/private/middlewares/verifyIdpAccess.ts index 2dbc1b8ff..29d997d3f 100644 --- a/server/private/middlewares/verifyIdpAccess.ts +++ b/server/private/middlewares/verifyIdpAccess.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/middlewares/verifyLoginPageAccess.ts b/server/private/middlewares/verifyLoginPageAccess.ts index bc9e8713a..5267e3c1b 100644 --- a/server/private/middlewares/verifyLoginPageAccess.ts +++ b/server/private/middlewares/verifyLoginPageAccess.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/middlewares/verifyRemoteExitNode.ts b/server/private/middlewares/verifyRemoteExitNode.ts index 8abdc47e7..4a56b47da 100644 --- a/server/private/middlewares/verifyRemoteExitNode.ts +++ b/server/private/middlewares/verifyRemoteExitNode.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/middlewares/verifyRemoteExitNodeAccess.ts b/server/private/middlewares/verifyRemoteExitNodeAccess.ts index 7d6128d8f..8c1a51e4c 100644 --- a/server/private/middlewares/verifyRemoteExitNodeAccess.ts +++ b/server/private/middlewares/verifyRemoteExitNodeAccess.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 8a8f8e3b3..27bd25dfe 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/middlewares/verifyValidLicense.ts b/server/private/middlewares/verifyValidLicense.ts index 8f828a354..73bec9392 100644 --- a/server/private/middlewares/verifyValidLicense.ts +++ b/server/private/middlewares/verifyValidLicense.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/alertEvents/index.ts b/server/private/routers/alertEvents/index.ts new file mode 100644 index 000000000..485b434eb --- /dev/null +++ b/server/private/routers/alertEvents/index.ts @@ -0,0 +1,16 @@ +/* + * 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 "./triggerSiteAlert"; +export * from "./triggerResourceAlert"; +export * from "./triggerHealthCheckAlert"; \ No newline at end of file diff --git a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts new file mode 100644 index 000000000..18761b568 --- /dev/null +++ b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts @@ -0,0 +1,118 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { targetHealthCheck } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckUnhealthyAlert +} from "@server/lib/alerts"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + healthCheckId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + eventType: z.enum(["health_check_healthy", "health_check_unhealthy"]) +}); + +export type TriggerHealthCheckAlertResponse = { + success: true; +}; + +export async function triggerHealthCheckAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId, healthCheckId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { eventType } = parsedBody.data; + + // Verify the health check exists and belongs to the org + const [healthCheck] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId) + ) + ) + .limit(1); + + if (!healthCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check ${healthCheckId} not found in organization ${orgId}` + ) + ); + } + + if (eventType === "health_check_healthy") { + await fireHealthCheckHealthyAlert( + orgId, + healthCheckId, + healthCheck.name ?? undefined + ); + } else { + await fireHealthCheckUnhealthyAlert( + orgId, + healthCheckId, + healthCheck.name ?? undefined + ); + } + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/alertEvents/triggerResourceAlert.ts b/server/private/routers/alertEvents/triggerResourceAlert.ts new file mode 100644 index 000000000..3c2f8fb96 --- /dev/null +++ b/server/private/routers/alertEvents/triggerResourceAlert.ts @@ -0,0 +1,130 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { + fireResourceHealthyAlert, + fireResourceUnhealthyAlert, + fireResourceDegradedAlert +} from "@server/lib/alerts"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + resourceId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + eventType: z.enum([ + "resource_healthy", + "resource_unhealthy", + "resource_degraded", + "resource_toggle" + ]) +}); + +export type TriggerResourceAlertResponse = { + success: true; +}; + +export async function triggerResourceAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId, resourceId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { eventType } = parsedBody.data; + + // Verify the resource exists and belongs to the org + const [resource] = await db + .select() + .from(resources) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource ${resourceId} not found in organization ${orgId}` + ) + ); + } + + if (eventType === "resource_healthy") { + await fireResourceHealthyAlert( + orgId, + resourceId, + resource.name ?? undefined + ); + } else if (eventType === "resource_unhealthy") { + await fireResourceUnhealthyAlert( + orgId, + resourceId, + resource.name ?? undefined + ); + } else if (eventType === "resource_degraded") { + await fireResourceDegradedAlert( + orgId, + resourceId, + resource.name ?? undefined + ); + } + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/alertEvents/triggerSiteAlert.ts b/server/private/routers/alertEvents/triggerSiteAlert.ts new file mode 100644 index 000000000..b9f182887 --- /dev/null +++ b/server/private/routers/alertEvents/triggerSiteAlert.ts @@ -0,0 +1,102 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { sites } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { fireSiteOnlineAlert, fireSiteOfflineAlert } from "@server/lib/alerts"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + siteId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + eventType: z.enum(["site_online", "site_offline"]) +}); + +export type TriggerSiteAlertResponse = { + success: true; +}; + +export async function triggerSiteAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId, siteId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { eventType } = parsedBody.data; + + // Verify the site exists and belongs to the org + const [site] = await db + .select() + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site ${siteId} not found in organization ${orgId}` + ) + ); + } + + if (eventType === "site_online") { + await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined); + } else { + await fireSiteOfflineAlert(orgId, siteId, site.name ?? undefined); + } + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts new file mode 100644 index 000000000..f9b84ebab --- /dev/null +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -0,0 +1,366 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, roles } from "@server/db"; +import { + alertRules, + alertSites, + alertHealthChecks, + alertResources, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions +} from "@server/db"; +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 { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import { CreateAlertRuleResponse } from "@server/routers/alertRule/types"; + +export const SITE_EVENT_TYPES = [ + "site_online", + "site_offline", + "site_toggle" +] as const; +export const HC_EVENT_TYPES = [ + "health_check_healthy", + "health_check_unhealthy", + "health_check_toggle" +] as const; +export const RESOURCE_EVENT_TYPES = [ + "resource_healthy", + "resource_unhealthy", + "resource_degraded", + "resource_toggle" +] as const; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const webhookActionSchema = z.strictObject({ + webhookUrl: z.string().url(), + config: z.string().optional(), + enabled: z.boolean().optional().default(true) +}); + +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + eventType: z.enum([ + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES + ]), + enabled: z.boolean().optional().default(true), + cooldownSeconds: z.number().int().nonnegative().optional().default(0), + // Source join tables - which is required depends on eventType + siteIds: z.array(z.number().int().positive()).optional().default([]), + allSites: z.boolean().optional().default(false), + healthCheckIds: z + .array(z.number().int().positive()) + .optional() + .default([]), + allHealthChecks: z.boolean().optional().default(false), + resourceIds: z + .array(z.number().int().positive()) + .optional() + .default([]), + allResources: z.boolean().optional().default(false), + // Email recipients (flat) + userIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.number()).optional().default([]), + emails: z.array(z.string().email()).optional().default([]), + // Webhook actions + webhookActions: z.array(webhookActionSchema).optional().default([]) + }) + .superRefine((val, ctx) => { + const isSiteEvent = (SITE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + const isResourceEvent = ( + RESOURCE_EVENT_TYPES as readonly string[] + ).includes(val.eventType); + + if (isSiteEvent && !val.allSites && val.siteIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "At least one siteId is required for site event types when allSites is false", + path: ["siteIds"] + }); + } + + if ( + isHcEvent && + !val.allHealthChecks && + val.healthCheckIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "At least one healthCheckId is required for health check event types when allHealthChecks is false", + path: ["healthCheckIds"] + }); + } + + if (isSiteEvent && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for site event types", + path: ["healthCheckIds"] + }); + } + + if (isHcEvent && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for health check event types", + path: ["siteIds"] + }); + } + + if ( + isResourceEvent && + !val.allResources && + val.resourceIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "At least one resourceId is required for resource event types when allResources is false", + path: ["resourceIds"] + }); + } + + if (isResourceEvent && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for resource event types", + path: ["siteIds"] + }); + } + + if (isResourceEvent && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "healthCheckIds must not be set for resource event types", + path: ["healthCheckIds"] + }); + } + + if (isSiteEvent && val.resourceIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "resourceIds must not be set for site event types", + path: ["resourceIds"] + }); + } + + if (isHcEvent && val.resourceIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "resourceIds must not be set for health check event types", + path: ["resourceIds"] + }); + } + }); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/alert-rule", + description: "Create an alert rule for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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, + eventType, + enabled, + cooldownSeconds, + siteIds, + allSites, + healthCheckIds, + allHealthChecks, + resourceIds, + allResources, + userIds, + roleIds, + emails, + webhookActions + } = parsedBody.data; + + const now = Date.now(); + + const [rule] = await db + .insert(alertRules) + .values({ + orgId, + name, + eventType, + enabled, + cooldownSeconds, + allSites, + allHealthChecks, + allResources, + createdAt: now, + updatedAt: now + }) + .returning(); + + // Insert site associations (skipped when allSites=true — empty junction = match all) + if (!allSites && siteIds.length > 0) { + await db.insert(alertSites).values( + siteIds.map((siteId) => ({ + alertRuleId: rule.alertRuleId, + siteId + })) + ); + } + + // Insert health check associations (skipped when allHealthChecks=true) + if (!allHealthChecks && healthCheckIds.length > 0) { + await db.insert(alertHealthChecks).values( + healthCheckIds.map((healthCheckId) => ({ + alertRuleId: rule.alertRuleId, + healthCheckId + })) + ); + } + + // Insert resource associations (skipped when allResources=true) + if (!allResources && resourceIds.length > 0) { + await db.insert(alertResources).values( + resourceIds.map((resourceId) => ({ + alertRuleId: rule.alertRuleId, + resourceId + })) + ); + } + + // Create the email action pivot row and recipients if any recipients + // were supplied (userIds, roleIds, or raw emails). + const hasRecipients = + userIds.length > 0 || roleIds.length > 0 || emails.length > 0; + + if (hasRecipients) { + const [emailActionRow] = await db + .insert(alertEmailActions) + .values({ alertRuleId: rule.alertRuleId }) + .returning(); + + const recipientRows = [ + ...userIds.map((userId) => ({ + emailActionId: emailActionRow.emailActionId, + userId, + roleId: null as number | null, + email: null as string | null + })), + ...roleIds.map((roleId) => ({ + emailActionId: emailActionRow.emailActionId, + userId: null as string | null, + roleId, + email: null as string | null + })), + ...emails.map((email) => ({ + emailActionId: emailActionRow.emailActionId, + userId: null as string | null, + roleId: null as number | null, + email + })) + ]; + + await db.insert(alertEmailRecipients).values(recipientRows); + } + + if (webhookActions.length > 0) { + const serverSecret = config.getRawConfig().server.secret!; + await db.insert(alertWebhookActions).values( + webhookActions.map((wa) => ({ + alertRuleId: rule.alertRuleId, + webhookUrl: wa.webhookUrl, + config: + wa.config != null + ? encrypt(wa.config, serverSecret) + : null, + enabled: wa.enabled + })) + ); + } + + return response(res, { + data: { + alertRuleId: rule.alertRuleId + }, + success: true, + error: false, + message: "Alert rule created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/alertRule/deleteAlertRule.ts b/server/private/routers/alertRule/deleteAlertRule.ts new file mode 100644 index 000000000..0988cd631 --- /dev/null +++ b/server/private/routers/alertRule/deleteAlertRule.ts @@ -0,0 +1,100 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { alertRules } from "@server/db"; +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 { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + alertRuleId: z.coerce.number() + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/alert-rule/{alertRuleId}", + description: "Delete an alert rule for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, alertRuleId } = parsedParams.data; + + const [existing] = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Alert rule not found") + ); + } + + await db + .delete(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Alert rule deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts new file mode 100644 index 000000000..9fd0157e3 --- /dev/null +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -0,0 +1,190 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + alertRules, + alertSites, + alertHealthChecks, + alertResources, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions +} from "@server/db"; +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 { and, eq } from "drizzle-orm"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import { GetAlertRuleResponse, WebhookAlertConfig } from "@server/routers/alertRule/types"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + alertRuleId: z.coerce.number() + }) + .strict(); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/alert-rule/{alertRuleId}", + description: "Get a specific alert rule for an organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function getAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, alertRuleId } = parsedParams.data; + + const [rule] = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + if (!rule) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Alert rule not found") + ); + } + + // Fetch site associations + const siteRows = await db + .select() + .from(alertSites) + .where(eq(alertSites.alertRuleId, alertRuleId)); + + // Fetch health check associations + const healthCheckRows = await db + .select() + .from(alertHealthChecks) + .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); + + // Fetch resource associations + const resourceRows = await db + .select() + .from(alertResources) + .where(eq(alertResources.alertRuleId, alertRuleId)); + + // Resolve the single email action row for this rule, then collect all + // recipients into a flat list. The emailAction pivot row is an internal + // implementation detail and is not surfaced to callers. + const [emailAction] = await db + .select() + .from(alertEmailActions) + .where(eq(alertEmailActions.alertRuleId, alertRuleId)); + + let recipients: GetAlertRuleResponse["recipients"] = []; + if (emailAction) { + const rows = await db + .select() + .from(alertEmailRecipients) + .where( + eq( + alertEmailRecipients.emailActionId, + emailAction.emailActionId + ) + ); + + recipients = rows.map((r) => ({ + recipientId: r.recipientId, + userId: r.userId ?? null, + roleId: r.roleId ?? null, + email: r.email ?? null + })); + } + + // Fetch webhook actions + const webhooks = await db + .select() + .from(alertWebhookActions) + .where(eq(alertWebhookActions.alertRuleId, alertRuleId)); + + return response(res, { + data: { + alertRuleId: rule.alertRuleId, + orgId: rule.orgId, + name: rule.name, + eventType: rule.eventType, + enabled: rule.enabled, + cooldownSeconds: rule.cooldownSeconds, + lastTriggeredAt: rule.lastTriggeredAt ?? null, + createdAt: rule.createdAt, + updatedAt: rule.updatedAt, + siteIds: siteRows.map((r) => r.siteId), + healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), + resourceIds: resourceRows.map((r) => r.resourceId), + recipients, + webhookActions: webhooks.map((w) => { + let parsedConfig: WebhookAlertConfig | null = null; + if (w.config) { + try { + const serverSecret = + config.getRawConfig().server.secret!; + const decrypted = decrypt(w.config, serverSecret); + parsedConfig = JSON.parse( + decrypted + ) as WebhookAlertConfig; + } catch { + // best-effort – return null if decryption fails + } + } + return { + webhookActionId: w.webhookActionId, + webhookUrl: w.webhookUrl, + enabled: w.enabled, + lastSentAt: w.lastSentAt ?? null, + config: parsedConfig + }; + }) + }, + success: true, + error: false, + message: "Alert rule retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/alertRule/index.ts b/server/private/routers/alertRule/index.ts new file mode 100644 index 000000000..19e35f7dc --- /dev/null +++ b/server/private/routers/alertRule/index.ts @@ -0,0 +1,18 @@ +/* + * 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 "./createAlertRule"; +export * from "./updateAlertRule"; +export * from "./deleteAlertRule"; +export * from "./listAlertRules"; +export * from "./getAlertRule"; \ No newline at end of file diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts new file mode 100644 index 000000000..9684b88a4 --- /dev/null +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -0,0 +1,350 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + alertRules, + alertSites, + alertHealthChecks, + alertResources +} from "@server/db"; +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 { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; +import { ListAlertRulesResponse } from "@server/routers/alertRule/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()), + query: z.string().optional(), + siteId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()), + resourceId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()), + healthCheckId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()), + sort_by: z.enum(["name", "last_triggered_at"]).optional(), + order: z.enum(["asc", "desc"]).optional().default("asc"), + enabled: z.enum(["true", "false"]).optional() +}); + +const SITE_ALERT_EVENT_TYPES = [ + "site_online", + "site_offline", + "site_toggle" +] as const; + +const RESOURCE_ALERT_EVENT_TYPES = [ + "resource_healthy", + "resource_unhealthy", + "resource_degraded", + "resource_toggle" +] as const; + +const HEALTH_CHECK_ALERT_EVENT_TYPES = [ + "health_check_healthy", + "health_check_unhealthy", + "health_check_toggle" +] as const; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/alert-rules", + description: "List all alert rules for a specific organization.", + tags: [OpenAPITags.Org], + request: { + query: querySchema, + params: paramsSchema + }, + responses: {} +}); + +export async function listAlertRules( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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 parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { + limit, + offset, + query, + siteId, + resourceId, + healthCheckId, + sort_by, + order, + enabled: enabledFilter + } = parsedQuery.data; + + const explicitSiteRuleIds: number[] = + siteId !== undefined + ? ( + await db + .select({ alertRuleId: alertSites.alertRuleId }) + .from(alertSites) + .where(eq(alertSites.siteId, siteId)) + ).map((r) => r.alertRuleId) + : []; + + const explicitResourceRuleIds: number[] = + resourceId !== undefined + ? ( + await db + .select({ + alertRuleId: alertResources.alertRuleId + }) + .from(alertResources) + .where(eq(alertResources.resourceId, resourceId)) + ).map((r) => r.alertRuleId) + : []; + + const explicitHealthCheckRuleIds: number[] = + healthCheckId !== undefined + ? ( + await db + .select({ + alertRuleId: alertHealthChecks.alertRuleId + }) + .from(alertHealthChecks) + .where( + eq(alertHealthChecks.healthCheckId, healthCheckId) + ) + ).map((r) => r.alertRuleId) + : []; + + const allSitesWildcardClause = and( + eq(alertRules.allSites, true), + inArray(alertRules.eventType, SITE_ALERT_EVENT_TYPES) + ); + + const siteScopeClause = + siteId !== undefined + ? explicitSiteRuleIds.length > 0 + ? or( + allSitesWildcardClause, + inArray(alertRules.alertRuleId, explicitSiteRuleIds) + ) + : allSitesWildcardClause + : undefined; + + const allResourcesWildcardClause = and( + eq(alertRules.allResources, true), + inArray(alertRules.eventType, RESOURCE_ALERT_EVENT_TYPES) + ); + + const resourceScopeClause = + resourceId !== undefined + ? explicitResourceRuleIds.length > 0 + ? or( + allResourcesWildcardClause, + inArray( + alertRules.alertRuleId, + explicitResourceRuleIds + ) + ) + : allResourcesWildcardClause + : undefined; + + const allHealthChecksWildcardClause = and( + eq(alertRules.allHealthChecks, true), + inArray(alertRules.eventType, HEALTH_CHECK_ALERT_EVENT_TYPES) + ); + + const healthCheckScopeClause = + healthCheckId !== undefined + ? explicitHealthCheckRuleIds.length > 0 + ? or( + allHealthChecksWildcardClause, + inArray( + alertRules.alertRuleId, + explicitHealthCheckRuleIds + ) + ) + : allHealthChecksWildcardClause + : undefined; + + const whereClause = and( + eq(alertRules.orgId, orgId), + query + ? like( + sql`LOWER(${alertRules.name})`, + `%${query.toLowerCase()}%` + ) + : undefined, + siteScopeClause, + resourceScopeClause, + healthCheckScopeClause, + enabledFilter !== undefined + ? eq(alertRules.enabled, enabledFilter === "true") + : undefined + ); + + const orderByClause = + sort_by === "name" + ? order === "asc" + ? asc(alertRules.name) + : desc(alertRules.name) + : sort_by === "last_triggered_at" + ? order === "asc" + ? sql`${alertRules.lastTriggeredAt} ASC NULLS FIRST` + : sql`${alertRules.lastTriggeredAt} DESC NULLS LAST` + : sql`${alertRules.createdAt} DESC`; + + const list = await db + .select() + .from(alertRules) + .where(whereClause) + .orderBy(orderByClause) + .limit(limit) + .offset(offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(alertRules) + .where(whereClause); + + // Batch-fetch site and health-check associations for all returned rules + // in two queries rather than N+1 individual lookups. + const ruleIds = list.map((r) => r.alertRuleId); + + const siteRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertSites) + .where(inArray(alertSites.alertRuleId, ruleIds)) + : []; + + const healthCheckRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertHealthChecks) + .where(inArray(alertHealthChecks.alertRuleId, ruleIds)) + : []; + + const resourceRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertResources) + .where(inArray(alertResources.alertRuleId, ruleIds)) + : []; + + // Index by alertRuleId for O(1) lookup when building the response + const sitesByRule = new Map(); + for (const row of siteRows) { + const existing = sitesByRule.get(row.alertRuleId) ?? []; + existing.push(row.siteId); + sitesByRule.set(row.alertRuleId, existing); + } + + const healthChecksByRule = new Map(); + for (const row of healthCheckRows) { + const existing = healthChecksByRule.get(row.alertRuleId) ?? []; + existing.push(row.healthCheckId); + healthChecksByRule.set(row.alertRuleId, existing); + } + + const resourcesByRule = new Map(); + for (const row of resourceRows) { + const existing = resourcesByRule.get(row.alertRuleId) ?? []; + existing.push(row.resourceId); + resourcesByRule.set(row.alertRuleId, existing); + } + + return response(res, { + data: { + alertRules: list.map((rule) => ({ + alertRuleId: rule.alertRuleId, + orgId: rule.orgId, + name: rule.name, + eventType: rule.eventType, + enabled: rule.enabled, + cooldownSeconds: rule.cooldownSeconds, + lastTriggeredAt: rule.lastTriggeredAt ?? null, + createdAt: rule.createdAt, + updatedAt: rule.updatedAt, + siteIds: sitesByRule.get(rule.alertRuleId) ?? [], + healthCheckIds: + healthChecksByRule.get(rule.alertRuleId) ?? [], + resourceIds: resourcesByRule.get(rule.alertRuleId) ?? [] + })), + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Alert rules retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts new file mode 100644 index 000000000..358661ac9 --- /dev/null +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -0,0 +1,403 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + alertRules, + alertSites, + alertHealthChecks, + alertResources, + alertEmailActions, + alertEmailRecipients, + alertWebhookActions +} from "@server/db"; +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 { and, eq } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule"; +import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + alertRuleId: z.coerce.number() + }) + .strict(); + +const webhookActionSchema = z.strictObject({ + webhookUrl: z.string().url(), + config: z.string().optional(), + enabled: z.boolean().optional().default(true) +}); + +const bodySchema = z + .strictObject({ + // Alert rule fields - all optional for partial updates + name: z.string().nonempty().optional(), + eventType: z + .enum([ + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES + ]) + .optional(), + enabled: z.boolean().optional(), + cooldownSeconds: z.number().int().nonnegative().optional(), + // Source join tables - if provided the full set is replaced + siteIds: z.array(z.number().int().positive()).optional(), + allSites: z.boolean().optional(), + healthCheckIds: z.array(z.number().int().positive()).optional(), + allHealthChecks: z.boolean().optional(), + resourceIds: z.array(z.number().int().positive()).optional(), + allResources: z.boolean().optional(), + // Recipient arrays - if any are provided the full recipient set is replaced + userIds: z.array(z.string().nonempty()).optional(), + roleIds: z.array(z.number()).optional(), + emails: z.array(z.string().email()).optional(), + // Webhook actions - if provided the full webhook set is replaced + webhookActions: z.array(webhookActionSchema).optional() + }) + .superRefine((val, ctx) => { + if (!val.eventType) return; + + const isSiteEvent = (SITE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + + if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one siteId is required for site event types when allSites is false", + path: ["siteIds"] + }); + } + + if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one healthCheckId is required for health check event types when allHealthChecks is false", + path: ["healthCheckIds"] + }); + } + + if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one resourceId is required for resource event types when allResources is false", + path: ["resourceIds"] + }); + } + + if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for site event types", + path: ["healthCheckIds"] + }); + } + + if (isHcEvent && val.siteIds !== undefined && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for health check event types", + path: ["siteIds"] + }); + } + + if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for resource event types", + path: ["siteIds"] + }); + } + + if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for resource event types", + path: ["healthCheckIds"] + }); + } + }); + +export type UpdateAlertRuleResponse = { + alertRuleId: number; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/alert-rule/{alertRuleId}", + description: "Update an alert rule for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, alertRuleId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(alertRules) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Alert rule not found") + ); + } + + const { + name, + eventType, + enabled, + cooldownSeconds, + siteIds, + allSites, + healthCheckIds, + allHealthChecks, + resourceIds, + allResources, + userIds, + roleIds, + emails, + webhookActions + } = parsedBody.data; + + // --- Update rule fields --- + const updateData: Record = { + updatedAt: Date.now() + }; + + if (name !== undefined) updateData.name = name; + if (eventType !== undefined) updateData.eventType = eventType; + if (enabled !== undefined) updateData.enabled = enabled; + if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds; + if (allSites !== undefined) updateData.allSites = allSites; + if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks; + if (allResources !== undefined) updateData.allResources = allResources; + + await db + .update(alertRules) + .set(updateData) + .where( + and( + eq(alertRules.alertRuleId, alertRuleId), + eq(alertRules.orgId, orgId) + ) + ); + + // --- Full-replace site associations if siteIds was provided --- + if (siteIds !== undefined || allSites !== undefined) { + await db + .delete(alertSites) + .where(eq(alertSites.alertRuleId, alertRuleId)); + + // Only insert junction rows when allSites is not true + const effectiveAllSites = allSites ?? false; + if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) { + await db.insert(alertSites).values( + siteIds.map((siteId) => ({ + alertRuleId, + siteId + })) + ); + } + } + + // --- Full-replace health check associations if healthCheckIds was provided --- + if (healthCheckIds !== undefined || allHealthChecks !== undefined) { + await db + .delete(alertHealthChecks) + .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); + + const effectiveAllHealthChecks = allHealthChecks ?? false; + if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) { + await db.insert(alertHealthChecks).values( + healthCheckIds.map((healthCheckId) => ({ + alertRuleId, + healthCheckId + })) + ); + } + } + + // --- Full-replace resource associations if resourceIds was provided --- + if (resourceIds !== undefined || allResources !== undefined) { + await db + .delete(alertResources) + .where(eq(alertResources.alertRuleId, alertRuleId)); + + const effectiveAllResources = allResources ?? false; + if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) { + await db.insert(alertResources).values( + resourceIds.map((resourceId) => ({ + alertRuleId, + resourceId + })) + ); + } + } + + // --- Full-replace recipients if any recipient array was provided --- + const recipientsProvided = + userIds !== undefined || + roleIds !== undefined || + emails !== undefined; + + if (recipientsProvided) { + const newRecipients = [ + ...(userIds ?? []).map((userId) => ({ + userId, + roleId: null as number | null, + email: null as string | null + })), + ...(roleIds ?? []).map((roleId) => ({ + userId: null as string | null, + roleId, + email: null as string | null + })), + ...(emails ?? []).map((email) => ({ + userId: null as string | null, + roleId: null as number | null, + email + })) + ]; + + const [existingEmailAction] = await db + .select() + .from(alertEmailActions) + .where(eq(alertEmailActions.alertRuleId, alertRuleId)); + + if (existingEmailAction) { + await db + .delete(alertEmailRecipients) + .where( + eq( + alertEmailRecipients.emailActionId, + existingEmailAction.emailActionId + ) + ); + + if (newRecipients.length > 0) { + await db.insert(alertEmailRecipients).values( + newRecipients.map((r) => ({ + emailActionId: existingEmailAction.emailActionId, + ...r + })) + ); + } + } else if (newRecipients.length > 0) { + const [emailActionRow] = await db + .insert(alertEmailActions) + .values({ alertRuleId, enabled: true }) + .returning(); + + await db.insert(alertEmailRecipients).values( + newRecipients.map((r) => ({ + emailActionId: emailActionRow.emailActionId, + ...r + })) + ); + } + } + + // --- Full-replace webhook actions if the array was provided --- + if (webhookActions !== undefined) { + await db + .delete(alertWebhookActions) + .where(eq(alertWebhookActions.alertRuleId, alertRuleId)); + + if (webhookActions.length > 0) { + const serverSecret = config.getRawConfig().server.secret!; + await db.insert(alertWebhookActions).values( + webhookActions.map((wa) => ({ + alertRuleId, + webhookUrl: wa.webhookUrl, + config: wa.config != null ? encrypt(wa.config, serverSecret) : null, + enabled: wa.enabled + })) + ); + } + } + + return response(res, { + data: { + alertRuleId + }, + success: true, + error: false, + message: "Alert rule updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/approvals/countApprovals.ts b/server/private/routers/approvals/countApprovals.ts index 0885c7e88..325b7b552 100644 --- a/server/private/routers/approvals/countApprovals.ts +++ b/server/private/routers/approvals/countApprovals.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/approvals/index.ts b/server/private/routers/approvals/index.ts index 118b3d28c..9a4c018b6 100644 --- a/server/private/routers/approvals/index.ts +++ b/server/private/routers/approvals/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts index fcac27f92..d19a638ed 100644 --- a/server/private/routers/approvals/listApprovals.ts +++ b/server/private/routers/approvals/listApprovals.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/approvals/processPendingApproval.ts b/server/private/routers/approvals/processPendingApproval.ts index fa60445f4..39497bd6c 100644 --- a/server/private/routers/approvals/processPendingApproval.ts +++ b/server/private/routers/approvals/processPendingApproval.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts index 68a78ff6e..61a52778d 100644 --- a/server/private/routers/auditLogs/exportAccessAuditLog.ts +++ b/server/private/routers/auditLogs/exportAccessAuditLog.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts index 853183b92..d0f025efa 100644 --- a/server/private/routers/auditLogs/exportActionAuditLog.ts +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auditLogs/exportConnectionAuditLog.ts b/server/private/routers/auditLogs/exportConnectionAuditLog.ts index 9349528ad..d4e5ec9b5 100644 --- a/server/private/routers/auditLogs/exportConnectionAuditLog.ts +++ b/server/private/routers/auditLogs/exportConnectionAuditLog.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index 122455fea..aacd37635 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index f9951c1ab..1ce03f716 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 8bbe73ee1..2fbd7e59c 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auditLogs/queryConnectionAuditLog.ts b/server/private/routers/auditLogs/queryConnectionAuditLog.ts index b638ed488..715652838 100644 --- a/server/private/routers/auditLogs/queryConnectionAuditLog.ts +++ b/server/private/routers/auditLogs/queryConnectionAuditLog.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auth/getSessionTransferToken.ts b/server/private/routers/auth/getSessionTransferToken.ts index bd6bc545e..041f01199 100644 --- a/server/private/routers/auth/getSessionTransferToken.ts +++ b/server/private/routers/auth/getSessionTransferToken.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auth/index.ts b/server/private/routers/auth/index.ts index 25adfa788..2656a9f8e 100644 --- a/server/private/routers/auth/index.ts +++ b/server/private/routers/auth/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/auth/transferSession.ts b/server/private/routers/auth/transferSession.ts index 52138a75c..78673e8a0 100644 --- a/server/private/routers/auth/transferSession.ts +++ b/server/private/routers/auth/transferSession.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts index 3c9b8e437..d82cbfeea 100644 --- a/server/private/routers/billing/changeTier.ts +++ b/server/private/routers/billing/changeTier.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/createCheckoutSession.ts b/server/private/routers/billing/createCheckoutSession.ts index b35c65329..670ec7e3b 100644 --- a/server/private/routers/billing/createCheckoutSession.ts +++ b/server/private/routers/billing/createCheckoutSession.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/createPortalSession.ts b/server/private/routers/billing/createPortalSession.ts index 9ebe84e09..d564e8736 100644 --- a/server/private/routers/billing/createPortalSession.ts +++ b/server/private/routers/billing/createPortalSession.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index d86e23cf0..6cb98ba5d 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -30,8 +30,10 @@ import { userOrgRoles, siteProvisioningKeyOrg, siteProvisioningKeys, + alertRules, + targetHealthCheck } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; /** * Get the maximum allowed retention days for a given tier @@ -318,6 +320,14 @@ async function disableFeature( await disableSiteProvisioningKeys(orgId); break; + case TierFeature.AlertingRules: + await disableAlertingRules(orgId); + break; + + case TierFeature.StandaloneHealthChecks: + await disableStandaloneHealthChecks(orgId); + break; + default: logger.warn( `Unknown feature ${feature} for org ${orgId}, skipping` @@ -360,8 +370,7 @@ async function disableFullRbac(orgId: string): Promise { async function disableSiteProvisioningKeys(orgId: string): Promise { const rows = await db .select({ - siteProvisioningKeyId: - siteProvisioningKeyOrg.siteProvisioningKeyId + siteProvisioningKeyId: siteProvisioningKeyOrg.siteProvisioningKeyId }) .from(siteProvisioningKeyOrg) .where(eq(siteProvisioningKeyOrg.orgId, orgId)); @@ -525,6 +534,29 @@ async function disablePasswordExpirationPolicies(orgId: string): Promise { logger.info(`Disabled password expiration policies for org ${orgId}`); } +async function disableAlertingRules(orgId: string): Promise { + await db + .update(alertRules) + .set({ enabled: false }) + .where(eq(alertRules.orgId, orgId)); + + logger.info(`Disabled all alert rules for org ${orgId}`); +} + +async function disableStandaloneHealthChecks(orgId: string): Promise { + await db + .update(targetHealthCheck) + .set({ hcEnabled: false }) + .where( + and( + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + logger.info(`Disabled standalone health checks for org ${orgId}`); +} + async function disableAutoProvisioning(orgId: string): Promise { // Get all IDP IDs for this org through the idpOrg join table const orgIdps = await db diff --git a/server/private/routers/billing/getOrgSubscriptions.ts b/server/private/routers/billing/getOrgSubscriptions.ts index 718c98f46..c29747187 100644 --- a/server/private/routers/billing/getOrgSubscriptions.ts +++ b/server/private/routers/billing/getOrgSubscriptions.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index cc722cec8..2429b1066 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/hooks/getSubType.ts b/server/private/routers/billing/hooks/getSubType.ts index 44cfe0026..3539ffed2 100644 --- a/server/private/routers/billing/hooks/getSubType.ts +++ b/server/private/routers/billing/hooks/getSubType.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/hooks/handleCustomerCreated.ts b/server/private/routers/billing/hooks/handleCustomerCreated.ts index fdccc8dde..66ad3a4fa 100644 --- a/server/private/routers/billing/hooks/handleCustomerCreated.ts +++ b/server/private/routers/billing/hooks/handleCustomerCreated.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -12,9 +12,10 @@ */ import Stripe from "stripe"; -import { customers, db } from "@server/db"; +import { customers, db, subscriptions } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; +import { generateId } from "@server/auth/sessions/app"; export async function handleCustomerCreated( customer: Stripe.Customer @@ -38,14 +39,31 @@ export async function handleCustomerCreated( return; } - await db.insert(customers).values({ - customerId: customer.id, - orgId: customer.metadata.orgId, - email: customer.email || null, - name: customer.name || null, - createdAt: customer.created, - updatedAt: customer.created + await db.transaction(async (trx) => { + await trx.insert(customers).values({ + customerId: customer.id, + orgId: customer.metadata.orgId, + email: customer.email || null, + name: customer.name || null, + createdAt: customer.created, + updatedAt: customer.created + }); + + // Insert a 14-day trial subscription at tier3 + const now = Math.floor(Date.now() / 1000); + const trialExpiresAt = now + 10 * 24 * 60 * 60; + const subscriptionId = `trial-${generateId(15)}`; + await trx.insert(subscriptions).values({ + subscriptionId, + customerId: customer.id, + status: "active", + type: "tier3", + createdAt: now, + expiresAt: trialExpiresAt, + trial: true + }); }); + logger.info(`Customer with ID ${customer.id} created successfully.`); } catch (error) { logger.error( diff --git a/server/private/routers/billing/hooks/handleCustomerDeleted.ts b/server/private/routers/billing/hooks/handleCustomerDeleted.ts index e41403539..518fe91bc 100644 --- a/server/private/routers/billing/hooks/handleCustomerDeleted.ts +++ b/server/private/routers/billing/hooks/handleCustomerDeleted.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/hooks/handleCustomerUpdated.ts b/server/private/routers/billing/hooks/handleCustomerUpdated.ts index 3a0210a94..926d20619 100644 --- a/server/private/routers/billing/hooks/handleCustomerUpdated.ts +++ b/server/private/routers/billing/hooks/handleCustomerUpdated.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index a40142526..947f28c14 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -174,6 +174,19 @@ export async function handleSubscriptionCreated( // TODO: update user in Sendy } } + + // delete the trial subscrition if we have one + await db + .delete(subscriptions) + .where( + and( + eq( + subscriptions.customerId, + subscription.customer as string + ), + eq(subscriptions.trial, true) + ) + ); } else if (type === "license") { logger.debug( `License subscription created for org ${customer.orgId}, no lifecycle handling needed.` @@ -217,7 +230,7 @@ export async function handleSubscriptionCreated( subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE] ) { numUsers = 50; - numSites = 50; + numSites = 100; } else { logger.error( `Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}` diff --git a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts index a029fc5c3..962cdd424 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index 0305e7f1b..e1ec7a7b9 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/index.ts b/server/private/routers/billing/index.ts index 6555f5499..af3556c48 100644 --- a/server/private/routers/billing/index.ts +++ b/server/private/routers/billing/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/internalGetOrgTier.ts b/server/private/routers/billing/internalGetOrgTier.ts index 92bbc2baa..36dcbde09 100644 --- a/server/private/routers/billing/internalGetOrgTier.ts +++ b/server/private/routers/billing/internalGetOrgTier.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/subscriptionLifecycle.ts b/server/private/routers/billing/subscriptionLifecycle.ts index a80f64c0a..76fb6ec8e 100644 --- a/server/private/routers/billing/subscriptionLifecycle.ts +++ b/server/private/routers/billing/subscriptionLifecycle.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/billing/webhooks.ts b/server/private/routers/billing/webhooks.ts index 9c64350c9..7c9cd1ed6 100644 --- a/server/private/routers/billing/webhooks.ts +++ b/server/private/routers/billing/webhooks.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/certificates/createCertificate.ts b/server/private/routers/certificates/createCertificate.ts index 43a3426e5..048b92352 100644 --- a/server/private/routers/certificates/createCertificate.ts +++ b/server/private/routers/certificates/createCertificate.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -15,7 +15,6 @@ import { Certificate, certificates, db, domains } from "@server/db"; import logger from "@server/logger"; import { Transaction } from "@server/db"; import { eq, or, and, like } from "drizzle-orm"; -import privateConfig from "#private/lib/config"; /** * Checks if a certificate exists for the given domain. @@ -27,10 +26,6 @@ export async function createCertificate( domain: string, trx: Transaction | typeof db ) { - if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - return; - } - const [domainRecord] = await trx .select() .from(domains) @@ -42,18 +37,25 @@ export async function createCertificate( } let existing: Certificate[] = []; - if (domainRecord.type == "ns") { + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + existing = await trx .select() .from(certificates) .where( and( eq(certificates.domainId, domainId), - eq(certificates.wildcard, true), // only NS domains can have wildcard certs or( eq(certificates.domain, domain), - eq(certificates.domain, domainLevelDown) + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) ) ) ); @@ -75,11 +77,38 @@ export async function createCertificate( return; } + let domainToWrite = domain; + if ( + domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD + domainRecord.preferWildcardCert && + !domain.startsWith("*.") + ) { + // in this case traefik is going to generate a domain one level down so we need to store it that way + const parts = domain.split("."); + if (parts.length > 2) { + domainToWrite = parts.slice(1).join("."); + domainToWrite = `*.${domainToWrite}`; + } + } else if (domainRecord.type == "ns") { + // first if we have a * in the domain for this case we dont want to include it because it will mess with the cert generator so remove it + if (domain.startsWith("*.")) { + domain = domain.slice(2); + } + + const parts = domain.split("."); + if (parts.length > 2) { + domainToWrite = parts.slice(1).join("."); + } + } + // No cert found, create a new one in pending state await trx.insert(certificates).values({ - domain, + domain: domainToWrite, domainId, - wildcard: domainRecord.type == "ns", // we can only create wildcard certs for NS domains + wildcard: + domainRecord.type == "ns" || + (domainRecord.type == "wildcard" && + domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains status: "pending", updatedAt: Math.floor(Date.now() / 1000), createdAt: Math.floor(Date.now() / 1000) diff --git a/server/private/routers/certificates/getCertificate.ts b/server/private/routers/certificates/getCertificate.ts index d06a1badc..fca53e9bb 100644 --- a/server/private/routers/certificates/getCertificate.ts +++ b/server/private/routers/certificates/getCertificate.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -40,9 +40,12 @@ async function query(domainId: string, domain: string) { throw new Error(`Domain with ID ${domainId} not found`); } + const domainType = domainRecord.type; + let existing: any[] = []; - if (domainRecord.type == "ns") { + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; existing = await db .select({ @@ -61,10 +64,15 @@ async function query(domainId: string, domain: string) { .where( and( eq(certificates.domainId, domainId), - eq(certificates.wildcard, true), // only NS domains can have wildcard certs or( eq(certificates.domain, domain), - eq(certificates.domain, domainLevelDown) + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) ) ) ); @@ -92,7 +100,7 @@ async function query(domainId: string, domain: string) { ); } - return existing.length > 0 ? existing[0] : null; + return existing.length > 0 ? { ...existing[0], domainType } : null; } registry.registerPath({ diff --git a/server/private/routers/certificates/index.ts b/server/private/routers/certificates/index.ts index b1543e5d5..18b942d5c 100644 --- a/server/private/routers/certificates/index.ts +++ b/server/private/routers/certificates/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -13,3 +13,4 @@ export * from "./getCertificate"; export * from "./restartCertificate"; +export * from "./syncCertToNewts"; diff --git a/server/private/routers/certificates/restartCertificate.ts b/server/private/routers/certificates/restartCertificate.ts index 0e4b19108..492aacc09 100644 --- a/server/private/routers/certificates/restartCertificate.ts +++ b/server/private/routers/certificates/restartCertificate.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/certificates/syncCertToNewts.ts b/server/private/routers/certificates/syncCertToNewts.ts new file mode 100644 index 000000000..ac6089acb --- /dev/null +++ b/server/private/routers/certificates/syncCertToNewts.ts @@ -0,0 +1,68 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { pushCertUpdateToAffectedNewts } from "#private/lib/acmeCertSync"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; + +const bodySchema = z.object({ + domain: z.string().min(1), + domainId: z.string().nullable().optional().default(null) +}); + +export async function syncCertToNewts( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsed = bodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + + const { domain, domainId } = parsed.data; + + logger.debug( + `syncCertToNewts: received request to push cert update for domain "${domain}" (domainId: ${domainId ?? "none"})` + ); + + try { + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); + + res.status(HttpCode.OK).json({ + data: null, + success: true, + error: false, + message: `Certificate update pushed to affected newts for domain "${domain}"` + }); + } catch (err) { + logger.error( + `syncCertToNewts: error pushing cert update for domain "${domain}": ${err}` + ); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to push certificate update to affected newts" + ) + ); + } +} \ No newline at end of file diff --git a/server/private/routers/domain/checkDomainNamespaceAvailability.ts b/server/private/routers/domain/checkDomainNamespaceAvailability.ts index 0bb7f8704..9caacb0c9 100644 --- a/server/private/routers/domain/checkDomainNamespaceAvailability.ts +++ b/server/private/routers/domain/checkDomainNamespaceAvailability.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/domain/index.ts b/server/private/routers/domain/index.ts index 3f4bbbf2a..f5bb04689 100644 --- a/server/private/routers/domain/index.ts +++ b/server/private/routers/domain/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/domain/listDomainNamespaces.ts b/server/private/routers/domain/listDomainNamespaces.ts index 5bbd25b1a..cb89f6a0d 100644 --- a/server/private/routers/domain/listDomainNamespaces.ts +++ b/server/private/routers/domain/listDomainNamespaces.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts index bef7ba7e9..cb8f95b07 100644 --- a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts index d93bc4405..2ab75d7c8 100644 --- a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/eventStreamingDestination/index.ts b/server/private/routers/eventStreamingDestination/index.ts index 595e9595b..235f1336e 100644 --- a/server/private/routers/eventStreamingDestination/index.ts +++ b/server/private/routers/eventStreamingDestination/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts index ac3f14e62..10a6c3600 100644 --- a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts +++ b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts index 24dc68aef..b7a9f8598 100644 --- a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 4410a44c8..a2667daa1 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -29,6 +29,8 @@ import * as ssh from "#private/routers/ssh"; import * as user from "#private/routers/user"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; +import * as alertRule from "#private/routers/alertRule"; +import * as healthChecks from "#private/routers/healthChecks"; import { verifyOrgAccess, @@ -40,7 +42,9 @@ import { verifyRoleAccess, verifyUserAccess, verifyUserCanSetUserOrgRoles, - verifySiteProvisioningKeyAccess + verifySiteProvisioningKeyAccess, + verifyIsLoggedInUser, + verifyAdmin } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -87,6 +91,7 @@ authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createIdp), @@ -94,10 +99,23 @@ authenticated.put( orgIdp.createOrgOidcIdp ); +authenticated.post( + "/org/:orgId/idp/:idpId/import", + verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), + orgIdp.requireOrgIdentityProviderMode, + verifyOrgAccess, + verifyLimits, + verifyAdmin, + logActionAudit(ActionsEnum.createIdp), + orgIdp.importOrgIdp +); + authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyIdpAccess, verifyLimits, @@ -109,6 +127,7 @@ authenticated.post( authenticated.delete( "/org/:orgId/idp/:idpId", verifyValidLicense, + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), @@ -116,6 +135,17 @@ authenticated.delete( orgIdp.deleteOrgIdp ); +authenticated.delete( + "/org/:orgId/idp/:idpId/association", + verifyValidLicense, + orgIdp.requireOrgIdentityProviderMode, + verifyOrgAccess, + verifyIdpAccess, + verifyUserHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.unassociateOrgIdp +); + authenticated.get( "/org/:orgId/idp/:idpId", verifyValidLicense, @@ -125,19 +155,16 @@ authenticated.get( orgIdp.getOrgIdp ); -authenticated.get( - "/org/:orgId/idp", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.listIdps), - orgIdp.listOrgIdps -); - authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this; it's just a list of idp names and ids +authenticated.get( + "/user/:userId/admin-org-idps", + verifyIsLoggedInUser, + orgIdp.listUserAdminOrgIdps +); + authenticated.get( "/org/:orgId/certificate/:domainId/:domain", - verifyValidLicense, verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.getCertificate), @@ -214,6 +241,13 @@ if (build === "saas") { generateLicense.generateNewEnterpriseLicense ); + authenticated.post( + "/org/:orgId/license/:licenseKey/clear-instance-name", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + generateLicense.clearInstanceName + ); + authenticated.post( "/send-support-request", rateLimit({ @@ -648,7 +682,96 @@ authenticated.delete( authenticated.get( "/org/:orgId/event-streaming-destinations", + verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listEventStreamingDestinations), eventStreamingDestination.listEventStreamingDestinations ); + +authenticated.put( + "/org/:orgId/alert-rule", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createAlertRule), + logActionAudit(ActionsEnum.createAlertRule), + alertRule.createAlertRule +); + +authenticated.post( + "/org/:orgId/alert-rule/:alertRuleId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateAlertRule), + logActionAudit(ActionsEnum.updateAlertRule), + alertRule.updateAlertRule +); + +authenticated.delete( + "/org/:orgId/alert-rule/:alertRuleId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteAlertRule), + logActionAudit(ActionsEnum.deleteAlertRule), + alertRule.deleteAlertRule +); + +authenticated.get( + "/org/:orgId/alert-rules", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listAlertRules), + alertRule.listAlertRules +); + +authenticated.get( + "/org/:orgId/alert-rule/:alertRuleId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getAlertRule), + alertRule.getAlertRule +); + +authenticated.get( + "/org/:orgId/health-checks", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listHealthChecks), + healthChecks.listHealthChecks +); + +authenticated.put( + "/org/:orgId/health-check", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createHealthCheck), + logActionAudit(ActionsEnum.createHealthCheck), + healthChecks.createHealthCheck +); + +authenticated.post( + "/org/:orgId/health-check/:healthCheckId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateHealthCheck), + logActionAudit(ActionsEnum.updateHealthCheck), + healthChecks.updateHealthCheck +); + +authenticated.delete( + "/org/:orgId/health-check/:healthCheckId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteHealthCheck), + logActionAudit(ActionsEnum.deleteHealthCheck), + healthChecks.deleteHealthCheck +); + +authenticated.get( + "/org/:orgId/health-check/:healthCheckId/status-history", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getTarget), + healthChecks.getHealthCheckStatusHistory +); diff --git a/server/private/routers/generatedLicense/clearInstanceName.ts b/server/private/routers/generatedLicense/clearInstanceName.ts new file mode 100644 index 000000000..ed176a976 --- /dev/null +++ b/server/private/routers/generatedLicense/clearInstanceName.ts @@ -0,0 +1,87 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib/response"; +import privateConfig from "#private/lib/config"; +import z from "zod"; +import { fromError } from "zod-validation-error"; + +const clearInstanceNameParamsSchema = z.object({ + orgId: z.string(), + licenseKey: z.string() +}); + +export async function clearInstanceName( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = clearInstanceNameParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { licenseKey } = parsedParams.data; + + const apiResponse = await fetch( + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/clear-instance-name`, + { + method: "POST", + headers: { + "api-key": + privateConfig.getRawPrivateConfig().server + .fossorial_api_key!, + "Content-Type": "application/json" + }, + body: JSON.stringify({ licenseKey }) + } + ); + + const data = await apiResponse.json(); + + if (!data.success || data.error) { + return next( + createHttpError( + data.status || HttpCode.BAD_REQUEST, + data.message || "Failed to clear instance name from Fossorial API" + ) + ); + } + + return sendResponse(res, { + data: null, + success: true, + error: false, + message: "Instance name cleared successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while clearing the instance name." + ) + ); + } +} \ No newline at end of file diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts index 50248f1f9..05b363d75 100644 --- a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index 9835f40a4..f9349fc46 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/generatedLicense/index.ts b/server/private/routers/generatedLicense/index.ts index 70b9b001c..b527dc721 100644 --- a/server/private/routers/generatedLicense/index.ts +++ b/server/private/routers/generatedLicense/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -14,3 +14,4 @@ export * from "./listGeneratedLicenses"; export * from "./generateNewLicense"; export * from "./generateNewEnterpriseLicense"; +export * from "./clearInstanceName"; diff --git a/server/private/routers/generatedLicense/listGeneratedLicenses.ts b/server/private/routers/generatedLicense/listGeneratedLicenses.ts index cb9308824..61b9d04f2 100644 --- a/server/private/routers/generatedLicense/listGeneratedLicenses.ts +++ b/server/private/routers/generatedLicense/listGeneratedLicenses.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/gerbil/createExitNode.ts b/server/private/routers/gerbil/createExitNode.ts index 03fe899d4..818c5f0e1 100644 --- a/server/private/routers/gerbil/createExitNode.ts +++ b/server/private/routers/gerbil/createExitNode.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts new file mode 100644 index 000000000..0fa5a77e9 --- /dev/null +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -0,0 +1,199 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, targetHealthCheck, newts, sites } 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 { addStandaloneHealthCheck } from "@server/routers/newt/targets"; +import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.strictObject({ + name: z.string().nonempty(), + siteId: z.number().int().positive(), + hcEnabled: z.boolean().default(false), + hcMode: z.string().default("http"), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().default("GET"), + hcInterval: z.number().int().positive().default(30), + hcUnhealthyInterval: z.number().int().positive().default(30), + hcTimeout: z.number().int().positive().default(1), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().default(true), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: z.number().int().positive().default(1), + hcUnhealthyThreshold: z.number().int().positive().default(1) +}); + +export type CreateHealthCheckResponse = { + targetHealthCheckId: number; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/health-check", + description: "Create a health check for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createHealthCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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, + siteId, + hcEnabled, + hcMode, + hcHostname, + hcPort, + hcPath, + hcScheme, + hcMethod, + hcInterval, + hcUnhealthyInterval, + hcTimeout, + hcHeaders, + hcFollowRedirects, + hcStatus, + hcTlsServerName, + hcHealthyThreshold, + hcUnhealthyThreshold + } = parsedBody.data; + + const [record] = await db + .insert(targetHealthCheck) + .values({ + targetId: null, + orgId, + siteId, + name, + hcEnabled, + hcMode, + hcHostname: hcHostname ?? null, + hcPort: hcPort ?? null, + hcPath: hcPath ?? null, + hcScheme: hcScheme ?? null, + hcMethod, + hcInterval, + hcUnhealthyInterval, + hcTimeout, + hcHeaders: hcHeaders ?? null, + hcFollowRedirects, + hcStatus: hcStatus ?? null, + hcTlsServerName: hcTlsServerName ?? null, + hcHealthyThreshold, + hcUnhealthyThreshold, + hcHealth: "unhealthy" + }) + .returning(); + + await fireHealthCheckUnhealthyAlert( + record.orgId, + record.targetHealthCheckId, + record.name || "", + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + + // Push health check to newt if the site is a newt site + if (siteId) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (site && site.type === "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (newt) { + await addStandaloneHealthCheck( + newt.newtId, + record, + newt.version + ); + } + } + } + + return response(res, { + data: { + targetHealthCheckId: record.targetHealthCheckId + }, + success: true, + error: false, + message: "Standalone health check created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/healthChecks/deleteHealthCheck.ts b/server/private/routers/healthChecks/deleteHealthCheck.ts new file mode 100644 index 000000000..530653aab --- /dev/null +++ b/server/private/routers/healthChecks/deleteHealthCheck.ts @@ -0,0 +1,123 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, targetHealthCheck, newts, sites } from "@server/db"; +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 { and, eq, isNull } from "drizzle-orm"; +import { removeStandaloneHealthCheck } from "@server/routers/newt/targets"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + healthCheckId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/health-check/{healthCheckId}", + description: "Delete a health check for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteHealthCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, healthCheckId } = parsedParams.data; + + const [existing] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Standalone health check not found" + ) + ); + } + + await db + .delete(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + // Remove health check from newt if the site is a newt site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, existing.siteId)) + .limit(1); + + if (newt) { + await removeStandaloneHealthCheck( + newt.newtId, + healthCheckId, + newt.version + ); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Standalone health check deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts new file mode 100644 index 000000000..51a59e2e1 --- /dev/null +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -0,0 +1,75 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +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 { + getCachedStatusHistory, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; + +const healthCheckParamsSchema = z.object({ + healthCheckId: z.string().transform((v) => parseInt(v, 10)) +}); + +export async function getHealthCheckStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = healthCheckParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "health_check"; + const entityId = parsedParams.data.healthCheckId; + const { days } = parsedQuery.data; + + const data = await getCachedStatusHistory(entityType, entityId, days); + + return response(res, { + data, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/healthChecks/index.ts b/server/private/routers/healthChecks/index.ts new file mode 100644 index 000000000..665ae5cca --- /dev/null +++ b/server/private/routers/healthChecks/index.ts @@ -0,0 +1,18 @@ +/* + * 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 "./listHealthChecks"; +export * from "./createHealthCheck"; +export * from "./updateHealthCheck"; +export * from "./deleteHealthCheck"; +export * from "./getStatusHistory"; diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts new file mode 100644 index 000000000..26cb75e9c --- /dev/null +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -0,0 +1,234 @@ +/* + * 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, targetHealthCheck, targets, resources, sites } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, exists, isNotNull, like, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()), + query: z.string().optional(), + hcMode: z.enum(["http", "tcp", "snmp", "ping"]).optional(), + siteId: z + .string() + .optional() + .transform((s) => (s == null || s === "" ? undefined : Number(s))) + .pipe(z.union([z.undefined(), z.number().int().positive()])), + resourceId: z + .string() + .optional() + .transform((s) => (s == null || s === "" ? undefined : Number(s))) + .pipe(z.union([z.undefined(), z.number().int().positive()])), + hcHealth: z.enum(["healthy", "unhealthy", "unknown"]).optional(), + hcEnabled: z + .enum(["true", "false"]) + .optional() + .transform((v) => (v === undefined ? undefined : v === "true")) +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/health-checks", + description: "List health checks for an organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listHealthChecks( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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 parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { + limit, + offset, + query, + hcMode, + siteId, + resourceId, + hcHealth, + hcEnabled + } = parsedQuery.data; + + const resourceIdFilter = resourceId + ? exists( + db + .select() + .from(targets) + .where( + and( + eq(targets.targetId, targetHealthCheck.targetId), + eq(targets.resourceId, resourceId) + ) + ) + ) + : undefined; + + const whereClause = and( + eq(targetHealthCheck.orgId, orgId), + isNotNull(targetHealthCheck.hcMode), // filter out the null ones attached to targets + query + ? like( + sql`LOWER(${targetHealthCheck.name})`, + `%${query.toLowerCase()}%` + ) + : undefined, + hcMode ? eq(targetHealthCheck.hcMode, hcMode) : undefined, + siteId ? eq(targetHealthCheck.siteId, siteId) : undefined, + resourceIdFilter, + hcHealth ? eq(targetHealthCheck.hcHealth, hcHealth) : undefined, + hcEnabled !== undefined + ? eq(targetHealthCheck.hcEnabled, hcEnabled) + : undefined + ); + + const list = await db + .select({ + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, + name: targetHealthCheck.name, + siteId: targetHealthCheck.siteId, + siteName: sites.name, + siteNiceId: sites.niceId, + hcEnabled: targetHealthCheck.hcEnabled, + hcHealth: targetHealthCheck.hcHealth, + hcMode: targetHealthCheck.hcMode, + hcHostname: targetHealthCheck.hcHostname, + hcPort: targetHealthCheck.hcPort, + hcPath: targetHealthCheck.hcPath, + hcScheme: targetHealthCheck.hcScheme, + hcMethod: targetHealthCheck.hcMethod, + hcInterval: targetHealthCheck.hcInterval, + hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, + hcTimeout: targetHealthCheck.hcTimeout, + hcHeaders: targetHealthCheck.hcHeaders, + hcFollowRedirects: targetHealthCheck.hcFollowRedirects, + hcStatus: targetHealthCheck.hcStatus, + hcTlsServerName: targetHealthCheck.hcTlsServerName, + hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold, + resourceId: resources.resourceId, + resourceName: resources.name, + resourceNiceId: resources.niceId + }) + .from(targetHealthCheck) + .leftJoin(targets, eq(targetHealthCheck.targetId, targets.targetId)) + .leftJoin(resources, eq(targets.resourceId, resources.resourceId)) + .leftJoin(sites, eq(targetHealthCheck.siteId, sites.siteId)) + .where(whereClause) + .orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`) + .limit(limit) + .offset(offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(targetHealthCheck) + .where(whereClause); + + return response(res, { + data: { + healthChecks: list.map((row) => ({ + targetHealthCheckId: row.targetHealthCheckId, + name: row.name ?? "", + siteId: row.siteId ?? null, + siteName: row.siteName ?? null, + siteNiceId: row.siteNiceId ?? null, + hcEnabled: row.hcEnabled, + hcHealth: (row.hcHealth ?? "unknown") as + | "unknown" + | "healthy" + | "unhealthy", + hcMode: row.hcMode ?? null, + hcHostname: row.hcHostname ?? null, + hcPort: row.hcPort ?? null, + hcPath: row.hcPath ?? null, + hcScheme: row.hcScheme ?? null, + hcMethod: row.hcMethod ?? null, + hcInterval: row.hcInterval ?? null, + hcUnhealthyInterval: row.hcUnhealthyInterval ?? null, + hcTimeout: row.hcTimeout ?? null, + hcHeaders: row.hcHeaders ?? null, + hcFollowRedirects: row.hcFollowRedirects ?? null, + hcStatus: row.hcStatus ?? null, + hcTlsServerName: row.hcTlsServerName ?? null, + hcHealthyThreshold: row.hcHealthyThreshold ?? null, + hcUnhealthyThreshold: row.hcUnhealthyThreshold ?? null, + resourceId: row.resourceId ?? null, + resourceName: row.resourceName ?? null, + resourceNiceId: row.resourceNiceId ?? null + })), + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Standalone health checks retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts new file mode 100644 index 000000000..4df92a5a7 --- /dev/null +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -0,0 +1,325 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, targetHealthCheck, newts, sites } from "@server/db"; +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 { and, eq, isNull } from "drizzle-orm"; +import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; +import { + fireHealthCheckUnhealthyAlert, + fireHealthCheckUnknownAlert, + fireHealthCheckHealthyAlert +} from "@server/lib/alerts"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + healthCheckId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +const bodySchema = z.strictObject({ + name: z.string().nonempty().optional(), + siteId: z.number().int().positive().optional(), + hcEnabled: z.boolean().optional(), + hcMode: z.string().optional(), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().optional(), + hcInterval: z.number().int().positive().optional(), + hcUnhealthyInterval: z.number().int().positive().optional(), + hcTimeout: z.number().int().positive().optional(), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().optional(), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: z.number().int().positive().optional(), + hcUnhealthyThreshold: z.number().int().positive().optional() +}); + +export type UpdateHealthCheckResponse = { + targetHealthCheckId: number; + name: string | null; + siteId: number | null; + hcEnabled: boolean; + hcHealth: string | null; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/health-check/{healthCheckId}", + description: "Update a health check for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateHealthCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, healthCheckId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Standalone health check not found" + ) + ); + } + + const { + name, + siteId, + hcEnabled, + hcMode, + hcHostname, + hcPort, + hcPath, + hcScheme, + hcMethod, + hcInterval, + hcUnhealthyInterval, + hcTimeout, + hcHeaders, + hcFollowRedirects, + hcStatus, + hcTlsServerName, + hcHealthyThreshold, + hcUnhealthyThreshold + } = parsedBody.data; + + const updateData: Record = {}; + + const [existingHealthCheck] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId) + ) + ) + .limit(1); + + if (name !== undefined) updateData.name = name; + if (siteId !== undefined) updateData.siteId = siteId; + if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; + if (hcMode !== undefined) updateData.hcMode = hcMode; + if (hcHostname !== undefined) updateData.hcHostname = hcHostname; + if (hcPort !== undefined) updateData.hcPort = hcPort; + if (hcPath !== undefined) updateData.hcPath = hcPath; + if (hcScheme !== undefined) updateData.hcScheme = hcScheme; + if (hcMethod !== undefined) updateData.hcMethod = hcMethod; + if (hcInterval !== undefined) updateData.hcInterval = hcInterval; + if (hcUnhealthyInterval !== undefined) + updateData.hcUnhealthyInterval = hcUnhealthyInterval; + if (hcTimeout !== undefined) updateData.hcTimeout = hcTimeout; + if (hcHeaders !== undefined) updateData.hcHeaders = hcHeaders; + if (hcFollowRedirects !== undefined) + updateData.hcFollowRedirects = hcFollowRedirects; + if (hcStatus !== undefined) updateData.hcStatus = hcStatus; + if (hcTlsServerName !== undefined) + updateData.hcTlsServerName = hcTlsServerName; + if (hcHealthyThreshold !== undefined) + updateData.hcHealthyThreshold = hcHealthyThreshold; + if (hcUnhealthyThreshold !== undefined) + updateData.hcUnhealthyThreshold = hcUnhealthyThreshold; + + const hcEnabledTurnedOn = + parsedBody.data.hcEnabled === true && + existingHealthCheck.hcEnabled === false; + + let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; + if ( + parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null + ) { + hcHealthValue = "unknown"; + } else if (hcEnabledTurnedOn) { + hcHealthValue = "unhealthy"; + } else { + hcHealthValue = undefined; + } + + if (hcHealthValue) { + updateData.hcHealth = hcHealthValue; + } + + const [updated] = await db + .update(targetHealthCheck) + .set(updateData) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ) + .returning(); + + if ( + updated.hcHealth === "unhealthy" && + existingHealthCheck.hcHealth !== "unhealthy" + ) { + await fireHealthCheckUnhealthyAlert( + updated.orgId, + updated.targetHealthCheckId, + updated.name || "", + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } else if ( + updated.hcHealth === "unknown" && + existingHealthCheck.hcHealth !== "unknown" + ) { + // if the health is unknown, we want to fire an alert to notify users to enable health checks + await fireHealthCheckUnknownAlert( + updated.orgId, + updated.targetHealthCheckId, + updated.name, + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } else if ( + updated.hcHealth === "healthy" && + existingHealthCheck.hcHealth !== "healthy" + ) { + await fireHealthCheckHealthyAlert( + updated.orgId, + updated.targetHealthCheckId, + updated.name, + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } + + // Push updated health check to newt if the site is a newt site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, updated.siteId)) + .limit(1); + + if (newt) { + await addStandaloneHealthCheck(newt.newtId, updated, newt.version); + } + + return response(res, { + data: { + targetHealthCheckId: updated.targetHealthCheckId, + siteId: updated.siteId ?? null, + name: updated.name ?? null, + hcEnabled: updated.hcEnabled, + hcHealth: updated.hcHealth ?? null, + hcMode: updated.hcMode ?? null, + hcHostname: updated.hcHostname ?? null, + hcPort: updated.hcPort ?? null, + hcPath: updated.hcPath ?? null, + hcScheme: updated.hcScheme ?? null, + hcMethod: updated.hcMethod ?? null, + hcInterval: updated.hcInterval ?? null, + hcUnhealthyInterval: updated.hcUnhealthyInterval ?? null, + hcTimeout: updated.hcTimeout ?? null, + hcHeaders: updated.hcHeaders ?? null, + hcFollowRedirects: updated.hcFollowRedirects ?? null, + hcStatus: updated.hcStatus ?? null, + hcTlsServerName: updated.hcTlsServerName ?? null, + hcHealthyThreshold: updated.hcHealthyThreshold ?? null, + hcUnhealthyThreshold: updated.hcUnhealthyThreshold ?? null + }, + success: true, + error: false, + message: "Standalone health check updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 13a6f70e0..98e7ff671 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -24,14 +24,8 @@ import { User, certificates, exitNodeOrgs, - RemoteExitNode, - olms, - newts, - clients, - sites, domains, orgDomains, - targets, loginPage, loginPageOrg, LoginPage, @@ -56,7 +50,7 @@ import { userOrgRoles, roles } from "@server/db"; -import { eq, and, inArray, isNotNull, ne } from "drizzle-orm"; +import { eq, and, inArray, isNotNull, ne, or, sql } from "drizzle-orm"; import { response } from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; @@ -70,12 +64,9 @@ import { updateAndGenerateEndpointDestinations, updateSiteBandwidth } from "@server/routers/gerbil"; -import * as gerbil from "@server/routers/gerbil"; import logger from "@server/logger"; -import { decryptData } from "@server/lib/encryption"; +import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import privateConfig from "#private/lib/config"; -import * as fs from "fs"; import { exchangeSession } from "@server/routers/badger"; import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; @@ -298,25 +289,11 @@ hybridRouter.get( } ); -let encryptionKeyHex = ""; -let encryptionKey: Buffer; -function loadEncryptData() { - if (encryptionKey) { - return; // already loaded - } - - encryptionKeyHex = - privateConfig.getRawPrivateConfig().server.encryption_key; - encryptionKey = Buffer.from(encryptionKeyHex, "hex"); -} - // Get valid certificates for given domains (supports wildcard certs) hybridRouter.get( "/certificates/domains", async (req: Request, res: Response, next: NextFunction) => { try { - loadEncryptData(); // Ensure encryption key is loaded - const parsed = getCertificatesByDomainsQuerySchema.safeParse( req.query ); @@ -447,13 +424,13 @@ hybridRouter.get( const result = filtered.map((cert) => { // Decrypt and save certificate file - const decryptedCert = decryptData( + const decryptedCert = decrypt( cert.certFile!, // is not null from query - encryptionKey + config.getRawConfig().server.secret! ); // Decrypt and save key file - const decryptedKey = decryptData(cert.keyFile!, encryptionKey); + const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!); // Return only the certificate data without org information return { @@ -515,7 +492,15 @@ hybridRouter.get( ); } - const [result] = await db + // Build wildcard domain candidates for the requested domain. + // e.g. "me.example.test.com" -> ["*.example.test.com", "*.test.com"] + const domainParts = domain.split("."); + const wildcardCandidates: string[] = []; + for (let i = 1; i < domainParts.length; i++) { + wildcardCandidates.push(`*.${domainParts.slice(i).join(".")}`); + } + + const potentialResults = await db .select() .from(resources) .leftJoin( @@ -538,10 +523,28 @@ hybridRouter.get( ) ) .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) - .where(eq(resources.fullDomain, domain)) - .limit(1); + .where( + or( + // Exact match + eq(resources.fullDomain, domain), + // Wildcard match + wildcardCandidates.length > 0 + ? and( + eq(resources.wildcard, true), + inArray(resources.fullDomain, wildcardCandidates) + ) + : sql`false` + ) + ); + + // Prefer exact match over wildcard match + const exactMatch = potentialResults.find( + (r) => r.resources?.fullDomain === domain + ); + const result = exactMatch ?? potentialResults[0]; if ( + result && await checkExitNodeOrg( remoteExitNode.exitNodeId, result.resources.orgId @@ -833,9 +836,12 @@ hybridRouter.get( ) ); - logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows); + logger.debug( + `User ${userId} has roles in org ${orgId}:`, + userOrgRoleRows + ); - return response<{ roleId: number, roleName: string }[]>(res, { + return response<{ roleId: number; roleName: string }[]>(res, { data: userOrgRoleRows, success: true, error: false, diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 40bb2b56c..820a843f0 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -14,6 +14,8 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; +import * as alertEvents from "#private/routers/alertEvents"; +import * as certificates from "#private/routers/certificates"; import { verifyApiKeyHasAction, @@ -36,17 +38,48 @@ import { } from "@server/routers/integration"; import { logActionAudit } from "#private/middlewares"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { build } from "@server/build"; export const unauthenticated = ua; export const authenticated = a; -authenticated.post( - `/org/:orgId/send-usage-notification`, - verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine - verifyApiKeyHasAction(ActionsEnum.sendUsageNotification), - logActionAudit(ActionsEnum.sendUsageNotification), - org.sendUsageNotification -); +if (build == "saas") { + authenticated.post( + "/org/:orgId/site/:siteId/trigger-alert", + verifyApiKeyIsRoot, + alertEvents.triggerSiteAlert + ); + + authenticated.post( + "/org/:orgId/resource/:resourceId/trigger-alert", + verifyApiKeyIsRoot, + alertEvents.triggerResourceAlert + ); + + authenticated.post( + "/org/:orgId/health-check/:healthCheckId/trigger-alert", + verifyApiKeyIsRoot, + alertEvents.triggerHealthCheckAlert + ); + + authenticated.post( + "/cert/sync-to-newts", + verifyApiKeyIsRoot, + certificates.syncCertToNewts + ); + + authenticated.post( + `/org/:orgId/send-usage-notification`, + verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine + org.sendUsageNotification + ); + + authenticated.post( + `/org/:orgId/send-trial-notification`, + verifyApiKeyIsRoot, + org.sendTrialNotification + ); +} authenticated.delete( "/idp/:idpId", diff --git a/server/private/routers/internal.ts b/server/private/routers/internal.ts index b599d6627..29b9b9506 100644 --- a/server/private/routers/internal.ts +++ b/server/private/routers/internal.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/license/activateLicense.ts b/server/private/routers/license/activateLicense.ts index f6c8d2663..c1bc322b8 100644 --- a/server/private/routers/license/activateLicense.ts +++ b/server/private/routers/license/activateLicense.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/license/deleteLicenseKey.ts b/server/private/routers/license/deleteLicenseKey.ts index 80212e6a4..9615616d7 100644 --- a/server/private/routers/license/deleteLicenseKey.ts +++ b/server/private/routers/license/deleteLicenseKey.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/license/getLicenseStatus.ts b/server/private/routers/license/getLicenseStatus.ts index c1d0ee78a..04bf49e90 100644 --- a/server/private/routers/license/getLicenseStatus.ts +++ b/server/private/routers/license/getLicenseStatus.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/license/index.ts b/server/private/routers/license/index.ts index 65c652656..6dca08119 100644 --- a/server/private/routers/license/index.ts +++ b/server/private/routers/license/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/license/listLicenseKeys.ts b/server/private/routers/license/listLicenseKeys.ts index 917833bee..69326fb1b 100644 --- a/server/private/routers/license/listLicenseKeys.ts +++ b/server/private/routers/license/listLicenseKeys.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/license/recheckStatus.ts b/server/private/routers/license/recheckStatus.ts index 21b473a6e..5f426aa0b 100644 --- a/server/private/routers/license/recheckStatus.ts +++ b/server/private/routers/license/recheckStatus.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/createLoginPage.ts b/server/private/routers/loginPage/createLoginPage.ts index 72b8a28f2..044d292fb 100644 --- a/server/private/routers/loginPage/createLoginPage.ts +++ b/server/private/routers/loginPage/createLoginPage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/deleteLoginPage.ts b/server/private/routers/loginPage/deleteLoginPage.ts index 0d17a7316..da4c0b8ff 100644 --- a/server/private/routers/loginPage/deleteLoginPage.ts +++ b/server/private/routers/loginPage/deleteLoginPage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/deleteLoginPageBranding.ts b/server/private/routers/loginPage/deleteLoginPageBranding.ts index 0a59ce4e6..579f245d3 100644 --- a/server/private/routers/loginPage/deleteLoginPageBranding.ts +++ b/server/private/routers/loginPage/deleteLoginPageBranding.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/getLoginPage.ts b/server/private/routers/loginPage/getLoginPage.ts index 73f6a3577..e73e047d3 100644 --- a/server/private/routers/loginPage/getLoginPage.ts +++ b/server/private/routers/loginPage/getLoginPage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/getLoginPageBranding.ts b/server/private/routers/loginPage/getLoginPageBranding.ts index ce133c7cd..ef7d1c4a0 100644 --- a/server/private/routers/loginPage/getLoginPageBranding.ts +++ b/server/private/routers/loginPage/getLoginPageBranding.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/index.ts b/server/private/routers/loginPage/index.ts index 1bfe6e16c..01aa39176 100644 --- a/server/private/routers/loginPage/index.ts +++ b/server/private/routers/loginPage/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/loadLoginPage.ts b/server/private/routers/loginPage/loadLoginPage.ts index 7a631c8a6..3b9e3e014 100644 --- a/server/private/routers/loginPage/loadLoginPage.ts +++ b/server/private/routers/loginPage/loadLoginPage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/loadLoginPageBranding.ts b/server/private/routers/loginPage/loadLoginPageBranding.ts index 1197bb10d..f5efa297b 100644 --- a/server/private/routers/loginPage/loadLoginPageBranding.ts +++ b/server/private/routers/loginPage/loadLoginPageBranding.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/updateLoginPage.ts b/server/private/routers/loginPage/updateLoginPage.ts index 6226dda2d..679d03fbc 100644 --- a/server/private/routers/loginPage/updateLoginPage.ts +++ b/server/private/routers/loginPage/updateLoginPage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index 232636543..7e0da2c53 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/misc/index.ts b/server/private/routers/misc/index.ts index da5984252..709e56aca 100644 --- a/server/private/routers/misc/index.ts +++ b/server/private/routers/misc/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/misc/sendSupportEmail.ts b/server/private/routers/misc/sendSupportEmail.ts index cd37560d9..f079d45d7 100644 --- a/server/private/routers/misc/sendSupportEmail.ts +++ b/server/private/routers/misc/sendSupportEmail.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/newt/handleConnectionLogMessage.ts b/server/private/routers/newt/handleConnectionLogMessage.ts index e980f85c9..6355eb783 100644 --- a/server/private/routers/newt/handleConnectionLogMessage.ts +++ b/server/private/routers/newt/handleConnectionLogMessage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -92,9 +92,14 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => { return; } - // Look up the org for this site + // Look up the org for this site and check retention settings const [site] = await db - .select({ orgId: sites.orgId, orgSubnet: orgs.subnet }) + .select({ + orgId: sites.orgId, + orgSubnet: orgs.subnet, + settingsLogRetentionDaysConnection: + orgs.settingsLogRetentionDaysConnection + }) .from(sites) .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) .where(eq(sites.siteId, newt.siteId)); @@ -108,6 +113,13 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => { const orgId = site.orgId; + if (site.settingsLogRetentionDaysConnection === 0) { + logger.debug( + `Connection log retention is disabled for org ${orgId}, skipping` + ); + return; + } + // Extract the CIDR suffix (e.g. "/16") from the org subnet so we can // reconstruct the exact subnet string stored on each client record. const cidrSuffix = site.orgSubnet?.includes("/") diff --git a/server/private/routers/newt/handleRequestLogMessage.ts b/server/private/routers/newt/handleRequestLogMessage.ts new file mode 100644 index 000000000..f06c59bc6 --- /dev/null +++ b/server/private/routers/newt/handleRequestLogMessage.ts @@ -0,0 +1,238 @@ +/* + * 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 } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import logger from "@server/logger"; +import { inflate } from "zlib"; +import { promisify } from "util"; +import { logRequestAudit } from "@server/routers/badger/logRequestAudit"; +import { getCountryCodeForIp } from "@server/lib/geoip"; + +export async function flushRequestLogToDb(): Promise { + return; +} + +const zlibInflate = promisify(inflate); + +interface HTTPRequestLogData { + requestId: string; + resourceId: number; // siteResourceId + timestamp: string; // ISO 8601 + method: string; + scheme: string; // "http" or "https" + host: string; + path: string; + rawQuery?: string; + userAgent?: string; + sourceAddr: string; // ip:port + tls: boolean; +} + +/** + * Decompress a base64-encoded zlib-compressed string into parsed JSON. + */ +async function decompressRequestLog( + compressed: string +): Promise { + const compressedBuffer = Buffer.from(compressed, "base64"); + const decompressed = await zlibInflate(compressedBuffer); + const jsonString = decompressed.toString("utf-8"); + const parsed = JSON.parse(jsonString); + + if (!Array.isArray(parsed)) { + throw new Error("Decompressed request log data is not an array"); + } + + return parsed; +} + +export const handleRequestLogMessage: MessageHandler = async (context) => { + const { message, client } = context; + const newt = client as Newt; + + if (!newt) { + logger.warn("Request log received but no newt client in context"); + return; + } + + if (!newt.siteId) { + logger.warn("Request log received but newt has no siteId"); + return; + } + + if (!message.data?.compressed) { + logger.warn("Request log message missing compressed data"); + return; + } + + // Look up the org for this site and check retention settings + const [site] = await db + .select({ + orgId: sites.orgId, + orgSubnet: orgs.subnet, + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(sites) + .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) + .where(eq(sites.siteId, newt.siteId)); + + if (!site) { + logger.warn( + `Request log received but site ${newt.siteId} not found in database` + ); + return; + } + + const orgId = site.orgId; + + if (site.settingsLogRetentionDaysRequest === 0) { + logger.debug( + `Request log retention is disabled for org ${orgId}, skipping` + ); + return; + } + + let entries: HTTPRequestLogData[]; + try { + entries = await decompressRequestLog(message.data.compressed); + } catch (error) { + logger.error("Failed to decompress request log data:", error); + return; + } + + if (entries.length === 0) { + return; + } + + logger.debug(`Request log entries: ${JSON.stringify(entries)}`); + + // Build a map from sourceIp → external endpoint string by joining clients + // with clientSitesAssociationsCache. The endpoint is the real-world IP:port + // of the client device and is used for GeoIP lookup. + const ipToEndpoint = new Map(); + + const cidrSuffix = site.orgSubnet?.includes("/") + ? site.orgSubnet.substring(site.orgSubnet.indexOf("/")) + : null; + + if (cidrSuffix) { + const uniqueSourceAddrs = new Set(); + for (const entry of entries) { + if (entry.sourceAddr) { + uniqueSourceAddrs.add(entry.sourceAddr); + } + } + + if (uniqueSourceAddrs.size > 0) { + const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => { + const ip = addr.includes(":") ? addr.split(":")[0] : addr; + return `${ip}${cidrSuffix}`; + }); + + const matchedClients = await db + .select({ + subnet: clients.subnet, + endpoint: clientSitesAssociationsCache.endpoint + }) + .from(clients) + .innerJoin( + clientSitesAssociationsCache, + and( + eq( + clientSitesAssociationsCache.clientId, + clients.clientId + ), + eq(clientSitesAssociationsCache.siteId, newt.siteId) + ) + ) + .where( + and( + eq(clients.orgId, orgId), + inArray(clients.subnet, subnetQueries) + ) + ); + + for (const c of matchedClients) { + if (c.endpoint) { + const ip = c.subnet.split("/")[0]; + ipToEndpoint.set(ip, c.endpoint); + } + } + } + } + + for (const entry of entries) { + if ( + !entry.requestId || + !entry.resourceId || + !entry.method || + !entry.scheme || + !entry.host || + !entry.path || + !entry.sourceAddr + ) { + logger.debug( + `Skipping request log entry with missing required fields: ${JSON.stringify(entry)}` + ); + continue; + } + + const originalRequestURL = + entry.scheme + + "://" + + entry.host + + entry.path + + (entry.rawQuery ? "?" + entry.rawQuery : ""); + + // Resolve the client's external endpoint for GeoIP lookup. + // sourceAddr is the WireGuard IP (possibly ip:port), so strip the port. + const sourceIp = entry.sourceAddr.includes(":") + ? entry.sourceAddr.split(":")[0] + : entry.sourceAddr; + const endpoint = ipToEndpoint.get(sourceIp); + let location: string | undefined; + if (endpoint) { + const endpointIp = endpoint.includes(":") + ? endpoint.split(":")[0] + : endpoint; + location = await getCountryCodeForIp(endpointIp); + } + + await logRequestAudit( + { + action: true, + reason: 108, + siteResourceId: entry.resourceId, + orgId, + location + }, + { + path: entry.path, + originalRequestURL, + scheme: entry.scheme, + host: entry.host, + method: entry.method, + tls: entry.tls, + requestIp: entry.sourceAddr + } + ); + } + + logger.debug( + `Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})` + ); +}; diff --git a/server/private/routers/newt/index.ts b/server/private/routers/newt/index.ts index 256d19cb7..94dfc8f05 100644 --- a/server/private/routers/newt/index.ts +++ b/server/private/routers/newt/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -12,3 +12,4 @@ */ export * from "./handleConnectionLogMessage"; +export * from "./handleRequestLogMessage"; diff --git a/server/private/routers/org/index.ts b/server/private/routers/org/index.ts index 8d11c42d9..5dc0faed8 100644 --- a/server/private/routers/org/index.ts +++ b/server/private/routers/org/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -12,3 +12,4 @@ */ export * from "./sendUsageNotifications"; +export * from "./sendTrialNotification"; diff --git a/server/private/routers/org/sendTrialNotification.ts b/server/private/routers/org/sendTrialNotification.ts new file mode 100644 index 000000000..c3b7f6518 --- /dev/null +++ b/server/private/routers/org/sendTrialNotification.ts @@ -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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db"; +import { eq, and, or } 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 { sendEmail } from "@server/emails"; +import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring"; +import config from "@server/lib/config"; + +const sendTrialNotificationParamsSchema = z.object({ + orgId: z.string() +}); + +const sendTrialNotificationBodySchema = z.object({ + notificationType: z.enum(["trial_ending_5d", "trial_ending_24h", "trial_ended"]), + orgName: z.string(), + trialEndsAt: z.number(), + billingLink: z.string().optional() +}); + +export type SendTrialNotificationResponse = { + success: boolean; + emailsSent: number; + adminEmails: string[]; +}; + +async function getOrgAdmins(orgId: string) { + const admins = await db + .select({ + userId: users.userId, + email: users.email, + name: users.name, + isOwner: userOrgs.isOwner, + roleName: roles.name, + isAdminRole: roles.isAdmin + }) + .from(userOrgs) + .innerJoin(users, eq(userOrgs.userId, users.userId)) + .leftJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgs.orgId, orgId), + or(eq(userOrgs.isOwner, true), eq(roles.isAdmin, true)) + ) + ); + + const byUserId = new Map( + admins.map((a) => [a.userId, a]) + ); + const orgAdmins = Array.from(byUserId.values()).filter( + (admin) => admin.email && admin.email.length > 0 + ); + + return orgAdmins; +} + +export async function sendTrialNotification( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = sendTrialNotificationParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = sendTrialNotificationBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } = + parsedBody.data; + + // Verify organization exists + const org = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (org.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + // Get all admin users for this organization + const orgAdmins = await getOrgAdmins(orgId); + + if (orgAdmins.length === 0) { + logger.warn(`No admin users found for organization ${orgId}`); + return response(res, { + data: { + success: true, + emailsSent: 0, + adminEmails: [] + }, + success: true, + error: false, + message: "No admin users found to notify", + status: HttpCode.OK + }); + } + + const billingLink = + bodyBillingLink ?? + `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`; + + const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString( + "en-US", + { year: "numeric", month: "long", day: "numeric" } + ); + + let daysRemaining: number | null; + let subject: string; + + if (notificationType === "trial_ending_5d") { + daysRemaining = 5; + subject = "Your trial ends in 5 days"; + } else if (notificationType === "trial_ending_24h") { + daysRemaining = 1; + subject = "Your trial ends tomorrow"; + } else { + daysRemaining = null; + subject = "Your trial has ended"; + } + + let emailsSent = 0; + const adminEmails: string[] = []; + + for (const admin of orgAdmins) { + if (!admin.email) continue; + + try { + const template = NotifyTrialExpiring({ + email: admin.email, + orgName, + trialEndsAt: trialEndsAtFormatted, + daysRemaining, + billingLink + }); + + await sendEmail(template, { + to: admin.email, + from: config.getNoReplyEmail(), + subject + }); + + emailsSent++; + adminEmails.push(admin.email); + + logger.info( + `Trial notification sent to admin ${admin.email} for org ${orgId}` + ); + } catch (emailError) { + logger.error( + `Failed to send trial notification to ${admin.email}:`, + emailError + ); + // Continue with other admins even if one fails + } + } + + return response(res, { + data: { + success: true, + emailsSent, + adminEmails + }, + success: true, + error: false, + message: `Trial notifications sent to ${emailsSent} administrators`, + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error sending trial notifications:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to send trial notifications" + ) + ); + } +} \ No newline at end of file diff --git a/server/private/routers/org/sendUsageNotifications.ts b/server/private/routers/org/sendUsageNotifications.ts index 72fc00d4c..c380eda5d 100644 --- a/server/private/routers/org/sendUsageNotifications.ts +++ b/server/private/routers/org/sendUsageNotifications.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index cc17d7cfc..97928d99f 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -27,7 +27,6 @@ import config from "@server/lib/config"; import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; import { isSubscribed } from "#private/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import privateConfig from "#private/lib/config"; import { build } from "@server/build"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); @@ -45,6 +44,7 @@ const bodySchema = z.strictObject({ autoProvision: z.boolean().optional(), variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"), roleMapping: z.string().optional(), + orgMapping: z.string().nullish(), tags: z.string().optional() }); @@ -94,18 +94,6 @@ export async function createOrgOidcIdp( ); } - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - const { clientId, clientSecret, @@ -118,6 +106,7 @@ export async function createOrgOidcIdp( name, variant, roleMapping, + orgMapping: orgMappingBody, tags } = parsedBody.data; @@ -169,7 +158,7 @@ export async function createOrgOidcIdp( idpId: idpRes.idpId, orgId: orgId, roleMapping: roleMapping || null, - orgMapping: `'${orgId}'` + orgMapping: orgMappingBody }); }); diff --git a/server/private/routers/orgIdp/deleteOrgIdp.ts b/server/private/routers/orgIdp/deleteOrgIdp.ts index 7d201dd17..9e5dfccee 100644 --- a/server/private/routers/orgIdp/deleteOrgIdp.ts +++ b/server/private/routers/orgIdp/deleteOrgIdp.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -22,7 +22,6 @@ import { fromError } from "zod-validation-error"; import { idp, idpOidcConfig, idpOrg } from "@server/db"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import privateConfig from "#private/lib/config"; const paramsSchema = z .object({ @@ -60,18 +59,6 @@ export async function deleteOrgIdp( const { idpId } = parsedParams.data; - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - // Check if IDP exists const [existingIdp] = await db .select() diff --git a/server/private/routers/orgIdp/getOrgIdp.ts b/server/private/routers/orgIdp/getOrgIdp.ts index 6941fc0fc..9d4891f15 100644 --- a/server/private/routers/orgIdp/getOrgIdp.ts +++ b/server/private/routers/orgIdp/getOrgIdp.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/orgIdp/importOrgIdp.ts b/server/private/routers/orgIdp/importOrgIdp.ts new file mode 100644 index 000000000..1f4f5ddd9 --- /dev/null +++ b/server/private/routers/orgIdp/importOrgIdp.ts @@ -0,0 +1,211 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +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 { idp, idpOrg, orgs, roles, userOrgs } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import { checkOrgAccessPolicy } from "#private/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + idpId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + sourceOrgId: z.string().nonempty() +}); + +async function userIsOrgAdmin( + userId: string, + orgId: string, + session: Request["session"] +): Promise { + const [userOrgRow] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!userOrgRow) { + return false; + } + + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId, + session + }); + if (!policyCheck.allowed || policyCheck.error) { + return false; + } + + const roleIds = await getUserOrgRoleIds(userId, orgId); + if (roleIds.length === 0) { + return false; + } + + const [adminRole] = await db + .select() + .from(roles) + .where(and(inArray(roles.roleId, roleIds), eq(roles.isAdmin, true))) + .limit(1); + + return !!adminRole; +} + +export async function importOrgIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId: targetOrgId, idpId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { sourceOrgId } = parsedBody.data; + + if (sourceOrgId === targetOrgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Source and target organization must be different" + ) + ); + } + + const userId = req.user!.userId; + + const sourceLinked = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, sourceOrgId))) + .limit(1); + + if (sourceLinked.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "IdP not found for the source organization" + ) + ); + } + + const sourceAdmin = await userIsOrgAdmin( + userId, + sourceOrgId, + req.session + ); + if (!sourceAdmin) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You must be an organization admin in the source organization where this IdP is linked" + ) + ); + } + + const [targetOrg] = await db + .select({ orgId: orgs.orgId }) + .from(orgs) + .where(eq(orgs.orgId, targetOrgId)) + .limit(1); + + if (!targetOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Target organization not found" + ) + ); + } + + const [existingIdp] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)) + .limit(1); + + if (!existingIdp) { + return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); + } + + const alreadyTarget = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, targetOrgId))) + .limit(1); + + if (alreadyTarget.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "This IdP is already linked to the target organization" + ) + ); + } + + await db.insert(idpOrg).values({ + idpId, + orgId: targetOrgId, + roleMapping: null, + orgMapping: null + }); + + const redirectUrl = await generateOidcRedirectUrl(idpId, targetOrgId); + + return response(res, { + data: { + idpId, + redirectUrl + }, + success: true, + error: false, + message: "Org IdP imported successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/index.ts b/server/private/routers/orgIdp/index.ts index 9cf937a41..192d883a6 100644 --- a/server/private/routers/orgIdp/index.ts +++ b/server/private/routers/orgIdp/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -12,7 +12,11 @@ */ export * from "./createOrgOidcIdp"; +export * from "./importOrgIdp"; export * from "./getOrgIdp"; export * from "./listOrgIdps"; +export * from "./listUserAdminOrgIdps"; export * from "./updateOrgOidcIdp"; export * from "./deleteOrgIdp"; +export * from "./unassociateOrgIdp"; +export * from "./requireOrgIdentityProviderMode"; diff --git a/server/private/routers/orgIdp/listOrgIdps.ts b/server/private/routers/orgIdp/listOrgIdps.ts index fed8a0aab..ba73095d1 100644 --- a/server/private/routers/orgIdp/listOrgIdps.ts +++ b/server/private/routers/orgIdp/listOrgIdps.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/orgIdp/listUserAdminOrgIdps.ts b/server/private/routers/orgIdp/listUserAdminOrgIdps.ts new file mode 100644 index 000000000..78faa48fa --- /dev/null +++ b/server/private/routers/orgIdp/listUserAdminOrgIdps.ts @@ -0,0 +1,160 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOidcConfig } from "@server/db"; +import { idp, idpOrg, orgs, roles, userOrgRoles } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +const paramsSchema = z.strictObject({ + userId: z.string().nonempty() +}); + +async function getOrgIdsWhereUserIsAdmin(userId: string): Promise { + const rows = await db + .select({ orgId: userOrgRoles.orgId }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where(and(eq(userOrgRoles.userId, userId), eq(roles.isAdmin, true))); + return [...new Set(rows.map((r) => r.orgId))]; +} + +async function queryIdpsForOrgs( + orgIds: string[], + limit: number, + offset: number +) { + return db + .select({ + idpId: idp.idpId, + orgId: idpOrg.orgId, + orgName: orgs.name, + name: idp.name, + type: idp.type, + variant: idpOidcConfig.variant, + tags: idp.tags + }) + .from(idpOrg) + .where(inArray(idpOrg.orgId, orgIds)) + .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) + .orderBy(sql`idp.name DESC`) + .limit(limit) + .offset(offset); +} + +async function countIdpsForOrgs(orgIds: string[]) { + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) + .where(inArray(idpOrg.orgId, orgIds)); + return count; +} + +export async function listUserAdminOrgIdps( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { userId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const adminOrgIds = await getOrgIdsWhereUserIsAdmin(userId); + + if (adminOrgIds.length === 0) { + return response(res, { + data: { + idps: [], + pagination: { + total: 0, + limit, + offset + } + }, + success: true, + error: false, + message: "Org Idps retrieved successfully", + status: HttpCode.OK + }); + } + + const list = await queryIdpsForOrgs(adminOrgIds, limit, offset); + const total = await countIdpsForOrgs(adminOrgIds); + + return response(res, { + data: { + idps: list, + pagination: { + total, + limit, + offset + } + }, + success: true, + error: false, + message: "Org Idps retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts b/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts new file mode 100644 index 000000000..7942af123 --- /dev/null +++ b/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts @@ -0,0 +1,34 @@ +/* + * 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 { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import privateConfig from "#private/lib/config"; +import HttpCode from "@server/types/HttpCode"; + +export function requireOrgIdentityProviderMode( + _req: Request, + _res: Response, + next: NextFunction +): void { + if (privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + + return next(); +} diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts new file mode 100644 index 000000000..f6ab557b3 --- /dev/null +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -0,0 +1,96 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOrg } from "@server/db"; +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 { and, eq, sql } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + idpId: z.coerce.number().int().positive() + }) + .strict(); + +export async function unassociateOrgIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, idpId } = parsedParams.data; + + const [association] = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))) + .limit(1); + + if (!association) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `IdP with ID ${idpId} is not associated with organization ${orgId}` + ) + ); + } + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + if (count <= 1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This is the last organization associated with this identity provider. Delete it instead." + ) + ); + } + + await db + .delete(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + + return response(res, { + data: null, + success: true, + error: false, + message: "Org IdP unassociated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/updateOrgOidcIdp.ts b/server/private/routers/orgIdp/updateOrgOidcIdp.ts index 191f49068..7c379f8ec 100644 --- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -26,7 +26,6 @@ import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { isSubscribed } from "#private/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import privateConfig from "#private/lib/config"; import { build } from "@server/build"; const paramsSchema = z @@ -48,6 +47,7 @@ const bodySchema = z.strictObject({ scopes: z.string().optional(), autoProvision: z.boolean().optional(), roleMapping: z.string().optional(), + orgMapping: z.string().nullish(), tags: z.string().optional() }); @@ -99,18 +99,6 @@ export async function updateOrgOidcIdp( ); } - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - const { idpId, orgId } = parsedParams.data; const { clientId, @@ -123,6 +111,7 @@ export async function updateOrgOidcIdp( namePath, name, roleMapping, + orgMapping, tags } = parsedBody.data; @@ -218,13 +207,20 @@ export async function updateOrgOidcIdp( .where(eq(idpOidcConfig.idpId, idpId)); } + const idpOrgPolicyPatch: { + roleMapping?: string; + orgMapping?: string | null; + } = {}; if (roleMapping !== undefined) { - // Update IdP-org policy + idpOrgPolicyPatch.roleMapping = roleMapping; + } + if (orgMapping !== undefined) { + idpOrgPolicyPatch.orgMapping = orgMapping; + } + if (Object.keys(idpOrgPolicyPatch).length > 0) { await trx .update(idpOrg) - .set({ - roleMapping - }) + .set(idpOrgPolicyPatch) .where( and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)) ); diff --git a/server/private/routers/re-key/index.ts b/server/private/routers/re-key/index.ts index 9c1bccf8a..922a021fd 100644 --- a/server/private/routers/re-key/index.ts +++ b/server/private/routers/re-key/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/re-key/reGenerateClientSecret.ts b/server/private/routers/re-key/reGenerateClientSecret.ts index b2f9e1511..48ec60c51 100644 --- a/server/private/routers/re-key/reGenerateClientSecret.ts +++ b/server/private/routers/re-key/reGenerateClientSecret.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/re-key/reGenerateExitNodeSecret.ts b/server/private/routers/re-key/reGenerateExitNodeSecret.ts index 021d2ce95..df5f57048 100644 --- a/server/private/routers/re-key/reGenerateExitNodeSecret.ts +++ b/server/private/routers/re-key/reGenerateExitNodeSecret.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/re-key/reGenerateSiteSecret.ts b/server/private/routers/re-key/reGenerateSiteSecret.ts index 09cf75994..6abf037b2 100644 --- a/server/private/routers/re-key/reGenerateSiteSecret.ts +++ b/server/private/routers/re-key/reGenerateSiteSecret.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index f24afdde1..d7e889222 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts index 6ff6841ce..e86476a5a 100644 --- a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/remoteExitNode/getRemoteExitNode.ts b/server/private/routers/remoteExitNode/getRemoteExitNode.ts index 01ea080c9..5b69f8f5f 100644 --- a/server/private/routers/remoteExitNode/getRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/getRemoteExitNode.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts b/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts index 025e2d34e..ec7fa6a26 100644 --- a/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts +++ b/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts index 9c2889a99..c2c710e11 100644 --- a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts +++ b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -11,78 +11,12 @@ * This file is not licensed under the AGPLv3. */ -import { db, exitNodes, sites } from "@server/db"; +import { db, exitNodes } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, RemoteExitNode } from "@server/db"; -import { eq, lt, isNull, and, or, inArray } from "drizzle-orm"; +import { RemoteExitNode } from "@server/db"; +import { eq } from "drizzle-orm"; import logger from "@server/logger"; -// Track if the offline checker interval is running -let offlineCheckerInterval: NodeJS.Timeout | null = null; -const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds -const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes - -/** - * Starts the background interval that checks for clients that haven't pinged recently - * and marks them as offline - */ -export const startRemoteExitNodeOfflineChecker = (): void => { - if (offlineCheckerInterval) { - return; // Already running - } - - offlineCheckerInterval = setInterval(async () => { - try { - const twoMinutesAgo = Math.floor( - (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 - ); - - // Find clients that haven't pinged in the last 2 minutes and mark them as offline - const offlineNodes = await db - .update(exitNodes) - .set({ online: false }) - .where( - and( - eq(exitNodes.online, true), - eq(exitNodes.type, "remoteExitNode"), - or( - lt(exitNodes.lastPing, twoMinutesAgo), - isNull(exitNodes.lastPing) - ) - ) - ) - .returning(); - - if (offlineNodes.length > 0) { - logger.info( - `checkRemoteExitNodeOffline: Marked ${offlineNodes.length} remoteExitNode client(s) offline due to inactivity` - ); - - for (const offlineClient of offlineNodes) { - logger.debug( - `checkRemoteExitNodeOffline: Client ${offlineClient.exitNodeId} marked offline (lastPing: ${offlineClient.lastPing})` - ); - } - } - } catch (error) { - logger.error("Error in offline checker interval", { error }); - } - }, OFFLINE_CHECK_INTERVAL); - - logger.debug("Started offline checker interval"); -}; - -/** - * Stops the background interval that checks for offline clients - */ -export const stopRemoteExitNodeOfflineChecker = (): void => { - if (offlineCheckerInterval) { - clearInterval(offlineCheckerInterval); - offlineCheckerInterval = null; - logger.info("Stopped offline checker interval"); - } -}; - /** * Handles ping messages from clients and responds with pong */ diff --git a/server/private/routers/remoteExitNode/handleRemoteExitNodeRegisterMessage.ts b/server/private/routers/remoteExitNode/handleRemoteExitNodeRegisterMessage.ts index 5ad37edcd..8d0c5b490 100644 --- a/server/private/routers/remoteExitNode/handleRemoteExitNodeRegisterMessage.ts +++ b/server/private/routers/remoteExitNode/handleRemoteExitNodeRegisterMessage.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 2a04f9d9d..730f6b693 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -21,3 +21,4 @@ export * from "./deleteRemoteExitNode"; export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; +export * from "./offlineChecker"; diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts index e65486005..061be1792 100644 --- a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts +++ b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -22,6 +22,91 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; +import cache from "#private/lib/cache"; +import semver from "semver"; + +let stalePangolinNodeVersion: string | null = null; + +async function getLatestPangolinNodeVersion(): Promise { + try { + const cachedVersion = await cache.get( + "cache:latestPangolinNodeVersion" + ); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const res = await fetch( + "https://api.github.com/repos/fosrl/pangolin-node/tags", + { signal: controller.signal } + ); + + clearTimeout(timeoutId); + + if (!res.ok) { + logger.warn( + `Failed to fetch latest pangolin-node version from GitHub: ${res.status} ${res.statusText}` + ); + return stalePangolinNodeVersion; + } + + let tags = await res.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for pangolin-node repository"); + return stalePangolinNodeVersion; + } + + tags = tags.filter((tag: any) => !tag.name.includes("rc")); + tags.sort((a: any, b: any) => { + const va = semver.coerce(a.name); + const vb = semver.coerce(b.name); + if (!va && !vb) return 0; + if (!va) return 1; + if (!vb) return -1; + return semver.rcompare(va, vb); + }); + + const seen = new Set(); + tags = tags.filter((tag: any) => { + const normalised = semver.coerce(tag.name)?.version; + if (!normalised || seen.has(normalised)) return false; + seen.add(normalised); + return true; + }); + + if (tags.length === 0) { + logger.warn( + "No valid semver tags found for pangolin-node repository" + ); + return stalePangolinNodeVersion; + } + + const latestVersion = tags[0].name; + stalePangolinNodeVersion = latestVersion; + await cache.set("cache:latestPangolinNodeVersion", latestVersion, 3600); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn( + "Request to fetch latest pangolin-node version timed out (1.5s)" + ); + } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { + logger.warn( + "Connection timeout while fetching latest pangolin-node version" + ); + } else { + logger.warn( + "Error fetching latest pangolin-node version:", + error.message || error + ); + } + return stalePangolinNodeVersion; + } +} const listRemoteExitNodesParamsSchema = z.strictObject({ orgId: z.string() @@ -118,9 +203,41 @@ export async function listRemoteExitNodes( const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + const latestPangolinNodeVersionPromise = getLatestPangolinNodeVersion(); + + const nodesWithUpdates = remoteExitNodesList.map((node) => ({ + ...node, + updateAvailable: false + })); + + try { + const latestPangolinNodeVersion = + await latestPangolinNodeVersionPromise; + + if (latestPangolinNodeVersion) { + nodesWithUpdates.forEach((node) => { + if (node.version) { + try { + node.updateAvailable = semver.lt( + node.version, + latestPangolinNodeVersion + ); + } catch { + node.updateAvailable = false; + } + } + }); + } + } catch (error) { + logger.warn( + "Failed to check for pangolin-node updates, continuing without update info:", + error + ); + } + return response(res, { data: { - remoteExitNodes: remoteExitNodesList, + remoteExitNodes: nodesWithUpdates, pagination: { total: totalCount, limit, diff --git a/server/private/routers/remoteExitNode/offlineChecker.ts b/server/private/routers/remoteExitNode/offlineChecker.ts new file mode 100644 index 000000000..7f5e906f8 --- /dev/null +++ b/server/private/routers/remoteExitNode/offlineChecker.ts @@ -0,0 +1,82 @@ +/* + * 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, exitNodes } from "@server/db"; +import { eq, lt, isNull, and, or } from "drizzle-orm"; +import logger from "@server/logger"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + +/** + * Starts the background interval that checks for clients that haven't pinged recently + * and marks them as offline + */ +export const startRemoteExitNodeOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 + ); + + // Find clients that haven't pinged in the last 2 minutes and mark them as offline + const offlineNodes = await db + .update(exitNodes) + .set({ online: false }) + .where( + and( + eq(exitNodes.online, true), + eq(exitNodes.type, "remoteExitNode"), + or( + lt(exitNodes.lastPing, twoMinutesAgo), + isNull(exitNodes.lastPing) + ) + ) + ) + .returning(); + + if (offlineNodes.length > 0) { + logger.info( + `checkRemoteExitNodeOffline: Marked ${offlineNodes.length} remoteExitNode client(s) offline due to inactivity` + ); + + for (const offlineClient of offlineNodes) { + logger.debug( + `checkRemoteExitNodeOffline: Client ${offlineClient.exitNodeId} marked offline (lastPing: ${offlineClient.lastPing})` + ); + } + } + } catch (error) { + logger.error("Error in offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.debug("Started offline checker interval"); +}; + +/** + * Stops the background interval that checks for offline clients + */ +export const stopRemoteExitNodeOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped offline checker interval"); + } +}; diff --git a/server/private/routers/remoteExitNode/pickRemoteExitNodeDefaults.ts b/server/private/routers/remoteExitNode/pickRemoteExitNodeDefaults.ts index 5dcd545e5..41d6aaea8 100644 --- a/server/private/routers/remoteExitNode/pickRemoteExitNodeDefaults.ts +++ b/server/private/routers/remoteExitNode/pickRemoteExitNodeDefaults.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/remoteExitNode/quickStartRemoteExitNode.ts b/server/private/routers/remoteExitNode/quickStartRemoteExitNode.ts index ebe365d1b..d3e9615ef 100644 --- a/server/private/routers/remoteExitNode/quickStartRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/quickStartRemoteExitNode.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/resource/getMaintenanceInfo.ts b/server/private/routers/resource/getMaintenanceInfo.ts index e3e739c6e..a90247b18 100644 --- a/server/private/routers/resource/getMaintenanceInfo.ts +++ b/server/private/routers/resource/getMaintenanceInfo.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/resource/index.ts b/server/private/routers/resource/index.ts index f82b55524..ea9b87d65 100644 --- a/server/private/routers/resource/index.ts +++ b/server/private/routers/resource/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts index e521eaa22..f5b64c0f3 100644 --- a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts index fc8b05e60..61fa0a850 100644 --- a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/siteProvisioning/index.ts b/server/private/routers/siteProvisioning/index.ts index d143274f6..809db1d2d 100644 --- a/server/private/routers/siteProvisioning/index.ts +++ b/server/private/routers/siteProvisioning/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts index dd51179d3..0e9838b24 100644 --- a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts +++ b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts index 2f4dafbdf..cee40212a 100644 --- a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/ssh/index.ts b/server/private/routers/ssh/index.ts index a98405ba2..d2f607f81 100644 --- a/server/private/routers/ssh/index.ts +++ b/server/private/routers/ssh/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index b02d2b23c..e29493a01 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -21,7 +21,7 @@ import { roles, roundTripMessageTracker, siteResources, - sites, + siteNetworks, userOrgs } from "@server/db"; import { logAccessAudit } from "#private/lib/logAccessAudit"; @@ -63,10 +63,12 @@ const bodySchema = z export type SignSshKeyResponse = { certificate: string; + messageIds: number[]; messageId: number; sshUsername: string; sshHost: string; resourceId: number; + siteIds: number[]; siteId: number; keyId: string; validPrincipals: string[]; @@ -260,10 +262,7 @@ export async function signSshKey( .update(userOrgs) .set({ pamUsername: usernameToUse }) .where( - and( - eq(userOrgs.orgId, orgId), - eq(userOrgs.userId, userId) - ) + and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)) ); } else { usernameToUse = userOrg.pamUsername; @@ -369,8 +368,8 @@ export async function signSshKey( const parsedSudoCommands: string[] = []; const parsedGroupsSet = new Set(); let homedir: boolean | null = null; - const sudoModeOrder = { none: 0, commands: 1, all: 2 }; - let sudoMode: "none" | "commands" | "all" = "none"; + const sudoModeOrder = { none: 0, commands: 1, full: 2 }; + let sudoMode: "none" | "commands" | "full" = "none"; for (const roleRow of roleRows) { try { const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); @@ -387,7 +386,7 @@ export async function signSshKey( if (roleRow?.sshCreateHomeDir === true) homedir = true; const m = roleRow?.sshSudoMode ?? "none"; if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) { - sudoMode = m as "none" | "commands" | "all"; + sudoMode = m as "none" | "commands" | "full"; } } const parsedGroups = Array.from(parsedGroupsSet); @@ -395,21 +394,12 @@ export async function signSshKey( homedir = roleRows[0].sshCreateHomeDir ?? null; } - // get the site - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, resource.siteId)) - .limit(1); + const sites = await db + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, resource.networkId!)); - if (!newt) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Site associated with resource not found" - ) - ); - } + const siteIds = sites.map((site) => site.siteId); // Sign the public key const now = BigInt(Math.floor(Date.now() / 1000)); @@ -423,43 +413,64 @@ export async function signSshKey( validBefore: now + validFor }); - const [message] = await db - .insert(roundTripMessageTracker) - .values({ - wsClientId: newt.newtId, - messageType: `newt/pam/connection`, - sentAt: Math.floor(Date.now() / 1000) - }) - .returning(); + const messageIds: number[] = []; + for (const siteId of siteIds) { + // get the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); - if (!message) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to create message tracker entry" - ) - ); - } - - await sendToClient(newt.newtId, { - type: `newt/pam/connection`, - data: { - messageId: message.messageId, - orgId: orgId, - agentPort: resource.authDaemonPort ?? 22123, - externalAuthDaemon: resource.authDaemonMode === "remote", - agentHost: resource.destination, - caCert: caKeys.publicKeyOpenSSH, - username: usernameToUse, - niceId: resource.niceId, - metadata: { - sudoMode: sudoMode, - sudoCommands: parsedSudoCommands, - homedir: homedir, - groups: parsedGroups - } + if (!newt) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site associated with resource not found" + ) + ); } - }); + + const [message] = await db + .insert(roundTripMessageTracker) + .values({ + wsClientId: newt.newtId, + messageType: `newt/pam/connection`, + sentAt: Math.floor(Date.now() / 1000) + }) + .returning(); + + if (!message) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create message tracker entry" + ) + ); + } + + messageIds.push(message.messageId); + + await sendToClient(newt.newtId, { + type: `newt/pam/connection`, + data: { + messageId: message.messageId, + orgId: orgId, + agentPort: resource.authDaemonPort ?? 22123, + externalAuthDaemon: resource.authDaemonMode === "remote", + agentHost: resource.destination, + caCert: caKeys.publicKeyOpenSSH, + username: usernameToUse, + niceId: resource.niceId, + metadata: { + sudoMode: sudoMode, + sudoCommands: parsedSudoCommands, + homedir: homedir, + groups: parsedGroups + } + } + }); + } const expiresIn = Number(validFor); // seconds @@ -480,7 +491,7 @@ export async function signSshKey( metadata: JSON.stringify({ resourceId: resource.siteResourceId, resource: resource.name, - siteId: resource.siteId, + siteIds: siteIds }) }); @@ -494,7 +505,7 @@ export async function signSshKey( : undefined, metadata: { resourceName: resource.name, - siteId: resource.siteId, + siteId: siteIds[0], sshUsername: usernameToUse, sshHost: sshHost }, @@ -505,11 +516,13 @@ export async function signSshKey( return response(res, { data: { certificate: cert.certificate, - messageId: message.messageId, + messageIds: messageIds, + messageId: messageIds[0], // just pick the first one for backward compatibility sshUsername: usernameToUse, sshHost: sshHost, resourceId: resource.siteResourceId, - siteId: resource.siteId, + siteIds: siteIds, + siteId: siteIds[0], // just pick the first one for backward compatibility keyId: cert.keyId, validPrincipals: cert.validPrincipals, validAfter: cert.validAfter.toISOString(), diff --git a/server/private/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts index a46bd1ed8..0789373a0 100644 --- a/server/private/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/user/index.ts b/server/private/routers/user/index.ts index 6317eced5..bc34fe1af 100644 --- a/server/private/routers/user/index.ts +++ b/server/private/routers/user/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts index e9c3d10c0..bd5c530d2 100644 --- a/server/private/routers/user/removeUserRole.ts +++ b/server/private/routers/user/removeUserRole.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts index 67563fd26..d1df4965a 100644 --- a/server/private/routers/user/setUserOrgRoles.ts +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/ws/index.ts b/server/private/routers/ws/index.ts index 3a8db5378..47e38f0f6 100644 --- a/server/private/routers/ws/index.ts +++ b/server/private/routers/ws/index.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index 5021cb966..b2553871e 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. @@ -18,12 +18,13 @@ import { } from "#private/routers/remoteExitNode"; import { MessageHandler } from "@server/routers/ws"; import { build } from "@server/build"; -import { handleConnectionLogMessage } from "#private/routers/newt"; +import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt"; export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage, "newt/access-log": handleConnectionLogMessage, + "newt/request-log": handleRequestLogMessage, }; if (build != "saas") { diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 21f4fad37..0970735e0 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -1,7 +1,7 @@ /* * This file is part of a proprietary work. * - * Copyright (c) 2025 Fossorial, Inc. + * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts new file mode 100644 index 000000000..ebffd3c5b --- /dev/null +++ b/server/routers/alertRule/types.ts @@ -0,0 +1,126 @@ +export type ListAlertRulesResponse = { + alertRules: { + alertRuleId: number; + orgId: string; + name: string; + eventType: string; + enabled: boolean; + cooldownSeconds: number; + lastTriggeredAt: number | null; + createdAt: number; + updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; + resourceIds: number[]; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +export type CreateAlertRuleResponse = { + alertRuleId: number; +}; + +export type GetAlertRuleResponse = { + alertRuleId: number; + orgId: string; + name: string; + eventType: + | "site_online" + | "site_offline" + | "site_toggle" + | "health_check_healthy" + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_degraded" + | "resource_toggle"; + enabled: boolean; + cooldownSeconds: number; + lastTriggeredAt: number | null; + createdAt: number; + updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; + resourceIds: number[]; + recipients: { + recipientId: number; + userId: string | null; + roleId: number | null; + email: string | null; + }[]; + webhookActions: { + webhookActionId: number; + webhookUrl: string; + enabled: boolean; + lastSentAt: number | null; + config: WebhookAlertConfig | null; + }[]; +}; + +/** + * Stored as an encrypted JSON blob in `alertWebhookActions.config`. + */ +export interface WebhookAlertConfig { + /** Authentication strategy for the webhook endpoint */ + authType: WebhookAuthType; + /** Bearer token – used when authType === "bearer" */ + bearerToken?: string; + /** Basic credentials – "username:password" – used when authType === "basic" */ + basicCredentials?: string; + /** Custom header name – used when authType === "custom" */ + customHeaderName?: string; + /** Custom header value – used when authType === "custom" */ + customHeaderValue?: string; + /** Extra headers to send with every webhook request */ + headers?: Array<{ key: string; value: string }>; + /** HTTP method (default POST) */ + method?: string; + /** Whether to use a custom body template */ + useBodyTemplate?: boolean; + /** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */ + bodyTemplate?: string; +} + +// --------------------------------------------------------------------------- +// Alert event types +// --------------------------------------------------------------------------- + +export type AlertEventType = + | "site_online" + | "site_offline" + | "site_toggle" + | "health_check_healthy" + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_degraded" + | "resource_toggle"; + +// --------------------------------------------------------------------------- +// Webhook authentication config (stored as encrypted JSON in the DB) +// --------------------------------------------------------------------------- + +export type WebhookAuthType = "none" | "bearer" | "basic" | "custom"; + +// --------------------------------------------------------------------------- +// Internal alert event passed through the processing pipeline +// --------------------------------------------------------------------------- + +export interface AlertContext { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + siteId?: number; + /** Set for health_check_* events */ + healthCheckId?: number; + /** Set for resource_* events */ + resourceId?: number; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; +} diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 176a9e5d3..000ec9815 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -1,8 +1,8 @@ -import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db"; +import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; +import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -92,7 +92,10 @@ function getWhere(data: Q) { lt(requestAuditLog.timestamp, data.timeEnd), eq(requestAuditLog.orgId, data.orgId), data.resourceId - ? eq(requestAuditLog.resourceId, data.resourceId) + ? or( + eq(requestAuditLog.resourceId, data.resourceId), + eq(requestAuditLog.siteResourceId, data.resourceId) + ) : undefined, data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, data.method ? eq(requestAuditLog.method, data.method) : undefined, @@ -110,15 +113,16 @@ export function queryRequest(data: Q) { return primaryLogsDb .select({ id: requestAuditLog.id, - timestamp: requestAuditLog.timestamp, - orgId: requestAuditLog.orgId, - action: requestAuditLog.action, - reason: requestAuditLog.reason, - actorType: requestAuditLog.actorType, - actor: requestAuditLog.actor, - actorId: requestAuditLog.actorId, - resourceId: requestAuditLog.resourceId, - ip: requestAuditLog.ip, + timestamp: requestAuditLog.timestamp, + orgId: requestAuditLog.orgId, + action: requestAuditLog.action, + reason: requestAuditLog.reason, + actorType: requestAuditLog.actorType, + actor: requestAuditLog.actor, + actorId: requestAuditLog.actorId, + resourceId: requestAuditLog.resourceId, + siteResourceId: requestAuditLog.siteResourceId, + ip: requestAuditLog.ip, location: requestAuditLog.location, userAgent: requestAuditLog.userAgent, metadata: requestAuditLog.metadata, @@ -137,37 +141,73 @@ export function queryRequest(data: Q) { } async function enrichWithResourceDetails(logs: Awaited>) { - // If logs database is the same as main database, we can do a join - // Otherwise, we need to fetch resource details separately const resourceIds = logs .map(log => log.resourceId) .filter((id): id is number => id !== null && id !== undefined); - if (resourceIds.length === 0) { + const siteResourceIds = logs + .filter(log => log.resourceId == null && log.siteResourceId != null) + .map(log => log.siteResourceId) + .filter((id): id is number => id !== null && id !== undefined); + + if (resourceIds.length === 0 && siteResourceIds.length === 0) { return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); } - // Fetch resource details from main database - const resourceDetails = await primaryDb - .select({ - resourceId: resources.resourceId, - name: resources.name, - niceId: resources.niceId - }) - .from(resources) - .where(inArray(resources.resourceId, resourceIds)); + const resourceMap = new Map(); - // Create a map for quick lookup - const resourceMap = new Map( - resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }]) - ); + if (resourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + resourceId: resources.resourceId, + name: resources.name, + niceId: resources.niceId + }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + for (const r of resourceDetails) { + resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId }); + } + } + + const siteResourceMap = new Map(); + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + niceId: siteResources.niceId + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + for (const r of siteResourceDetails) { + siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId }); + } + } // Enrich logs with resource details - return logs.map(log => ({ - ...log, - resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null, - resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null - })); + return logs.map(log => { + if (log.resourceId != null) { + const details = resourceMap.get(log.resourceId); + return { + ...log, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } else if (log.siteResourceId != null) { + const details = siteResourceMap.get(log.siteResourceId); + return { + ...log, + resourceId: log.siteResourceId, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } + return { ...log, resourceName: null, resourceNiceId: null }; + }); } export function countRequestQuery(data: Q) { @@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes( uniqueLocations, uniqueHosts, uniquePaths, - uniqueResources + uniqueResources, + uniqueSiteResources ] = await Promise.all([ primaryLogsDb .selectDistinct({ actor: requestAuditLog.actor }) @@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes( }) .from(requestAuditLog) .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + primaryLogsDb + .selectDistinct({ + id: requestAuditLog.siteResourceId + }) + .from(requestAuditLog) + .where(and(baseConditions, isNull(requestAuditLog.resourceId))) .limit(DISTINCT_LIMIT + 1) ]); @@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes( .map(row => row.id) .filter((id): id is number => id !== null); + const siteResourceIds = uniqueSiteResources + .map(row => row.id) + .filter((id): id is number => id !== null); + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; if (resourceIds.length > 0) { @@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes( .from(resources) .where(inArray(resources.resourceId, resourceIds)); - resourcesWithNames = resourceDetails.map(r => ({ - id: r.resourceId, - name: r.name - })); + resourcesWithNames = [ + ...resourcesWithNames, + ...resourceDetails.map(r => ({ + id: r.resourceId, + name: r.name + })) + ]; + } + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + resourcesWithNames = [ + ...resourcesWithNames, + ...siteResourceDetails.map(r => ({ + id: r.siteResourceId, + name: r.name + })) + ]; } return { diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 4c278cba5..972eebfe3 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = { actor: string | null; actorId: string | null; resourceId: number | null; + siteResourceId: number | null; resourceNiceId: string | null; resourceName: string | null; ip: string | null; diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts index 248d5a181..07bdf883d 100644 --- a/server/routers/auth/deleteMyAccount.ts +++ b/server/routers/auth/deleteMyAccount.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, orgs, userOrgs, users } from "@server/db"; -import { eq, and, inArray } from "drizzle-orm"; +import { eq, and, inArray, not } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -17,11 +17,10 @@ import { verifyTotpCode } from "@server/auth/totp"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { build } from "@server/build"; import { getOrgTierData } from "#dynamic/lib/billing"; -import { - deleteOrgById, - sendTerminationMessages -} from "@server/lib/deleteOrg"; +import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; import { UserType } from "@server/types/UserTypes"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; const deleteMyAccountBody = z.strictObject({ password: z.string().optional(), @@ -98,15 +97,16 @@ export async function deleteMyAccount( and(eq(userOrgs.userId, userId), eq(userOrgs.isOwner, true)) ); - const orgIds = ownedOrgsRows.map((r) => r.orgId); + const ownedOrgIds = ownedOrgsRows.map((r) => r.orgId); - if (build === "saas" && orgIds.length > 0) { + if (build === "saas" && ownedOrgIds.length > 0) { const primaryOrgId = ownedOrgsRows.find( (r) => r.isBillingOrg && r.isOwner )?.orgId; if (primaryOrgId) { - const { tier, active } = await getOrgTierData(primaryOrgId); - if (active && tier) { + const { tier, active, isTrial } = + await getOrgTierData(primaryOrgId); + if (active && tier && !isTrial) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -119,14 +119,14 @@ export async function deleteMyAccount( if (!password) { const orgsWithNames = - orgIds.length > 0 + ownedOrgIds.length > 0 ? await db .select({ orgId: orgs.orgId, name: orgs.name }) .from(orgs) - .where(inArray(orgs.orgId, orgIds)) + .where(inArray(orgs.orgId, ownedOrgIds)) : []; return response(res, { data: { @@ -206,9 +206,23 @@ export async function deleteMyAccount( olmsToTerminate: allOlmsToTerminate }); + const otherOrgsTheUserWasIn = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + not(inArray(userOrgs.orgId, ownedOrgIds)) + ) + ); + await db.transaction(async (trx) => { await trx.delete(users).where(eq(users.userId, userId)); await calculateUserClientsForOrgs(userId, trx); + // loop through the other orgs and decrement the count + for (const userOrg of otherOrgsTheUserWasIn) { + await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx); + } }); try { @@ -233,10 +247,7 @@ export async function deleteMyAccount( } catch (error) { logger.error(error); return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "An error occurred" - ) + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index bde5518b8..08987961d 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -6,7 +6,7 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { resourceAccessToken, resources, sessions } from "@server/db"; import { db } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray, or, sql } from "drizzle-orm"; import { createResourceSession, serializeResourceSessionCookie, @@ -65,11 +65,31 @@ export async function exchangeSession( const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined; - const [resource] = await db + const parts = cleanHost.split("."); + const wildcardCandidates: string[] = []; + for (let i = 1; i < parts.length; i++) { + wildcardCandidates.push(`*.${parts.slice(i).join(".")}`); + } + + const potentialResources = await db .select() .from(resources) - .where(eq(resources.fullDomain, cleanHost)) - .limit(1); + .where( + or( + eq(resources.fullDomain, cleanHost), + wildcardCandidates.length > 0 + ? and( + eq(resources.wildcard, true), + inArray(resources.fullDomain, wildcardCandidates) + ) + : sql`false` + ) + ); + + const exactMatch = potentialResources.find( + (r) => r.fullDomain === cleanHost + ); + const resource = exactMatch ?? potentialResources[0]; if (!resource) { return next( @@ -178,7 +198,7 @@ export async function exchangeSession( const cookieName = `${config.getRawConfig().server.session_cookie_name}`; const cookie = serializeResourceSessionCookie( cookieName, - resource.fullDomain!, + cleanHost, token, !resource.ssl, expiresAt ? new Date(expiresAt) : undefined diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 92d01332e..884fb7ae4 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -18,6 +18,7 @@ Reasons: 105 - Valid Password 106 - Valid email 107 - Valid SSO +108 - Connected Client 201 - Resource Not Found 202 - Resource Blocked @@ -38,6 +39,7 @@ const auditLogBuffer: Array<{ metadata: any; action: boolean; resourceId?: number; + siteResourceId?: number; reason: number; location?: string; originalRequestURL: string; @@ -186,6 +188,7 @@ export async function logRequestAudit( action: boolean; reason: number; resourceId?: number; + siteResourceId?: number; orgId?: string; location?: string; user?: { username: string; userId: string }; @@ -262,6 +265,7 @@ export async function logRequestAudit( metadata: sanitizeString(metadata), action: data.action, resourceId: data.resourceId, + siteResourceId: data.siteResourceId, reason: data.reason, location: sanitizeString(data.location), originalRequestURL: sanitizeString(body.originalRequestURL) ?? "", diff --git a/server/routers/certificates/types.ts b/server/routers/certificates/types.ts index bca9412c4..e6aeecdf8 100644 --- a/server/routers/certificates/types.ts +++ b/server/routers/certificates/types.ts @@ -3,6 +3,7 @@ export type GetCertificateResponse = { domain: string; domainId: string; wildcard: boolean; + domainType: string; status: string; // pending, requested, valid, expired, failed expiresAt: string | null; lastRenewalAttempt: Date | null; @@ -10,4 +11,4 @@ export type GetCertificateResponse = { updatedAt: number; errorMessage?: string | null; renewalCount: number; -}; +}; \ No newline at end of file diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 375c027a7..c97612b07 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, olms, users } from "@server/db"; +import { db, idp, idpOidcConfig, olms, users } from "@server/db"; import { clients, currentFingerprint } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -236,6 +236,9 @@ export type GetClientResponse = NonNullable< lastSeen: number | null; } | null; posture: PostureData | null; + userType: string | null; + idpName: string | null; + idpVariant: string | null; }; registry.registerPath({ @@ -243,7 +246,7 @@ registry.registerPath({ path: "/org/{orgId}/client/{niceId}", description: "Get a client by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.", - tags: [OpenAPITags.Site], + tags: [OpenAPITags.Client], request: { params: z.object({ orgId: z.string(), @@ -337,6 +340,30 @@ export async function getClient( : maskPostureDataWithPlaceholder(rawPosture) : null; + let userType: string | null = null; + let idpName: string | null = null; + let idpVariant: string | null = null; + + if (client.clients.userId) { + const [idpRow] = await db + .select({ + userType: users.type, + idpName: idp.name, + idpVariant: idpOidcConfig.variant + }) + .from(users) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .where(eq(users.userId, client.clients.userId)) + .limit(1); + + if (idpRow) { + userType = idpRow.userType; + idpName = idpRow.idpName; + idpVariant = idpRow.idpVariant; + } + } + const data: GetClientResponse = { ...client.clients, name: clientName, @@ -347,7 +374,10 @@ export async function getClient( userName: client.user?.name ?? null, userUsername: client.user?.username ?? null, fingerprint: fingerprintData, - posture: postureData + posture: postureData, + userType, + idpName, + idpVariant }; return response(res, { diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 0bf798509..f5d69857d 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -29,65 +29,9 @@ import { } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import NodeCache from "node-cache"; -import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -const olmVersionCache = new NodeCache({ stdTTL: 3600 }); - -async function getLatestOlmVersion(): Promise { - try { - const cachedVersion = olmVersionCache.get("latestOlmVersion"); - if (cachedVersion) { - return cachedVersion; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); - - const response = await fetch( - "https://api.github.com/repos/fosrl/olm/tags", - { - signal: controller.signal - } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.warn( - `Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}` - ); - return null; - } - - let tags = await response.json(); - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn("No tags found for Olm repository"); - return null; - } - tags = tags.filter((version) => !version.name.includes("rc")); - const latestVersion = tags[0].name; - - olmVersionCache.set("latestOlmVersion", latestVersion, 3600); - - return latestVersion; - } catch (error: any) { - if (error.name === "AbortError") { - logger.warn("Request to fetch latest Olm version timed out (1.5s)"); - } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { - logger.warn("Connection timeout while fetching latest Olm version"); - } else { - logger.warn( - "Error fetching latest Olm version:", - error.message || error - ); - } - return null; - } -} - const listClientsParamsSchema = z.strictObject({ orgId: z.string() }); @@ -413,44 +357,45 @@ export async function listClients( }; }); - const latestOlVersionPromise = getLatestOlmVersion(); + // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW + // const latestOlmVersionPromise = getLatestOlmVersion(); - const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map( - (client) => { - const OlmWithUpdate: OlmWithUpdateAvailable = { ...client }; - // Initially set to false, will be updated if version check succeeds - OlmWithUpdate.olmUpdateAvailable = false; - return OlmWithUpdate; - } - ); + // const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map( + // (client) => { + // const OlmWithUpdate: OlmWithUpdateAvailable = { ...client }; + // // Initially set to false, will be updated if version check succeeds + // OlmWithUpdate.olmUpdateAvailable = false; + // return OlmWithUpdate; + // } + // ); // Try to get the latest version, but don't block if it fails - try { - const latestOlVersion = await latestOlVersionPromise; + // try { + // const latestOlmVersion = await latestOlVersionPromise; - if (latestOlVersion) { - olmsWithUpdates.forEach((client) => { - try { - client.olmUpdateAvailable = semver.lt( - client.olmVersion ? client.olmVersion : "", - latestOlVersion - ); - } catch (error) { - client.olmUpdateAvailable = false; - } - }); - } - } catch (error) { - // Log the error but don't let it block the response - logger.warn( - "Failed to check for OLM updates, continuing without update info:", - error - ); - } + // if (latestOlVersion) { + // olmsWithUpdates.forEach((client) => { + // try { + // client.olmUpdateAvailable = semver.lt( + // client.olmVersion ? client.olmVersion : "", + // latestOlVersion + // ); + // } catch (error) { + // client.olmUpdateAvailable = false; + // } + // }); + // } + // } catch (error) { + // // Log the error but don't let it block the response + // logger.warn( + // "Failed to check for OLM updates, continuing without update info:", + // error + // ); + // } return response(res, { data: { - clients: olmsWithUpdates, + clients: clientsWithSites, pagination: { total: totalCount, page, diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 0ae31165a..567eb0d6b 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -3,6 +3,8 @@ import { clients, currentFingerprint, db, + idp, + idpOidcConfig, olms, orgs, roleClients, @@ -30,65 +32,10 @@ import { } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import NodeCache from "node-cache"; import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -const olmVersionCache = new NodeCache({ stdTTL: 3600 }); - -async function getLatestOlmVersion(): Promise { - try { - const cachedVersion = olmVersionCache.get("latestOlmVersion"); - if (cachedVersion) { - return cachedVersion; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); - - const response = await fetch( - "https://api.github.com/repos/fosrl/olm/tags", - { - signal: controller.signal - } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.warn( - `Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}` - ); - return null; - } - - let tags = await response.json(); - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn("No tags found for Olm repository"); - return null; - } - tags = tags.filter((version) => !version.name.includes("rc")); - const latestVersion = tags[0].name; - - olmVersionCache.set("latestOlmVersion", latestVersion, 3600); - - return latestVersion; - } catch (error: any) { - if (error.name === "AbortError") { - logger.warn("Request to fetch latest Olm version timed out (1.5s)"); - } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { - logger.warn("Connection timeout while fetching latest Olm version"); - } else { - logger.warn( - "Error fetching latest Olm version:", - error.message || error - ); - } - return null; - } -} - const listUserDevicesParamsSchema = z.strictObject({ orgId: z.string() }); @@ -220,6 +167,9 @@ function queryUserDevicesBase() { userId: clients.userId, username: users.username, userEmail: users.email, + userType: users.type, + idpName: idp.name, + idpVariant: idpOidcConfig.variant, niceId: clients.niceId, agent: olms.agent, approvalState: clients.approvalState, @@ -239,6 +189,8 @@ function queryUserDevicesBase() { .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(users, eq(clients.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)); } @@ -453,29 +405,30 @@ export async function listUserDevices( } ); - // Try to get the latest version, but don't block if it fails - try { - const latestOlmVersion = await getLatestOlmVersion(); + // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW + // // Try to get the latest version, but don't block if it fails + // try { + // const latestOlmVersion = await getLatestOlmVersion(); - if (latestOlmVersion) { - olmsWithUpdates.forEach((client) => { - try { - client.olmUpdateAvailable = semver.lt( - client.olmVersion ? client.olmVersion : "", - latestOlmVersion - ); - } catch (error) { - client.olmUpdateAvailable = false; - } - }); - } - } catch (error) { - // Log the error but don't let it block the response - logger.warn( - "Failed to check for OLM updates, continuing without update info:", - error - ); - } + // if (latestOlmVersion) { + // olmsWithUpdates.forEach((client) => { + // try { + // client.olmUpdateAvailable = semver.lt( + // client.olmVersion ? client.olmVersion : "", + // latestOlmVersion + // ); + // } catch (error) { + // client.olmUpdateAvailable = false; + // } + // }); + // } + // } catch (error) { + // // Log the error but don't let it block the response + // logger.warn( + // "Failed to check for OLM updates, continuing without update info:", + // error + // ); + // } return response(res, { data: { diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index 085acf0c6..94dddb1cf 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -103,7 +103,8 @@ export async function listDomains( const [{ count }] = await db .select({ count: sql`count(*)` }) - .from(domains); + .from(orgDomains) + .where(eq(orgDomains.orgId, orgId)); return response(res, { data: { diff --git a/server/routers/external.ts b/server/routers/external.ts index d7729bca5..a17c88fb1 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -285,6 +285,13 @@ authenticated.get( site.listContainers ); +authenticated.get( + "/site/:siteId/status-history", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.getSite), + site.getSiteStatusHistory +); + // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", @@ -420,6 +427,13 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/resource/:resourceId/status-history", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResource), + resource.getResourceStatusHistory +); + authenticated.get( "/org/:orgId/resources", verifyOrgAccess, diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index dcd897471..eacf3dad4 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -88,11 +88,11 @@ async function dbQueryRows>( ): Promise { const anyDb = db as any; if (typeof anyDb.execute === "function") { - // PostgreSQL (node-postgres via Drizzle) — returns { rows: [...] } or an array + // PostgreSQL (node-postgres via Drizzle) - returns { rows: [...] } or an array const result = await anyDb.execute(query); return (Array.isArray(result) ? result : (result.rows ?? [])) as T[]; } - // SQLite (better-sqlite3 via Drizzle) — returns an array directly + // SQLite (better-sqlite3 via Drizzle) - returns an array directly return (await anyDb.all(query)) as T[]; } @@ -106,7 +106,7 @@ function isSQLite(): boolean { * Swaps out the accumulator before writing so that any bandwidth messages * received during the flush are captured in the new accumulator rather than * being lost or causing contention. Sites are updated in chunks via a single - * batch UPDATE per chunk. Failed chunks are discarded — exact per-flush + * batch UPDATE per chunk. Failed chunks are discarded - exact per-flush * accuracy is not critical and re-queuing is not worth the added complexity. * * This function is exported so that the application's graceful-shutdown @@ -125,7 +125,7 @@ export async function flushSiteBandwidthToDb(): Promise { const currentTime = new Date().toISOString(); // Sort by publicKey for consistent lock ordering across concurrent - // writers — deadlock-prevention strategy. + // writers - deadlock-prevention strategy. const sortedEntries = [...snapshot.entries()].sort(([a], [b]) => a.localeCompare(b) ); @@ -150,7 +150,7 @@ export async function flushSiteBandwidthToDb(): Promise { try { rows = await withDeadlockRetry(async () => { if (isSQLite()) { - // SQLite: one UPDATE per row — no need for batch efficiency here. + // SQLite: one UPDATE per row - no need for batch efficiency here. const results: { orgId: string; pubKey: string }[] = []; for (const [publicKey, { bytesIn, bytesOut }] of chunk) { const result = await dbQueryRows<{ @@ -170,7 +170,7 @@ export async function flushSiteBandwidthToDb(): Promise { return results; } - // PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk. + // PostgreSQL: batch UPDATE … FROM (VALUES …) - single round-trip per chunk. const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) => sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)` ); @@ -191,7 +191,7 @@ export async function flushSiteBandwidthToDb(): Promise { `Failed to flush bandwidth chunk [${i}–${chunkEnd}], discarding ${chunk.length} site(s):`, error ); - // Discard the chunk — exact per-flush accuracy is not critical. + // Discard the chunk - exact per-flush accuracy is not critical. continue; } @@ -232,7 +232,7 @@ export async function flushSiteBandwidthToDb(): Promise { totalBandwidth ); if (bandwidthUsage) { - // Fire-and-forget — don't block the flush on limit checking. + // Fire-and-forget - don't block the flush on limit checking. usageService .checkLimitSet( orgId, @@ -298,7 +298,7 @@ export async function updateSiteBandwidth( exitNodeId?: number ): Promise { for (const { publicKey, bytesIn, bytesOut } of bandwidthData) { - // Skip peers that haven't transferred any data — writing zeros to the + // Skip peers that haven't transferred any data - writing zeros to the // database would be a no-op anyway. if (bytesIn <= 0 && bytesOut <= 0) { continue; diff --git a/server/routers/healthChecks/types.ts b/server/routers/healthChecks/types.ts new file mode 100644 index 000000000..0def60833 --- /dev/null +++ b/server/routers/healthChecks/types.ts @@ -0,0 +1,34 @@ +export type ListHealthChecksResponse = { + healthChecks: { + targetHealthCheckId: number; + name: string; + siteId: number | null; + siteName: string | null; + siteNiceId: string | null; + hcEnabled: boolean; + hcHealth: "unknown" | "healthy" | "unhealthy"; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 7c9e53cf2..d26a8fbe3 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -336,31 +336,22 @@ export async function validateOidcCallback( .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)); allOrgs = idpOrgs.map((o) => o.orgs); - // TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1 - if (allOrgs.length > 1) { - // for some reason there is more than one org - logger.error( - "More than one organization linked to this IdP. This should not happen with auto-provisioning enabled." + for (const org of allOrgs) { + const subscribed = await isSubscribed( + org.orgId, + tierMatrix.autoProvisioning ); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Multiple organizations linked to this IdP. Please contact support." - ) - ); - } + if (!subscribed) { + // filter out the org + allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId); - const subscribed = await isSubscribed( - allOrgs[0].orgId, - tierMatrix.autoProvisioning - ); - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); + // return next( + // createHttpError( + // HttpCode.FORBIDDEN, + // "This organization's current plan does not support this feature." + // ) + // ); + } } } else { allOrgs = await db.select().from(orgs); diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 35d52816e..f87d38450 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -4,8 +4,10 @@ import { clientSitesAssociationsCache, db, ExitNode, + networks, resources, Site, + siteNetworks, siteResources, targetHealthCheck, targets @@ -84,7 +86,8 @@ export async function buildClientConfigurationForNewtClient( // ) // ); - if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm + if (!client.clientSitesAssociationsCache.isJitMode) { + // if we are adding sites through jit then dont add the site to the olm // update the peer info on the olm // if the peer has not been added yet this will be a no-op await updatePeer(client.clients.clientId, { @@ -137,11 +140,14 @@ export async function buildClientConfigurationForNewtClient( // Filter out any null values from peers that didn't have an olm const validPeers = peers.filter((peer) => peer !== null); - // Get all enabled site resources for this site + // Get all enabled site resources for this site by joining through siteNetworks and networks const allSiteResources = await db .select() .from(siteResources) - .where(eq(siteResources.siteId, siteId)); + .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) + .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) + .where(eq(siteNetworks.siteId, siteId)) + .then((rows) => rows.map((r) => r.siteResources)); const targetsToSend: SubnetProxyTargetV2[] = []; @@ -168,13 +174,13 @@ export async function buildClientConfigurationForNewtClient( ) ); - const resourceTarget = generateSubnetProxyTargetV2( + const resourceTargets = await generateSubnetProxyTargetV2( resource, resourceClients ); - if (resourceTarget) { - targetsToSend.push(resourceTarget); + if (resourceTargets) { + targetsToSend.push(...resourceTargets); } } @@ -184,7 +190,10 @@ export async function buildClientConfigurationForNewtClient( }; } -export async function buildTargetConfigurationForNewtClient(siteId: number) { +export async function buildTargetConfigurationForNewtClient( + siteId: number, + version?: string | null +) { // Get all enabled targets with their resource protocol information const allTargets = await db .select({ @@ -195,7 +204,15 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { port: targets.port, internalPort: targets.internalPort, enabled: targets.enabled, - protocol: resources.protocol, + protocol: resources.protocol + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); + + const allHealthChecks = await db + .select({ + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, hcScheme: targetHealthCheck.hcScheme, @@ -206,17 +223,15 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, hcTimeout: targetHealthCheck.hcTimeout, hcHeaders: targetHealthCheck.hcHeaders, + hcFollowRedirects: targetHealthCheck.hcFollowRedirects, hcMethod: targetHealthCheck.hcMethod, hcTlsServerName: targetHealthCheck.hcTlsServerName, - hcStatus: targetHealthCheck.hcStatus + hcStatus: targetHealthCheck.hcStatus, + hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold }) - .from(targets) - .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) - .leftJoin( - targetHealthCheck, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); + .from(targetHealthCheck) + .where(eq(targetHealthCheck.siteId, siteId)); const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { @@ -240,19 +255,14 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { { tcpTargets: [] as string[], udpTargets: [] as string[] } ); - const healthCheckTargets = allTargets.map((target) => { + const healthCheckTargets = allHealthChecks.map((target) => { // make sure the stuff is defined - if ( - !target.hcPath || - !target.hcHostname || - !target.hcPort || - !target.hcInterval || - !target.hcMethod - ) { - // logger.debug( - // `Skipping adding target health check ${target.targetId} due to missing health check fields` - // ); - return null; // Skip targets with missing health check fields + const isTCP = target.hcMode?.toLowerCase() === "tcp"; + if (!target.hcHostname || !target.hcPort || !target.hcInterval) { + return null; + } + if (!isTCP && (!target.hcPath || !target.hcMethod)) { + return null; } // parse headers @@ -269,7 +279,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { } return { - id: target.targetId, + id: target.targetHealthCheckId, hcEnabled: target.hcEnabled, hcPath: target.hcPath, hcScheme: target.hcScheme, @@ -280,9 +290,12 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { hcUnhealthyInterval: target.hcUnhealthyInterval, // in seconds hcTimeout: target.hcTimeout, // in seconds hcHeaders: hcHeadersSend, + hcFollowRedirects: target.hcFollowRedirects, hcMethod: target.hcMethod, hcTlsServerName: target.hcTlsServerName, - hcStatus: target.hcStatus + hcStatus: target.hcStatus, + hcHealthyThreshold: target.hcHealthyThreshold, + hcUnhealthyThreshold: target.hcUnhealthyThreshold }; }); diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index 02c5a95ac..a2b963fc9 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -2,6 +2,7 @@ import { MessageHandler } from "@server/routers/ws"; import { db, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; +import { fireSiteOfflineAlert } from "@server/lib/alerts"; /** * Handles disconnecting messages from sites to show disconnected in the ui @@ -24,12 +25,23 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( try { // Update the client's last ping timestamp - await db - .update(sites) - .set({ - online: false - }) - .where(eq(sites.siteId, newt.siteId)); + await db.transaction(async (trx) => { + const [site] = await trx + .update(sites) + .set({ + online: false + }) + .where(eq(sites.siteId, newt.siteId!)) + .returning(); + + await fireSiteOfflineAlert( + site.orgId, + site.siteId, + site.name, + undefined, + trx + ); + }); } catch (error) { logger.error("Error handling disconnecting message", { error }); } diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts similarity index 92% rename from server/routers/newt/handleGetConfigMessage.ts rename to server/routers/newt/handleNewtGetConfigMessage.ts index 9c67f53ee..787151a5a 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -10,7 +10,7 @@ import { convertTargetsIfNessicary } from "../client/targets"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; -export const handleGetConfigMessage: MessageHandler = async (context) => { +export const handleNewtGetConfigMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; @@ -56,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { logger.warn( - `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` + `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` ); return; } @@ -113,7 +113,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { exitNode ); - const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); + const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format return { message: { diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index 32f665758..56b8a2a24 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -1,180 +1,12 @@ -import { db, newts, sites, targetHealthCheck, targets } from "@server/db"; -import { - hasActiveConnections, - getClientConfigVersion -} from "#dynamic/routers/ws"; +import { db, sites } from "@server/db"; +import { getClientConfigVersion } from "#dynamic/routers/ws"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; -import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import logger from "@server/logger"; import { sendNewtSyncMessage } from "./sync"; import { recordPing } from "./pingAccumulator"; -// Track if the offline checker interval is running -let offlineCheckerInterval: NodeJS.Timeout | null = null; -const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds -const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes -const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes - -/** - * Starts the background interval that checks for newt sites that haven't - * pinged recently and marks them as offline. For backward compatibility, - * a site is only marked offline when there is no active WebSocket connection - * either — so older newt versions that don't send pings but remain connected - * continue to be treated as online. - */ -export const startNewtOfflineChecker = (): void => { - if (offlineCheckerInterval) { - return; // Already running - } - - offlineCheckerInterval = setInterval(async () => { - try { - const twoMinutesAgo = Math.floor( - (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 - ); - - // Find all online newt-type sites that haven't pinged recently - // (or have never pinged at all). Join newts to obtain the newtId - // needed for the WebSocket connection check. - const staleSites = await db - .select({ - siteId: sites.siteId, - newtId: newts.newtId, - lastPing: sites.lastPing - }) - .from(sites) - .innerJoin(newts, eq(newts.siteId, sites.siteId)) - .where( - and( - eq(sites.online, true), - eq(sites.type, "newt"), - or( - lt(sites.lastPing, twoMinutesAgo), - isNull(sites.lastPing) - ) - ) - ); - - for (const staleSite of staleSites) { - // Backward-compatibility check: if the newt still has an - // active WebSocket connection (older clients that don't send - // pings), keep the site online. - const isConnected = await hasActiveConnections( - staleSite.newtId - ); - if (isConnected) { - logger.debug( - `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online` - ); - continue; - } - - logger.info( - `Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection` - ); - - await db - .update(sites) - .set({ online: false }) - .where(eq(sites.siteId, staleSite.siteId)); - - const healthChecksOnSite = await db - .select() - .from(targetHealthCheck) - .innerJoin( - targets, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .innerJoin(sites, eq(sites.siteId, targets.siteId)) - .where(eq(sites.siteId, staleSite.siteId)); - - for (const healthCheck of healthChecksOnSite) { - logger.info( - `Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline` - ); - await db - .update(targetHealthCheck) - .set({ hcHealth: "unknown" }) - .where( - eq( - targetHealthCheck.targetHealthCheckId, - healthCheck.targetHealthCheck - .targetHealthCheckId - ) - ); - } - } - - // this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites - // select all of the wireguard sites to evaluate if they need to be offline due to the last bandwidth update - const allWireguardSites = await db - .select({ - siteId: sites.siteId, - online: sites.online, - lastBandwidthUpdate: sites.lastBandwidthUpdate - }) - .from(sites) - .where( - and( - eq(sites.type, "wireguard"), - not(isNull(sites.lastBandwidthUpdate)) - ) - ); - - const wireguardOfflineThreshold = Math.floor( - (Date.now() - OFFLINE_THRESHOLD_BANDWIDTH_MS) / 1000 - ); - - // loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline - for (const site of allWireguardSites) { - const lastBandwidthUpdate = - new Date(site.lastBandwidthUpdate!).getTime() / 1000; - if ( - lastBandwidthUpdate < wireguardOfflineThreshold && - site.online - ) { - logger.info( - `Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes` - ); - - await db - .update(sites) - .set({ online: false }) - .where(eq(sites.siteId, site.siteId)); - } else if ( - lastBandwidthUpdate >= wireguardOfflineThreshold && - !site.online - ) { - logger.info( - `Marking wireguard site ${site.siteId} online: recent bandwidth update` - ); - - await db - .update(sites) - .set({ online: true }) - .where(eq(sites.siteId, site.siteId)); - } - } - } catch (error) { - logger.error("Error in newt offline checker interval", { error }); - } - }, OFFLINE_CHECK_INTERVAL); - - logger.debug("Started newt offline checker interval"); -}; - -/** - * Stops the background interval that checks for offline newt sites. - */ -export const stopNewtOfflineChecker = (): void => { - if (offlineCheckerInterval) { - clearInterval(offlineCheckerInterval); - offlineCheckerInterval = null; - logger.info("Stopped newt offline checker interval"); - } -}; - /** * Handles ping messages from newt clients. * diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index fce42caa3..f3902a35d 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -192,7 +192,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { } const { tcpTargets, udpTargets, validHealthCheckTargets } = - await buildTargetConfigurationForNewtClient(siteId); + await buildTargetConfigurationForNewtClient(siteId, newtVersion); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` diff --git a/server/routers/newt/handleReceiveBandwidthMessage.ts b/server/routers/newt/handleReceiveBandwidthMessage.ts index f086333e7..2d5d99b09 100644 --- a/server/routers/newt/handleReceiveBandwidthMessage.ts +++ b/server/routers/newt/handleReceiveBandwidthMessage.ts @@ -88,7 +88,7 @@ export async function flushBandwidthToDb(): Promise { const currentTime = new Date().toISOString(); // Sort by publicKey for consistent lock ordering across concurrent - // writers — this is the same deadlock-prevention strategy used in the + // writers - this is the same deadlock-prevention strategy used in the // original per-message implementation. const sortedEntries = [...snapshot.entries()].sort(([a], [b]) => a.localeCompare(b) @@ -143,7 +143,7 @@ const flushTimer = setInterval(async () => { }, FLUSH_INTERVAL_MS); // Calling unref() means this timer will not keep the Node.js event loop alive -// on its own — the process can still exit normally when there is no other work +// on its own - the process can still exit normally when there is no other work // left. The graceful-shutdown path (see server/cleanup.ts) will call // flushBandwidthToDb() explicitly before process.exit(), so no data is lost. flushTimer.unref(); @@ -167,7 +167,7 @@ export const handleReceiveBandwidthMessage: MessageHandler = async ( // Accumulate the incoming data in memory; the periodic timer (and the // shutdown hook) will take care of writing it to the database. for (const { publicKey, bytesIn, bytesOut } of bandwidthData) { - // Skip peers that haven't transferred any data — writing zeros to the + // Skip peers that haven't transferred any data - writing zeros to the // database would be a no-op anyway. if (bytesIn <= 0 && bytesOut <= 0) { continue; diff --git a/server/routers/newt/handleRequestLogMessage.ts b/server/routers/newt/handleRequestLogMessage.ts new file mode 100644 index 000000000..190020ad1 --- /dev/null +++ b/server/routers/newt/handleRequestLogMessage.ts @@ -0,0 +1,9 @@ +import { MessageHandler } from "@server/routers/ws"; + +export async function flushRequestLogToDb(): Promise { + return; +} + +export const handleRequestLogMessage: MessageHandler = async (context) => { + return; +}; \ No newline at end of file diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 383ab5541..5d5497ee1 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -8,26 +8,26 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; - logger.info("Handling Docker socket check response"); + logger.debug("Handling Docker socket check response"); if (!newt) { logger.warn("Newt not found"); return; } - logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); + logger.debug(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); const { available, socketPath } = message.data; - logger.info( + logger.debug( `Docker socket availability for Newt ${newt.newtId}: available=${available}, socketPath=${socketPath}` ); if (available) { - logger.info(`Newt ${newt.newtId} has Docker socket access`); + logger.debug(`Newt ${newt.newtId} has Docker socket access`); await cache.set(`${newt.newtId}:socketPath`, socketPath, 0); await cache.set(`${newt.newtId}:isAvailable`, available, 0); } else { - logger.warn(`Newt ${newt.newtId} does not have Docker socket access`); + logger.debug(`Newt ${newt.newtId} does not have Docker socket access`); } return; @@ -39,28 +39,28 @@ export const handleDockerContainersMessage: MessageHandler = async ( const { message, client, sendToClient } = context; const newt = client as Newt; - logger.info("Handling Docker containers response"); + logger.debug("Handling Docker containers response"); if (!newt) { logger.warn("Newt not found"); return; } - logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); + logger.debug(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); const { containers } = message.data; - logger.info( + logger.debug( `Docker containers for Newt ${newt.newtId}: ${containers ? containers.length : 0}` ); if (containers && containers.length > 0) { await cache.set(`${newt.newtId}:dockerContainers`, containers, 0); } else { - logger.warn(`Newt ${newt.newtId} does not have Docker containers`); + logger.debug(`Newt ${newt.newtId} does not have Docker containers`); } if (!newt.siteId) { - logger.warn("Newt has no site!"); + logger.debug("Newt has no site!"); return; } diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 33b5caf7c..368cdf636 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -2,11 +2,13 @@ export * from "./createNewt"; export * from "./getNewtToken"; export * from "./handleNewtRegisterMessage"; export * from "./handleReceiveBandwidthMessage"; -export * from "./handleGetConfigMessage"; +export * from "./handleNewtGetConfigMessage"; export * from "./handleSocketMessages"; export * from "./handleNewtPingRequestMessage"; export * from "./handleApplyBlueprintMessage"; export * from "./handleNewtPingMessage"; export * from "./handleNewtDisconnectingMessage"; export * from "./handleConnectionLogMessage"; +export * from "./handleRequestLogMessage"; export * from "./registerNewt"; +export * from "./offlineChecker"; diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts new file mode 100644 index 000000000..0d9148509 --- /dev/null +++ b/server/routers/newt/offlineChecker.ts @@ -0,0 +1,178 @@ +import { db, newts, sites } from "@server/db"; +import { hasActiveConnections } from "#dynamic/routers/ws"; +import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm"; +import logger from "@server/logger"; +import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "@server/lib/alerts"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes +const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes + +/** + * Starts the background interval that checks for newt sites that haven't + * pinged recently and marks them as offline. For backward compatibility, + * a site is only marked offline when there is no active WebSocket connection + * either - so older newt versions that don't send pings but remain connected + * continue to be treated as online. + */ +export const startNewtOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 + ); + + // Find all online newt-type sites that haven't pinged recently + // (or have never pinged at all). Join newts to obtain the newtId + // needed for the WebSocket connection check. + const staleSites = await db + .select({ + siteId: sites.siteId, + orgId: sites.orgId, + name: sites.name, + newtId: newts.newtId, + lastPing: sites.lastPing + }) + .from(sites) + .innerJoin(newts, eq(newts.siteId, sites.siteId)) + .where( + and( + eq(sites.online, true), + eq(sites.type, "newt"), + or( + lt(sites.lastPing, twoMinutesAgo), + isNull(sites.lastPing) + ) + ) + ); + + for (const staleSite of staleSites) { + // Backward-compatibility check: if the newt still has an + // active WebSocket connection (older clients that don't send + // pings), keep the site online. + const isConnected = await hasActiveConnections( + staleSite.newtId + ); + if (isConnected) { + logger.debug( + `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket - keeping site ${staleSite.siteId} online` + ); + continue; + } + + logger.info( + `Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection` + ); + + await db.transaction(async (trx) => { + await trx + .update(sites) + .set({ online: false }) + .where(eq(sites.siteId, staleSite.siteId)); + + await fireSiteOfflineAlert( + staleSite.orgId, + staleSite.siteId, + staleSite.name, + undefined, + trx + ); + }); + } + + // this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites + // select all of the wireguard sites to evaluate if they need to be offline due to the last bandwidth update + const allWireguardSites = await db + .select({ + siteId: sites.siteId, + orgId: sites.orgId, + name: sites.name, + online: sites.online, + lastBandwidthUpdate: sites.lastBandwidthUpdate + }) + .from(sites) + .where( + and( + eq(sites.type, "wireguard"), + not(isNull(sites.lastBandwidthUpdate)) + ) + ); + + const wireguardOfflineThreshold = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_BANDWIDTH_MS) / 1000 + ); + + // loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline + for (const site of allWireguardSites) { + const lastBandwidthUpdate = + new Date(site.lastBandwidthUpdate!).getTime() / 1000; + if ( + lastBandwidthUpdate < wireguardOfflineThreshold && + site.online + ) { + logger.info( + `Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes` + ); + + await db.transaction(async (trx) => { + await trx + .update(sites) + .set({ online: false }) + .where(eq(sites.siteId, site.siteId)); + + await fireSiteOfflineAlert( + site.orgId, + site.siteId, + site.name, + undefined, + trx + ); + }); + } else if ( + lastBandwidthUpdate >= wireguardOfflineThreshold && + !site.online + ) { + logger.info( + `Marking wireguard site ${site.siteId} online: recent bandwidth update` + ); + + await db.transaction(async (trx) => { + await trx + .update(sites) + .set({ online: true }) + .where(eq(sites.siteId, site.siteId)); + + await fireSiteOnlineAlert( + site.orgId, + site.siteId, + site.name, + undefined, + trx + ); + }); + } + } + } catch (error) { + logger.error("Error in newt offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.debug("Started newt offline checker interval"); +}; + +/** + * Stops the background interval that checks for offline newt sites. + */ +export const stopNewtOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped newt offline checker interval"); + } +}; diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index fe2cde216..5351c6723 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -1,7 +1,8 @@ import { db } from "@server/db"; import { sites, clients, olms } from "@server/db"; -import { inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; +import { fireSiteOnlineAlert } from "@server/lib/alerts"; /** * Ping Accumulator @@ -110,15 +111,56 @@ async function flushSitePingsToDb(): Promise { const siteIds = batch.map(([id]) => id); try { - await withRetry(async () => { - await db + const newlyOnlineSites = await withRetry(async () => { + // Only update sites that were offline - these are the + // offline→online transitions. .returning() gives us exactly + // the site IDs that changed state. + const transitioned = await db .update(sites) .set({ online: true, lastPing: maxTimestamp }) - .where(inArray(sites.siteId, siteIds)); + .where( + and( + inArray(sites.siteId, siteIds), + eq(sites.online, false) + ) + ) + .returning({ + siteId: sites.siteId, + orgId: sites.orgId, + name: sites.name + }); + + // Update lastPing for sites that were already online. + // After the update above, the newly-online sites now have + // online = true, so this catches all remaining sites in the + // batch and keeps lastPing current for them too. + await db + .update(sites) + .set({ lastPing: maxTimestamp }) + .where( + and( + inArray(sites.siteId, siteIds), + eq(sites.online, true) + ) + ); + + return transitioned; }, "flushSitePingsToDb"); + + for (const site of newlyOnlineSites) { + await db.transaction(async (trx) => { + await fireSiteOnlineAlert( + site.orgId, + site.siteId, + site.name, + undefined, + trx + ); + }); + } } catch (error) { logger.error( `Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`, @@ -219,7 +261,7 @@ async function flushClientPingsToDb(): Promise { } /** - * Flush everything — called by the interval timer and during shutdown. + * Flush everything - called by the interval timer and during shutdown. */ export async function flushPingsToDb(): Promise { await flushSitePingsToDb(); @@ -284,7 +326,7 @@ function isTransientError(error: any): boolean { return true; } - // PostgreSQL deadlock detected — always safe to retry (one winner guaranteed) + // PostgreSQL deadlock detected - always safe to retry (one winner guaranteed) if (code === "40P01" || message.includes("deadlock")) { return true; } @@ -344,7 +386,7 @@ export function startPingAccumulator(): void { // Don't prevent the process from exiting flushTimer.unref(); - logger.info( + logger.debug( `Ping accumulator started (flush interval: ${FLUSH_INTERVAL_MS}ms)` ); } diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index de68ab2de..b79118b58 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, logsDb, statusHistory } from "@server/db"; import { siteProvisioningKeys, siteProvisioningKeyOrg, @@ -84,7 +84,7 @@ export async function registerNewt( maxBatchSize: siteProvisioningKeys.maxBatchSize, numUsed: siteProvisioningKeys.numUsed, validUntil: siteProvisioningKeys.validUntil, - approveNewSites: siteProvisioningKeys.approveNewSites, + approveNewSites: siteProvisioningKeys.approveNewSites }) .from(siteProvisioningKeys) .innerJoin( @@ -125,7 +125,10 @@ export async function registerNewt( ); } - if (keyRecord.maxBatchSize && keyRecord.numUsed >= keyRecord.maxBatchSize) { + if ( + keyRecord.maxBatchSize && + keyRecord.numUsed >= keyRecord.maxBatchSize + ) { return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -134,7 +137,10 @@ export async function registerNewt( ); } - if (keyRecord.validUntil && new Date(keyRecord.validUntil) < new Date()) { + if ( + keyRecord.validUntil && + new Date(keyRecord.validUntil) < new Date() + ) { return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -154,7 +160,10 @@ export async function registerNewt( } if (!org.subnet) { return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Organization subnet not found") + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Organization subnet not found" + ) ); } @@ -195,7 +204,6 @@ export async function registerNewt( let newSiteId: number | undefined; await db.transaction(async (trx) => { - const newClientAddress = await getNextAvailableClientSubnet(orgId); if (!newClientAddress) { return next( @@ -219,10 +227,18 @@ export async function registerNewt( address: clientAddress, type: "newt", dockerSocketEnabled: true, - status: keyRecord.approveNewSites ? "approved" : "pending", + status: keyRecord.approveNewSites ? "approved" : "pending" }) .returning(); + await logsDb.insert(statusHistory).values({ + entityType: "site", + entityId: newSite.siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); + newSiteId = newSite.siteId; // Grant admin role access to the new site @@ -249,7 +265,7 @@ export async function registerNewt( dateCreated: moment().toISOString() }); - // Consume the provisioning key — cascade removes siteProvisioningKeyOrg + // Consume the provisioning key - cascade removes siteProvisioningKeyOrg await trx .update(siteProvisioningKeys) .set({ diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 6a523ebe9..ac25fb27d 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,7 +1,6 @@ -import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db"; +import { Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; -import { eq, inArray } from "drizzle-orm"; import { canCompress } from "@server/lib/clientVersionChecks"; export async function addTargets( @@ -18,42 +17,36 @@ export async function addTargets( }:${target.port}`; }); - await sendToClient(newtId, { - type: `newt/${protocol}/add`, - data: { - targets: payloadTargets - } - }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); + if (payloadTargets.length > 0) { + await sendToClient( + newtId, + { + type: `newt/${protocol}/add`, + data: { + targets: payloadTargets + } + }, + { + incrementConfigVersion: true, + compress: canCompress(version, "newt") + } + ); + } - // Create a map for quick lookup - const healthCheckMap = new Map(); - healthCheckData.forEach((hc) => { - healthCheckMap.set(hc.targetId, hc); - }); - - const healthCheckTargets = targets.map((target) => { - const hc = healthCheckMap.get(target.targetId); - - // If no health check data found, skip this target - if (!hc) { - logger.warn( - `No health check configuration found for target ${target.targetId}` + const healthCheckTargets = healthCheckData.map((hc) => { + // Ensure all necessary fields are present + const isTCP = hc.hcMode?.toLowerCase() === "tcp"; + if (!hc.hcHostname || !hc.hcPort || !hc.hcInterval) { + logger.debug( + `Skipping hc ${hc.targetHealthCheckId} due to missing health check fields` ); return null; } - - // Ensure all necessary fields are present - if ( - !hc.hcPath || - !hc.hcHostname || - !hc.hcPort || - !hc.hcInterval || - !hc.hcMethod - ) { + if (!isTCP && (!hc.hcPath || !hc.hcMethod)) { logger.debug( - `Skipping target ${target.targetId} due to missing health check fields` + `Skipping hc ${hc.targetHealthCheckId} due to missing HTTP health check fields` ); - return null; // Skip targets with missing health check fields + return null; } const hcHeadersParse = hc.hcHeaders ? JSON.parse(hc.hcHeaders) : null; @@ -77,7 +70,7 @@ export async function addTargets( } return { - id: target.targetId, + id: hc.targetHealthCheckId, hcEnabled: hc.hcEnabled, hcPath: hc.hcPath, hcScheme: hc.hcScheme, @@ -88,28 +81,126 @@ export async function addTargets( hcUnhealthyInterval: hc.hcUnhealthyInterval, // in seconds hcTimeout: hc.hcTimeout, // in seconds hcHeaders: hcHeadersSend, + hcFollowRedirects: hc.hcFollowRedirects, hcMethod: hc.hcMethod, hcStatus: hcStatus, - hcTlsServerName: hc.hcTlsServerName + hcTlsServerName: hc.hcTlsServerName, + hcHealthyThreshold: hc.hcHealthyThreshold, + hcUnhealthyThreshold: hc.hcUnhealthyThreshold }; }); // Filter out any null values from health check targets const validHealthCheckTargets = healthCheckTargets.filter( - (target) => target !== null + (hc) => hc !== null ); - await sendToClient(newtId, { - type: `newt/healthcheck/add`, - data: { - targets: validHealthCheckTargets + await sendToClient( + newtId, + { + type: `newt/healthcheck/add`, + data: { + targets: validHealthCheckTargets + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); +} + +export async function addStandaloneHealthCheck( + newtId: string, + healthCheck: TargetHealthCheck, + version?: string | null +) { + const isTCP = healthCheck.hcMode?.toLowerCase() === "tcp"; + if ( + !healthCheck.hcHostname || + !healthCheck.hcPort || + !healthCheck.hcInterval + ) { + logger.debug( + `Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing fields` + ); + return; + } + if (!isTCP && (!healthCheck.hcPath || !healthCheck.hcMethod)) { + logger.debug( + `Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing HTTP health check fields` + ); + return; + } + + const hcHeadersParse = healthCheck.hcHeaders + ? JSON.parse(healthCheck.hcHeaders) + : null; + const hcHeadersSend: { [key: string]: string } = {}; + if (hcHeadersParse) { + hcHeadersParse.forEach((header: { name: string; value: string }) => { + hcHeadersSend[header.name] = header.value; + }); + } + + let hcStatus: number | undefined = undefined; + if (healthCheck.hcStatus) { + const parsedStatus = parseInt(healthCheck.hcStatus.toString()); + if (!isNaN(parsedStatus)) { + hcStatus = parsedStatus; } - }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); + } + + await sendToClient( + newtId, + { + type: `newt/healthcheck/add`, + data: { + targets: [ + { + id: healthCheck.targetHealthCheckId, + hcEnabled: healthCheck.hcEnabled, + hcPath: healthCheck.hcPath, + hcScheme: healthCheck.hcScheme, + hcMode: healthCheck.hcMode, + hcHostname: healthCheck.hcHostname, + hcPort: healthCheck.hcPort, + hcInterval: healthCheck.hcInterval, + hcUnhealthyInterval: healthCheck.hcUnhealthyInterval, + hcTimeout: healthCheck.hcTimeout, + hcHeaders: hcHeadersSend, + hcFollowRedirects: healthCheck.hcFollowRedirects, + hcMethod: healthCheck.hcMethod, + hcStatus: hcStatus, + hcTlsServerName: healthCheck.hcTlsServerName, + hcHealthyThreshold: healthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: healthCheck.hcUnhealthyThreshold + } + ] + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); +} + +export async function removeStandaloneHealthCheck( + newtId: string, + healthCheckId: number, + version?: string | null +) { + await sendToClient( + newtId, + { + type: `newt/healthcheck/remove`, + data: { + ids: [healthCheckId] + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); } export async function removeTargets( newtId: string, targets: Target[], + healthCheckData: TargetHealthCheck[], protocol: string, version?: string | null ) { @@ -120,21 +211,31 @@ export async function removeTargets( }:${target.port}`; }); - await sendToClient(newtId, { - type: `newt/${protocol}/remove`, - data: { - targets: payloadTargets - } - }, { incrementConfigVersion: true }); + if (payloadTargets.length > 0) { + await sendToClient( + newtId, + { + type: `newt/${protocol}/remove`, + data: { + targets: payloadTargets + } + }, + { incrementConfigVersion: true } + ); + } - const healthCheckTargets = targets.map((target) => { - return target.targetId; + const healthCheckTargets = healthCheckData.map((hc) => { + return hc.targetHealthCheckId; }); - await sendToClient(newtId, { - type: `newt/healthcheck/remove`, - data: { - ids: healthCheckTargets - } - }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); + await sendToClient( + newtId, + { + type: `newt/healthcheck/remove`, + data: { + ids: healthCheckTargets + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); } diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index bc2611b1c..4182725d3 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -4,6 +4,8 @@ import { clientSitesAssociationsCache, db, exitNodes, + networks, + siteNetworks, siteResources, sites } from "@server/db"; @@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient( clientSiteResourcesAssociationsCache.siteResourceId ) ) + .innerJoin( + networks, + eq(siteResources.networkId, networks.networkId) + ) + .innerJoin( + siteNetworks, + eq(networks.networkId, siteNetworks.networkId) + ) .where( and( - eq(siteResources.siteId, site.siteId), + eq(siteNetworks.siteId, site.siteId), eq( clientSiteResourcesAssociationsCache.clientId, client.clientId @@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient( ) ); + if (jitMode) { // Add site configuration to the array siteConfigurations.push({ diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 0f520b234..0e18c7f5b 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -1,104 +1,17 @@ -import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws"; +import { getClientConfigVersion } from "#dynamic/routers/ws"; import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, olms, Olm } from "@server/db"; -import { eq, lt, isNull, and, or } from "drizzle-orm"; +import { clients, Olm } from "@server/db"; +import { eq } from "drizzle-orm"; import { recordClientPing } from "@server/routers/newt/pingAccumulator"; import logger from "@server/logger"; import { validateSessionToken } from "@server/auth/sessions/app"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; -import { sendTerminateClient } from "../client/terminate"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { sendOlmSyncMessage } from "./sync"; -import { OlmErrorCodes } from "./error"; import { handleFingerprintInsertion } from "./fingerprintingUtils"; -// Track if the offline checker interval is running -let offlineCheckerInterval: NodeJS.Timeout | null = null; -const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds -const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes - -/** - * Starts the background interval that checks for clients that haven't pinged recently - * and marks them as offline - */ -export const startOlmOfflineChecker = (): void => { - if (offlineCheckerInterval) { - return; // Already running - } - - offlineCheckerInterval = setInterval(async () => { - try { - const twoMinutesAgo = Math.floor( - (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 - ); - - // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING - - // Find clients that haven't pinged in the last 2 minutes and mark them as offline - const offlineClients = await db - .update(clients) - .set({ online: false }) - .where( - and( - eq(clients.online, true), - or( - lt(clients.lastPing, twoMinutesAgo), - isNull(clients.lastPing) - ) - ) - ) - .returning(); - - for (const offlineClient of offlineClients) { - logger.info( - `Kicking offline olm client ${offlineClient.clientId} due to inactivity` - ); - - if (!offlineClient.olmId) { - logger.warn( - `Offline client ${offlineClient.clientId} has no olmId, cannot disconnect` - ); - continue; - } - - // Send a disconnect message to the client if connected - try { - await sendTerminateClient( - offlineClient.clientId, - OlmErrorCodes.TERMINATED_INACTIVITY, - offlineClient.olmId - ); // terminate first - // wait a moment to ensure the message is sent - await new Promise((resolve) => setTimeout(resolve, 1000)); - await disconnectClient(offlineClient.olmId); - } catch (error) { - logger.error( - `Error sending disconnect to offline olm ${offlineClient.clientId}`, - { error } - ); - } - } - } catch (error) { - logger.error("Error in offline checker interval", { error }); - } - }, OFFLINE_CHECK_INTERVAL); - - logger.debug("Started offline checker interval"); -}; - -/** - * Stops the background interval that checks for offline clients - */ -export const stopOlmOfflineChecker = (): void => { - if (offlineCheckerInterval) { - clearInterval(offlineCheckerInterval); - offlineCheckerInterval = null; - logger.info("Stopped offline checker interval"); - } -}; - /** * Handles ping messages from clients and responds with pong */ diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 01495de3b..a4a62973d 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -17,7 +17,6 @@ import { getUserDeviceName } from "@server/db/names"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { OlmErrorCodes, sendOlmError } from "./error"; import { handleFingerprintInsertion } from "./fingerprintingUtils"; -import { Alias } from "@server/lib/ip"; import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; diff --git a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts index 54badb2dc..05a83a146 100644 --- a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts +++ b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts @@ -4,10 +4,12 @@ import { db, exitNodes, Site, - siteResources + siteNetworks, + siteResources, + sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, Olm, sites } from "@server/db"; +import { clients, Olm } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import logger from "@server/logger"; import { initPeerAddHandshake } from "./peers"; @@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( const { siteId, resourceId, chainId } = message.data; - let site: Site | null = null; + const sendCancel = async () => { + await sendToClient( + olm.olmId, + { + type: "olm/wg/peer/chain/cancel", + data: { chainId } + }, + { incrementConfigVersion: false } + ).catch((error) => { + logger.warn(`Error sending message:`, error); + }); + }; + + let sitesToProcess: Site[] = []; + if (siteId) { - // get the site const [siteRes] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (siteRes) { - site = siteRes; + sitesToProcess = [siteRes]; } - } - - if (resourceId && !site) { + } else if (resourceId) { const resources = await db .select() .from(siteResources) @@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( ); if (!resources || resources.length === 0) { - logger.error(`handleOlmServerPeerAddMessage: Resource not found`); - // cancel the request from the olm side to not keep doing this - await sendToClient( - olm.olmId, - { - type: "olm/wg/peer/chain/cancel", - data: { - chainId - } - }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); + logger.error( + `handleOlmServerInitAddPeerHandshake: Resource not found` + ); + await sendCancel(); return; } if (resources.length > 1) { // error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches logger.error( - `handleOlmServerPeerAddMessage: Multiple resources found matching the criteria` + `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria` ); return; } @@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( if (currentResourceAssociationCaches.length === 0) { logger.error( - `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` + `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` ); - // cancel the request from the olm side to not keep doing this - await sendToClient( - olm.olmId, - { - type: "olm/wg/peer/chain/cancel", - data: { - chainId - } - }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); + await sendCancel(); return; } - const siteIdFromResource = resource.siteId; - - // get the site - const [siteRes] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteIdFromResource)); - if (!siteRes) { + if (!resource.networkId) { logger.error( - `handleOlmServerPeerAddMessage: Site with ID ${site} not found` + `handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network` ); + await sendCancel(); return; } - site = siteRes; + // Get all sites associated with this resource's network via siteNetworks + const siteRows = await db + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(eq(siteNetworks.networkId, resource.networkId)); + + if (!siteRows || siteRows.length === 0) { + logger.error( + `handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}` + ); + await sendCancel(); + return; + } + + // Fetch full site objects for all network members + const foundSites = await Promise.all( + siteRows.map(async ({ siteId: sid }) => { + const [s] = await db + .select() + .from(sites) + .where(eq(sites.siteId, sid)) + .limit(1); + return s ?? null; + }) + ); + + sitesToProcess = foundSites.filter((s): s is Site => s !== null); } - if (!site) { - logger.error(`handleOlmServerPeerAddMessage: Site not found`); + if (sitesToProcess.length === 0) { + logger.error( + `handleOlmServerInitAddPeerHandshake: No sites to process` + ); + await sendCancel(); return; } - // check if the client can access this site using the cache - const currentSiteAssociationCaches = await db - .select() - .from(clientSitesAssociationsCache) - .where( - and( - eq(clientSitesAssociationsCache.clientId, client.clientId), - eq(clientSitesAssociationsCache.siteId, site.siteId) - ) - ); + let handshakeInitiated = false; - if (currentSiteAssociationCaches.length === 0) { - logger.error( - `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}` - ); - // cancel the request from the olm side to not keep doing this - await sendToClient( - olm.olmId, + for (const site of sitesToProcess) { + // Check if the client can access this site using the cache + const currentSiteAssociationCaches = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, client.clientId), + eq(clientSitesAssociationsCache.siteId, site.siteId) + ) + ); + + if (currentSiteAssociationCaches.length === 0) { + logger.warn( + `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping` + ); + continue; + } + + if (!site.exitNodeId) { + logger.error( + `handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping` + ); + continue; + } + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)); + + if (!exitNode) { + logger.error( + `handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping` + ); + continue; + } + + // Trigger the peer add handshake - if the peer was already added this will be a no-op + await initPeerAddHandshake( + client.clientId, { - type: "olm/wg/peer/chain/cancel", - data: { - chainId + siteId: site.siteId, + exitNode: { + publicKey: exitNode.publicKey, + endpoint: exitNode.endpoint } }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); - return; - } - - if (!site.exitNodeId) { - logger.error( - `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` - ); - // cancel the request from the olm side to not keep doing this - await sendToClient( olm.olmId, - { - type: "olm/wg/peer/chain/cancel", - data: { - chainId - } - }, - { incrementConfigVersion: false } - ).catch((error) => { - logger.warn(`Error sending message:`, error); - }); - return; - } - - // get the exit node from the side - const [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)); - - if (!exitNode) { - logger.error( - `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` + chainId ); - return; + + handshakeInitiated = true; } - // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch - // if it has already been added this will be a no-op - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clientId, - { - siteId: site.siteId, - exitNode: { - publicKey: exitNode.publicKey, - endpoint: exitNode.endpoint - } - }, - olm.olmId, - chainId - ); + if (!handshakeInitiated) { + logger.error( + `handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain` + ); + await sendCancel(); + } return; }; diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts index 64284f493..5f46ea84c 100644 --- a/server/routers/olm/handleOlmServerPeerAddMessage.ts +++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts @@ -1,43 +1,25 @@ import { - Client, clientSiteResourcesAssociationsCache, db, - ExitNode, - Org, - orgs, - roleClients, - roles, + networks, + siteNetworks, siteResources, - Transaction, - userClients, - userOrgs, - users } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, - exitNodes, Olm, - olms, sites } from "@server/db"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; -import { addPeer, deletePeer } from "../newt/peers"; import logger from "@server/logger"; -import { listExitNodes } from "#dynamic/lib/exitNodes"; import { generateAliasConfig, - getNextAvailableClientSubnet } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; -import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; -import { validateSessionToken } from "@server/auth/sessions/app"; -import config from "@server/lib/config"; import { addPeer as newtAddPeer, - deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; export const handleOlmServerPeerAddMessage: MessageHandler = async ( @@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( clientSiteResourcesAssociationsCache.siteResourceId ) ) - .where( + .innerJoin( + networks, + eq(siteResources.networkId, networks.networkId) + ) + .innerJoin( + siteNetworks, and( - eq(siteResources.siteId, site.siteId), - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) + eq(networks.networkId, siteNetworks.networkId), + eq(siteNetworks.siteId, site.siteId) + ) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId ) ); diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 322428572..5c151a8cf 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -12,3 +12,4 @@ export * from "./handleOlmUnRelayMessage"; export * from "./recoverOlmWithFingerprint"; export * from "./handleOlmDisconnectingMessage"; export * from "./handleOlmServerInitAddPeerHandshake"; +export * from "./offlineChecker"; diff --git a/server/routers/olm/offlineChecker.ts b/server/routers/olm/offlineChecker.ts new file mode 100644 index 000000000..7dd06a29c --- /dev/null +++ b/server/routers/olm/offlineChecker.ts @@ -0,0 +1,92 @@ +import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq, lt, isNull, and, or } from "drizzle-orm"; +import logger from "@server/logger"; +import { sendTerminateClient } from "../client/terminate"; +import { OlmErrorCodes } from "./error"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + +/** + * Starts the background interval that checks for clients that haven't pinged recently + * and marks them as offline + */ +export const startOlmOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 + ); + + // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING + + // Find clients that haven't pinged in the last 2 minutes and mark them as offline + const offlineClients = await db + .update(clients) + .set({ online: false }) + .where( + and( + eq(clients.online, true), + or( + lt(clients.lastPing, twoMinutesAgo), + isNull(clients.lastPing) + ) + ) + ) + .returning(); + + for (const offlineClient of offlineClients) { + logger.info( + `Kicking offline olm client ${offlineClient.clientId} due to inactivity` + ); + + if (!offlineClient.olmId) { + logger.warn( + `Offline client ${offlineClient.clientId} has no olmId, cannot disconnect` + ); + continue; + } + + // Send a disconnect message to the client if connected + try { + await sendTerminateClient( + offlineClient.clientId, + OlmErrorCodes.TERMINATED_INACTIVITY, + offlineClient.olmId + ); // terminate first + // wait a moment to ensure the message is sent + await new Promise((resolve) => setTimeout(resolve, 1000)); + await disconnectClient(offlineClient.olmId); + } catch (error) { + logger.error( + `Error sending disconnect to offline olm ${offlineClient.clientId}`, + { error } + ); + } + } + } catch (error) { + logger.error("Error in offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.debug("Started offline checker interval"); +}; + +/** + * Stops the background interval that checks for offline clients + */ +export const stopOlmOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped offline checker interval"); + } +}; diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 88f76c29c..5fccbcd1f 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -12,7 +12,9 @@ import { userOrgRoles, userOrgs, users, - actions + actions, + customers, + subscriptions } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -31,6 +33,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor import { doCidrsOverlap } from "@server/lib/ip"; import { generateCA } from "@server/lib/sshCA"; import { encrypt } from "@server/lib/crypto"; +import { generateId } from "@server/auth/sessions/app"; const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/; diff --git a/server/routers/orgIdp/types.ts b/server/routers/orgIdp/types.ts index f6f581eed..40dbb2cf4 100644 --- a/server/routers/orgIdp/types.ts +++ b/server/routers/orgIdp/types.ts @@ -25,3 +25,22 @@ export type ListOrgIdpsResponse = { offset: number; }; }; + +export type ListUserAdminOrgIdpsEntry = { + idpId: number; + orgId: string; + orgName: string; + name: string; + type: string; + variant: string; + tags: string | null; +}; + +export type ListUserAdminOrgIdpsResponse = { + idps: ListUserAdminOrgIdpsEntry[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/server/routers/remoteExitNode/types.ts b/server/routers/remoteExitNode/types.ts index 25a7d6c53..9984b1b4f 100644 --- a/server/routers/remoteExitNode/types.ts +++ b/server/routers/remoteExitNode/types.ts @@ -21,6 +21,7 @@ export type ListRemoteExitNodesResponse = { remoteExitNodeId: string; dateCreated: string; version: string | null; + updateAvailable?: boolean; exitNodeId: number | null; name: string; address: string; diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index d8820de79..f8b7551e9 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -17,14 +17,15 @@ import createHttpError from "http-errors"; import { eq, and } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { subdomainSchema } from "@server/lib/schemas"; +import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { getUniqueResourceName } from "@server/db/names"; -import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; const createResourceParamsSchema = z.strictObject({ @@ -44,7 +45,10 @@ const createHttpResourceSchema = z .refine( (data) => { if (data.subdomain) { - return subdomainSchema.safeParse(data.subdomain).success; + return ( + subdomainSchema.safeParse(data.subdomain).success || + wildcardSubdomainSchema.safeParse(data.subdomain).success + ); } return true; }, @@ -198,9 +202,25 @@ async function createHttpResource( const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; + // Wildcard subdomains are a paid feature + if (subdomain && subdomain.includes("*")) { + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.wildcardSubdomain + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + } + if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) { // grandfather in existing users - const lastAllowedDate = new Date("2026-04-12"); + const lastAllowedDate = new Date("2026-04-13"); const userCreatedDate = new Date(req.user?.dateCreated || new Date()); if (userCreatedDate > lastAllowedDate) { // check if this domain id is a namespace domain and if so, reject @@ -232,7 +252,7 @@ async function createHttpResource( return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error)); } - const { fullDomain, subdomain: finalSubdomain } = domainResult; + const { fullDomain, subdomain: finalSubdomain, wildcard } = domainResult; logger.debug(`Full domain: ${fullDomain}`); @@ -251,6 +271,13 @@ async function createHttpResource( ); } + const wildcardConflict = await checkWildcardDomainConflict(fullDomain); + if (wildcardConflict.conflict) { + return next( + createHttpError(HttpCode.CONFLICT, wildcardConflict.message) + ); + } + // Prevent creating resource with same domain as dashboard const dashboardUrl = config.getRawConfig().app.dashboard_url; if (dashboardUrl) { @@ -299,7 +326,9 @@ async function createHttpResource( protocol: "tcp", ssl: true, stickySession: stickySession, - postAuthPath: postAuthPath + postAuthPath: postAuthPath, + wildcard, + health: "unknown" }) .returning(); diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index e63301867..682fd6aa9 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, targetHealthCheck } from "@server/db"; import { newts, resources, sites, targets } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -52,6 +52,16 @@ export async function deleteResource( .from(targets) .where(eq(targets.resourceId, resourceId)); + const healthChecksToBeRemoved = await db + .select() + .from(targetHealthCheck) + .where( + inArray( + targetHealthCheck.targetId, + targetsToBeRemoved.map((t) => t.targetId) + ) + ); + const [deletedResource] = await db .delete(resources) .where(eq(resources.resourceId, resourceId)) @@ -66,44 +76,43 @@ export async function deleteResource( ); } - // const [site] = await db - // .select() - // .from(sites) - // .where(eq(sites.siteId, deletedResource.siteId!)) - // .limit(1); - // - // if (!site) { - // return next( - // createHttpError( - // HttpCode.NOT_FOUND, - // `Site with ID ${deletedResource.siteId} not found` - // ) - // ); - // } - // - // if (site.pubKey) { - // if (site.type == "wireguard") { - // await addPeer(site.exitNodeId!, { - // publicKey: site.pubKey, - // allowedIps: await getAllowedIps(site.siteId) - // }); - // } else if (site.type == "newt") { - // // get the newt on the site by querying the newt table for siteId - // const [newt] = await db - // .select() - // .from(newts) - // .where(eq(newts.siteId, site.siteId)) - // .limit(1); - // - // removeTargets( - // newt.newtId, - // targetsToBeRemoved, - // deletedResource.protocol, - // deletedResource.proxyPort - // ); - // } - // } - // + for (const target of targetsToBeRemoved) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, target.siteId)) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${target.siteId} not found` + ) + ); + } + + if (site.pubKey) { + if (site.type == "newt") { + // get the newt on the site by querying the newt table for siteId + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + await removeTargets( + newt.newtId, + // [target], + [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this + healthChecksToBeRemoved, + deletedResource.protocol, + newt.version + ); + } + } + } + return response(res, { data: null, success: true, diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 7def75d5b..30ff4699a 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -32,6 +32,8 @@ export type GetResourceAuthInfoResponse = { sso: boolean; blockAccess: boolean; url: string; + wildcard: boolean; + fullDomain: string | null; whitelist: boolean; skipToIdpId: number | null; orgId: string; @@ -130,7 +132,9 @@ export async function getResourceAuthInfo( const headerAuthExtendedCompatibility = result?.resourceHeaderAuthExtendedCompatibility; - const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + const url = resource.fullDomain + ? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` + : null; return response(res, { data: { @@ -145,7 +149,9 @@ export async function getResourceAuthInfo( headerAuthExtendedCompatibility !== null, sso: resource.sso, blockAccess: resource.blockAccess, - url, + url: url ?? "", + wildcard: resource.wildcard ?? false, + fullDomain: resource.fullDomain, whitelist: resource.emailWhitelistEnabled, skipToIdpId: resource.skipToIdpId, orgId: resource.orgId, diff --git a/server/routers/resource/getStatusHistory.ts b/server/routers/resource/getStatusHistory.ts new file mode 100644 index 000000000..c3dcf6c88 --- /dev/null +++ b/server/routers/resource/getStatusHistory.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +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 { + getCachedStatusHistory, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; + +const resourceParamsSchema = z.object({ + resourceId: z.string().transform((v) => parseInt(v, 10)) +}); + +export async function getResourceStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = resourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "resource"; + const entityId = parsedParams.data.resourceId; + const { days } = parsedQuery.data; + + const data = await getCachedStatusHistory(entityType, entityId, days); + + return response(res, { + data, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 802fffb1b..c0f21a440 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -86,7 +86,12 @@ export async function getUserResources( .where(inArray(roleSiteResources.roleId, userRoleIds)) : Promise.resolve([]); - const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ + const [ + directResources, + roleResourceResults, + directSiteResourceResults, + roleSiteResourceResults + ] = await Promise.all([ directResourcesQuery, roleResourcesQuery, directSiteResourcesQuery, @@ -118,24 +123,24 @@ export async function getUserResources( }> = []; if (accessibleResourceIds.length > 0) { resourcesData = await db - .select({ - resourceId: resources.resourceId, - name: resources.name, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - enabled: resources.enabled, - sso: resources.sso, - protocol: resources.protocol, - emailWhitelistEnabled: resources.emailWhitelistEnabled - }) - .from(resources) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId), - eq(resources.enabled, true) - ) - ); + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + enabled: resources.enabled, + sso: resources.sso, + protocol: resources.protocol, + emailWhitelistEnabled: resources.emailWhitelistEnabled + }) + .from(resources) + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ) + ); } // Get site resource details for accessible site resources @@ -145,7 +150,7 @@ export async function getUserResources( niceId: string; destination: string; mode: string; - protocol: string | null; + scheme: string | null; enabled: boolean; alias: string | null; aliasAddress: string | null; @@ -158,7 +163,7 @@ export async function getUserResources( niceId: siteResources.niceId, destination: siteResources.destination, mode: siteResources.mode, - protocol: siteResources.protocol, + scheme: siteResources.scheme, enabled: siteResources.enabled, alias: siteResources.alias, aliasAddress: siteResources.aliasAddress @@ -166,7 +171,10 @@ export async function getUserResources( .from(siteResources) .where( and( - inArray(siteResources.siteResourceId, accessibleSiteResourceIds), + inArray( + siteResources.siteResourceId, + accessibleSiteResourceIds + ), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true) ) @@ -242,11 +250,11 @@ export async function getUserResources( name: siteResource.name, destination: siteResource.destination, mode: siteResource.mode, - protocol: siteResource.protocol, + protocol: siteResource.scheme, enabled: siteResource.enabled, alias: siteResource.alias, aliasAddress: siteResource.aliasAddress, - type: 'site' as const + type: "site" as const }; }); @@ -291,7 +299,7 @@ export type GetUserResourcesResponse = { enabled: boolean; alias: string | null; aliasAddress: string | null; - type: 'site'; + type: "site"; }>; }; }; diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 12e98a70d..6a259d7fe 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -32,3 +32,4 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./getStatusHistory"; diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index fa7ec8a48..16a82e400 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -6,6 +6,7 @@ import { resourcePincode, resources, roleResources, + sites, targetHealthCheck, targets, userResources @@ -104,15 +105,20 @@ const listResourcesSchema = z.object({ "Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)." }), healthStatus: z - .enum(["no_targets", "healthy", "degraded", "offline", "unknown"]) + .enum(["healthy", "degraded", "unhealthy", "unknown"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["no_targets", "healthy", "degraded", "offline", "unknown"], + enum: ["healthy", "degraded", "offline", "unknown"], description: - "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets." - }) + "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status." + }), + siteId: z.coerce.number().int().positive().optional().openapi({ + type: "integer", + description: + "When set, only resources that have at least one target on this site are returned" + }) }); // grouped by resource with targets[]) @@ -132,36 +138,24 @@ export type ResourceWithTargets = { domainId: string | null; niceId: string; headerAuthId: number | null; + wildcard: boolean; + health: string | null; targets: Array<{ targetId: number; ip: string; port: number; enabled: boolean; healthStatus: "healthy" | "unhealthy" | "unknown" | null; + siteName: string | null; + }>; + sites: Array<{ + siteId: number; + siteName: string; + siteNiceId: string; + online?: boolean; // undefined for local sites }>; }; -// Aggregate filters -const total_targets = count(targets.targetId); -const healthy_targets = sql`SUM( - CASE - WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1 - ELSE 0 - END - ) `; -const unknown_targets = sql`SUM( - CASE - WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1 - ELSE 0 - END - ) `; -const unhealthy_targets = sql`SUM( - CASE - WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1 - ELSE 0 - END - ) `; - function queryResourcesBase() { return db .select({ @@ -179,9 +173,11 @@ function queryResourcesBase() { enabled: resources.enabled, domainId: resources.domainId, niceId: resources.niceId, + wildcard: resources.wildcard, headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, + health: resources.health }) .from(resources) .leftJoin( @@ -258,7 +254,8 @@ export async function listResources( query, healthStatus, sort_by, - order + order, + siteId } = parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); @@ -378,44 +375,19 @@ export async function listResources( } } - let aggregateFilters: SQL | undefined = sql`1 = 1`; - if (typeof healthStatus !== "undefined") { - switch (healthStatus) { - case "healthy": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${healthy_targets} = ${total_targets}` - ); - break; - case "degraded": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${unhealthy_targets} > 0` - ); - break; - case "no_targets": - aggregateFilters = sql`${total_targets} = 0`; - break; - case "offline": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${healthy_targets} = 0`, - sql`${unhealthy_targets} = ${total_targets}` - ); - break; - case "unknown": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${unknown_targets} = ${total_targets}` - ); - break; - } + conditions.push(eq(resources.health, healthStatus)); + } + if (siteId != null) { + const resourcesWithSite = db + .select({ resourceId: targets.resourceId }) + .from(targets) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))); + conditions.push(inArray(resources.resourceId, resourcesWithSite)); } - const baseQuery = queryResourcesBase() - .where(and(...conditions)) - .having(aggregateFilters); + const baseQuery = queryResourcesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count(baseQuery.as("filtered_resources")); @@ -442,18 +414,24 @@ export async function listResources( .select({ targetId: targets.targetId, resourceId: targets.resourceId, + siteId: targets.siteId, ip: targets.ip, port: targets.port, enabled: targets.enabled, healthStatus: targetHealthCheck.hcHealth, - hcEnabled: targetHealthCheck.hcEnabled + hcEnabled: targetHealthCheck.hcEnabled, + siteName: sites.name, + siteNiceId: sites.niceId, + siteOnline: sites.online, + siteType: sites.type }) .from(targets) .where(inArray(targets.resourceId, resourceIdList)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) - ); + ) + .leftJoin(sites, eq(targets.siteId, sites.siteId)); // avoids TS issues with reduce/never[] const map = new Map(); @@ -474,10 +452,13 @@ export async function listResources( http: row.http, protocol: row.protocol, proxyPort: row.proxyPort, + wildcard: row.wildcard, enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, - targets: [] + health: row.health ?? null, + targets: [], + sites: [] }; map.set(row.resourceId, entry); } @@ -487,6 +468,34 @@ export async function listResources( ); } + for (const entry of map.values()) { + const raw = allResourceTargets.filter( + (t) => t.resourceId === entry.resourceId + ); + const siteById = new Map< + number, + { + siteId: number; + siteName: string; + siteNiceId: string; + online?: boolean; + } + >(); + for (const t of raw) { + if (typeof t.siteId !== "number" || siteById.has(t.siteId)) { + continue; + } + const isLocal = t.siteType === "local"; + siteById.set(t.siteId, { + siteId: t.siteId, + siteName: t.siteName ?? "", + siteNiceId: t.siteNiceId ?? "", + online: isLocal ? undefined : Boolean(t.siteOnline) + }); + } + entry.sites = Array.from(siteById.values()); + } + const resourcesList: ResourceWithTargets[] = Array.from(map.values()); return response(res, { diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 07e566194..0a7052dce 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -16,12 +16,15 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import config from "@server/lib/config"; -import { tlsNameSchema } from "@server/lib/schemas"; -import { subdomainSchema } from "@server/lib/schemas"; +import { + tlsNameSchema, + subdomainSchema, + wildcardSubdomainSchema +} from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; -import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -43,7 +46,7 @@ const updateHttpResourceBodySchema = z "niceId can only contain letters, numbers, and dashes" ) .optional(), - subdomain: subdomainSchema.nullable().optional(), + subdomain: z.string().nullable().optional(), ssl: z.boolean().optional(), sso: z.boolean().optional(), blockAccess: z.boolean().optional(), @@ -73,7 +76,10 @@ const updateHttpResourceBodySchema = z .refine( (data) => { if (data.subdomain) { - return subdomainSchema.safeParse(data.subdomain).success; + return ( + subdomainSchema.safeParse(data.subdomain).success || + wildcardSubdomainSchema.safeParse(data.subdomain).success + ); } return true; }, @@ -318,6 +324,22 @@ async function updateHttpResource( } } + // Wildcard subdomains are a paid feature + if (updateData.subdomain && updateData.subdomain.includes("*")) { + const isLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.wildcardSubdomain + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + } + if (updateData.domainId) { const domainId = updateData.domainId; @@ -326,7 +348,7 @@ async function updateHttpResource( !isSubscribed(resource.orgId, tierMatrix.domainNamespaces) ) { // grandfather in existing users - const lastAllowedDate = new Date("2026-04-12"); + const lastAllowedDate = new Date("2026-04-13"); const userCreatedDate = new Date( req.user?.dateCreated || new Date() ); @@ -362,7 +384,11 @@ async function updateHttpResource( ); } - const { fullDomain, subdomain: finalSubdomain } = domainResult; + const { + fullDomain, + subdomain: finalSubdomain, + wildcard + } = domainResult; logger.debug(`Full domain: ${fullDomain}`); @@ -384,6 +410,16 @@ async function updateHttpResource( ); } + const wildcardConflict = await checkWildcardDomainConflict( + fullDomain, + resource.resourceId + ); + if (wildcardConflict.conflict) { + return next( + createHttpError(HttpCode.CONFLICT, wildcardConflict.message) + ); + } + // Prevent updating resource with same domain as dashboard const dashboardUrl = config.getRawConfig().app.dashboard_url; if (dashboardUrl) { @@ -419,7 +455,7 @@ async function updateHttpResource( if (fullDomain && fullDomain !== resource.fullDomain) { await db .update(resources) - .set({ fullDomain }) + .set({ fullDomain, wildcard }) .where(eq(resources.resourceId, resource.resourceId)); } diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index f1b057a11..ba46e40c4 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -3,34 +3,68 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm"; import { ActionsEnum } from "@server/auth/actions"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { object, z } from "zod"; import { fromError } from "zod-validation-error"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listRolesParamsSchema = z.strictObject({ orgId: z.string() }); const listRolesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }) }); -async function queryRoles(orgId: string, limit: number, offset: number) { - return await db +function queryRolesBase() { + return db .select({ roleId: roles.roleId, orgId: roles.orgId, @@ -45,20 +79,15 @@ async function queryRoles(orgId: string, limit: number, offset: number) { sshUnixGroups: roles.sshUnixGroups }) .from(roles) - .leftJoin(orgs, eq(roles.orgId, orgs.orgId)) - .where(eq(roles.orgId, orgId)) - .limit(limit) - .offset(offset); + .leftJoin(orgs, eq(roles.orgId, orgs.orgId)); + // .where(eq(roles.orgId, orgId)) + // .limit(limit) + // .offset(offset); } -export type ListRolesResponse = { - roles: NonNullable>>; - pagination: { - total: number; - limit: number; - offset: number; - }; -}; +export type ListRolesResponse = PaginatedResponse<{ + roles: NonNullable>>; +}>; registry.registerPath({ method: "get", @@ -88,7 +117,7 @@ export async function listRoles( ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, query, sort_by, order } = parsedQuery.data; const parsedParams = listRolesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -102,14 +131,36 @@ export async function listRoles( const { orgId } = parsedParams.data; - const countQuery: any = db - .select({ count: sql`cast(count(*) as integer)` }) - .from(roles) - .where(eq(roles.orgId, orgId)); + const conditions = [and(eq(roles.orgId, orgId))]; - const rolesList = await queryRoles(orgId, limit, offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + if (query) { + conditions.push( + like(sql`LOWER(${roles.name})`, "%" + query.toLowerCase() + "%") + ); + } + + const countQuery = db.$count( + queryRolesBase() + .where(and(...conditions)) + .as("filtered_roles") + ); + + const rolesListQuery = queryRolesBase() + .where(and(...conditions)) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(roles[sort_by]) + : desc(roles[sort_by]) + : asc(roles.name) + ); + + const [totalCount, rolesList] = await Promise.all([ + countQuery, + rolesListQuery + ]); let rolesWithAllowSsh = rolesList; if (rolesList.length > 0) { @@ -135,8 +186,8 @@ export async function listRoles( roles: rolesWithAllowSsh, pagination: { total: totalCount, - limit, - offset + page, + pageSize } }, success: true, diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index d397b2784..29eb4935d 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { clients, db, exitNodes } from "@server/db"; +import { clients, db, exitNodes, logsDb, statusHistory } from "@server/db"; import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -15,11 +15,12 @@ import moment from "moment"; import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; import { isValidIP } from "@server/lib/validators"; -import { isIpInCidr } from "@server/lib/ip"; +import { getNextAvailableClientSubnet, isIpInCidr } from "@server/lib/ip"; import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; +import { generateId } from "@server/auth/sessions/app"; const createSiteParamsSchema = z.strictObject({ orgId: z.string() @@ -28,6 +29,7 @@ const createSiteParamsSchema = z.strictObject({ const createSiteSchema = z.strictObject({ name: z.string().min(1).max(255), exitNodeId: z.int().positive().optional(), + niceId: z.string().min(1).max(255).optional(), // subdomain: z // .string() // .min(1) @@ -52,7 +54,10 @@ const createSiteSchema = z.strictObject({ export type CreateSiteBody = z.infer; -export type CreateSiteResponse = Site; +export type CreateSiteResponse = Site & { + newtId?: string; + secret?: string; +}; registry.registerPath({ method: "put", @@ -64,7 +69,11 @@ registry.registerPath({ body: { content: { "application/json": { - schema: createSiteSchema + schema: createSiteSchema, + example: { + name: "My Site", + type: "newt" + } } } } @@ -96,9 +105,13 @@ export async function createSite( subnet, newtId, secret, - address + address, + niceId } = parsedBody.data; + const updatedNewtSecret = secret || generateId(48); + const updatedNewtId = newtId || generateId(15); + const parsedParams = createSiteParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( @@ -111,7 +124,10 @@ export async function createSite( const { orgId } = parsedParams.data; - if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { + if ( + req.user && + (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0) + ) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -227,6 +243,18 @@ export async function createSite( ) ); } + } else { + const newClientAddress = await getNextAvailableClientSubnet(orgId); + if (!newClientAddress) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "No available address found" + ) + ); + } + + updatedAddress = newClientAddress.split("/")[0]; } if (subnet && exitNodeId) { @@ -285,23 +313,51 @@ export async function createSite( } } - const niceId = await getUniqueSiteName(orgId); + let updatedNiceId = niceId; + if (!niceId) { + updatedNiceId = await getUniqueSiteName(orgId); + } else { + // make sure the niceId is unique + const existingSite = await db + .select() + .from(sites) + .where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId))) + .limit(1); + + if (existingSite.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Nice ID ${niceId} already exists. Please choose a different one.` + ) + ); + } + } let newSite: Site | undefined; await db.transaction(async (trx) => { if (type == "newt") { [newSite] = await trx .insert(sites) - .values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT + .values({ + // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT orgId, name, - niceId, + niceId: updatedNiceId!, address: updatedAddress || null, type, dockerSocketEnabled: true, status: "approved" }) .returning(); + + await logsDb.insert(statusHistory).values({ + entityType: "site", + entityId: newSite.siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); } else if (type == "wireguard") { // we are creating a site with an exit node (tunneled) if (!subnet) { @@ -353,7 +409,7 @@ export async function createSite( orgId, exitNodeId, name, - niceId, + niceId: updatedNiceId!, subnet, type, pubKey: pubKey || null, @@ -367,8 +423,7 @@ export async function createSite( exitNodeId: exitNodeId || null, orgId, name, - niceId, - address: updatedAddress || null, + niceId: updatedNiceId!, type, dockerSocketEnabled: false, online: true, @@ -402,7 +457,10 @@ export async function createSite( siteId: newSite.siteId }); - if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { + if ( + req.user && + !req.userOrgRoleIds?.includes(adminRole[0].roleId) + ) { // make sure the user can access the site trx.insert(userSites).values({ userId: req.user?.userId!, @@ -412,10 +470,10 @@ export async function createSite( // add the peer to the exit node if (type == "newt") { - const secretHash = await hashPassword(secret!); + const secretHash = await hashPassword(updatedNewtSecret); await trx.insert(newts).values({ - newtId: newtId!, + newtId: updatedNewtId, secretHash, siteId: newSite.siteId, dateCreated: moment().toISOString() @@ -458,7 +516,11 @@ export async function createSite( } return response(res, { - data: newSite, + data: { + ...newSite, + newtId: type == "newt" ? updatedNewtId : undefined, + secret: type == "newt" ? updatedNewtSecret : undefined + }, success: true, error: false, message: "Site created successfully", diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 587572535..10ecbbf1e 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, Site, siteResources } from "@server/db"; +import { db, Site, siteNetworks, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -71,16 +71,24 @@ export async function deleteSite( await deletePeer(site.exitNodeId!, site.pubKey); } } else if (site.type == "newt") { - // delete all of the site resources on this site - const siteResourcesOnSite = trx - .delete(siteResources) - .where(eq(siteResources.siteId, siteId)) - .returning(); + const networks = await trx + .select({ networkId: siteNetworks.networkId }) + .from(siteNetworks) + .where(eq(siteNetworks.siteId, siteId)); // loop through them - for (const removedSiteResource of await siteResourcesOnSite) { + const updatedSiteResources = await trx + .select() + .from(siteResources) + .where( + inArray( + siteResources.networkId, + networks.map((n) => n.networkId) + ) + ); + for (const siteResource of updatedSiteResources) { await rebuildClientAssociationsFromSiteResource( - removedSiteResource, + siteResource, trx ); } diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index 45d49abe6..a16547b8d 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -42,9 +42,12 @@ async function query(siteId?: number, niceId?: string, orgId?: string) { } } -export type GetSiteResponse = NonNullable< - Awaited> ->["sites"] & { newtId: string | null }; +type SiteQueryRow = NonNullable>>; + +export type GetSiteResponse = SiteQueryRow["sites"] & { + newtId: string | null; + newtVersion: string | null; +}; registry.registerPath({ method: "get", @@ -100,7 +103,8 @@ export async function getSite( const data: GetSiteResponse = { ...site.sites, - newtId: site.newt ? site.newt.newtId : null + newtId: site.newt ? site.newt.newtId : null, + newtVersion: site.newt?.version ?? null }; return response(res, { diff --git a/server/routers/site/getStatusHistory.ts b/server/routers/site/getStatusHistory.ts new file mode 100644 index 000000000..26f1dbbd2 --- /dev/null +++ b/server/routers/site/getStatusHistory.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +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 { + getCachedStatusHistory, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; + +const siteParamsSchema = z.object({ + siteId: z.string().transform((v) => parseInt(v, 10)) +}); + +export async function getSiteStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = siteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "site"; + const entityId = parsedParams.data.siteId; + const { days } = parsedQuery.data; + + const data = await getCachedStatusHistory(entityType, entityId, days); + + return response(res, { + data, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 3edf67c14..00fdeda91 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -1,4 +1,5 @@ export * from "./getSite"; +export * from "./getStatusHistory"; export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 6f085d74d..fc4ea5be1 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -5,6 +5,9 @@ import { orgs, remoteExitNodes, roleSites, + siteNetworks, + siteResources, + targets, sites, userSites } from "@server/db"; @@ -21,15 +24,22 @@ import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +// Stale-while-revalidate: keeps the last successfully fetched version so that +// a transient network failure / timeout does not flip every site back to +// newtUpdateAvailable: false. +let staleNewtVersion: string | null = null; + async function getLatestNewtVersion(): Promise { try { - const cachedVersion = await cache.get("latestNewtVersion"); + const cachedVersion = await cache.get( + "cache:latestNewtVersion" + ); if (cachedVersion) { return cachedVersion; } const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); // Reduced timeout to 1.5 seconds + const timeoutId = setTimeout(() => controller.abort(), 1500); const response = await fetch( "https://api.github.com/repos/fosrl/newt/tags", @@ -44,18 +54,46 @@ async function getLatestNewtVersion(): Promise { logger.warn( `Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}` ); - return null; + return staleNewtVersion; } let tags = await response.json(); if (!Array.isArray(tags) || tags.length === 0) { logger.warn("No tags found for Newt repository"); - return null; + return staleNewtVersion; } - tags = tags.filter((version) => !version.name.includes("rc")); + + // Remove release-candidates, then sort descending by semver so that + // duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks + // from the GitHub API do not cause an older tag to be selected. + tags = tags.filter((tag: any) => !tag.name.includes("rc")); + tags.sort((a: any, b: any) => { + const va = semver.coerce(a.name); + const vb = semver.coerce(b.name); + if (!va && !vb) return 0; + if (!va) return 1; + if (!vb) return -1; + return semver.rcompare(va, vb); + }); + + // Deduplicate: keep only the first (highest) entry per normalised version + const seen = new Set(); + tags = tags.filter((tag: any) => { + const normalised = semver.coerce(tag.name)?.version; + if (!normalised || seen.has(normalised)) return false; + seen.add(normalised); + return true; + }); + + if (tags.length === 0) { + logger.warn("No valid semver tags found for Newt repository"); + return staleNewtVersion; + } + const latestVersion = tags[0].name; - await cache.set("latestNewtVersion", latestVersion, 3600); + staleNewtVersion = latestVersion; + await cache.set("cache:latestNewtVersion", latestVersion, 3600); return latestVersion; } catch (error: any) { @@ -73,7 +111,7 @@ async function getLatestNewtVersion(): Promise { error.message || error ); } - return null; + return staleNewtVersion; } } @@ -166,6 +204,18 @@ function querySitesBase() { exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, remoteExitNodeId: remoteExitNodes.remoteExitNodeId, + resourceCount: sql`( + SELECT COUNT(DISTINCT ${targets.resourceId}) + FROM ${targets} + WHERE ${targets.siteId} = ${sites.siteId} + ) + ( + SELECT COUNT(DISTINCT ${siteResources.siteResourceId}) + FROM ${siteResources} + INNER JOIN ${siteNetworks} + ON ${siteResources.networkId} = ${siteNetworks.networkId} + WHERE ${siteNetworks.siteId} = ${sites.siteId} + AND ${siteResources.orgId} = ${sites.orgId} + )`, status: sites.status }) .from(sites) @@ -178,7 +228,10 @@ function querySitesBase() { ); } -type SiteWithUpdateAvailable = Awaited>[0] & { +type SiteRowBase = Awaited>[0]; + +type SiteWithUpdateAvailable = Omit & { + online?: SiteRowBase["online"]; // undefined for local sites newtUpdateAvailable?: boolean; }; @@ -286,12 +339,13 @@ export async function listSites( if (typeof status !== "undefined") { conditions.push(eq(sites.status, status)); } - const baseQuery = querySitesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count( - querySitesBase().where(and(...conditions)).as("filtered_sites") + querySitesBase() + .where(and(...conditions)) + .as("filtered_sites") ); const siteListQuery = baseQuery @@ -350,9 +404,13 @@ export async function listSites( ); } + const sitesPayload = sitesWithUpdates.map((site) => + site.type === "local" ? { ...site, online: undefined } : site + ); + return response(res, { data: { - sites: sitesWithUpdates, + sites: sitesPayload, pagination: { total: totalCount, pageSize, diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index f5e95ca10..4e6e3bb17 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -124,7 +124,7 @@ export async function pickSiteDefaults( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "No available subnet found" + "No available address" ) ); } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 1485a4192..01f7a0d9c 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -5,6 +5,8 @@ import { orgs, roles, roleSiteResources, + siteNetworks, + networks, SiteResource, siteResources, sites, @@ -17,17 +19,20 @@ import { portRangeStringSchema } from "@server/lib/ip"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { build } from "@server/build"; const createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -36,11 +41,15 @@ const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "port"]), - siteId: z.int(), + niceId: z.string().optional(), // protocol: z.enum(["tcp", "udp"]).optional(), + mode: z.enum(["host", "cidr", "http"]), + ssl: z.boolean().optional(), // only used for http mode + scheme: z.enum(["http", "https"]).optional(), + siteIds: z.array(z.int()).optional(), + siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided // proxyPort: z.int().positive().optional(), - // destinationPort: z.int().positive().optional(), + destinationPort: z.int().positive().optional(), destination: z.string().min(1), enabled: z.boolean().default(true), alias: z @@ -57,20 +66,24 @@ const createSiteResourceSchema = z udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), authDaemonPort: z.int().positive().optional(), - authDaemonMode: z.enum(["site", "remote"]).optional() + authDaemonMode: z.enum(["site", "remote"]).optional(), + domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org + subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org }) .strict() .refine( (data) => { if (data.mode === "host") { - // Check if it's a valid IP address using zod (v4 or v6) - const isValidIP = z - // .union([z.ipv4(), z.ipv6()]) - .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere - .safeParse(data.destination).success; + if (data.mode == "host") { + // Check if it's a valid IP address using zod (v4 or v6) + const isValidIP = z + // .union([z.ipv4(), z.ipv6()]) + .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .safeParse(data.destination).success; - if (isValidIP) { - return true; + if (isValidIP) { + return true; + } } // Check if it's a valid domain (hostname pattern, TLD not required) @@ -105,6 +118,32 @@ const createSiteResourceSchema = z { message: "Destination must be a valid CIDR notation for cidr mode" } + ) + .refine( + (data) => { + if (data.mode !== "http") return true; + return ( + data.scheme !== undefined && + data.destinationPort !== undefined && + data.destinationPort >= 1 && + data.destinationPort <= 65535 + ); + }, + { + message: + "HTTP mode requires scheme (http or https) and a valid destination port" + } + ) + .refine( + (data) => { + return ( + (data.siteIds !== undefined && data.siteIds.length > 0) || + data.siteId !== undefined + ); + }, + { + message: "At least one of siteIds or siteId must be provided" + } ); export type CreateSiteResourceBody = z.infer; @@ -159,13 +198,16 @@ export async function createSiteResource( const { orgId } = parsedParams.data; const { name, + niceId, + siteIds: siteIdsInput = [], siteId, mode, - // protocol, + scheme, // proxyPort, - // destinationPort, + destinationPort, destination, enabled, + ssl, alias, userIds, roleIds, @@ -174,18 +216,42 @@ export async function createSiteResource( udpPortRangeString, disableIcmp, authDaemonPort, - authDaemonMode + authDaemonMode, + domainId, + subdomain } = parsedBody.data; + // Backward compatibility: merge deprecated siteId into siteIds array + const siteIds = [...siteIdsInput]; + if (siteId !== undefined && !siteIds.includes(siteId)) { + siteIds.push(siteId); + } + + if (mode == "http") { + const hasHttpFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.HTTPPrivateResources] + ); + if (!hasHttpFeature) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "HTTP private resources are not included in your current plan. Please upgrade." + ) + ); + } + } + // Verify the site exists and belongs to the org - const [site] = await db + const sitesToAssign = await db .select() .from(sites) - .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) - .limit(1); + .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId))); - if (!site) { - return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); + if (sitesToAssign.length !== siteIds.length) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Some site not found") + ); } const [org] = await db @@ -226,29 +292,50 @@ export async function createSiteResource( ); } - // // check if resource with same protocol and proxy port already exists (only for port mode) - // if (mode === "port" && protocol && proxyPort) { - // const [existingResource] = await db - // .select() - // .from(siteResources) - // .where( - // and( - // eq(siteResources.siteId, siteId), - // eq(siteResources.orgId, orgId), - // eq(siteResources.protocol, protocol), - // eq(siteResources.proxyPort, proxyPort) - // ) - // ) - // .limit(1); - // if (existingResource && existingResource.siteResourceId) { - // return next( - // createHttpError( - // HttpCode.CONFLICT, - // "A resource with the same protocol and proxy port already exists" - // ) - // ); - // } - // } + if (domainId && alias) { + // throw an error because we can only have one or the other + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Alias and domain cannot both be set. Please choose one or the other." + ) + ); + } + + let fullDomain: string | null = null; + let finalSubdomain: string | null = null; + if (domainId) { + // Validate domain and construct full domain + const domainResult = await validateAndConstructDomain( + domainId, + orgId, + subdomain + ); + + if (!domainResult.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, domainResult.error) + ); + } + + fullDomain = domainResult.fullDomain; + finalSubdomain = domainResult.subdomain; + + // make sure the full domain is unique + const existingResource = await db + .select() + .from(siteResources) + .where(eq(siteResources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + } // make sure the alias is unique within the org if provided if (alias) { @@ -278,29 +365,56 @@ export async function createSiteResource( tierMatrix.sshPam ); - const niceId = await getUniqueSiteResourceName(orgId); + let updatedNiceId = niceId; + if (!niceId) { + updatedNiceId = await getUniqueSiteResourceName(orgId); + } + let aliasAddress: string | null = null; - if (mode == "host") { - // we can only have an alias on a host + if (mode === "host" || mode === "http") { aliasAddress = await getNextAvailableAliasAddress(orgId); } let newSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); + + if (!network) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Failed to create network` + ) + ); + } + // Create the site resource const insertValues: typeof siteResources.$inferInsert = { - siteId, - niceId, + niceId: updatedNiceId!, orgId, name, - mode: mode as "host" | "cidr", + mode, + ssl, + networkId: network.networkId, destination, + scheme, + destinationPort, enabled, - alias, + alias: alias ? alias.trim() : null, aliasAddress, - tcpPortRangeString, - udpPortRangeString, - disableIcmp + tcpPortRangeString: + mode == "http" ? "443,80" : tcpPortRangeString, + udpPortRangeString: mode == "http" ? "" : udpPortRangeString, + disableIcmp: disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false + domainId, + subdomain: finalSubdomain, + fullDomain }; if (isLicensedSshPam) { if (authDaemonPort !== undefined) @@ -317,6 +431,13 @@ export async function createSiteResource( //////////////////// update the associations //////////////////// + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: network.networkId + }); + } + const [adminRole] = await trx .select() .from(roles) @@ -359,22 +480,22 @@ export async function createSiteResource( ); } - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); + for (const siteToAssign of sitesToAssign) { + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, siteToAssign.siteId)) + .limit(1); - if (!newt) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Newt not found") - ); + if (!newt) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Newt not found for site ${siteToAssign.siteId}` + ) + ); + } } - - await rebuildClientAssociationsFromSiteResource( - newSiteResource, - trx - ); // we need to call this because we added to the admin role }); if (!newSiteResource) { @@ -387,9 +508,35 @@ export async function createSiteResource( } logger.info( - `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` + `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}` ); + if ( + ssl && + mode === "http" && + domainId && + fullDomain && + build != "oss" + ) { + await createCertificate(domainId, fullDomain, db); + } + + // Run in the background after the response is sent. Wrapped in its + // own transaction so it always executes on the primary — avoiding any + // replica-lag issues while still allowing the HTTP response to return + // early. + db.transaction(async (trx) => { + await rebuildClientAssociationsFromSiteResource( + newSiteResource!, + trx + ); + }).catch((err) => { + logger.error( + `Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`, + err + ); + }); + return response(res, { data: newSiteResource, success: true, diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 5b50b0ea3..7dbb111ad 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -63,29 +63,26 @@ export async function deleteSiteResource( ); } - await db.transaction(async (trx) => { - // Delete the site resource - const [removedSiteResource] = await trx - .delete(siteResources) - .where(and(eq(siteResources.siteResourceId, siteResourceId))) - .returning(); - - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, removedSiteResource.siteId)) - .limit(1); - - if (!newt) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Newt not found") - ); - } + // Delete the site resource + const [removedSiteResource] = await db + .delete(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .returning(); + // Run in the background after the response is sent. Wrapped in its + // own transaction so it always executes on the primary — avoiding any + // replica-lag issues while still allowing the HTTP response to return + // early. + db.transaction(async (trx) => { await rebuildClientAssociationsFromSiteResource( removedSiteResource, trx ); + }).catch((err) => { + logger.error( + `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, + err + ); }); logger.info(`Deleted site resource ${siteResourceId}`); diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index be28d36e4..2e3dfe87b 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({ .transform((val) => (val ? Number(val) : undefined)) .pipe(z.int().positive().optional()) .optional(), - siteId: z.string().transform(Number).pipe(z.int().positive()), niceId: z.string().optional(), orgId: z.string() }); async function query( siteResourceId?: number, - siteId?: number, niceId?: string, orgId?: string ) { - if (siteResourceId && siteId && orgId) { + if (siteResourceId && orgId) { const [siteResource] = await db .select() .from(siteResources) .where( and( eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), eq(siteResources.orgId, orgId) ) ) .limit(1); return siteResource; - } else if (niceId && siteId && orgId) { + } else if (niceId && orgId) { const [siteResource] = await db .select() .from(siteResources) .where( and( eq(siteResources.niceId, niceId), - eq(siteResources.siteId, siteId), eq(siteResources.orgId, orgId) ) ) @@ -84,7 +80,6 @@ registry.registerPath({ request: { params: z.object({ niceId: z.string(), - siteId: z.number(), orgId: z.string() }) }, @@ -107,10 +102,10 @@ export async function getSiteResource( ); } - const { siteResourceId, siteId, niceId, orgId } = parsedParams.data; + const { siteResourceId, niceId, orgId } = parsedParams.data; // Get the site resource - const siteResource = await query(siteResourceId, siteId, niceId, orgId); + const siteResource = await query(siteResourceId, niceId, orgId); if (!siteResource) { return next( diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 3320aa3b7..c7099de40 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,10 +1,10 @@ -import { db, SiteResource, siteResources, sites } from "@server/db"; +import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ }), query: z.string().optional(), mode: z - .enum(["host", "cidr"]) + .enum(["host", "cidr", "http"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["host", "cidr"], + enum: ["host", "cidr", "http"], description: "Filter site resources by mode" }), sort_by: z @@ -68,27 +68,95 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ enum: ["asc", "desc"], default: "asc", description: "Sort order" + }), + siteId: z.coerce + .number() + .int() + .positive() + .optional() + .openapi({ + type: "integer", + description: + "When set, only site resources associated with this site (via network) are returned" }) }); export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteResources: (SiteResource & { - siteName: string; - siteNiceId: string; - siteAddress: string | null; + siteOnlines: boolean[]; + siteIds: number[]; + siteNames: string[]; + siteNiceIds: string[]; + siteAddresses: (string | null)[]; })[]; }>; +/** + * Returns an aggregation expression compatible with both SQLite and PostgreSQL. + * - SQLite: json_group_array(col) → returns a JSON array string, parsed after fetch + * - PostgreSQL: array_agg(col) → returns a native array + */ +function aggCol(column: any) { + if (DB_TYPE === "sqlite") { + // json_group_array will include NULLs for left-joined missing rows; + // we filter them out in transformSiteResourceRow keeping arrays aligned. + return sql`json_group_array(${column})`; + } + return sql`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`; +} + +/** + * For SQLite the aggregated columns come back as JSON strings; parse them into + * proper arrays. For PostgreSQL the driver already returns native arrays, so + * the row is returned unchanged. + */ +function transformSiteResourceRow(row: any) { + if (DB_TYPE !== "sqlite") { + return row; + } + const siteIdsRaw = JSON.parse(row.siteIds) as (number | null)[]; + const siteNamesRaw = JSON.parse(row.siteNames) as (string | null)[]; + const siteNiceIdsRaw = JSON.parse(row.siteNiceIds) as (string | null)[]; + const siteAddressesRaw = JSON.parse(row.siteAddresses) as (string | null)[]; + const siteOnlinesRaw = JSON.parse(row.siteOnlines) as (0 | 1 | null)[]; + + // When a site resource has no associated sites (left join produced no + // matches), the aggregated arrays will contain a single NULL entry. Strip + // those out, keeping the parallel arrays aligned by siteId presence. + const siteIds: number[] = []; + const siteNames: string[] = []; + const siteNiceIds: string[] = []; + const siteAddresses: (string | null)[] = []; + const siteOnlines: boolean[] = []; + for (let i = 0; i < siteIdsRaw.length; i++) { + if (siteIdsRaw[i] == null) continue; + siteIds.push(siteIdsRaw[i] as number); + siteNames.push((siteNamesRaw[i] ?? "") as string); + siteNiceIds.push((siteNiceIdsRaw[i] ?? "") as string); + siteAddresses.push(siteAddressesRaw[i] ?? null); + siteOnlines.push(siteOnlinesRaw[i] === 1); + } + + return { + ...row, + siteNames, + siteNiceIds, + siteIds, + siteAddresses, + siteOnlines + }; +} + function querySiteResourcesBase() { return db .select({ siteResourceId: siteResources.siteResourceId, - siteId: siteResources.siteId, orgId: siteResources.orgId, niceId: siteResources.niceId, name: siteResources.name, mode: siteResources.mode, - protocol: siteResources.protocol, + ssl: siteResources.ssl, + scheme: siteResources.scheme, proxyPort: siteResources.proxyPort, destinationPort: siteResources.destinationPort, destination: siteResources.destination, @@ -100,12 +168,24 @@ function querySiteResourcesBase() { disableIcmp: siteResources.disableIcmp, authDaemonMode: siteResources.authDaemonMode, authDaemonPort: siteResources.authDaemonPort, - siteName: sites.name, - siteNiceId: sites.niceId, - siteAddress: sites.address + subdomain: siteResources.subdomain, + domainId: siteResources.domainId, + fullDomain: siteResources.fullDomain, + networkId: siteResources.networkId, + defaultNetworkId: siteResources.defaultNetworkId, + siteNames: aggCol(sites.name), + siteNiceIds: aggCol(sites.niceId), + siteIds: aggCol(sites.siteId), + siteAddresses: aggCol<(string | null)[]>(sites.address), + siteOnlines: aggCol(sites.online) }) .from(siteResources) - .innerJoin(sites, eq(siteResources.siteId, sites.siteId)); + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .groupBy(siteResources.siteResourceId); } registry.registerPath({ @@ -151,10 +231,33 @@ export async function listAllSiteResourcesByOrg( } const { orgId } = parsedParams.data; - const { page, pageSize, query, mode, sort_by, order } = + const { page, pageSize, query, mode, sort_by, order, siteId } = parsedQuery.data; const conditions = [and(eq(siteResources.orgId, orgId))]; + + if (siteId != null) { + // Keep inner joins here: filtering by a specific site implies the + // resource must have at least one matching site. + const resourcesForSite = db + .select({ id: siteResources.siteResourceId }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where( + and( + eq(siteResources.orgId, orgId), + eq(sites.orgId, orgId), + eq(sites.siteId, siteId) + ) + ); + conditions.push( + inArray(siteResources.siteResourceId, resourcesForSite) + ); + } if (query) { conditions.push( or( @@ -193,10 +296,12 @@ export async function listAllSiteResourcesByOrg( const baseQuery = querySiteResourcesBase().where(and(...conditions)); const countQuery = db.$count( - querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources") + querySiteResourcesBase() + .where(and(...conditions)) + .as("filtered_site_resources") ); - const [siteResourcesList, totalCount] = await Promise.all([ + const [siteResourcesRaw, totalCount] = await Promise.all([ baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) @@ -210,6 +315,8 @@ export async function listAllSiteResourcesByOrg( countQuery ]); + const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow); + return response(res, { data: { siteResources: siteResourcesList, @@ -233,4 +340,4 @@ export async function listAllSiteResourcesByOrg( ) ); } -} +} \ No newline at end of file diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 358aa0497..61460c2d0 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, networks, siteNetworks } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -108,13 +108,18 @@ export async function listSiteResources( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } - // Get site resources + // Get site resources by joining networks to siteResources via siteNetworks const siteResourcesList = await db .select() - .from(siteResources) + .from(siteNetworks) + .innerJoin(networks, eq(siteNetworks.networkId, networks.networkId)) + .innerJoin( + siteResources, + eq(siteResources.networkId, networks.networkId) + ) .where( and( - eq(siteResources.siteId, siteId), + eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId) ) ) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 8f56ece0f..8a3f93326 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -1,4 +1,3 @@ -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { clientSiteResources, clientSiteResourcesAssociationsCache, @@ -7,13 +6,21 @@ import { orgs, roles, roleSiteResources, + siteNetworks, SiteResource, siteResources, sites, + networks, Transaction, userSiteResources } from "@server/db"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import response from "@server/lib/response"; +import { eq, and, ne, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { updatePeerData, updateTargets } from "@server/routers/client/targets"; import { generateAliasConfig, generateRemoteSubnets, @@ -22,12 +29,8 @@ import { portRangeStringSchema } from "@server/lib/ip"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; -import response from "@server/lib/response"; import logger from "@server/logger"; -import { OpenAPITags, registry } from "@server/openApi"; -import { updatePeerData, updateTargets } from "@server/routers/client/targets"; import HttpCode from "@server/types/HttpCode"; -import { and, eq, ne } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -40,7 +43,9 @@ const updateSiteResourceParamsSchema = z.strictObject({ const updateSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255).optional(), - siteId: z.int(), + siteIds: z.array(z.int()).optional(), + siteId: z.int().positive().optional(), + // niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(), niceId: z .string() .min(1) @@ -51,10 +56,11 @@ const updateSiteResourceSchema = z ) .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), - mode: z.enum(["host", "cidr"]).optional(), - // protocol: z.enum(["tcp", "udp"]).nullish(), + mode: z.enum(["host", "cidr", "http"]).optional(), + ssl: z.boolean().optional(), + scheme: z.enum(["http", "https"]).nullish(), // proxyPort: z.int().positive().nullish(), - // destinationPort: z.int().positive().nullish(), + destinationPort: z.int().positive().nullish(), destination: z.string().min(1).optional(), enabled: z.boolean().optional(), alias: z @@ -71,7 +77,9 @@ const updateSiteResourceSchema = z udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), authDaemonPort: z.int().positive().nullish(), - authDaemonMode: z.enum(["site", "remote"]).optional() + authDaemonMode: z.enum(["site", "remote"]).optional(), + domainId: z.string().optional(), + subdomain: z.string().optional() }) .strict() .refine( @@ -118,6 +126,34 @@ const updateSiteResourceSchema = z { message: "Destination must be a valid CIDR notation for cidr mode" } + ) + .refine( + (data) => { + if (data.mode !== "http") return true; + return ( + data.scheme !== undefined && + data.scheme !== null && + data.destinationPort !== undefined && + data.destinationPort !== null && + data.destinationPort >= 1 && + data.destinationPort <= 65535 + ); + }, + { + message: + "HTTP mode requires scheme (http or https) and a valid destination port" + } + ) + .refine( + (data) => { + return ( + (data.siteIds !== undefined && data.siteIds.length > 0) || + data.siteId !== undefined + ); + }, + { + message: "At least one of siteIds or siteId must be provided" + } ); export type UpdateSiteResourceBody = z.infer; @@ -172,11 +208,15 @@ export async function updateSiteResource( const { siteResourceId } = parsedParams.data; const { name, - siteId, // because it can change + siteIds: siteIdsInput = [], // because it can change + siteId, niceId, mode, + scheme, destination, + destinationPort, alias, + ssl, enabled, userIds, roleIds, @@ -185,17 +225,15 @@ export async function updateSiteResource( udpPortRangeString, disableIcmp, authDaemonPort, - authDaemonMode + authDaemonMode, + domainId, + subdomain } = parsedBody.data; - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - - if (!site) { - return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); + // Backward compatibility: merge deprecated siteId into siteIds array + const siteIds = [...siteIdsInput]; + if (siteId !== undefined && !siteIds.includes(siteId)) { + siteIds.push(siteId); } // Check if site resource exists @@ -211,6 +249,21 @@ export async function updateSiteResource( ); } + if (mode == "http") { + const hasHttpFeature = await isLicensedOrSubscribed( + existingSiteResource.orgId, + tierMatrix[TierFeature.HTTPPrivateResources] + ); + if (!hasHttpFeature) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "HTTP private resources are not included in your current plan. Please upgrade." + ) + ); + } + } + const isLicensedSshPam = await isLicensedOrSubscribed( existingSiteResource.orgId, tierMatrix.sshPam @@ -237,6 +290,23 @@ export async function updateSiteResource( ); } + // Verify the site exists and belongs to the org + const sitesToAssign = await db + .select() + .from(sites) + .where( + and( + inArray(sites.siteId, siteIds), + eq(sites.orgId, existingSiteResource.orgId) + ) + ); + + if (sitesToAssign.length !== siteIds.length) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Some site not found") + ); + } + // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) @@ -254,22 +324,60 @@ export async function updateSiteResource( ); } - let existingSite = site; - let siteChanged = false; - if (existingSiteResource.siteId !== siteId) { - siteChanged = true; - // get the existing site - [existingSite] = await db - .select() - .from(sites) - .where(eq(sites.siteId, existingSiteResource.siteId)) - .limit(1); + let sitesChanged = false; + const existingSiteIds = existingSiteResource.networkId + ? await db + .select() + .from(siteNetworks) + .where( + eq(siteNetworks.networkId, existingSiteResource.networkId) + ) + : []; - if (!existingSite) { + const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); + const newSiteIdSet = new Set(siteIds); + + if ( + existingSiteIdSet.size !== newSiteIdSet.size || + ![...existingSiteIdSet].every((id) => newSiteIdSet.has(id)) + ) { + sitesChanged = true; + } + + let fullDomain: string | null = null; + let finalSubdomain: string | null = null; + if (domainId) { + // Validate domain and construct full domain + const domainResult = await validateAndConstructDomain( + domainId, + org.orgId, + subdomain + ); + + if (!domainResult.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, domainResult.error) + ); + } + + fullDomain = domainResult.fullDomain; + finalSubdomain = domainResult.subdomain; + + // make sure the full domain is unique + const [existingDomain] = await db + .select() + .from(siteResources) + .where(eq(siteResources.fullDomain, fullDomain)); + + if ( + existingDomain && + existingDomain.siteResourceId !== + existingSiteResource.siteResourceId + ) { return next( createHttpError( - HttpCode.NOT_FOUND, - "Existing site not found" + HttpCode.CONFLICT, + "Resource with that domain already exists" ) ); } @@ -302,7 +410,7 @@ export async function updateSiteResource( let updatedSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place - if (siteChanged) { + if (sitesChanged) { // delete the existing site resource await trx .delete(siteResources) @@ -323,9 +431,6 @@ export async function updateSiteResource( }) .returning(); - // wait some time to allow for messages to be handled - await new Promise((resolve) => setTimeout(resolve, 750)); - const sshPamSet = isLicensedSshPam && (authDaemonPort !== undefined || @@ -343,15 +448,23 @@ export async function updateSiteResource( .update(siteResources) .set({ name, - siteId, niceId, mode, + scheme, + ssl, destination, + destinationPort, enabled, - alias: alias && alias.trim() ? alias : null, - tcpPortRangeString, - udpPortRangeString, - disableIcmp, + alias: alias ? alias.trim() : null, + tcpPortRangeString: + mode == "http" ? "443,80" : tcpPortRangeString, + udpPortRangeString: + mode == "http" ? "" : udpPortRangeString, + disableIcmp: + disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false + domainId, + subdomain: finalSubdomain, + fullDomain, ...sshPamSet }) .where( @@ -372,6 +485,23 @@ export async function updateSiteResource( //////////////////// update the associations //////////////////// + // delete the site - site resources associations + await trx + .delete(siteNetworks) + .where( + eq( + siteNetworks.networkId, + updatedSiteResource.networkId! + ) + ); + + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: updatedSiteResource.networkId! + }); + } + const [adminRole] = await trx .select() .from(roles) @@ -423,11 +553,6 @@ export async function updateSiteResource( })) ); } - - await rebuildClientAssociationsFromSiteResource( - updatedSiteResource, - trx - ); } else { // Update the site resource const sshPamSet = @@ -447,14 +572,20 @@ export async function updateSiteResource( .update(siteResources) .set({ name: name, - siteId: siteId, + niceId: niceId, mode: mode, + scheme, + ssl, destination: destination, + destinationPort: destinationPort, enabled: enabled, - alias: alias && alias.trim() ? alias : null, + alias: alias ? alias.trim() : null, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, disableIcmp: disableIcmp, + domainId, + subdomain: finalSubdomain, + fullDomain, ...sshPamSet }) .where( @@ -464,6 +595,23 @@ export async function updateSiteResource( //////////////////// update the associations //////////////////// + // delete the site - site resources associations + await trx + .delete(siteNetworks) + .where( + eq( + siteNetworks.networkId, + updatedSiteResource.networkId! + ) + ); + + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: updatedSiteResource.networkId! + }); + } + await trx .delete(clientSiteResources) .where( @@ -533,17 +681,40 @@ export async function updateSiteResource( ); } - logger.info( - `Updated site resource ${siteResourceId} for site ${siteId}` - ); + logger.info(`Updated site resource ${siteResourceId}`); + } + }); + // Background: wait for removal messages to propagate, then rebuild + // associations for the re-created resource. Own transaction ensures + // execution on the primary against fully committed state. + (async () => { + await db.transaction(async (trx) => { + if (!updatedSiteResource) { + throw new Error("No updated resource found after update"); + } + if (sitesChanged) { + await new Promise((resolve) => setTimeout(resolve, 750)); + await rebuildClientAssociationsFromSiteResource( + updatedSiteResource, + trx + ); + } await handleMessagingForUpdatedSiteResource( existingSiteResource, updatedSiteResource, - { siteId: site.siteId, orgId: site.orgId }, + siteIds.map((siteId) => ({ + siteId, + orgId: existingSiteResource.orgId + })), trx ); - } + }); + })().catch((err) => { + logger.error( + `Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`, + err + ); }); return response(res, { @@ -567,7 +738,7 @@ export async function updateSiteResource( export async function handleMessagingForUpdatedSiteResource( existingSiteResource: SiteResource | undefined, updatedSiteResource: SiteResource, - site: { siteId: number; orgId: string }, + sites: { siteId: number; orgId: string }[], trx: Transaction ) { logger.debug( @@ -589,9 +760,19 @@ export async function handleMessagingForUpdatedSiteResource( const destinationChanged = existingSiteResource && existingSiteResource.destination !== updatedSiteResource.destination; + const destinationPortChanged = + existingSiteResource && + existingSiteResource.destinationPort !== + updatedSiteResource.destinationPort; const aliasChanged = existingSiteResource && existingSiteResource.alias !== updatedSiteResource.alias; + const fullDomainChanged = + existingSiteResource && + existingSiteResource.fullDomain !== updatedSiteResource.fullDomain; + const sslChanged = + existingSiteResource && + existingSiteResource.ssl !== updatedSiteResource.ssl; const portRangesChanged = existingSiteResource && (existingSiteResource.tcpPortRangeString !== @@ -603,106 +784,126 @@ export async function handleMessagingForUpdatedSiteResource( // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all - if (destinationChanged || aliasChanged || portRangesChanged) { - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - if (!newt) { - throw new Error( - "Newt not found for site during site resource update" - ); - } - - // Only update targets on newt if destination changed - if (destinationChanged || portRangesChanged) { - const oldTarget = generateSubnetProxyTargetV2( - existingSiteResource, - mergedAllClients - ); - const newTarget = generateSubnetProxyTargetV2( - updatedSiteResource, - mergedAllClients - ); - - await updateTargets( - newt.newtId, - { - oldTargets: oldTarget ? [oldTarget] : [], - newTargets: newTarget ? [newTarget] : [] - }, - newt.version - ); - } - - const olmJobs: Promise[] = []; - for (const client of mergedAllClients) { - // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet - // todo: optimize this query if needed - const oldDestinationStillInUseSites = await trx + if ( + destinationChanged || + aliasChanged || + fullDomainChanged || + sslChanged || + portRangesChanged || + destinationPortChanged + ) { + for (const site of sites) { + const [newt] = await trx .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .where( - and( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ), - eq(siteResources.siteId, site.siteId), - eq( - siteResources.destination, - existingSiteResource.destination - ), - ne( - siteResources.siteResourceId, - existingSiteResource.siteResourceId - ) - ) + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (!newt) { + throw new Error( + "Newt not found for site during site resource update" + ); + } + + // Only update targets on newt if these items change + if ( + destinationChanged || + sslChanged || // we need to push a new cert if the ssl changed + portRangesChanged || + fullDomainChanged || // if the domain changes we need to update the certs and stuff + destinationPortChanged + ) { + const oldTargets = await generateSubnetProxyTargetV2( + existingSiteResource, + mergedAllClients + ); + const newTargets = await generateSubnetProxyTargetV2( + updatedSiteResource, + mergedAllClients ); - const oldDestinationStillInUseByASite = - oldDestinationStillInUseSites.length > 0; + await updateTargets( + newt.newtId, + { + oldTargets: oldTargets ? oldTargets : [], + newTargets: newTargets ? newTargets : [] + }, + newt.version + ); + } - // we also need to update the remote subnets on the olms for each client that has access to this site - olmJobs.push( - updatePeerData( - client.clientId, - updatedSiteResource.siteId, - destinationChanged - ? { - oldRemoteSubnets: !oldDestinationStillInUseByASite - ? generateRemoteSubnets([ - existingSiteResource - ]) - : [], - newRemoteSubnets: generateRemoteSubnets([ - updatedSiteResource - ]) - } - : undefined, - aliasChanged - ? { - oldAliases: generateAliasConfig([ - existingSiteResource - ]), - newAliases: generateAliasConfig([ - updatedSiteResource - ]) - } - : undefined - ) - ); + const olmJobs: Promise[] = []; + for (const client of mergedAllClients) { + // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet + // todo: optimize this query if needed + const oldDestinationStillInUseSites = await trx + .select() + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ), + eq(siteNetworks.siteId, site.siteId), + eq( + siteResources.destination, + existingSiteResource.destination + ), + ne( + siteResources.siteResourceId, + existingSiteResource.siteResourceId + ) + ) + ); + + const oldDestinationStillInUseByASite = + oldDestinationStillInUseSites.length > 0; + + // we also need to update the remote subnets on the olms for each client that has access to this site + olmJobs.push( + updatePeerData( + client.clientId, + site.siteId, + destinationChanged + ? { + oldRemoteSubnets: + !oldDestinationStillInUseByASite + ? generateRemoteSubnets([ + existingSiteResource + ]) + : [], + newRemoteSubnets: generateRemoteSubnets([ + updatedSiteResource + ]) + } + : undefined, + aliasChanged || fullDomainChanged // the full domain is sent down as an alias + ? { + oldAliases: generateAliasConfig([ + existingSiteResource + ]), + newAliases: generateAliasConfig([ + updatedSiteResource + ]) + } + : undefined + ) + ); + } + + await Promise.all(olmJobs); } - - await Promise.all(olmJobs); } } diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index ba52d85a1..c629e378e 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -1,6 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, TargetHealthCheck, targetHealthCheck } from "@server/db"; +import { + db, + statusHistory, + TargetHealthCheck, + targetHealthCheck +} from "@server/db"; import { newts, resources, sites, Target, targets } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -14,6 +19,11 @@ import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckUnhealthyAlert, + fireHealthCheckUnknownAlert +} from "@server/lib/alerts"; const createTargetParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -31,8 +41,8 @@ const createTargetSchema = z.strictObject({ hcMode: z.string().optional().nullable(), hcHostname: z.string().optional().nullable(), hcPort: z.int().positive().optional().nullable(), - hcInterval: z.int().positive().min(5).optional().nullable(), - hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(), + hcInterval: z.int().positive().min(1).optional().nullable(), + hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), hcTimeout: z.int().positive().min(1).optional().nullable(), hcHeaders: z .array(z.strictObject({ name: z.string(), value: z.string() })) @@ -42,6 +52,8 @@ const createTargetSchema = z.strictObject({ hcMethod: z.string().min(1).optional().nullable(), hcStatus: z.int().optional().nullable(), hcTlsServerName: z.string().optional().nullable(), + hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), + hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), path: z.string().optional().nullable(), pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), rewritePath: z.string().optional().nullable(), @@ -134,116 +146,155 @@ export async function createTarget( ); } - const existingTargets = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resourceId)); - - const existingTarget = existingTargets.find( - (target) => - target.ip === targetData.ip && - target.port === targetData.port && - target.method === targetData.method && - target.siteId === targetData.siteId - ); - - if (existingTarget) { - // log a warning - logger.warn( - `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` - ); - } - let newTarget: Target[] = []; - let healthCheck: TargetHealthCheck[] = []; let targetIps: string[] = []; - if (site.type == "local") { - newTarget = await db - .insert(targets) - .values({ - resourceId, - ...targetData, - priority: targetData.priority || 100 - }) - .returning(); - } else { - // make sure the target is within the site subnet - if ( - site.type == "wireguard" && - !isIpInCidr(targetData.ip, site.subnet!) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Target IP is not within the site subnet` - ) - ); - } + let healthCheck: TargetHealthCheck[] = []; + await db.transaction(async (trx) => { + const existingTargets = await trx + .select() + .from(targets) + .where(eq(targets.resourceId, resourceId)); - const { internalPort, targetIps: newTargetIps } = await pickPort( - site.siteId!, - db + const existingTarget = existingTargets.find( + (target) => + target.ip === targetData.ip && + target.port === targetData.port && + target.method === targetData.method && + target.siteId === targetData.siteId ); - if (!internalPort) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `No available internal port` - ) + if (existingTarget) { + // log a warning + logger.warn( + `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` ); } - newTarget = await db - .insert(targets) + if (site.type == "local") { + newTarget = await trx + .insert(targets) + .values({ + resourceId, + ...targetData, + priority: targetData.priority || 100 + }) + .returning(); + } else { + // make sure the target is within the site subnet + if ( + site.type == "wireguard" && + !isIpInCidr(targetData.ip, site.subnet!) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Target IP is not within the site subnet` + ) + ); + } + + const { internalPort, targetIps: newTargetIps } = + await pickPort(site.siteId!, trx); + + if (!internalPort) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `No available internal port` + ) + ); + } + + newTarget = await trx + .insert(targets) + .values({ + resourceId, + siteId: site.siteId, + ip: targetData.ip, + method: targetData.method, + port: targetData.port, + internalPort, + enabled: targetData.enabled, + path: targetData.path, + pathMatchType: targetData.pathMatchType, + rewritePath: targetData.rewritePath, + rewritePathType: targetData.rewritePathType, + priority: targetData.priority || 100 + }) + .returning(); + + // add the new target to the targetIps array + newTargetIps.push(`${targetData.ip}/32`); + + targetIps = newTargetIps; + } + + let hcHeaders = null; + if (targetData.hcHeaders) { + hcHeaders = JSON.stringify(targetData.hcHeaders); + } + + healthCheck = await trx + .insert(targetHealthCheck) .values({ - resourceId, - siteId: site.siteId, - ip: targetData.ip, - method: targetData.method, - port: targetData.port, - internalPort, - enabled: targetData.enabled, - path: targetData.path, - pathMatchType: targetData.pathMatchType, - rewritePath: targetData.rewritePath, - rewritePathType: targetData.rewritePathType, - priority: targetData.priority || 100 + orgId: resource.orgId, + targetId: newTarget[0].targetId, + siteId: targetData.siteId, + name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, + hcEnabled: targetData.hcEnabled ?? false, + hcPath: targetData.hcPath ?? null, + hcScheme: targetData.hcScheme ?? null, + hcMode: targetData.hcMode ?? null, + hcHostname: targetData.hcHostname ?? null, + hcPort: targetData.hcPort ?? null, + hcInterval: targetData.hcInterval ?? null, + hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null, + hcTimeout: targetData.hcTimeout ?? null, + hcHeaders: hcHeaders, + hcFollowRedirects: targetData.hcFollowRedirects ?? null, + hcMethod: targetData.hcMethod ?? null, + hcStatus: targetData.hcStatus ?? null, + hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown", + hcTlsServerName: targetData.hcTlsServerName ?? null, + hcHealthyThreshold: targetData.hcHealthyThreshold ?? null, + hcUnhealthyThreshold: + targetData.hcUnhealthyThreshold ?? null }) .returning(); - // add the new target to the targetIps array - newTargetIps.push(`${targetData.ip}/32`); - - targetIps = newTargetIps; - } - - let hcHeaders = null; - if (targetData.hcHeaders) { - hcHeaders = JSON.stringify(targetData.hcHeaders); - } - - healthCheck = await db - .insert(targetHealthCheck) - .values({ - targetId: newTarget[0].targetId, - hcEnabled: targetData.hcEnabled ?? false, - hcPath: targetData.hcPath ?? null, - hcScheme: targetData.hcScheme ?? null, - hcMode: targetData.hcMode ?? null, - hcHostname: targetData.hcHostname ?? null, - hcPort: targetData.hcPort ?? null, - hcInterval: targetData.hcInterval ?? null, - hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null, - hcTimeout: targetData.hcTimeout ?? null, - hcHeaders: hcHeaders, - hcFollowRedirects: targetData.hcFollowRedirects ?? null, - hcMethod: targetData.hcMethod ?? null, - hcStatus: targetData.hcStatus ?? null, - hcHealth: "unknown", - hcTlsServerName: targetData.hcTlsServerName ?? null - }) - .returning(); + if (healthCheck[0].hcHealth === "unhealthy") { + await fireHealthCheckUnhealthyAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name || "", + healthCheck[0].targetId, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (healthCheck[0].hcHealth === "unknown") { + // if the health is unknown, we want to fire an alert to notify users to enable health checks + await fireHealthCheckUnknownAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + healthCheck[0].targetId, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (healthCheck[0].hcHealth === "healthy") { + await fireHealthCheckHealthyAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name || "", + healthCheck[0].targetId, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } + }); if (site.pubKey) { if (site.type == "wireguard") { @@ -271,8 +322,8 @@ export async function createTarget( return response(res, { data: { - ...newTarget[0], - ...healthCheck[0] + ...healthCheck[0], + ...newTarget[0] }, success: true, error: false, diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 606d86351..685c41e7e 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -2,16 +2,15 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { newts, resources, sites, targets } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, ne, and } 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 { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; -import { getAllowedIps } from "./helpers"; import { OpenAPITags, registry } from "@server/openApi"; +import { targetHealthCheck } from "@server/db"; const deleteTargetSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) @@ -46,6 +45,11 @@ export async function deleteTarget( const { targetId } = parsedParams.data; + const [deletedHealthCheck] = await db + .delete(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .returning(); + const [deletedTarget] = await db .delete(targets) .where(eq(targets.targetId, targetId)) @@ -74,38 +78,59 @@ export async function deleteTarget( ); } - // const [site] = await db - // .select() - // .from(sites) - // .where(eq(sites.siteId, resource.siteId!)) - // .limit(1); - // - // if (!site) { - // return next( - // createHttpError( - // HttpCode.NOT_FOUND, - // `Site with ID ${resource.siteId} not found` - // ) - // ); - // } - // - // if (site.pubKey) { - // if (site.type == "wireguard") { - // await addPeer(site.exitNodeId!, { - // publicKey: site.pubKey, - // allowedIps: await getAllowedIps(site.siteId) - // }); - // } else if (site.type == "newt") { - // // get the newt on the site by querying the newt table for siteId - // const [newt] = await db - // .select() - // .from(newts) - // .where(eq(newts.siteId, site.siteId)) - // .limit(1); - // - // removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort); - // } - // } + // check if there are other targets on the resource + const otherTargets = await db + .select() + .from(targets) + .where( + and( + eq(targets.resourceId, resource.resourceId), + ne(targets.targetId, targetId) + ) + ); + + if (otherTargets.length == 0) { + // set the resource status + await db + .update(resources) + .set({ health: "unknown" }) + .where(eq(resources.resourceId, resource.resourceId)); + } + + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, deletedTarget.siteId)) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${targets.siteId} not found` + ) + ); + } + + if (site.pubKey) { + if (site.type == "newt") { + // get the newt on the site by querying the newt table for siteId + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + await removeTargets( + newt.newtId, + // [deletedTarget], + [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this + [deletedHealthCheck], + resource.protocol, + newt.version + ); + } + } return response(res, { data: null, diff --git a/server/routers/target/getTarget.ts b/server/routers/target/getTarget.ts index 749e1399b..281c39906 100644 --- a/server/routers/target/getTarget.ts +++ b/server/routers/target/getTarget.ts @@ -15,8 +15,8 @@ const getTargetSchema = z.strictObject({ }); type GetTargetResponse = Target & - Omit & { - hcHeaders: { name: string; value: string }[] | null; + Partial> & { + hcHeaders: { name: string; value: string }[] | null | undefined; }; registry.registerPath({ @@ -70,20 +70,19 @@ export async function getTarget( .limit(1); // Parse hcHeaders from JSON string back to array - let parsedHcHeaders = null; + let parsedHcHeaders: { name: string; value: string }[] | null = null; if (targetHc?.hcHeaders) { try { parsedHcHeaders = JSON.parse(targetHc.hcHeaders); } catch (error) { - // If parsing fails, keep as string for backward compatibility - parsedHcHeaders = targetHc.hcHeaders; + // If parsing fails, keep as null for safety } } return response(res, { data: { - ...target[0], ...targetHc, + ...target[0], hcHeaders: parsedHcHeaders }, success: true, diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 7ea1730ce..61a927d3e 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -1,9 +1,12 @@ -import { db, targets, resources, sites, targetHealthCheck } from "@server/db"; +import { db, primaryDb, targetHealthCheck } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import logger from "@server/logger"; -import { unknown } from "zod"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckUnhealthyAlert +} from "@server/lib/alerts"; interface TargetHealthStatus { status: string; @@ -11,7 +14,7 @@ interface TargetHealthStatus { checkCount: number; lastError?: string; config: { - id: string; + id: string; // this could be the hc id or the target id, depending on the version of newt hcEnabled: boolean; hcPath?: string; hcScheme?: string; @@ -22,7 +25,11 @@ interface TargetHealthStatus { hcUnhealthyInterval?: number; hcTimeout?: number; hcHeaders?: any; + hcFollowRedirects?: boolean; hcMethod?: string; + hcTlsServerName?: string; + hcHealthyThreshold?: number; + hcUnhealthyThreshold?: number; }; } @@ -74,54 +81,87 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( continue; } - const [targetCheck] = await db + const [targetCheck] = await primaryDb // using the primary db here in case it has just been updated and we are getting the immediate status back and it has not made it out to the repliacs yet .select({ - targetId: targets.targetId, - siteId: targets.siteId, - hcStatus: targetHealthCheck.hcHealth + targetId: targetHealthCheck.targetId, + orgId: targetHealthCheck.orgId, + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, + name: targetHealthCheck.name, + hcHealth: targetHealthCheck.hcHealth, + hcEnabled: targetHealthCheck.hcEnabled }) - .from(targets) - .innerJoin( - resources, - eq(targets.resourceId, resources.resourceId) - ) - .innerJoin(sites, eq(targets.siteId, sites.siteId)) - .innerJoin(targetHealthCheck, eq(targets.targetId, targetHealthCheck.targetId)) + .from(targetHealthCheck) .where( and( - eq(targets.targetId, targetIdNum), - eq(sites.siteId, newt.siteId) + eq(targetHealthCheck.targetHealthCheckId, targetIdNum), + eq(targetHealthCheck.siteId, newt.siteId) ) ) .limit(1); if (!targetCheck) { - logger.warn( + logger.debug( `Target ${targetId} not found or does not belong to site ${newt.siteId}` ); errorCount++; continue; } + if (!targetCheck.hcEnabled) { + logger.debug( + `Health check for target ${targetId} is not enabled, skipping update` + ); + continue; + } + // check if the status has changed - if (targetCheck.hcStatus === healthStatus.status) { + if (targetCheck.hcHealth === healthStatus.status) { logger.debug( `Health status for target ${targetId} is already ${healthStatus.status}, skipping update` ); continue; } - // Update the target's health status in the database - await db - .update(targetHealthCheck) - .set({ - hcHealth: healthStatus.status as - | "unknown" - | "healthy" - | "unhealthy" - }) - .where(eq(targetHealthCheck.targetId, targetIdNum)) - .execute(); + // Update the target's health status in the database and fire alert in a transaction + await db.transaction(async (trx) => { + await trx + .update(targetHealthCheck) + .set({ + hcHealth: healthStatus.status as + | "unknown" + | "healthy" + | "unhealthy" + }) + .where( + eq( + targetHealthCheck.targetHealthCheckId, + targetCheck.targetHealthCheckId + ) + ); + + // because we are checking above if there was a change we can fire the alert here because it changed + if (healthStatus.status === "unhealthy") { + await fireHealthCheckUnhealthyAlert( + targetCheck.orgId, + targetCheck.targetHealthCheckId, + targetCheck.name ?? undefined, + targetCheck.targetId, + undefined, + true, + trx + ); + } else if (healthStatus.status === "healthy") { + await fireHealthCheckHealthyAlert( + targetCheck.orgId, + targetCheck.targetHealthCheckId, + targetCheck.name ?? undefined, + targetCheck.targetId, + undefined, + true, + trx + ); + } + }); logger.debug( `Updated health status for target ${targetId} to ${healthStatus.status}` diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 1f9eff716..4533dc2e5 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,10 +10,14 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckUnknownAlert, + fireHealthCheckUnhealthyAlert +} from "@server/lib/alerts"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { vs } from "@react-email/components"; const updateTargetParamsSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) @@ -32,8 +36,8 @@ const updateTargetBodySchema = z hcMode: z.string().optional().nullable(), hcHostname: z.string().optional().nullable(), hcPort: z.int().positive().optional().nullable(), - hcInterval: z.int().positive().min(5).optional().nullable(), - hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(), + hcInterval: z.int().positive().min(1).optional().nullable(), + hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), hcTimeout: z.int().positive().min(1).optional().nullable(), hcHeaders: z .array(z.strictObject({ name: z.string(), value: z.string() })) @@ -43,6 +47,8 @@ const updateTargetBodySchema = z hcMethod: z.string().min(1).optional().nullable(), hcStatus: z.int().optional().nullable(), hcTlsServerName: z.string().optional().nullable(), + hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), + hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), path: z.string().optional().nullable(), pathMatchType: z .enum(["exact", "prefix", "regex"]) @@ -151,32 +157,6 @@ export async function updateTarget( ); } - const targetData = { - ...target, - ...parsedBody.data - }; - - const existingTargets = await db - .select() - .from(targets) - .where(eq(targets.resourceId, target.resourceId)); - - const foundTarget = existingTargets.find( - (target) => - target.targetId !== targetId && // Exclude the current target being updated - target.ip === targetData.ip && - target.port === targetData.port && - target.method === targetData.method && - target.siteId === targetData.siteId - ); - - if (foundTarget) { - // log a warning - logger.warn( - `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${target.resourceId}` - ); - } - const { internalPort, targetIps } = await pickPort(site.siteId!, db); if (!internalPort) { @@ -190,60 +170,149 @@ export async function updateTarget( const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; - const [updatedTarget] = await db - .update(targets) - .set({ - siteId: parsedBody.data.siteId, - ip: parsedBody.data.ip, - method: parsedBody.data.method, - port: parsedBody.data.port, - internalPort, - enabled: parsedBody.data.enabled, - path: parsedBody.data.path, - pathMatchType: parsedBody.data.pathMatchType, - priority: parsedBody.data.priority, - rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, - rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType - }) - .where(eq(targets.targetId, targetId)) - .returning(); + let updatedTarget: any; + let updatedHc: any; + await db.transaction(async (trx) => { + [updatedTarget] = await trx + .update(targets) + .set({ + siteId: parsedBody.data.siteId, + ip: parsedBody.data.ip, + method: parsedBody.data.method, + port: parsedBody.data.port, + internalPort, + enabled: parsedBody.data.enabled, + path: parsedBody.data.path, + pathMatchType: parsedBody.data.pathMatchType, + priority: parsedBody.data.priority, + rewritePath: pathMatchTypeRemoved + ? null + : parsedBody.data.rewritePath, + rewritePathType: pathMatchTypeRemoved + ? null + : parsedBody.data.rewritePathType + }) + .where(eq(targets.targetId, targetId)) + .returning(); - let hcHeaders = null; - if (parsedBody.data.hcHeaders) { - hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); - } + const [existingHc] = await trx + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); - // When health check is disabled, reset hcHealth to "unknown" - // to prevent previously unhealthy targets from being excluded - // Also when the site is not a newt, set hcHealth to "unknown" - const hcHealthValue = - parsedBody.data.hcEnabled === false || - parsedBody.data.hcEnabled === null || - site.type !== "newt" - ? "unknown" - : undefined; + if (!existingHc) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check for target with ID ${targetId} not found` + ) + ); + } - const [updatedHc] = await db - .update(targetHealthCheck) - .set({ - hcEnabled: parsedBody.data.hcEnabled || false, - hcPath: parsedBody.data.hcPath, - hcScheme: parsedBody.data.hcScheme, - hcMode: parsedBody.data.hcMode, - hcHostname: parsedBody.data.hcHostname, - hcPort: parsedBody.data.hcPort, - hcInterval: parsedBody.data.hcInterval, - hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval, - hcTimeout: parsedBody.data.hcTimeout, - hcHeaders: hcHeaders, - hcFollowRedirects: parsedBody.data.hcFollowRedirects, - hcMethod: parsedBody.data.hcMethod, - hcStatus: parsedBody.data.hcStatus, - hcTlsServerName: parsedBody.data.hcTlsServerName, - ...(hcHealthValue !== undefined && { hcHealth: hcHealthValue }) - }) - .where(eq(targetHealthCheck.targetId, targetId)) - .returning(); + let hcHeaders = null; + if (parsedBody.data.hcHeaders) { + hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); + } + + // When health check is disabled, reset hcHealth to "unknown" + // to prevent previously unhealthy targets from being excluded. + // Also when the site is not a newt, set hcHealth to "unknown". + // If hcEnabled is being turned on (was false, now true), set to "unhealthy" + // so the target must pass a health check before being considered healthy. + const hcEnabledTurnedOn = + parsedBody.data.hcEnabled === true && + existingHc.hcEnabled === false; + + let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; + if ( + parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null || + site.type !== "newt" + ) { + hcHealthValue = "unknown"; + } else if (hcEnabledTurnedOn) { + hcHealthValue = "unhealthy"; + } else { + hcHealthValue = undefined; + } + + [updatedHc] = await trx + .update(targetHealthCheck) + .set({ + siteId: parsedBody.data.siteId, + hcEnabled: parsedBody.data.hcEnabled || false, + hcPath: parsedBody.data.hcPath, + hcScheme: parsedBody.data.hcScheme, + hcMode: parsedBody.data.hcMode, + hcHostname: parsedBody.data.hcHostname, + hcPort: parsedBody.data.hcPort, + hcInterval: parsedBody.data.hcInterval, + hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval, + hcTimeout: parsedBody.data.hcTimeout, + hcHeaders: hcHeaders, + hcFollowRedirects: parsedBody.data.hcFollowRedirects, + hcMethod: parsedBody.data.hcMethod, + hcStatus: parsedBody.data.hcStatus, + hcTlsServerName: parsedBody.data.hcTlsServerName, + hcHealthyThreshold: parsedBody.data.hcHealthyThreshold, + hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold, + hcHealth: hcHealthValue + }) + .where(eq(targetHealthCheck.targetId, targetId)) + .returning(); + + if ( + updatedHc.hcHealth === "unhealthy" && + existingHc.hcHealth !== "unhealthy" + ) { + logger.debug( + `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert` + ); + await fireHealthCheckUnhealthyAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name || "", + updatedHc.targetId, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if ( + updatedHc.hcHealth === "unknown" && + existingHc.hcHealth !== "unknown" + ) { + logger.debug( + `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert` + ); + // if the health is unknown, we want to fire an alert to notify users to enable health checks + await fireHealthCheckUnknownAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name, + updatedHc.targetId, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if ( + updatedHc.hcHealth === "healthy" && + existingHc.hcHealth !== "healthy" + ) { + logger.debug( + `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert` + ); + await fireHealthCheckHealthyAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name, + updatedHc.targetId, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } + }); if (site.pubKey) { if (site.type == "wireguard") { @@ -268,6 +337,7 @@ export async function updateTarget( ); } } + return response(res, { data: { ...updatedTarget, diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index 3a965259c..3d7bac4b3 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -1,31 +1,98 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idp, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq } from "drizzle-orm"; +import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; import logger from "@server/logger"; -import { idp, users } from "@server/db"; import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { UserType } from "@server/types/UserTypes"; const listUsersSchema = z.strictObject({ - limit: z - .string() + pageSize: z.coerce + .number() + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["username", "email", "name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["username", "email", "name"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), + idp_id: z + .preprocess( + (val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (val === "internal") { + return "internal"; + } + if (typeof val === "string" && /^\d+$/.test(val)) { + return parseInt(val, 10); + } + return undefined; + }, + z + .union([z.literal("internal"), z.number().int().positive()]) + .optional() + ) + .openapi({ + description: + 'Filter by identity provider id, or "internal" for internal users' + }), + two_factor: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: + "Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested" + }) }); -async function queryUsers(limit: number, offset: number) { - return await db +function queryUsersBase() { + return db .select({ id: users.userId, email: users.email, @@ -40,17 +107,39 @@ async function queryUsers(limit: number, offset: number) { twoFactorSetupRequested: users.twoFactorSetupRequested }) .from(users) - .leftJoin(idp, eq(users.idpId, idp.idpId)) - .where(eq(users.serverAdmin, false)) - .limit(limit) - .offset(offset); + .leftJoin(idp, eq(users.idpId, idp.idpId)); } -export type AdminListUsersResponse = { - users: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; +/** Row shape returned by `queryUsersBase()` (matches selected columns + join). */ +export type AdminListUserRow = { + id: string; + email: string | null; + username: string; + name: string | null; + dateCreated: string; + serverAdmin: boolean; + type: string; + idpName: string | null; + idpId: number | null; + twoFactorEnabled: boolean; + twoFactorSetupRequested: boolean | null; }; +export type AdminListUsersResponse = PaginatedResponse<{ + users: AdminListUserRow[]; +}>; + +registry.registerPath({ + method: "get", + path: "/users", + description: "List non–server-admin users (server admin).", + tags: [OpenAPITags.User], + request: { + query: listUsersSchema + }, + responses: {} +}); + export async function adminListUsers( req: Request, res: Response, @@ -66,21 +155,96 @@ export async function adminListUsers( ) ); } - const { limit, offset } = parsedQuery.data; + const { + page, + pageSize, + query, + sort_by, + order, + idp_id, + two_factor: twoFactorFilter + } = parsedQuery.data; - const allUsers = await queryUsers(limit, offset); + if (typeof idp_id === "number") { + const idpOk = await db + .select({ one: sql`1` }) + .from(idp) + .where(eq(idp.idpId, idp_id)) + .limit(1); + if (idpOk.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "idp_id does not exist" + ) + ); + } + } - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(users); + const conditions = [eq(users.serverAdmin, false)]; + + if (query) { + const q = "%" + query.toLowerCase() + "%"; + conditions.push( + or( + like(sql`LOWER(${users.username})`, q), + like(sql`LOWER(${users.email})`, q), + like(sql`LOWER(${users.name})`, q) + )! + ); + } + + if (idp_id === "internal") { + conditions.push(eq(users.type, UserType.Internal)); + } else if (typeof idp_id === "number") { + conditions.push(eq(users.idpId, idp_id)); + } + + if (typeof twoFactorFilter === "boolean") { + if (twoFactorFilter) { + conditions.push( + or( + eq(users.twoFactorEnabled, true), + eq(users.twoFactorSetupRequested, true) + )! + ); + } else { + conditions.push( + and( + eq(users.twoFactorEnabled, false), + eq(users.twoFactorSetupRequested, false) + )! + ); + } + } + + const whereClause = and(...conditions); + + const countQuery = db.$count( + queryUsersBase().where(whereClause).as("filtered_admin_users") + ); + + const userListQuery = queryUsersBase() + .where(whereClause) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(users[sort_by]) + : desc(users[sort_by]) + : asc(users.username) + ); + + const [total, rows] = await Promise.all([countQuery, userListQuery]); return response(res, { data: { - users: allUsers, + users: rows, pagination: { - total: count, - limit, - offset + total, + page, + pageSize } }, success: true, diff --git a/server/routers/user/getUser.ts b/server/routers/user/getUser.ts index c2e43e16e..9ff52fd2d 100644 --- a/server/routers/user/getUser.ts +++ b/server/routers/user/getUser.ts @@ -21,7 +21,8 @@ async function queryUser(userId: string) { serverAdmin: users.serverAdmin, idpName: idp.name, idpId: users.idpId, - locale: users.locale + locale: users.locale, + dateCreated: users.dateCreated }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index fe7f6b250..42a62636d 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,36 +1,113 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; -import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db"; +import { + idp, + idpOrg, + roles, + userOrgRoles, + userOrgs, + users +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, exists, inArray, like, or, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { UserType } from "@server/types/UserTypes"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() }); const listUsersSchema = z.strictObject({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["username"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["username"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), + idp_id: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (val === "internal") { + return "internal"; + } + if (typeof val === "string" && /^\d+$/.test(val)) { + return parseInt(val, 10); + } + return undefined; + }, z.union([z.literal("internal"), z.number().int().positive()]).optional()) + .openapi({ + description: + 'Filter by identity provider id, or "internal" for internal users' + }), + role_id: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive()).max(50).optional()) + .openapi({ + description: + "Filter users who have any of these role ids in the organization (repeat query param)" + }) }); -async function queryUsers(orgId: string, limit: number, offset: number) { - const rows = await db +function queryUsersBase() { + return db .select({ id: users.userId, email: users.email, @@ -50,53 +127,19 @@ async function queryUsers(orgId: string, limit: number, offset: number) { .from(users) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) - .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) - .where(eq(userOrgs.orgId, orgId)) - .limit(limit) - .offset(offset); - - const userIds = rows.map((r) => r.id); - const roleRows = - userIds.length === 0 - ? [] - : await db - .select({ - userId: userOrgRoles.userId, - roleId: userOrgRoles.roleId, - roleName: roles.name - }) - .from(userOrgRoles) - .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) - .where( - and( - eq(userOrgRoles.orgId, orgId), - inArray(userOrgRoles.userId, userIds) - ) - ); - - const rolesByUser = new Map< - string, - { roleId: number; roleName: string }[] - >(); - for (const r of roleRows) { - const list = rolesByUser.get(r.userId) ?? []; - list.push({ roleId: r.roleId, roleName: r.roleName ?? "" }); - rolesByUser.set(r.userId, list); - } - - return rows.map((row) => { - const userRoles = rolesByUser.get(row.id) ?? []; - return { - ...row, - roles: userRoles - }; - }); + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)); } -export type ListUsersResponse = { - users: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; -}; +export type ListUsersResponse = PaginatedResponse<{ + users: Array< + NonNullable>>[number] & { + roles: Array<{ + roleId: number; + roleName: string; + }>; + } + >; +}>; registry.registerPath({ method: "get", @@ -125,7 +168,9 @@ export async function listUsers( ) ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, sort_by, order, query, idp_id, role_id } = + parsedQuery.data; + const roleIds = role_id ?? []; const parsedParams = listUsersParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -139,24 +184,154 @@ export async function listUsers( const { orgId } = parsedParams.data; - const usersWithRoles = await queryUsers( - orgId.toString(), - limit, - offset + if (typeof idp_id === "number") { + const idpOk = await db + .select({ one: sql`1` }) + .from(idpOrg) + .where( + and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idp_id)) + ) + .limit(1); + if (idpOk.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "idp_id is not linked to this organization" + ) + ); + } + } + + if (roleIds.length > 0) { + const validRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and(eq(roles.orgId, orgId), inArray(roles.roleId, roleIds)) + ); + if (validRoles.length !== roleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more role_id values are not valid for this organization" + ) + ); + } + } + + const conditions = [and(eq(userOrgs.orgId, orgId))]; + + if (query) { + conditions.push( + or( + like( + sql`LOWER(${users.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.username})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.email})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } + + if (idp_id === "internal") { + conditions.push(eq(users.type, UserType.Internal)); + } else if (typeof idp_id === "number") { + conditions.push(eq(users.idpId, idp_id)); + } + + if (roleIds.length > 0) { + conditions.push( + exists( + db + .select() + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, users.userId), + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.roleId, roleIds) + ) + ) + ) + ); + } + + const countQuery = db.$count( + queryUsersBase() + .where(and(...conditions)) + .as("filtered_users") ); - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(userOrgs) - .where(eq(userOrgs.orgId, orgId)); + const userListQuery = queryUsersBase() + .where(and(...conditions)) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(users[sort_by]) + : desc(users[sort_by]) + : asc(users.name) + ); + + const [total, usersWithoutRoles] = await Promise.all([ + countQuery, + userListQuery + ]); + + const userIds = usersWithoutRoles.map((r) => r.id); + const roleRows = + userIds.length === 0 + ? [] + : await db + .select({ + userId: userOrgRoles.userId, + roleId: userOrgRoles.roleId, + roleName: roles.name + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.userId, userIds) + ) + ); + + const rolesByUser = new Map< + string, + { roleId: number; roleName: string }[] + >(); + for (const r of roleRows) { + const list = rolesByUser.get(r.userId) ?? []; + list.push({ roleId: r.roleId, roleName: r.roleName ?? "" }); + rolesByUser.set(r.userId, list); + } + + const usersWithRoles: ListUsersResponse["users"] = []; + + for (const user of usersWithoutRoles) { + const userRoles = rolesByUser.get(user.id) ?? []; + usersWithRoles.push({ + ...user, + roles: userRoles + }); + } return response(res, { data: { users: usersWithRoles, pagination: { - total: count, - limit, - offset + total, + page, + pageSize } }, success: true, diff --git a/server/routers/user/myDevice.ts b/server/routers/user/myDevice.ts index 591d6178e..1a767d4db 100644 --- a/server/routers/user/myDevice.ts +++ b/server/routers/user/myDevice.ts @@ -64,7 +64,8 @@ export async function myDevice( serverAdmin: users.serverAdmin, idpName: idp.name, idpId: users.idpId, - locale: users.locale + locale: users.locale, + dateCreated: users.dateCreated }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index 143e4d516..f89284389 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -2,7 +2,7 @@ import { build } from "@server/build"; import { handleNewtRegisterMessage, handleReceiveBandwidthMessage, - handleGetConfigMessage, + handleNewtGetConfigMessage, handleDockerStatusMessage, handleDockerContainersMessage, handleNewtPingRequestMessage, @@ -37,7 +37,7 @@ export const messageHandlers: Record = { "newt/disconnecting": handleNewtDisconnectingMessage, "newt/ping": handleNewtPingMessage, "newt/wg/register": handleNewtRegisterMessage, - "newt/wg/get-config": handleGetConfigMessage, + "newt/wg/get-config": handleNewtGetConfigMessage, "newt/receive-bandwidth": handleReceiveBandwidthMessage, "newt/socket/status": handleDockerStatusMessage, "newt/socket/containers": handleDockerContainersMessage, @@ -47,7 +47,7 @@ export const messageHandlers: Record = { "ws/round-trip/complete": handleRoundTripMessage }; -// Start the ping accumulator for all builds — it batches per-site online/lastPing +// Start the ping accumulator for all builds - it batches per-site online/lastPing // updates into periodic bulk writes, preventing connection pool exhaustion. startPingAccumulator(); diff --git a/server/setup/ensureRootApiKey.ts b/server/setup/ensureRootApiKey.ts new file mode 100644 index 000000000..55f5186b3 --- /dev/null +++ b/server/setup/ensureRootApiKey.ts @@ -0,0 +1,106 @@ +import { db, apiKeys } from "@server/db"; +import { eq } from "drizzle-orm"; +import { generateRandomString, RandomReader } from "@oslojs/crypto/random"; +import moment from "moment"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; + +const random: RandomReader = { + read(bytes: Uint8Array): void { + crypto.getRandomValues(bytes); + } +}; + +function validateApiKeyId(id: string): boolean { + return /^[a-z0-9]{15}$/.test(id); +} + +function validateApiKeySecret(secret: string): boolean { + return secret.length > 0; +} + +function showRootApiKey(apiKeyId: string, source: string): void { + console.log(`=== ROOT API KEY ${source} ===`); + console.log("API Key ID:", apiKeyId); + console.log( + "The root API key from PANGOLIN_ROOT_API_KEY has been applied." + ); + console.log("Use the full key value (apiKeyId.apiKeySecret) in requests."); + console.log("================================"); +} + +export async function ensureRootApiKey() { + try { + const envApiKey = process.env.PANGOLIN_ROOT_API_KEY; + + if (!envApiKey) { + // logger.debug( + // "PANGOLIN_ROOT_API_KEY not set. Root API key from environment skipped." + // ); + return; + } + + const parts = envApiKey.split("."); + if (parts.length !== 2) { + throw new Error( + "Invalid format for PANGOLIN_ROOT_API_KEY. Expected format: {apiKeyId}.{apiKeySecret}" + ); + } + + const [apiKeyId, apiKeySecret] = parts; + + if (!validateApiKeyId(apiKeyId)) { + throw new Error( + "Invalid apiKeyId in PANGOLIN_ROOT_API_KEY. Must be 15 lowercase alphanumeric characters." + ); + } + + if (!validateApiKeySecret(apiKeySecret)) { + throw new Error( + "Invalid apiKeySecret in PANGOLIN_ROOT_API_KEY. Secret must not be empty." + ); + } + + const apiKeyHash = await hashPassword(apiKeySecret); + const lastChars = apiKeySecret.slice(-4); + const createdAt = moment().toISOString(); + + const [existingKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)); + + if (existingKey) { + if (!existingKey.isRoot) { + console.warn( + `API key with ID ${apiKeyId} exists but is not a root key. Promoting to root and updating hash.` + ); + } else { + console.warn( + `Overwriting existing root API key hash since PANGOLIN_ROOT_API_KEY is set (apiKeyId: ${apiKeyId})` + ); + } + + await db + .update(apiKeys) + .set({ apiKeyHash, lastChars, isRoot: true }) + .where(eq(apiKeys.apiKeyId, apiKeyId)); + + showRootApiKey(apiKeyId, "UPDATED FROM ENVIRONMENT"); + } else { + await db.insert(apiKeys).values({ + apiKeyId, + name: "Root API Key (Environment)", + apiKeyHash, + lastChars, + createdAt, + isRoot: true + }); + + showRootApiKey(apiKeyId, "CREATED FROM ENVIRONMENT"); + } + } catch (error) { + console.error("Failed to ensure root API key:", error); + throw error; + } +} diff --git a/server/setup/index.ts b/server/setup/index.ts index 2dfb633e5..c46e6b8fd 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -2,10 +2,12 @@ import { ensureActions } from "./ensureActions"; import { copyInConfig } from "./copyInConfig"; import { clearStaleData } from "./clearStaleData"; import { ensureSetupToken } from "./ensureSetupToken"; +import { ensureRootApiKey } from "./ensureRootApiKey"; export async function runSetupFunctions() { await copyInConfig(); // copy in the config to the db as needed await ensureActions(); // make sure all of the actions are in the db and the roles await clearStaleData(); await ensureSetupToken(); // ensure setup token exists for initial setup + await ensureRootApiKey(); } diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 9ba0b9767..992cc2583 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -22,6 +22,7 @@ import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; import m16 from "./scriptsPg/1.17.0"; +import m17 from "./scriptsPg/1.18.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -43,7 +44,8 @@ const migrations = [ { version: "1.15.3", run: m13 }, { version: "1.15.4", run: m14 }, { version: "1.16.0", run: m15 }, - { version: "1.17.0", run: m16 } + { version: "1.17.0", run: m16 }, + { version: "1.18.0", run: m17 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 45a29ec29..c32437aec 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -40,6 +40,7 @@ import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; import m37 from "./scriptsSqlite/1.17.0"; +import m38 from "./scriptsSqlite/1.18.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -77,7 +78,8 @@ const migrations = [ { version: "1.15.3", run: m34 }, { version: "1.15.4", run: m35 }, { version: "1.16.0", run: m36 }, - { version: "1.17.0", run: m37 } + { version: "1.17.0", run: m37 }, + { version: "1.18.0", run: m38 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts new file mode 100644 index 000000000..88b2fb5bc --- /dev/null +++ b/server/setup/scriptsPg/1.18.0.ts @@ -0,0 +1,655 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.18.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Query existing targetHealthCheck data with joined siteId and orgId before + // the transaction adds the new columns (which start NULL for existing rows). + // We will delete all rows and reinsert them with targetHealthCheckId = targetId + // so the two IDs form a stable 1:1 mapping. + const healthChecksQuery = await db.execute( + sql`SELECT + thc."targetHealthCheckId", + thc."targetId", + t."siteId", + s."orgId", + r."name" AS "resourceName", + t."ip", + t."port", + thc."hcEnabled", + thc."hcPath", + thc."hcScheme", + thc."hcMode", + thc."hcHostname", + thc."hcPort", + thc."hcInterval", + thc."hcUnhealthyInterval", + thc."hcTimeout", + thc."hcHeaders", + thc."hcFollowRedirects", + thc."hcMethod", + thc."hcStatus", + thc."hcHealth", + thc."hcTlsServerName" + FROM "targetHealthCheck" thc + JOIN "targets" t ON thc."targetId" = t."targetId" + JOIN "sites" s ON t."siteId" = s."siteId" + JOIN "resources" r ON t."resourceId" = r."resourceId"` + ); + const existingHealthChecks = healthChecksQuery.rows as { + targetHealthCheckId: number; + targetId: number; + siteId: number; + orgId: string; + resourceName: string; + ip: string; + port: number; + hcEnabled: boolean; + hcPath: string | null; + hcScheme: string | null; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcMethod: string | null; + hcStatus: number | null; + hcHealth: string | null; + hcTlsServerName: string | null; + }[]; + + console.log( + `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` + ); + + // Query existing siteResources with siteId before it is dropped by the DDL below. + const siteResourcesForNetworkQuery = await db.execute( + sql`SELECT sr."siteResourceId", sr."orgId", sr."siteId" + FROM "siteResources" sr + WHERE sr."siteId" IS NOT NULL` + ); + const existingSiteResourcesForNetwork = + siteResourcesForNetworkQuery.rows as { + siteResourceId: number; + orgId: string; + siteId: number; + }[]; + + console.log( + `Found ${existingSiteResourcesForNetwork.length} existing siteResource(s) to migrate to networks` + ); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "alertEmailActions" ( + "emailActionId" serial PRIMARY KEY NOT NULL, + "alertRuleId" integer NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "lastSentAt" bigint + ); + `); + + await db.execute(sql` + CREATE TABLE "alertEmailRecipients" ( + "recipientId" serial PRIMARY KEY NOT NULL, + "emailActionId" integer NOT NULL, + "userId" varchar, + "roleId" integer, + "email" varchar(255) + ); + `); + + await db.execute(sql` + CREATE TABLE "alertHealthChecks" ( + "alertRuleId" integer NOT NULL, + "healthCheckId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertResources" ( + "alertRuleId" integer NOT NULL, + "resourceId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertRules" ( + "alertRuleId" serial PRIMARY KEY NOT NULL, + "orgId" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "eventType" varchar(100) NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "cooldownSeconds" integer DEFAULT 300 NOT NULL, + "allSites" boolean DEFAULT false NOT NULL, + "allHealthChecks" boolean DEFAULT false NOT NULL, + "allResources" boolean DEFAULT false NOT NULL, + "lastTriggeredAt" bigint, + "createdAt" bigint NOT NULL, + "updatedAt" bigint NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertSites" ( + "alertRuleId" integer NOT NULL, + "siteId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertWebhookActions" ( + "webhookActionId" serial PRIMARY KEY NOT NULL, + "alertRuleId" integer NOT NULL, + "webhookUrl" text NOT NULL, + "config" text, + "enabled" boolean DEFAULT true NOT NULL, + "lastSentAt" bigint + ); + `); + + await db.execute(sql` + CREATE TABLE "networks" ( + "networkId" serial PRIMARY KEY NOT NULL, + "niceId" text, + "name" text, + "scope" varchar DEFAULT 'global' NOT NULL, + "orgId" varchar NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "siteNetworks" ( + "siteId" integer NOT NULL, + "networkId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "statusHistory" ( + "id" serial PRIMARY KEY NOT NULL, + "entityType" varchar NOT NULL, + "entityId" integer NOT NULL, + "orgId" varchar NOT NULL, + "status" varchar NOT NULL, + "timestamp" integer NOT NULL + ); + `); + + await db.execute(sql` + ALTER TABLE "siteResources" DROP CONSTRAINT "siteResources_siteId_sites_siteId_fk"; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ALTER COLUMN "targetId" DROP NOT NULL; + `); + + await db.execute(sql` + ALTER TABLE "subscriptions" ADD COLUMN "expiresAt" bigint; + `); + + await db.execute(sql` + ALTER TABLE "subscriptions" ADD COLUMN "trial" boolean DEFAULT false; + `); + + await db.execute(sql` + ALTER TABLE "requestAuditLog" ADD COLUMN "siteResourceId" integer; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "networkId" integer; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "defaultNetworkId" integer; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "ssl" boolean DEFAULT false NOT NULL; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "scheme" varchar; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "domainId" varchar; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "subdomain" varchar; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "fullDomain" varchar; + `); + + // Add orgId and siteId as nullable first; NOT NULL constraints are applied + // after the data migration below once every row has been populated. + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "orgId" varchar; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "siteId" integer; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "name" varchar; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "hcHealthyThreshold" integer DEFAULT 1; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "hcUnhealthyThreshold" integer DEFAULT 1; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailActions" ADD CONSTRAINT "alertEmailActions_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailRecipients" ADD CONSTRAINT "alertEmailRecipients_emailActionId_alertEmailActions_emailActionId_fk" FOREIGN KEY ("emailActionId") REFERENCES "public"."alertEmailActions"("emailActionId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailRecipients" ADD CONSTRAINT "alertEmailRecipients_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailRecipients" ADD CONSTRAINT "alertEmailRecipients_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertHealthChecks" ADD CONSTRAINT "alertHealthChecks_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertHealthChecks" ADD CONSTRAINT "alertHealthChecks_healthCheckId_targetHealthCheck_targetHealthCheckId_fk" FOREIGN KEY ("healthCheckId") REFERENCES "public"."targetHealthCheck"("targetHealthCheckId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertResources" ADD CONSTRAINT "alertResources_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertResources" ADD CONSTRAINT "alertResources_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertRules" ADD CONSTRAINT "alertRules_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertSites" ADD CONSTRAINT "alertSites_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertSites" ADD CONSTRAINT "alertSites_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertWebhookActions" ADD CONSTRAINT "alertWebhookActions_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "networks" ADD CONSTRAINT "networks_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteNetworks" ADD CONSTRAINT "siteNetworks_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteNetworks" ADD CONSTRAINT "siteNetworks_networkId_networks_networkId_fk" FOREIGN KEY ("networkId") REFERENCES "public"."networks"("networkId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "statusHistory" ADD CONSTRAINT "statusHistory_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + CREATE INDEX "idx_statusHistory_entity" ON "statusHistory" USING btree ("entityType","entityId","timestamp"); + `); + + await db.execute(sql` + CREATE INDEX "idx_statusHistory_org_timestamp" ON "statusHistory" USING btree ("orgId","timestamp"); + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_networkId_networks_networkId_fk" FOREIGN KEY ("networkId") REFERENCES "public"."networks"("networkId") ON DELETE set null ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_defaultNetworkId_networks_networkId_fk" FOREIGN KEY ("defaultNetworkId") REFERENCES "public"."networks"("networkId") ON DELETE restrict ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_domainId_domains_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domains"("domainId") ON DELETE set null ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD CONSTRAINT "targetHealthCheck_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD CONSTRAINT "targetHealthCheck_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" DROP COLUMN "siteId"; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" DROP COLUMN "protocol"; + `); + + await db.execute(sql` + ALTER TABLE "resources" ADD "health" varchar DEFAULT 'unknown'; + `); + + await db.execute(sql` + ALTER TABLE "resources" ADD "wildcard" boolean DEFAULT false NOT NULL; + `); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + // Reinsert targetHealthCheck rows with corrected IDs: + // targetHealthCheckId is set to the same integer as targetId (1:1 mapping), + // siteId and orgId are populated from the associated target and site. + // + // Because targetHealthCheckId is a serial (sequence-backed) column, inserting + // explicit values is allowed in PostgreSQL — the sequence is simply bypassed. + // After all inserts we advance the sequence to MAX(targetHealthCheckId) via + // setval() so future auto-inserts never collide with the explicit IDs we used. + if (existingHealthChecks.length > 0) { + try { + // Remove all existing rows first. The alertHealthChecks table is brand + // new in this migration so there are no FK references to worry about. + await db.execute(sql`DELETE FROM "targetHealthCheck"`); + + for (const hc of existingHealthChecks) { + await db.execute(sql` + INSERT INTO "targetHealthCheck" ( + "targetHealthCheckId", + "targetId", + "orgId", + "siteId", + "name", + "hcEnabled", + "hcPath", + "hcScheme", + "hcMode", + "hcHostname", + "hcPort", + "hcInterval", + "hcUnhealthyInterval", + "hcTimeout", + "hcHeaders", + "hcFollowRedirects", + "hcMethod", + "hcStatus", + "hcHealth", + "hcTlsServerName" + ) VALUES ( + ${hc.targetId}, + ${hc.targetId}, + ${hc.orgId}, + ${hc.siteId}, + ${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`}, + ${hc.hcEnabled}, + ${hc.hcPath}, + ${hc.hcScheme}, + ${hc.hcMode}, + ${hc.hcHostname}, + ${hc.hcPort}, + ${hc.hcInterval}, + ${hc.hcUnhealthyInterval}, + ${hc.hcTimeout}, + ${hc.hcHeaders}, + ${hc.hcFollowRedirects}, + ${hc.hcMethod}, + ${hc.hcStatus}, + ${hc.hcHealth}, + ${hc.hcTlsServerName} + ) + `); + } + + // Now that every row has orgId and siteId populated, enforce NOT NULL. + await db.execute( + sql`ALTER TABLE "targetHealthCheck" ALTER COLUMN "orgId" SET NOT NULL` + ); + await db.execute( + sql`ALTER TABLE "targetHealthCheck" ALTER COLUMN "siteId" SET NOT NULL` + ); + + // Advance the sequence so the next auto-insert picks up after the + // largest ID we explicitly wrote. setval(..., max, true) means the + // next nextval() call will return max + 1. + await db.execute(sql` + SELECT setval( + pg_get_serial_sequence('"targetHealthCheck"', 'targetHealthCheckId'), + (SELECT MAX("targetHealthCheckId") FROM "targetHealthCheck"), + true + ) + `); + + console.log( + `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` + ); + } catch (e) { + console.error("Error while migrating targetHealthCheck rows:", e); + throw e; + } + } + + // Create a dedicated "resource"-scoped network for each existing siteResource, + // populate siteNetworks with the old siteId, and set networkId / defaultNetworkId + // on the siteResource row. + if (existingSiteResourcesForNetwork.length > 0) { + try { + for (const sr of existingSiteResourcesForNetwork) { + const networkResult = await db.execute(sql` + INSERT INTO "networks" ("scope", "orgId") + VALUES ('resource', ${sr.orgId}) + RETURNING "networkId" + `); + const networkId = ( + networkResult.rows[0] as { networkId: number } + ).networkId; + + await db.execute(sql` + INSERT INTO "siteNetworks" ("siteId", "networkId") + VALUES (${sr.siteId}, ${networkId}) + `); + + await db.execute(sql` + UPDATE "siteResources" + SET "networkId" = ${networkId}, "defaultNetworkId" = ${networkId} + WHERE "siteResourceId" = ${sr.siteResourceId} + `); + } + + console.log( + `Migrated ${existingSiteResourcesForNetwork.length} siteResource(s) to networks` + ); + } catch (e) { + console.error( + "Error while migrating siteResources to networks:", + e + ); + throw e; + } + } + + // Seed statusHistory for all existing sites + try { + const sitesQuery = await db.execute( + sql`SELECT "siteId", "orgId", "online" FROM "sites"` + ); + const allSites = sitesQuery.rows as { + siteId: number; + orgId: string; + online: boolean; + }[]; + + const now = Math.floor(Date.now() / 1000); + + for (const site of allSites) { + await db.execute(sql` + INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp") + VALUES ('site', ${site.siteId}, ${site.orgId}, ${site.online ? "online" : "offline"}, ${now}) + `); + } + + console.log(`Seeded statusHistory for ${allSites.length} site(s)`); + } catch (e) { + console.error("Error while seeding statusHistory for sites:", e); + throw e; + } + + // Seed statusHistory for all existing resources + try { + const resourcesQuery = await db.execute( + sql`SELECT "resourceId", "orgId", "health" FROM "resources"` + ); + const allResources = resourcesQuery.rows as { + resourceId: number; + orgId: string; + health: string | null; + }[]; + + const now = Math.floor(Date.now() / 1000); + + for (const resource of allResources) { + await db.execute(sql` + INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp") + VALUES ('resource', ${resource.resourceId}, ${resource.orgId}, ${resource.health ?? "unknown"}, ${now}) + `); + } + + console.log( + `Seeded statusHistory for ${allResources.length} resource(s)` + ); + } catch (e) { + console.error("Error while seeding statusHistory for resources:", e); + throw e; + } + + // Recompute resource health by aggregating across the resource's targets' + // target health checks, then update the resources.health column to match. + try { + const resourceTargetHealthQuery = await db.execute( + sql`SELECT + r."resourceId" AS "resourceId", + thc."hcHealth" AS "hcHealth" + FROM "resources" r + LEFT JOIN "targets" t ON t."resourceId" = r."resourceId" + LEFT JOIN "targetHealthCheck" thc ON thc."targetId" = t."targetId"` + ); + const resourceTargetHealthRows = + resourceTargetHealthQuery.rows as { + resourceId: number; + hcHealth: string | null; + }[]; + + const resourceHealthMap = new Map< + number, + { hasHealthy: boolean; hasUnhealthy: boolean; hasUnknown: boolean } + >(); + for (const row of resourceTargetHealthRows) { + const entry = resourceHealthMap.get(row.resourceId) ?? { + hasHealthy: false, + hasUnhealthy: false, + hasUnknown: false + }; + const status = row.hcHealth ?? "unknown"; + if (status === "healthy") entry.hasHealthy = true; + else if (status === "unhealthy") entry.hasUnhealthy = true; + else entry.hasUnknown = true; + resourceHealthMap.set(row.resourceId, entry); + } + + let updatedResourceCount = 0; + for (const [resourceId, flags] of resourceHealthMap.entries()) { + let aggregated: "healthy" | "unhealthy" | "degraded" | "unknown"; + if (flags.hasHealthy && flags.hasUnhealthy) { + aggregated = "degraded"; + } else if (flags.hasHealthy) { + aggregated = "healthy"; + } else if (flags.hasUnhealthy) { + aggregated = "unhealthy"; + } else { + aggregated = "unknown"; + } + + await db.execute(sql` + UPDATE "resources" + SET "health" = ${aggregated} + WHERE "resourceId" = ${resourceId} + `); + updatedResourceCount++; + } + + console.log( + `Recomputed health for ${updatedResourceCount} resource(s) based on target health checks` + ); + } catch (e) { + console.error( + "Error while recomputing resource health from target health checks:", + e + ); + throw e; + } + + // Seed statusHistory for all existing health checks + try { + const healthChecksQuery = await db.execute( + sql`SELECT "targetHealthCheckId", "orgId", "hcHealth" FROM "targetHealthCheck"` + ); + const allHealthChecks = healthChecksQuery.rows as { + targetHealthCheckId: number; + orgId: string; + hcHealth: string | null; + }[]; + + const now = Math.floor(Date.now() / 1000); + + for (const hc of allHealthChecks) { + await db.execute(sql` + INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp") + VALUES ('health_check', ${hc.targetHealthCheckId}, ${hc.orgId}, ${hc.hcHealth ?? "unknown"}, ${now}) + `); + } + + console.log( + `Seeded statusHistory for ${allHealthChecks.length} health check(s)` + ); + } catch (e) { + console.error( + "Error while seeding statusHistory for health checks:", + e + ); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts new file mode 100644 index 000000000..a5078e2d3 --- /dev/null +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -0,0 +1,620 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.18.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + // Query existing targetHealthCheck data with joined siteId and orgId before + // the transaction drops and recreates the table + const existingHealthChecks = db + .prepare( + `SELECT + thc."targetHealthCheckId", + thc."targetId", + t."siteId", + s."orgId", + r."name" AS "resourceName", + t."ip", + t."port", + thc."hcEnabled", + thc."hcPath", + thc."hcScheme", + thc."hcMode", + thc."hcHostname", + thc."hcPort", + thc."hcInterval", + thc."hcUnhealthyInterval", + thc."hcTimeout", + thc."hcHeaders", + thc."hcFollowRedirects", + thc."hcMethod", + thc."hcStatus", + thc."hcHealth", + thc."hcTlsServerName" + FROM 'targetHealthCheck' thc + JOIN 'targets' t ON thc."targetId" = t."targetId" + JOIN 'sites' s ON t."siteId" = s."siteId" + JOIN 'resources' r ON t."resourceId" = r."resourceId"` + ) + .all() as { + targetHealthCheckId: number; + targetId: number; + siteId: number; + orgId: string; + resourceName: string; + ip: string; + port: number; + hcEnabled: number; + hcPath: string | null; + hcScheme: string | null; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: number | null; + hcMethod: string | null; + hcStatus: number | null; + hcHealth: string | null; + hcTlsServerName: string | null; + }[]; + + console.log( + `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` + ); + + // Query existing siteResources with siteId before the transaction recreates + // the table without that column. We use this data below to create a dedicated + // network for each resource. + const existingSiteResourcesForNetwork = db + .prepare( + `SELECT sr."siteResourceId", sr."orgId", sr."siteId" + FROM 'siteResources' sr + WHERE sr."siteId" IS NOT NULL` + ) + .all() as { + siteResourceId: number; + orgId: string; + siteId: number; + }[]; + + console.log( + `Found ${existingSiteResourcesForNetwork.length} existing siteResource(s) to migrate to networks` + ); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'alertEmailActions' ( + 'emailActionId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'alertRuleId' integer NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'lastSentAt' integer, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertEmailRecipients' ( + 'recipientId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'emailActionId' integer NOT NULL, + 'userId' text, + 'roleId' integer, + 'email' text, + FOREIGN KEY ('emailActionId') REFERENCES 'alertEmailActions'('emailActionId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertHealthChecks' ( + 'alertRuleId' integer NOT NULL, + 'healthCheckId' integer NOT NULL, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('healthCheckId') REFERENCES 'targetHealthCheck'('targetHealthCheckId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertResources' ( + 'alertRuleId' integer NOT NULL, + 'resourceId' integer NOT NULL, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertRules' ( + 'alertRuleId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'orgId' text NOT NULL, + 'name' text NOT NULL, + 'eventType' text NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'cooldownSeconds' integer DEFAULT 300 NOT NULL, + 'allSites' integer DEFAULT false NOT NULL, + 'allHealthChecks' integer DEFAULT false NOT NULL, + 'allResources' integer DEFAULT false NOT NULL, + 'lastTriggeredAt' integer, + 'createdAt' integer NOT NULL, + 'updatedAt' integer NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertSites' ( + 'alertRuleId' integer NOT NULL, + 'siteId' integer NOT NULL, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertWebhookActions' ( + 'webhookActionId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'alertRuleId' integer NOT NULL, + 'webhookUrl' text NOT NULL, + 'config' text, + 'enabled' integer DEFAULT true NOT NULL, + 'lastSentAt' integer, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'networks' ( + 'networkId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'niceId' text, + 'name' text, + 'scope' text DEFAULT 'global' NOT NULL, + 'orgId' text NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'siteNetworks' ( + 'siteId' integer NOT NULL, + 'networkId' integer NOT NULL, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('networkId') REFERENCES 'networks'('networkId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'statusHistory' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'entityType' text NOT NULL, + 'entityId' integer NOT NULL, + 'orgId' text NOT NULL, + 'status' text NOT NULL, + 'timestamp' integer NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE INDEX 'idx_statusHistory_entity' ON 'statusHistory' ('entityType','entityId','timestamp'); + ` + ).run(); + db.prepare( + ` + CREATE INDEX 'idx_statusHistory_org_timestamp' ON 'statusHistory' ('orgId','timestamp'); + ` + ).run(); + + db.prepare( + ` + CREATE TABLE '__new_siteResources' ( + 'siteResourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'orgId' text NOT NULL, + 'networkId' integer, + 'defaultNetworkId' integer, + 'niceId' text NOT NULL, + 'name' text NOT NULL, + 'ssl' integer DEFAULT false NOT NULL, + 'mode' text NOT NULL, + 'scheme' text, + 'proxyPort' integer, + 'destinationPort' integer, + 'destination' text NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'alias' text, + 'aliasAddress' text, + 'tcpPortRangeString' text DEFAULT '*' NOT NULL, + 'udpPortRangeString' text DEFAULT '*' NOT NULL, + 'disableIcmp' integer DEFAULT false NOT NULL, + 'authDaemonPort' integer DEFAULT 22123, + 'authDaemonMode' text DEFAULT 'site', + 'domainId' text, + 'subdomain' text, + 'fullDomain' text, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('networkId') REFERENCES 'networks'('networkId') ON UPDATE no action ON DELETE set null, + FOREIGN KEY ('defaultNetworkId') REFERENCES 'networks'('networkId') ON UPDATE no action ON DELETE restrict, + FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE set null + ); + ` + ).run(); + db.prepare( + ` + INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", NULL, NULL, "niceId", "name", 0, "mode", NULL, "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", COALESCE("tcpPortRangeString", '*'), COALESCE("udpPortRangeString", '*'), COALESCE("disableIcmp", 0), "authDaemonPort", "authDaemonMode", NULL, NULL, NULL FROM 'siteResources'; + ` + ).run(); + db.prepare( + ` + DROP TABLE 'siteResources'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE '__new_siteResources' RENAME TO 'siteResources'; + ` + ).run(); + db.prepare( + ` + CREATE TABLE '__new_targetHealthCheck' ( + 'targetHealthCheckId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'targetId' integer, + 'orgId' text NOT NULL, + 'siteId' integer NOT NULL, + 'name' text, + 'hcEnabled' integer DEFAULT false NOT NULL, + 'hcPath' text, + 'hcScheme' text, + 'hcMode' text DEFAULT 'http', + 'hcHostname' text, + 'hcPort' integer, + 'hcInterval' integer DEFAULT 30, + 'hcUnhealthyInterval' integer DEFAULT 30, + 'hcTimeout' integer DEFAULT 5, + 'hcHeaders' text, + 'hcFollowRedirects' integer DEFAULT true, + 'hcMethod' text DEFAULT 'GET', + 'hcStatus' integer, + 'hcHealth' text DEFAULT 'unknown', + 'hcTlsServerName' text, + 'hcHealthyThreshold' integer DEFAULT 1, + 'hcUnhealthyThreshold' integer DEFAULT 1, + FOREIGN KEY ('targetId') REFERENCES 'targets'('targetId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + // INSERT INTO '__new_targetHealthCheck'("targetHealthCheckId", "targetId", "orgId", "siteId", "name", "hcEnabled", "hcPath", "hcScheme", "hcMode", "hcHostname", "hcPort", "hcInterval", "hcUnhealthyInterval", "hcTimeout", "hcHeaders", "hcFollowRedirects", "hcMethod", "hcStatus", "hcHealth", "hcTlsServerName", "hcHealthyThreshold", "hcUnhealthyThreshold") SELECT "targetHealthCheckId", "targetId", "orgId", "siteId", "name", "hcEnabled", "hcPath", "hcScheme", "hcMode", "hcHostname", "hcPort", "hcInterval", "hcUnhealthyInterval", "hcTimeout", "hcHeaders", "hcFollowRedirects", "hcMethod", "hcStatus", "hcHealth", "hcTlsServerName", "hcHealthyThreshold", "hcUnhealthyThreshold" FROM 'targetHealthCheck'; + db.prepare( + ` + DROP TABLE 'targetHealthCheck'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE '__new_targetHealthCheck' RENAME TO 'targetHealthCheck'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'subscriptions' ADD 'expiresAt' integer; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'subscriptions' ADD 'trial' integer DEFAULT false; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'requestAuditLog' ADD 'siteResourceId' integer; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'sites' ADD 'networkId' integer REFERENCES networks(networkId); + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'resources' ADD 'health' text DEFAULT 'unknown'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'resources' ADD 'wildcard' integer DEFAULT false NOT NULL; + ` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + // Create a dedicated network for each existing siteResource and link the + // old siteId via siteNetworks. Then set networkId and defaultNetworkId on + // the siteResource row so the app can use the new network model. + if (existingSiteResourcesForNetwork.length > 0) { + const insertNetwork = db.prepare( + `INSERT INTO 'networks' ("scope", "orgId") VALUES (?, ?)` + ); + const insertSiteNetwork = db.prepare( + `INSERT INTO 'siteNetworks' ("siteId", "networkId") VALUES (?, ?)` + ); + const updateSiteResource = db.prepare( + `UPDATE 'siteResources' SET "networkId" = ?, "defaultNetworkId" = ? WHERE "siteResourceId" = ?` + ); + + const migrateNetworks = db.transaction(() => { + for (const sr of existingSiteResourcesForNetwork) { + const result = insertNetwork.run("resource", sr.orgId); + const networkId = result.lastInsertRowid as number; + insertSiteNetwork.run(sr.siteId, networkId); + updateSiteResource.run( + networkId, + networkId, + sr.siteResourceId + ); + } + }); + + migrateNetworks(); + + console.log( + `Migrated ${existingSiteResourcesForNetwork.length} siteResource(s) to networks` + ); + } + + // Re-insert targetHealthCheck rows with corrected IDs: + // targetHealthCheckId is set to the same integer as targetId (1:1 mapping), + // siteId and orgId are populated from the associated target and site. + // + // Because targetHealthCheckId is AUTOINCREMENT, inserting explicit values is + // allowed, but sqlite_sequence must be updated afterwards so future + // auto-increments don't reuse or collide with these IDs. + if (existingHealthChecks.length > 0) { + const insertHealthCheck = db.prepare( + `INSERT INTO 'targetHealthCheck' ( + "targetHealthCheckId", + "targetId", + "orgId", + "siteId", + "name", + "hcEnabled", + "hcPath", + "hcScheme", + "hcMode", + "hcHostname", + "hcPort", + "hcInterval", + "hcUnhealthyInterval", + "hcTimeout", + "hcHeaders", + "hcFollowRedirects", + "hcMethod", + "hcStatus", + "hcHealth", + "hcTlsServerName" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + + const insertAll = db.transaction(() => { + for (const hc of existingHealthChecks) { + insertHealthCheck.run( + hc.targetId, // targetHealthCheckId = targetId (explicit, non-sequential is fine) + hc.targetId, + hc.orgId, + hc.siteId, + `Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`, + hc.hcEnabled, + hc.hcPath, + hc.hcScheme, + hc.hcMode, + hc.hcHostname, + hc.hcPort, + hc.hcInterval, + hc.hcUnhealthyInterval, + hc.hcTimeout, + hc.hcHeaders, + hc.hcFollowRedirects, + hc.hcMethod, + hc.hcStatus, + hc.hcHealth, + hc.hcTlsServerName + ); + } + }); + + insertAll(); + + // Ensure sqlite_sequence reflects the true max so that future + // AUTOINCREMENT inserts never reuse one of the explicitly-set IDs. + // INSERT OR IGNORE handles the case where no auto-insert has happened + // yet and the row doesn't exist in sqlite_sequence. + db.prepare( + `INSERT OR IGNORE INTO sqlite_sequence (name, seq) VALUES ('targetHealthCheck', 0)` + ).run(); + db.prepare( + `UPDATE sqlite_sequence + SET seq = MAX(seq, (SELECT COALESCE(MAX("targetHealthCheckId"), 0) FROM 'targetHealthCheck')) + WHERE name = 'targetHealthCheck'` + ).run(); + + console.log( + `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` + ); + } + + console.log(`Migrated database`); + + // Seed statusHistory for all existing sites + const allSites = db + .prepare(`SELECT "siteId", "orgId", "online" FROM 'sites'`) + .all() as { siteId: number; orgId: string; online: number }[]; + + const insertSiteHistory = db.prepare( + `INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)` + ); + const now = Math.floor(Date.now() / 1000); + const seedSites = db.transaction(() => { + for (const site of allSites) { + insertSiteHistory.run( + "site", + site.siteId, + site.orgId, + site.online ? "online" : "offline", + now + ); + } + }); + seedSites(); + console.log(`Seeded statusHistory for ${allSites.length} site(s)`); + + // Seed statusHistory for all existing resources + const allResources = db + .prepare(`SELECT "resourceId", "orgId", "health" FROM 'resources'`) + .all() as { + resourceId: number; + orgId: string; + health: string | null; + }[]; + + const insertResourceHistory = db.prepare( + `INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)` + ); + const seedResources = db.transaction(() => { + for (const resource of allResources) { + insertResourceHistory.run( + "resource", + resource.resourceId, + resource.orgId, + resource.health ?? "unknown", + now + ); + } + }); + seedResources(); + console.log( + `Seeded statusHistory for ${allResources.length} resource(s)` + ); + + // Recompute resource health by aggregating across the resource's + // targets' target health checks, then update resources.health. + const resourceTargetHealthRows = db + .prepare( + `SELECT + r."resourceId" AS "resourceId", + thc."hcHealth" AS "hcHealth" + FROM 'resources' r + LEFT JOIN 'targets' t ON t."resourceId" = r."resourceId" + LEFT JOIN 'targetHealthCheck' thc ON thc."targetId" = t."targetId"` + ) + .all() as { + resourceId: number; + hcHealth: string | null; + }[]; + + const resourceHealthMap = new Map< + number, + { + hasHealthy: boolean; + hasUnhealthy: boolean; + hasUnknown: boolean; + } + >(); + for (const row of resourceTargetHealthRows) { + const entry = resourceHealthMap.get(row.resourceId) ?? { + hasHealthy: false, + hasUnhealthy: false, + hasUnknown: false + }; + const status = row.hcHealth ?? "unknown"; + if (status === "healthy") entry.hasHealthy = true; + else if (status === "unhealthy") entry.hasUnhealthy = true; + else entry.hasUnknown = true; + resourceHealthMap.set(row.resourceId, entry); + } + + const updateResourceHealth = db.prepare( + `UPDATE 'resources' SET "health" = ? WHERE "resourceId" = ?` + ); + const recomputeResourceHealth = db.transaction(() => { + for (const [resourceId, flags] of resourceHealthMap.entries()) { + let aggregated: + | "healthy" + | "unhealthy" + | "degraded" + | "unknown"; + if (flags.hasHealthy && flags.hasUnhealthy) { + aggregated = "degraded"; + } else if (flags.hasHealthy) { + aggregated = "healthy"; + } else if (flags.hasUnhealthy) { + aggregated = "unhealthy"; + } else { + aggregated = "unknown"; + } + updateResourceHealth.run(aggregated, resourceId); + } + }); + recomputeResourceHealth(); + console.log( + `Recomputed health for ${resourceHealthMap.size} resource(s) based on target health checks` + ); + + // Seed statusHistory for all existing health checks + const allHealthChecks = db + .prepare( + `SELECT "targetHealthCheckId", "orgId", "hcHealth" FROM 'targetHealthCheck'` + ) + .all() as { + targetHealthCheckId: number; + orgId: string; + hcHealth: string | null; + }[]; + + const insertHealthCheckHistory = db.prepare( + `INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)` + ); + const seedHealthChecks = db.transaction(() => { + for (const hc of allHealthChecks) { + insertHealthCheckHistory.run( + "health_check", + hc.targetHealthCheckId, + hc.orgId, + hc.hcHealth ?? "unknown", + now + ); + } + }); + seedHealthChecks(); + console.log( + `Seeded statusHistory for ${allHealthChecks.length} health check(s)` + ); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 8dc28001e..fe0077427 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -21,6 +21,7 @@ import { Layout } from "@app/components/Layout"; import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect"; import SubscriptionViolation from "@app/components/SubscriptionViolation"; + export default async function OrgLayout(props: { children: React.ReactNode; params: Promise<{ orgId: string }>; @@ -110,6 +111,7 @@ export default async function OrgLayout(props: { {props.children} {build === "saas" && } + ); diff --git a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx index de69de046..7f7060b05 100644 --- a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx +++ b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx @@ -12,6 +12,11 @@ import type { ListRolesResponse } from "@server/routers/role"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Approvals" +}; export interface ApprovalFeedPageProps { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/(private)/billing/layout.tsx b/src/app/[orgId]/settings/(private)/billing/layout.tsx index 69c3da485..2bb88963d 100644 --- a/src/app/[orgId]/settings/(private)/billing/layout.tsx +++ b/src/app/[orgId]/settings/(private)/billing/layout.tsx @@ -7,6 +7,11 @@ import { getTranslations } from "next-intl/server"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { build } from "@server/build"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Billing" +}; type BillingSettingsProps = { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index ba08f6022..778062e8e 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -219,6 +219,7 @@ export default function BillingPage() { ); const [hasSubscription, setHasSubscription] = useState(false); + const [isTrial, setIsTrial] = useState(false); const [isLoading, setIsLoading] = useState(false); const [currentTier, setCurrentTier] = useState(null); @@ -263,6 +264,7 @@ export default function BillingPage() { setHasSubscription( tierSub.subscription.status === "active" ); + setIsTrial(tierSub.subscription.expiresAt != null); } // Find license subscription @@ -477,7 +479,7 @@ export default function BillingPage() { }; const handleContactUs = () => { - window.open("https://pangolin.net/talk-to-us", "_blank"); + window.open("https://pangolin.net/contact", "_blank"); }; // Get current plan ID from tier @@ -491,6 +493,10 @@ export default function BillingPage() { const currentPlanId = getCurrentPlanId(); + const visiblePlanOptions = planOptions.filter( + (plan) => plan.id !== "home" || currentPlanId === "home" + ); + // Check if subscription is in a problematic state that requires attention const hasProblematicSubscription = (): boolean => { if (!tierSubscription?.subscription) return false; @@ -554,6 +560,14 @@ export default function BillingPage() { // Get button label and action for each plan const getPlanAction = (plan: PlanOption) => { if (plan.id === "enterprise") { + if (plan.id === currentPlanId && !isTrial) { + return { + label: "Manage Current Plan", + action: handleModifySubscription, + variant: "default" as const, + disabled: false + }; + } return { label: "Contact Us", action: handleContactUs, @@ -585,6 +599,19 @@ export default function BillingPage() { disabled: false }; } + // If this is a trial subscription, show an upgrade button that starts a real checkout + if (isTrial) { + return { + label: "Upgrade", + action: () => { + if (plan.tierType) { + handleStartSubscription(plan.tierType); + } + }, + variant: "default" as const, + disabled: isProblematicState + }; + } return { label: "Manage Current Plan", action: handleModifySubscription, @@ -598,7 +625,8 @@ export default function BillingPage() { ); const planIndex = planOptions.findIndex((p) => p.id === plan.id); - if (planIndex < currentIndex) { + // During a trial, never show a downgrade option — all non-current plans are upgrades + if (!isTrial && planIndex < currentIndex) { return { label: "Downgrade", action: () => { @@ -630,18 +658,23 @@ export default function BillingPage() { label: "Upgrade", action: () => { if (plan.tierType) { - showTierConfirmation( - plan.tierType, - "upgrade", - plan.name, - plan.price + (" " + plan.priceDetail || "") - ); + // During a trial, go straight to checkout instead of the tier-change flow + if (isTrial) { + handleStartSubscription(plan.tierType); + } else { + showTierConfirmation( + plan.tierType, + "upgrade", + plan.name, + plan.price + (" " + plan.priceDetail || "") + ); + } } else { handleModifySubscription(); } }, variant: "outline" as const, - disabled: isProblematicState + disabled: isProblematicState || (isTrial && plan.id == "basic") }; }; @@ -803,8 +836,15 @@ export default function BillingPage() { {/* Plan Cards Grid */} -
- {planOptions.map((plan) => { +
+ {visiblePlanOptions.map((plan) => { const isCurrentPlan = plan.id === currentPlanId; const planAction = getPlanAction(plan); @@ -934,7 +974,7 @@ export default function BillingPage() { {t("billingCurrentUsage") || "Current Usage"}
- + {getUserCount()} @@ -1265,7 +1305,7 @@ export default function BillingPage() { "Current Keys"}
- + {getLicenseKeyCount()} diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 37334e342..90b89f76f 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -97,7 +97,8 @@ export default function GeneralPage() { emailPath: z.string().nullable().optional(), namePath: z.string().nullable().optional(), scopes: z.string().min(1, { message: t("idpScopeRequired") }), - autoProvision: z.boolean().default(false) + autoProvision: z.boolean().default(false), + orgMapping: z.string().optional() }); // Google form schema (simplified) @@ -109,7 +110,8 @@ export default function GeneralPage() { .min(1, { message: t("idpClientSecretRequired") }), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional(), - autoProvision: z.boolean().default(false) + autoProvision: z.boolean().default(false), + orgMapping: z.string().optional() }); // Azure form schema (simplified with tenant ID) @@ -122,7 +124,8 @@ export default function GeneralPage() { tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional(), - autoProvision: z.boolean().default(false) + autoProvision: z.boolean().default(false), + orgMapping: z.string().optional() }); type OidcFormValues = z.infer; @@ -160,7 +163,8 @@ export default function GeneralPage() { autoProvision: true, roleMapping: null, roleId: null, - tenantId: "" + tenantId: "", + orgMapping: "" } }); @@ -227,7 +231,8 @@ export default function GeneralPage() { clientSecret: data.idpOidcConfig.clientSecret, autoProvision: data.idp.autoProvision, roleMapping: roleMapping || null, - roleId: null + roleId: null, + orgMapping: data.idpOrg?.orgMapping ?? "" }; // Add variant-specific fields @@ -344,12 +349,14 @@ export default function GeneralPage() { } // Build payload based on variant + const orgMappingTrimmed = data.orgMapping?.trim() ?? ""; let payload: any = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, autoProvision: data.autoProvision, - roleMapping: roleMappingExpression + roleMapping: roleMappingExpression, + orgMapping: orgMappingTrimmed === "" ? null : orgMappingTrimmed }; // Add variant-specific fields @@ -532,6 +539,10 @@ export default function GeneralPage() { } rawExpression={rawRoleExpression} onRawExpressionChange={setRawRoleExpression} + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} /> diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx index 6cdbf23c0..2d57d878b 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx @@ -6,6 +6,11 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Identity Provider" +}; interface SettingsLayoutProps { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx index ecc2aa835..a9c69d6bb 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Identity Provider" +}; + export default async function IdpPage(props: { params: Promise<{ orgId: string; idpId: string }>; }) { diff --git a/src/app/[orgId]/settings/(private)/idp/create/layout.tsx b/src/app/[orgId]/settings/(private)/idp/create/layout.tsx new file mode 100644 index 000000000..8f606fca1 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/idp/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create Identity Provider" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index 10d86b976..a7796e2a9 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -91,7 +91,8 @@ export default function Page() { tenantId: z.string().optional(), autoProvision: z.boolean().default(false), roleMapping: z.string().nullable().optional(), - roleId: z.number().nullable().optional() + roleId: z.number().nullable().optional(), + orgMapping: z.string().optional() }); type CreateIdpFormValues = z.infer; @@ -112,7 +113,8 @@ export default function Page() { tenantId: "", autoProvision: false, roleMapping: null, - roleId: null + roleId: null, + orgMapping: "" } }); @@ -177,7 +179,7 @@ export default function Page() { return; } - const payload = { + const payload: Record = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, @@ -191,6 +193,10 @@ export default function Page() { scopes: data.scopes, variant: data.type }; + const trimmedOrgMapping = data.orgMapping?.trim(); + if (trimmedOrgMapping) { + payload.orgMapping = trimmedOrgMapping; + } // Use the appropriate endpoint based on provider type const endpoint = "oidc"; @@ -336,6 +342,10 @@ export default function Page() { } rawExpression={rawRoleExpression} onRawExpressionChange={setRawRoleExpression} + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} /> diff --git a/src/app/[orgId]/settings/(private)/idp/page.tsx b/src/app/[orgId]/settings/(private)/idp/page.tsx index cd0bc5566..27d636fa5 100644 --- a/src/app/[orgId]/settings/(private)/idp/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/page.tsx @@ -7,6 +7,11 @@ import { getTranslations } from "next-intl/server"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { IdpGlobalModeBanner } from "@app/components/IdpGlobalModeBanner"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Identity Providers" +}; type OrgIdpPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/(private)/license/page.tsx b/src/app/[orgId]/settings/(private)/license/page.tsx index 1ecc94c19..6327689b3 100644 --- a/src/app/[orgId]/settings/(private)/license/page.tsx +++ b/src/app/[orgId]/settings/(private)/license/page.tsx @@ -3,6 +3,11 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; import { AxiosResponse } from "axios"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Enterprise Licenses" +}; type Props = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx index 5b9fd628d..a368ec687 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Remote Exit Node" +}; + export default async function RemoteExitNodePage(props: { params: Promise<{ orgId: string; remoteExitNodeId: string }>; }) { diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/layout.tsx new file mode 100644 index 000000000..e0c382654 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create Remote Exit Node" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx index 2da0e0da5..890a14564 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx @@ -7,6 +7,11 @@ import ExitNodesTable, { } from "@app/components/ExitNodesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Remote Exit Nodes" +}; type RemoteExitNodesPageProps = { params: Promise<{ orgId: string }>; @@ -40,6 +45,7 @@ export default async function RemoteExitNodesPage( type: node.type, dateCreated: node.dateCreated, version: node.version || undefined, + updateAvailable: node.updateAvailable, orgId: params.orgId }; } diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx index ae37c3752..84a864ba8 100644 --- a/src/app/[orgId]/settings/access/invitations/page.tsx +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -11,6 +11,11 @@ import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Invitations" +}; type InvitationsPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/access/page.tsx b/src/app/[orgId]/settings/access/page.tsx index 229ffffbc..f6df6ed3a 100644 --- a/src/app/[orgId]/settings/access/page.tsx +++ b/src/app/[orgId]/settings/access/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Access" +}; + type AccessPageProps = { params: Promise<{ orgId: string }>; }; diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 7165d9e6c..218b035d3 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -8,27 +8,40 @@ import RolesTable, { type RoleRow } from "@app/components/RolesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Roles" +}; type RolesPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function RolesPage(props: RolesPageProps) { const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); let roles: ListRolesResponse["roles"] = []; + let pagination: ListRolesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; let hasInvitations = false; const res = await internal .get< AxiosResponse - >(`/org/${params.orgId}/roles`, await authCookieHeader()) + >(`/org/${params.orgId}/roles?${searchParams.toString()}`, await authCookieHeader()) .catch((e) => {}); if (res && res.status === 200) { roles = res.data.data.roles; + pagination = res.data.data.pagination; } const invitationsRes = await internal @@ -63,7 +76,14 @@ export default async function RolesPage(props: RolesPageProps) { description={t("accessRolesDescription")} /> - + ); diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 7d527f84e..0a9815c36 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -8,6 +8,11 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { cache } from "react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "User" +}; interface UserLayoutProps { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/access/users/[userId]/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/page.tsx index 041537286..c56533dad 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "User" +}; + export default async function UserPage(props: { params: Promise<{ orgId: string; userId: string }>; }) { diff --git a/src/app/[orgId]/settings/access/users/create/layout.tsx b/src/app/[orgId]/settings/access/users/create/layout.tsx new file mode 100644 index 000000000..2796ddbc0 --- /dev/null +++ b/src/app/[orgId]/settings/access/users/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create User" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 0263d2b72..cec399a32 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -46,10 +46,11 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { ListIdpsResponse } from "@server/routers/idp"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import OrgRolesTagField from "@app/components/OrgRolesTagField"; +import CopyToClipboard from "@app/components/CopyToClipboard"; type UserType = "internal" | "oidc"; @@ -152,31 +153,8 @@ export default function Page() { const getIdpIcon = (variant: string | null) => { if (!variant) return null; - - switch (variant.toLowerCase()) { - case "google": - return ( - {t("idpGoogleAlt")} - ); - case "azure": - return ( - {t("idpAzureAlt")} - ); - default: - return null; - } + const type = variant.toLowerCase(); + return ; }; const validFor = [ @@ -340,15 +318,16 @@ export default function Page() { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); - const res = await api.post>( - `/org/${orgId}/create-invite`, - { - email: values.email, - roleIds, - validHours: parseInt(values.validForHours), - sendEmail - } - ) + const res = await api + .post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleIds, + validHours: parseInt(values.validForHours), + sendEmail + } + ) .catch((e) => { if (e.response?.status === 409) { toast({ @@ -489,7 +468,7 @@ export default function Page() {
- {!inviteLink ? ( + {!inviteLink && userOptions.length > 1 ? ( @@ -512,7 +491,7 @@ export default function Page() { genericOidcForm.reset(); } }} - cols={2} + cols={3} /> @@ -692,9 +671,8 @@ export default function Page() { days: expiresInDays })}

-
diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 84685cc04..462122a95 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -1,8 +1,9 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import type { ListOrgIdpsResponse } from "@server/routers/orgIdp/types"; +import type { ListRolesResponse } from "@server/routers/role/listRoles"; import { ListUsersResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; import UsersTable, { UserRow } from "@app/components/UsersTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; @@ -11,39 +12,81 @@ import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Users" +}; type UsersPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function UsersPage(props: UsersPageProps) { const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); - const getUser = cache(verifySession); - const user = await getUser(); - const t = await getTranslations(); + const user = await verifySession(); let users: ListUsersResponse["users"] = []; + let pagination: ListUsersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; let hasInvitations = false; - const res = await internal - .get< - AxiosResponse - >(`/org/${params.orgId}/users`, await authCookieHeader()) - .catch((e) => {}); + const cookieHeader = await authCookieHeader(); - if (res && res.status === 200) { - users = res.data.data.users; + const [usersRes, idpsRes, rolesRes] = await Promise.all([ + internal + .get( + `/org/${params.orgId}/users?${searchParams.toString()}`, + cookieHeader + ) + .catch(() => {}), + internal + .get(`/org/${params.orgId}/idp?limit=500&offset=0`, cookieHeader) + .catch(() => {}), + internal + .get(`/org/${params.orgId}/roles?pageSize=500&page=1`, cookieHeader) + .catch(() => {}) + ]); + + if (usersRes && usersRes.status === 200) { + const list = usersRes.data.data as ListUsersResponse; + users = list.users; + pagination = list.pagination; } + const t = await getTranslations(); + + const orgIdps = + idpsRes && idpsRes.status === 200 ? (idpsRes.data.data.idps ?? []) : []; + const idpFilterOptions = [ + { value: "internal", label: t("idpNameInternal") }, + ...orgIdps.map((i: ListOrgIdpsResponse["idps"][number]) => ({ + value: String(i.idpId), + label: i.name + })) + ]; + + const orgRoles = + rolesRes && rolesRes.status === 200 + ? (rolesRes.data.data.roles ?? []) + : []; + const roleFilterOptions = orgRoles.map( + (r: ListRolesResponse["roles"][number]) => ({ + value: String(r.roleId), + label: r.name + }) + ); + const invitationsRes = await internal - .get< - AxiosResponse<{ - pagination: { total: number }; - }> - >( + .get( `/org/${params.orgId}/invitations?limit=1&offset=0`, await authCookieHeader() ) @@ -56,9 +99,7 @@ export default async function UsersPage(props: UsersPageProps) { let org: GetOrgResponse | null = null; const getOrg = cache(async () => internal - .get< - AxiosResponse - >(`/org/${params.orgId}`, await authCookieHeader()) + .get(`/org/${params.orgId}`, await authCookieHeader()) .catch((e) => { console.error(e); }) @@ -105,7 +146,16 @@ export default async function UsersPage(props: UsersPageProps) { /> - + diff --git a/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx new file mode 100644 index 000000000..f3cf0160f --- /dev/null +++ b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx @@ -0,0 +1,204 @@ +import HealthChecksTable from "@app/components/HealthChecksTable"; +import DismissableBanner from "@app/components/DismissableBanner"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; +import { GetResourceResponse } from "@server/routers/resource/getResource"; +import { GetSiteResponse } from "@server/routers/site/getSite"; +import type ResponseT from "@server/types/Response"; +import { HeartPulse } from "lucide-react"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export const metadata: Metadata = { + title: "Health Checks" +}; + +type AlertingHealthChecksPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +function parsePositiveInt(s: string | undefined): number | undefined { + if (!s) return undefined; + const n = Number(s); + if (!Number.isInteger(n) || n <= 0) return undefined; + return n; +} + +function appendListFilters( + apiSp: URLSearchParams, + searchParams: URLSearchParams +) { + const query = searchParams.get("query"); + if (query) apiSp.set("query", query); + + const hcMode = searchParams.get("hcMode"); + if ( + hcMode === "http" || + hcMode === "tcp" || + hcMode === "snmp" || + hcMode === "ping" + ) { + apiSp.set("hcMode", hcMode); + } + + const hcHealth = searchParams.get("hcHealth"); + if ( + hcHealth === "healthy" || + hcHealth === "unhealthy" || + hcHealth === "unknown" + ) { + apiSp.set("hcHealth", hcHealth); + } + + const hcEnabled = searchParams.get("hcEnabled"); + if (hcEnabled === "true" || hcEnabled === "false") { + apiSp.set("hcEnabled", hcEnabled); + } + + const siteId = parsePositiveInt(searchParams.get("siteId") ?? undefined); + if (siteId) { + apiSp.set("siteId", String(siteId)); + } + + const resourceId = parsePositiveInt( + searchParams.get("resourceId") ?? undefined + ); + if (resourceId) { + apiSp.set("resourceId", String(resourceId)); + } +} + +export default async function AlertingHealthChecksPage( + props: AlertingHealthChecksPageProps +) { + const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); + + const page = Math.max( + 1, + parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1 + ); + const pageSize = Math.max( + 1, + parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20 + ); + const pageIndex = page - 1; + + const apiSp = new URLSearchParams(); + apiSp.set("limit", String(pageSize)); + apiSp.set("offset", String(pageIndex * pageSize)); + appendListFilters(apiSp, searchParams); + + let healthChecks: ListHealthChecksResponse["healthChecks"] = []; + let pagination: ListHealthChecksResponse["pagination"] = { + total: 0, + limit: pageSize, + offset: pageIndex * pageSize + }; + + const siteIdParam = parsePositiveInt( + searchParams.get("siteId") ?? undefined + ); + const resourceIdParam = parsePositiveInt( + searchParams.get("resourceId") ?? undefined + ); + + const header = await authCookieHeader(); + + try { + const res = await internal.get( + `/org/${params.orgId}/health-checks?${apiSp.toString()}`, + header + ); + const responseData = (res.data as ResponseT) + .data; + if (responseData) { + healthChecks = responseData.healthChecks; + pagination = responseData.pagination; + } + } catch { + // leave defaults + } + + let initialFilterSite: { + siteId: number; + name: string; + type: string; + } | null = null; + if (siteIdParam) { + try { + const siteRes = await internal.get(`/site/${siteIdParam}`, header); + const s = (siteRes.data as ResponseT).data; + if (s && s.orgId === params.orgId) { + initialFilterSite = { + siteId: s.siteId, + name: s.name, + type: s.type + }; + } + } catch { + // leave null + } + } + + let initialFilterResource: { + name: string; + resourceId: number; + fullDomain: string | null; + niceId: string; + ssl: boolean; + wildcard: boolean; + } | null = null; + if (resourceIdParam) { + try { + const resourceRes = await internal.get( + `/resource/${resourceIdParam}`, + header + ); + const r = (resourceRes.data as ResponseT).data; + if (r && r.orgId === params.orgId) { + initialFilterResource = { + name: r.name, + resourceId: r.resourceId, + fullDomain: r.fullDomain, + niceId: r.niceId, + ssl: r.ssl, + wildcard: r.wildcard + }; + } + } catch { + // leave null + } + } + + const t = await getTranslations(); + + return ( +
+ + } + description={t("alertingHealthChecksBannerDescription")} + /> + +
+ ); +} diff --git a/src/app/[orgId]/settings/alerting/(list)/layout.tsx b/src/app/[orgId]/settings/alerting/(list)/layout.tsx new file mode 100644 index 000000000..7393e7044 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/(list)/layout.tsx @@ -0,0 +1,38 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; + +type AlertingListLayoutProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function AlertingListLayout({ + children, + params +}: AlertingListLayoutProps) { + const { orgId } = await params; + const t = await getTranslations(); + + const navItems = [ + { + title: t("alertingTabRules"), + href: `/${orgId}/settings/alerting/rules`, + activePrefix: `/${orgId}/settings/alerting` + }, + { + title: t("alertingTabHealthChecks"), + href: `/${orgId}/settings/alerting/health-checks` + } + ]; + + return ( + <> + + {children} + + ); +} diff --git a/src/app/[orgId]/settings/alerting/(list)/rules/page.tsx b/src/app/[orgId]/settings/alerting/(list)/rules/page.tsx new file mode 100644 index 000000000..159d5d9d2 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/(list)/rules/page.tsx @@ -0,0 +1,105 @@ +import AlertingRulesTable from "@app/components/AlertingRulesTable"; +import DismissableBanner from "@app/components/DismissableBanner"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; +import { AxiosResponse } from "axios"; +import { BellRing } from "lucide-react"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export const metadata: Metadata = { + title: "Alerting" +}; + +type AlertingRulesPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +function parsePositiveInt(s: string | undefined): number | undefined { + if (!s) return undefined; + const n = Number(s); + if (!Number.isInteger(n) || n <= 0) return undefined; + return n; +} + +export default async function AlertingRulesPage(props: AlertingRulesPageProps) { + const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); + + const page = Math.max(1, parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1); + const pageSize = Math.max( + 1, + parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20 + ); + const pageIndex = page - 1; + const query = searchParams.get("query") ?? undefined; + const sortBy = searchParams.get("sort_by") ?? undefined; + const order = searchParams.get("order") ?? undefined; + const enabled = searchParams.get("enabled"); + const enabledParam = + enabled === "true" || enabled === "false" ? enabled : undefined; + const siteId = parsePositiveInt(searchParams.get("siteId") ?? undefined); + const resourceId = parsePositiveInt( + searchParams.get("resourceId") ?? undefined + ); + const healthCheckId = parsePositiveInt( + searchParams.get("healthCheckId") ?? undefined + ); + + const apiSp = new URLSearchParams(); + apiSp.set("limit", String(pageSize)); + apiSp.set("offset", String(pageIndex * pageSize)); + if (query) apiSp.set("query", query); + if (siteId != null) apiSp.set("siteId", String(siteId)); + if (resourceId != null) apiSp.set("resourceId", String(resourceId)); + if (healthCheckId != null) + apiSp.set("healthCheckId", String(healthCheckId)); + if (sortBy) { + apiSp.set("sort_by", sortBy); + if (order) apiSp.set("order", order); + } + if (enabledParam) apiSp.set("enabled", enabledParam); + + let alertRules: ListAlertRulesResponse["alertRules"] = []; + let pagination: ListAlertRulesResponse["pagination"] = { + total: 0, + limit: pageSize, + offset: pageIndex * pageSize + }; + try { + const res = await internal.get>( + `/org/${params.orgId}/alert-rules?${apiSp.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + alertRules = responseData.alertRules; + pagination = responseData.pagination; + } catch { + // leave defaults + } + + const t = await getTranslations(); + + return ( +
+ + } + description={t("alertingRulesBannerDescription")} + /> + +
+ ); +} diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/layout.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/layout.tsx new file mode 100644 index 000000000..be2188758 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/[ruleId]/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Edit Alert" +}; + +export default function EditAlertRuleLayout({ + children +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx new file mode 100644 index 000000000..b57f5dea4 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { apiResponseToFormValues } from "@app/lib/alertRuleForm"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import type { AxiosResponse } from "axios"; +import type { GetAlertRuleResponse } from "@server/routers/alertRule/types"; +import type { AlertRuleFormValues } from "@app/lib/alertRuleForm"; + +export default function EditAlertRulePage() { + const t = useTranslations(); + const params = useParams(); + const router = useRouter(); + const orgId = params.orgId as string; + const ruleIdParam = params.ruleId as string; + const alertRuleId = parseInt(ruleIdParam, 10); + + const api = createApiClient(useEnvContext()); + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.alertingRules); + + const [formValues, setFormValues] = useState< + AlertRuleFormValues | null | undefined + >(undefined); + + useEffect(() => { + if (isNaN(alertRuleId)) { + router.replace(`/${orgId}/settings/alerting/rules`); + return; + } + + api.get>( + `/org/${orgId}/alert-rule/${alertRuleId}` + ) + .then((res) => { + const rule = res.data.data; + setFormValues(apiResponseToFormValues(rule)); + }) + .catch((e) => { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + setFormValues(null); + }); + }, [orgId, alertRuleId]); + + useEffect(() => { + if (formValues === null) { + router.replace(`/${orgId}/settings/alerting/rules`); + } + }, [formValues, orgId, router]); + + if (formValues === undefined) { + return ( + <> + + + ); + } + + if (formValues === null) { + return null; + } + + return ( + <> + + + + ); +} diff --git a/src/app/[orgId]/settings/alerting/create/layout.tsx b/src/app/[orgId]/settings/alerting/create/layout.tsx new file mode 100644 index 000000000..9cb33ac65 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/create/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create Alert" +}; + +export default function CreateAlertRuleLayout({ + children +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/[orgId]/settings/alerting/create/page.tsx b/src/app/[orgId]/settings/alerting/create/page.tsx new file mode 100644 index 000000000..9f3f20611 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/create/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { defaultFormValues } from "@app/lib/alertRuleForm"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +export default function NewAlertRulePage() { + const params = useParams(); + const orgId = params.orgId as string; + const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.alertingRules); + + return ( + <> + + + + ); +} diff --git a/src/app/[orgId]/settings/alerting/layout.tsx b/src/app/[orgId]/settings/alerting/layout.tsx new file mode 100644 index 000000000..a541860eb --- /dev/null +++ b/src/app/[orgId]/settings/alerting/layout.tsx @@ -0,0 +1,7 @@ +type AlertingLayoutProps = { + children: React.ReactNode; +}; + +export default function AlertingLayout({ children }: AlertingLayoutProps) { + return <>{children}; +} diff --git a/src/app/[orgId]/settings/alerting/page.tsx b/src/app/[orgId]/settings/alerting/page.tsx new file mode 100644 index 000000000..1768fbced --- /dev/null +++ b/src/app/[orgId]/settings/alerting/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Alerting" +}; + +type AlertingIndexPageProps = { + params: Promise<{ orgId: string }>; +}; + +export default async function AlertingIndexPage(props: AlertingIndexPageProps) { + const params = await props.params; + redirect(`/${params.orgId}/settings/alerting/rules`); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx index 19b695ca2..300058432 100644 --- a/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx @@ -7,6 +7,11 @@ import { GetApiKeyResponse } from "@server/routers/apiKeys"; import ApiKeyProvider from "@app/providers/ApiKeyProvider"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "API Key" +}; interface SettingsLayoutProps { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx index 518db250b..63516208d 100644 --- a/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "API Key" +}; + export default async function ApiKeysPage(props: { params: Promise<{ orgId: string; apiKeyId: string }>; }) { diff --git a/src/app/[orgId]/settings/api-keys/create/layout.tsx b/src/app/[orgId]/settings/api-keys/create/layout.tsx new file mode 100644 index 000000000..22e868c85 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create API Key" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/api-keys/page.tsx b/src/app/[orgId]/settings/api-keys/page.tsx index 0ed9553af..d06e0983b 100644 --- a/src/app/[orgId]/settings/api-keys/page.tsx +++ b/src/app/[orgId]/settings/api-keys/page.tsx @@ -2,11 +2,14 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import OrgApiKeysTable, { - OrgApiKeyRow -} from "@app/components/OrgApiKeysTable"; +import OrgApiKeysTable, { OrgApiKeyRow } from "@app/components/OrgApiKeysTable"; import { ListOrgApiKeysResponse } from "@server/routers/apiKeys"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "API Keys" +}; type ApiKeyPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx b/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx index fe3ae4b9f..102b7b781 100644 --- a/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx +++ b/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx @@ -17,7 +17,7 @@ type BluePrintsPageProps = { }; export const metadata: Metadata = { - title: "Blueprint Detail" + title: "Edit Blueprint" }; export default async function BluePrintDetailPage(props: BluePrintsPageProps) { diff --git a/src/app/[orgId]/settings/blueprints/create/page.tsx b/src/app/[orgId]/settings/blueprints/create/page.tsx index e7a0490e2..17fe60bf2 100644 --- a/src/app/[orgId]/settings/blueprints/create/page.tsx +++ b/src/app/[orgId]/settings/blueprints/create/page.tsx @@ -12,7 +12,7 @@ export interface CreateBlueprintPageProps { } export const metadata: Metadata = { - title: "Create blueprint" + title: "Create Blueprint" }; export default async function CreateBlueprintPage( diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/layout.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/layout.tsx index 145fb1728..9d13e6ba4 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/layout.tsx @@ -8,6 +8,11 @@ import { GetClientResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Machine Client" +}; type SettingsLayoutProps = { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/page.tsx index 3aa4a2c4a..50594c62e 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Machine Client" +}; + export default async function ClientPage(props: { params: Promise<{ orgId: string; niceId: number | string }>; }) { diff --git a/src/app/[orgId]/settings/clients/machine/create/layout.tsx b/src/app/[orgId]/settings/clients/machine/create/layout.tsx new file mode 100644 index 000000000..945b20a99 --- /dev/null +++ b/src/app/[orgId]/settings/clients/machine/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create Machine Client" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index 4b40c906c..fe9281ac7 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -8,6 +8,11 @@ import { ListClientsResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import type { Pagination } from "@server/types/Pagination"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Machine Clients" +}; type ClientsPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index aeea1c83f..dcb8a2b84 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Clients" +}; + type ClientsPageProps = { params: Promise<{ orgId: string }>; searchParams: Promise<{ view?: string }>; diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx index 2d9934cbe..9d3b169d8 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx @@ -8,6 +8,11 @@ import { GetClientResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "User Device" +}; type SettingsLayoutProps = { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx index 9ad97186d..a2c798c1c 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx @@ -1,10 +1,13 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "User Device" +}; + export default async function ClientPage(props: { params: Promise<{ orgId: string; niceId: number | string }>; }) { const params = await props.params; - redirect( - `/${params.orgId}/settings/clients/user/${params.niceId}/general` - ); + redirect(`/${params.orgId}/settings/clients/user/${params.niceId}/general`); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index fcb24e4e3..880019177 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -7,6 +7,11 @@ import { type ListUserDevicesResponse } from "@server/routers/client"; import type { Pagination } from "@server/types/Pagination"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "User Devices" +}; type ClientsPageProps = { params: Promise<{ orgId: string }>; @@ -91,6 +96,9 @@ export default async function ClientsPage(props: ClientsPageProps) { userId: client.userId, username: client.username, userEmail: client.userEmail, + userType: client.userType ?? null, + idpName: client.idpName ?? null, + idpVariant: client.idpVariant ?? null, niceId: client.niceId, agent: client.agent, archived: Boolean(client.archived), diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index 23a79737d..9f9878967 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -1,16 +1,13 @@ -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import DomainInfoCard from "@app/components/DomainInfoCard"; -import RestartDomainButton from "@app/components/RestartDomainButton"; +import DomainPageClient from "@app/components/DomainPageClient"; import { GetDomainResponse } from "@server/routers/domain/getDomain"; -import { pullEnv } from "@app/lib/pullEnv"; -import { getTranslations } from "next-intl/server"; -import RefreshButton from "@app/components/RefreshButton"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { GetDNSRecordsResponse } from "@server/routers/domain"; -import DNSRecordsTable from "@app/components/DNSRecordTable"; -import DomainCertForm from "@app/components/DomainCertForm"; -import { build } from "@server/build"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Domain" +}; interface DomainSettingsPageProps { params: Promise<{ domainId: string; orgId: string }>; @@ -20,8 +17,6 @@ export default async function DomainSettingsPage({ params }: DomainSettingsPageProps) { const { domainId, orgId } = await params; - const t = await getTranslations(); - const env = pullEnv(); let domain: GetDomainResponse | null = null; try { @@ -34,57 +29,27 @@ export default async function DomainSettingsPage({ return null; } - let dnsRecords; + let dnsRecords: GetDNSRecordsResponse | null = null; try { const response = await internal.get( `/org/${orgId}/domain/${domainId}/dns-records`, await authCookieHeader() ); dnsRecords = response.data.data; - } catch (error) { + } catch { return null; } - if (!domain) { + if (!domain || !dnsRecords) { return null; } return ( - <> -
- - {env.flags.usePangolinDns && domain.failed ? ( - - ) : ( - - )} -
-
- {build != "oss" && env.flags.usePangolinDns ? ( - - ) : null} - - - - {domain.type == "wildcard" && !domain.configManaged && ( - - )} -
- + ); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index d1325d32b..affad2551 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -11,6 +11,11 @@ import OrgProvider from "@app/providers/OrgProvider"; import { ListDomainsResponse } from "@server/routers/domain"; import { toUnicode } from "punycode"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Domains" +}; type Props = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/general/auth-page/page.tsx b/src/app/[orgId]/settings/general/auth-page/page.tsx index 0bd482864..7712334f1 100644 --- a/src/app/[orgId]/settings/general/auth-page/page.tsx +++ b/src/app/[orgId]/settings/general/auth-page/page.tsx @@ -11,6 +11,7 @@ import { GetLoginPageResponse } from "@server/routers/loginPage/types"; import { AxiosResponse } from "axios"; +import type { Metadata } from "next"; import { redirect } from "next/navigation"; export interface AuthPageProps { diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 736e2037e..8620cd529 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -11,6 +11,11 @@ import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; import { build } from "@server/build"; import { pullEnv } from "@app/lib/pullEnv"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Organization" +}; type GeneralSettingsProps = { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/health-checks/page.tsx b/src/app/[orgId]/settings/health-checks/page.tsx new file mode 100644 index 000000000..2ee133d91 --- /dev/null +++ b/src/app/[orgId]/settings/health-checks/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Health Checks" +}; + +type LegacyHealthChecksPageProps = { + params: Promise<{ orgId: string }>; +}; + +/** @deprecated Use `/settings/alerting/health-checks` */ +export default async function LegacyHealthChecksRedirect( + props: LegacyHealthChecksPageProps +) { + const params = await props.params; + redirect(`/${params.orgId}/settings/alerting/health-checks`); +} diff --git a/src/app/[orgId]/settings/logs/access/layout.tsx b/src/app/[orgId]/settings/logs/access/layout.tsx new file mode 100644 index 000000000..07d7f6f28 --- /dev/null +++ b/src/app/[orgId]/settings/logs/access/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Access Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index a0f1b5386..826e11c17 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -471,11 +471,7 @@ export default function GeneralPage() { : `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}` } > - diff --git a/src/app/[orgId]/settings/logs/action/layout.tsx b/src/app/[orgId]/settings/logs/action/layout.tsx new file mode 100644 index 000000000..889617712 --- /dev/null +++ b/src/app/[orgId]/settings/logs/action/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Action Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/logs/analytics/page.tsx b/src/app/[orgId]/settings/logs/analytics/page.tsx index f5bd4e7aa..9246c3cbb 100644 --- a/src/app/[orgId]/settings/logs/analytics/page.tsx +++ b/src/app/[orgId]/settings/logs/analytics/page.tsx @@ -2,6 +2,11 @@ import { LogAnalyticsData } from "@app/components/LogAnalyticsData"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Log Analytics" +}; export interface AnalyticsPageProps { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/logs/connection/layout.tsx b/src/app/[orgId]/settings/logs/connection/layout.tsx new file mode 100644 index 000000000..20d93f802 --- /dev/null +++ b/src/app/[orgId]/settings/logs/connection/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Connection Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/logs/connection/page.tsx b/src/app/[orgId]/settings/logs/connection/page.tsx index e15708f8e..0fc8f95b7 100644 --- a/src/app/[orgId]/settings/logs/connection/page.tsx +++ b/src/app/[orgId]/settings/logs/connection/page.tsx @@ -22,7 +22,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; function formatBytes(bytes: number | null): string { - if (bytes === null || bytes === undefined) return "—"; + 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)); @@ -33,7 +33,7 @@ function formatBytes(bytes: number | null): string { function formatDuration(startedAt: number, endedAt: number | null): string { if (endedAt === null || endedAt === undefined) return "Active"; const durationSec = endedAt - startedAt; - if (durationSec < 0) return "—"; + if (durationSec < 0) return "-"; if (durationSec < 60) return `${durationSec}s`; if (durationSec < 3600) { const m = Math.floor(durationSec / 60); @@ -451,11 +451,7 @@ export default function ConnectionLogsPage() { - @@ -464,7 +460,7 @@ export default function ConnectionLogsPage() { } return ( - {row.original.resourceName ?? "—"} + {row.original.resourceName ?? "-"} ); } @@ -497,11 +493,7 @@ export default function ConnectionLogsPage() { -
@@ -646,7 +638,7 @@ export default function ConnectionLogsPage() {
*/} {/*
Resource:{" "} - {row.resourceName ?? "—"} + {row.resourceName ?? "-"} {row.resourceNiceId && ( ({row.resourceNiceId}) @@ -654,7 +646,7 @@ export default function ConnectionLogsPage() { )}
*/}
- Site: {row.siteName ?? "—"} + Site: {row.siteName ?? "-"} {row.siteNiceId && ( ({row.siteNiceId}) @@ -662,7 +654,7 @@ export default function ConnectionLogsPage() { )}
- Site ID: {row.siteId ?? "—"} + Site ID: {row.siteId ?? "-"}
Started At:{" "} @@ -670,14 +662,12 @@ export default function ConnectionLogsPage() { ? new Date( row.startedAt * 1000 ).toLocaleString() - : "—"} + : "-"}
Ended At:{" "} {row.endedAt - ? new Date( - row.endedAt * 1000 - ).toLocaleString() + ? new Date(row.endedAt * 1000).toLocaleString() : "Active"}
@@ -686,7 +676,7 @@ export default function ConnectionLogsPage() {
{/*
Resource ID:{" "} - {row.siteResourceId ?? "—"} + {row.siteResourceId ?? "-"}
*/}
diff --git a/src/app/[orgId]/settings/logs/page.tsx b/src/app/[orgId]/settings/logs/page.tsx index d9663e721..7c2a6532b 100644 --- a/src/app/[orgId]/settings/logs/page.tsx +++ b/src/app/[orgId]/settings/logs/page.tsx @@ -1,3 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Logs" +}; + export default function GeneralPage() { return null; } diff --git a/src/app/[orgId]/settings/logs/request/layout.tsx b/src/app/[orgId]/settings/logs/request/layout.tsx new file mode 100644 index 000000000..61c3a3a7d --- /dev/null +++ b/src/app/[orgId]/settings/logs/request/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Request Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 4a1fe3cd9..6380d0473 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -360,6 +360,7 @@ export default function GeneralPage() { // 105 - Valid Password // 106 - Valid email // 107 - Valid SSO + // 108 - Connected Client // 201 - Resource Not Found // 202 - Resource Blocked @@ -377,6 +378,7 @@ export default function GeneralPage() { 105: t("validPassword"), 106: t("validEmail"), 107: t("validSSO"), + 108: t("connectedClient"), 201: t("resourceNotFound"), 202: t("resourceBlocked"), 203: t("droppedByRule"), @@ -510,14 +512,14 @@ export default function GeneralPage() { cell: ({ row }) => { return ( e.stopPropagation()} > - @@ -634,6 +636,7 @@ export default function GeneralPage() { { value: "105", label: t("validPassword") }, { value: "106", label: t("validEmail") }, { value: "107", label: t("validSSO") }, + { value: "108", label: t("connectedClient") }, { value: "201", label: t("resourceNotFound") }, { value: "202", label: t("resourceBlocked") }, { value: "203", label: t("droppedByRule") }, diff --git a/src/app/[orgId]/settings/logs/streaming/layout.tsx b/src/app/[orgId]/settings/logs/streaming/layout.tsx new file mode 100644 index 000000000..a5baea411 --- /dev/null +++ b/src/app/[orgId]/settings/logs/streaming/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Streaming Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/logs/streaming/page.tsx b/src/app/[orgId]/settings/logs/streaming/page.tsx index 7e48d7566..022a8eb2e 100644 --- a/src/app/[orgId]/settings/logs/streaming/page.tsx +++ b/src/app/[orgId]/settings/logs/streaming/page.tsx @@ -38,6 +38,8 @@ import { HttpDestinationCredenza, parseHttpConfig } from "@app/components/HttpDestinationCredenza"; +import { S3DestinationCredenza } from "@app/components/S3DestinationCredenza"; +import { DatadogDestinationCredenza } from "@app/components/DatadogDestinationCredenza"; import { useTranslations } from "next-intl"; // ── Re-export Destination so the rest of the file can use it ────────────────── @@ -203,7 +205,6 @@ function DestinationTypePicker({ id: "s3", title: t("streamingS3Title"), description: t("streamingS3Description"), - disabled: true, icon: ( setSelected(type)} cols={1} />
@@ -291,6 +291,7 @@ export default function StreamingDestinationsPage() { const [typePickerOpen, setTypePickerOpen] = useState(false); const [editingDestination, setEditingDestination] = useState(null); + const [pickedType, setPickedType] = useState("http"); const [togglingIds, setTogglingIds] = useState>(new Set()); // Delete state @@ -392,7 +393,8 @@ export default function StreamingDestinationsPage() { setTypePickerOpen(true); }; - const handleTypePicked = (_type: DestinationType) => { + const handleTypePicked = (type: DestinationType) => { + setPickedType(type); setTypePickerOpen(false); setEditingDestination(null); setModalOpen(true); @@ -400,6 +402,7 @@ export default function StreamingDestinationsPage() { const openEdit = (destination: Destination) => { setEditingDestination(destination); + setPickedType((destination.type as DestinationType) ?? "http"); setModalOpen(true); }; @@ -434,7 +437,7 @@ export default function StreamingDestinationsPage() { disabled={!isEnterprise} /> ))} - {/* Add card is always clickable — paywall is enforced inside the picker */} + {/* Add card is always clickable - paywall is enforced inside the picker */} )} @@ -446,13 +449,33 @@ export default function StreamingDestinationsPage() { isPaywalled={!isEnterprise} /> - + {pickedType === "http" && ( + + )} + {pickedType === "s3" && ( + + )} + {pickedType === "datadog" && ( + + )} {deleteTarget && ( -

404

+

404

{t("pageNotFound")}

diff --git a/src/app/[orgId]/settings/page.tsx b/src/app/[orgId]/settings/page.tsx index 9956bc859..bf8beab72 100644 --- a/src/app/[orgId]/settings/page.tsx +++ b/src/app/[orgId]/settings/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Settings" +}; + type OrgPageProps = { params: Promise<{ orgId: string }>; }; diff --git a/src/app/[orgId]/settings/provisioning/keys/page.tsx b/src/app/[orgId]/settings/provisioning/keys/page.tsx index 32a06706d..fc95a655d 100644 --- a/src/app/[orgId]/settings/provisioning/keys/page.tsx +++ b/src/app/[orgId]/settings/provisioning/keys/page.tsx @@ -12,6 +12,11 @@ import DismissableBanner from "@app/components/DismissableBanner"; import Link from "next/link"; import { Button } from "@app/components/ui/button"; import { ArrowRight, Plug } from "lucide-react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Provisioning Keys" +}; type ProvisioningKeysPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx index 51db66c2d..1e0377590 100644 --- a/src/app/[orgId]/settings/provisioning/page.tsx +++ b/src/app/[orgId]/settings/provisioning/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Provisioning" +}; + type ProvisioningPageProps = { params: Promise<{ orgId: string }>; }; @@ -7,4 +12,4 @@ type ProvisioningPageProps = { export default async function ProvisioningPage(props: ProvisioningPageProps) { const params = await props.params; redirect(`/${params.orgId}/settings/provisioning/keys`); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx index 4669f9160..a85b0d7d9 100644 --- a/src/app/[orgId]/settings/provisioning/pending/page.tsx +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -11,6 +11,11 @@ import { Button } from "@app/components/ui/button"; import { ArrowRight, Plug } from "lucide-react"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Pending Sites" +}; type PendingSitesPageProps = { params: Promise<{ orgId: string }>; @@ -64,6 +69,7 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) { address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type), + resourceCount: Number(site.resourceCount ?? 0), orgId: params.orgId, type: site.type as any, online: site.online, diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index f0f582f0f..42d4e69eb 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -7,16 +7,30 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; import type { ListResourcesResponse } from "@server/routers/resource"; +import { GetSiteResponse } from "@server/routers/site/getSite"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; +import type ResponseT from "@server/types/Response"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Private Resources" +}; + export interface ClientResourcesPageProps { params: Promise<{ orgId: string }>; searchParams: Promise>; } +function parsePositiveInt(s: string | undefined): number | undefined { + if (!s) return undefined; + const n = Number(s); + if (!Number.isInteger(n) || n <= 0) return undefined; + return n; +} + export default async function ClientResourcesPage( props: ClientResourcesPageProps ) { @@ -42,6 +56,32 @@ export default async function ClientResourcesPage( pagination = responseData.pagination; } catch (e) {} + const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined); + + let initialFilterSite: { + siteId: number; + name: string; + type: string; + } | null = null; + if (siteIdParam) { + try { + const siteRes = await internal.get( + `/site/${siteIdParam}`, + await authCookieHeader() + ); + const s = (siteRes.data as ResponseT).data; + if (s && s.orgId === params.orgId) { + initialFilterSite = { + siteId: s.siteId, + name: s.name, + type: s.type + }; + } + } catch { + // leave null + } + } + let org = null; try { const res = await getCachedOrg(params.orgId); @@ -60,23 +100,34 @@ export default async function ClientResourcesPage( id: siteResource.siteResourceId, name: siteResource.name, orgId: params.orgId, - siteName: siteResource.siteName, - siteAddress: siteResource.siteAddress || null, - mode: siteResource.mode || ("port" as any), + sites: siteResource.siteIds.map((siteId, idx) => ({ + siteId, + siteName: siteResource.siteNames[idx], + siteNiceId: siteResource.siteNiceIds[idx], + online: siteResource.siteOnlines[idx] + })), + mode: siteResource.mode, + scheme: siteResource.scheme, + ssl: siteResource.ssl, + siteNames: siteResource.siteNames, + siteAddresses: siteResource.siteAddresses || null, // protocol: siteResource.protocol, // proxyPort: siteResource.proxyPort, - siteId: siteResource.siteId, + siteIds: siteResource.siteIds, destination: siteResource.destination, - // destinationPort: siteResource.destinationPort, + httpHttpsPort: siteResource.destinationPort ?? null, alias: siteResource.alias || null, aliasAddress: siteResource.aliasAddress || null, - siteNiceId: siteResource.siteNiceId, + siteNiceIds: siteResource.siteNiceIds, niceId: siteResource.niceId, tcpPortRangeString: siteResource.tcpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null, disableIcmp: siteResource.disableIcmp || false, authDaemonMode: siteResource.authDaemonMode ?? null, - authDaemonPort: siteResource.authDaemonPort ?? null + authDaemonPort: siteResource.authDaemonPort ?? null, + subdomain: siteResource.subdomain ?? null, + domainId: siteResource.domainId ?? null, + fullDomain: siteResource.fullDomain ?? null }; } ); @@ -98,6 +149,7 @@ export default async function ClientResourcesPage( pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + initialFilterSite={initialFilterSite} /> diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index 954b966ac..55ebe6554 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Public Resources" +}; + export interface ResourcesPageProps { params: Promise<{ orgId: string }>; } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 414a9b652..12f511078 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -133,8 +133,7 @@ export default function ResourceAuthenticationPage() { ...orgQueries.identityProviders({ orgId: org.org.orgId, useOrgOnlyIdp: env.app.identityProviderMode === "org" - }), - enabled: isPaidUser(tierMatrix.orgOidc) + }) }); const pageLoading = diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 9589f6a2e..62a6b9fed 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -13,16 +13,6 @@ import { import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { useResourceContext } from "@app/hooks/useResourceContext"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; import DomainPicker from "@app/components/DomainPicker"; import { SettingsContainer, @@ -42,7 +32,7 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { UpdateResourceResponse } from "@server/routers/resource"; import { AxiosResponse } from "axios"; -import { AlertCircle, Globe } from "lucide-react"; +import { AlertCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; import { toASCII, toUnicode } from "punycode"; @@ -62,6 +52,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import UptimeAlertSection from "@app/components/UptimeAlertSection"; type MaintenanceSectionFormProps = { resource: GetResourceResponse; @@ -171,6 +162,7 @@ function MaintenanceSectionForm({ +
- { - if (!resource.fullDomain) { - return ""; - } try { const url = new URL(resourceFullDomain); return url.hostname; } catch { return ""; } - }, [resourceFullDomain, resource.fullDomain]); - - const [selectedDomain, setSelectedDomain] = useState<{ - domainId: string; - domainNamespaceId?: string; - subdomain?: string; - fullDomain: string; - baseDomain: string; - } | null>(null); + }, [resourceFullDomain]); const GeneralFormSchema = z .object({ @@ -530,7 +507,7 @@ export default function GeneralForm() { name: data.name, niceId: data.niceId, subdomain: data.subdomain - ? toASCII(data.subdomain) + ? toASCII(finalizeSubdomainSanitize(data.subdomain, true)) : undefined, domainId: data.domainId, proxyPort: data.proxyPort @@ -578,6 +555,13 @@ export default function GeneralForm() { return ( <> + {resource?.resourceId && resource?.orgId && ( + + )} @@ -596,37 +580,6 @@ export default function GeneralForm() { className="space-y-4" id="general-settings-form" > - ( - -
- - - form.setValue( - "enabled", - val - ) - } - /> - -
- -
- )} - /> - - -
- - - {resourceFullDomain} - - + defaultDomainId={ + form.watch( + "domainId" + ) ?? undefined + } + defaultFullDomain={ + resourceFullDomainName || + undefined + } + onDomainChange={(res) => { + if (res === null) { + form.setValue( + "domainId", + undefined + ); + form.setValue( + "subdomain", + undefined + ); + setResourceFullDomain( + `${resource.ssl ? "https" : "http"}://` + ); + return; + } + form.setValue( + "domainId", + res.domainId + ); + form.setValue( + "subdomain", + res.subdomain ?? + undefined + ); + setResourceFullDomain( + `${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}` + ); + }} + />
)} + + ( + +
+ + + form.setValue( + "enabled", + val + ) + } + /> + +
+ +
+ )} + />
@@ -759,86 +776,6 @@ export default function GeneralForm() { /> )} - - setEditDomainOpen(setOpen)} - > - - - Edit Domain - - Select a domain for your resource - - - - { - const selected = - res === null - ? null - : { - domainId: res.domainId, - subdomain: res.subdomain, - fullDomain: res.fullDomain, - baseDomain: res.baseDomain, - domainNamespaceId: - res.domainNamespaceId - }; - - setSelectedDomain(selected); - }} - /> - - - - - - - - - ); } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx index f410b4c8b..2f6cd1492 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx @@ -14,6 +14,11 @@ import OrgProvider from "@app/providers/OrgProvider"; import { cache } from "react"; import ResourceInfoBox from "@app/components/ResourceInfoBox"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Public Resource" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx index 5ec1cf00d..06a4af045 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Public Resource" +}; + export default async function ResourcePage(props: { params: Promise<{ niceId: string; orgId: string }>; }) { diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index a9128b9d3..0846fc896 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -1,6 +1,6 @@ "use client"; -import HealthCheckDialog from "@/components/HealthCheckDialog"; +import HealthCheckCredenza from "@/components/HealthCheckCredenza"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -62,6 +62,7 @@ import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { DockerManager, DockerState } from "@app/lib/docker"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; import { tlsNameSchema } from "@server/lib/schemas"; import { type GetResourceResponse } from "@server/routers/resource"; import type { ListSitesResponse } from "@server/routers/site"; @@ -168,6 +169,30 @@ function ProxyResourceTargetsForm({ const [targets, setTargets] = useState(initialTargets); const [targetsToRemove, setTargetsToRemove] = useState([]); + + const { data: polledTargets } = useQuery({ + ...resourceQueries.resourceTargets({ + resourceId: resource.resourceId + }), + refetchInterval: 10_000 + }); + + useEffect(() => { + if (!polledTargets) return; + setTargets((prev) => + prev.map((t) => { + const fresh = polledTargets.find( + (p) => p.targetId === t.targetId + ); + if (!fresh) return t; + return { + ...t, + hcHealth: fresh.hcHealth, + hcEnabled: t.updated ? t.hcEnabled : fresh.hcEnabled + }; + }) + ); + }, [polledTargets]); const [dockerStates, setDockerStates] = useState>( new Map() ); @@ -317,19 +342,6 @@ function ProxyResourceTargetsForm({ header: () => {t("healthCheck")}, cell: ({ row }) => { const status = row.original.hcHealth || "unknown"; - const isEnabled = row.original.hcEnabled; - - const getStatusColor = (status: string) => { - switch (status) { - case "healthy": - return "green"; - case "unhealthy": - return "red"; - case "unknown": - default: - return "secondary"; - } - }; const getStatusText = (status: string) => { switch (status) { @@ -343,19 +355,7 @@ function ProxyResourceTargetsForm({ } }; - const getStatusIcon = (status: string) => { - switch (status) { - case "healthy": - return ; - case "unhealthy": - return ; - case "unknown": - default: - return null; - } - }; - - return ( + return (
{row.original.siteType === "newt" ? ( + ) : ( - )} @@ -640,10 +643,10 @@ function ProxyResourceTargetsForm({ hcInterval: null, hcTimeout: null, hcHeaders: null, + hcFollowRedirects: null, hcScheme: null, hcHostname: null, hcPort: null, - hcFollowRedirects: null, hcHealth: "unknown", hcStatus: null, hcMode: null, @@ -951,6 +954,18 @@ function ProxyResourceTargetsForm({
)} + {build === "saas" && + targets.length > 1 && + new Set(targets.map((t) => t.siteId)).size > 1 && ( +

+ + + Round robin routing will not work between + sites that are not connected to the same + node, but failover will work. + +

+ )}
@@ -965,10 +980,10 @@ function ProxyResourceTargetsForm({ {selectedTargetForHealthCheck && ( - {t("healthCheck")}, cell: ({ row }) => { const status = row.original.hcHealth || "unknown"; - const isEnabled = row.original.hcEnabled; - - const getStatusColor = (status: string) => { - switch (status) { - case "healthy": - return "green"; - case "unhealthy": - return "red"; - case "unknown": - default: - return "secondary"; - } - }; const getStatusText = (status: string) => { switch (status) { @@ -720,19 +707,7 @@ export default function Page() { } }; - const getStatusIcon = (status: string) => { - switch (status) { - case "healthy": - return ; - case "unhealthy": - return ; - case "unknown": - default: - return null; - } - }; - - return ( + return (
{row.original.siteType === "newt" ? ( + ) : ( - )} @@ -776,9 +755,15 @@ export default function Page() { pathMatchType: row.original.pathMatchType }} onChange={(config) => - updateTarget(row.original.targetId, - config.path === null && config.pathMatchType === null - ? { ...config, rewritePath: null, rewritePathType: null } + updateTarget( + row.original.targetId, + config.path === null && + config.pathMatchType === null + ? { + ...config, + rewritePath: null, + rewritePathType: null + } : config ) } @@ -804,9 +789,15 @@ export default function Page() { pathMatchType: row.original.pathMatchType }} onChange={(config) => - updateTarget(row.original.targetId, - config.path === null && config.pathMatchType === null - ? { ...config, rewritePath: null, rewritePathType: null } + updateTarget( + row.original.targetId, + config.path === null && + config.pathMatchType === null + ? { + ...config, + rewritePath: null, + rewritePathType: null + } : config ) } @@ -1061,7 +1052,7 @@ export default function Page() { : null ); }} - cols={2} + cols={3} /> )} @@ -1118,28 +1109,31 @@ export default function Page() { - = 1 - } - onDomainChange={(res) => { - if (!res) return; + + = 1 + } + onDomainChange={(res) => { + if (!res) return; - httpForm.setValue( - "subdomain", - res.subdomain - ); - httpForm.setValue( - "domainId", - res.domainId - ); - console.log( - "Domain changed:", - res - ); - }} - /> + httpForm.setValue( + "subdomain", + res.subdomain + ); + httpForm.setValue( + "domainId", + res.domainId + ); + console.log( + "Domain changed:", + res + ); + }} + /> + ) : ( @@ -1155,98 +1149,101 @@ export default function Page() { - - { - if (e.key === "Enter") { - e.preventDefault(); // block default enter refresh - } - }} - className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" - id="tcp-udp-settings-form" - > - ( - - - {t("protocol")} - - - - - )} - /> + + + { + if (e.key === "Enter") { + e.preventDefault(); // block default enter refresh + } + }} + className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" + id="tcp-udp-settings-form" + > + ( + + + {t( + "protocol" + )} + + + + + )} + /> - ( - - - {t( - "resourcePortNumber" - )} - - - - field.onChange( + ( + + + {t( + "resourcePortNumber" + )} + + + - - - - {t( - "resourcePortNumberDescription" - )} - - - )} - /> - - + ) => + field.onChange( + e + .target + .value + ? parseInt( + e + .target + .value + ) + : undefined + ) + } + /> + + + + )} + /> + + + )} @@ -1422,6 +1419,18 @@ export default function Page() {
)} + {build === "enterprise" && + targets.length > 1 && + new Set(targets.map((t) => t.siteId)).size > 1 && ( +

+ + + Round robin routing will not work between + sites that are not connected to the same + node, but failover will work. + +

+ )}
@@ -1460,12 +1469,10 @@ export default function Page() { {selectedTargetForHealthCheck && ( - ; searchParams: Promise>; } +function parsePositiveInt(s: string | undefined): number | undefined { + if (!s) return undefined; + const n = Number(s); + if (!Number.isInteger(n) || n <= 0) return undefined; + return n; +} + export default async function ProxyResourcesPage( props: ProxyResourcesPageProps ) { @@ -42,13 +55,31 @@ export default async function ProxyResourcesPage( pagination = responseData.pagination; } catch (e) {} - let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; - try { - const res = await internal.get< - AxiosResponse - >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); - siteResources = res.data.data.siteResources; - } catch (e) {} + const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined); + + let initialFilterSite: { + siteId: number; + name: string; + type: string; + } | null = null; + if (siteIdParam) { + try { + const siteRes = await internal.get( + `/site/${siteIdParam}`, + await authCookieHeader() + ); + const s = (siteRes.data as ResponseT).data; + if (s && s.orgId === params.orgId) { + initialFilterSite = { + siteId: s.siteId, + name: s.name, + type: s.type + }; + } + } catch { + // leave null + } + } let org = null; try { @@ -89,14 +120,18 @@ export default async function ProxyResourcesPage( : "not_protected", enabled: resource.enabled, domainId: resource.domainId || undefined, + fullDomain: resource.fullDomain ?? null, ssl: resource.ssl, targets: resource.targets?.map((target) => ({ targetId: target.targetId, ip: target.ip, port: target.port, enabled: target.enabled, - healthStatus: target.healthStatus - })) + healthStatus: target.healthStatus, + siteName: target.siteName + })), + sites: resource.sites ?? [], + health: (resource.health as ResourceRow["health"]) ?? undefined }; }); return ( @@ -117,6 +152,7 @@ export default async function ProxyResourcesPage( pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + initialFilterSite={initialFilterSite} /> diff --git a/src/app/[orgId]/settings/share-links/page.tsx b/src/app/[orgId]/settings/share-links/page.tsx index b41a3d1ce..1a732f714 100644 --- a/src/app/[orgId]/settings/share-links/page.tsx +++ b/src/app/[orgId]/settings/share-links/page.tsx @@ -7,10 +7,13 @@ import { cache } from "react"; import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { ListAccessTokensResponse } from "@server/routers/accessToken"; -import ShareLinksTable, { - ShareLinkRow -} from "@app/components/ShareLinksTable"; +import ShareLinksTable, { ShareLinkRow } from "@app/components/ShareLinksTable"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Shareable Links" +}; type ShareLinksPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 71dc32e70..f8bb6cf5e 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -1,5 +1,7 @@ "use client"; +import UptimeAlertSection from "@app/components/UptimeAlertSection"; + import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -111,6 +113,13 @@ export default function GeneralPage() { return ( + {site?.siteId && site?.orgId && site.type != "local" && ( + + )} diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index aa02bb667..ba65d06e0 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -8,7 +8,11 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SiteInfoCard from "@app/components/SiteInfoCard"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; +export const metadata: Metadata = { + title: "Site" +}; interface SettingsLayoutProps { children: React.ReactNode; @@ -38,6 +42,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { title: t("general"), href: `/${params.orgId}/settings/sites/${params.niceId}/general` }, + { + title: t("siteResourcesTab"), + href: `/${params.orgId}/settings/sites/${params.niceId}/resources` + }, ...(site.type !== "local" ? [ { diff --git a/src/app/[orgId]/settings/sites/[niceId]/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/page.tsx index 045b762e3..8f505e85c 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Site" +}; + export default async function SitePage(props: { params: Promise<{ orgId: string; niceId: string }>; }) { diff --git a/src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx new file mode 100644 index 000000000..fcb460b87 --- /dev/null +++ b/src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx @@ -0,0 +1,64 @@ +import SiteResourcesOverview from "@app/components/SiteResourcesOverview"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ListResourcesResponse } from "@server/routers/resource"; +import type { GetSiteResponse } from "@server/routers/site"; +import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; +import type { AxiosResponse } from "axios"; + +type SiteResourcesPageProps = { + params: Promise<{ orgId: string; niceId: string }>; +}; + +export default async function SiteResourcesPage(props: SiteResourcesPageProps) { + const { orgId, niceId } = await props.params; + + const siteRes = await internal.get>( + `/org/${orgId}/site/${niceId}`, + await authCookieHeader() + ); + const site = siteRes.data.data; + + const baseSearch = new URLSearchParams({ + page: "1", + pageSize: "5", + siteId: String(site.siteId) + }); + + let initialPublicData: ListResourcesResponse | null = null; + let initialPrivateData: ListAllSiteResourcesByOrgResponse | null = null; + let initialPublicForbidden = false; + let initialPrivateForbidden = false; + + try { + const res = await internal.get>( + `/org/${orgId}/resources?${baseSearch.toString()}`, + await authCookieHeader() + ); + initialPublicData = res.data.data; + } catch (e: any) { + initialPublicForbidden = e?.response?.status === 403; + } + + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${orgId}/site-resources?${baseSearch.toString()}`, + await authCookieHeader() + ); + initialPrivateData = res.data.data; + } catch (e: any) { + initialPrivateForbidden = e?.response?.status === 403; + } + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/sites/[niceId]/wireguardConfig.ts b/src/app/[orgId]/settings/sites/[niceId]/wireguardConfig.ts index 5e3d1281d..d0cd1967a 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/wireguardConfig.ts +++ b/src/app/[orgId]/settings/sites/[niceId]/wireguardConfig.ts @@ -1,7 +1,5 @@ -/*! SPDX-License-Identifier: GPL-2.0 - * - * Copyright (C) 2015-2020 Jason A. Donenfeld . All Rights Reserved. - */ +// SPDX-License-Identifier: GPL-2.0 +// Copyright (C) 2015-2020 Jason A. Donenfeld . All Rights Reserved. function gf(init: number[] | undefined = undefined) { var r = new Float64Array(16); diff --git a/src/app/[orgId]/settings/sites/create/layout.tsx b/src/app/[orgId]/settings/sites/create/layout.tsx new file mode 100644 index 000000000..fc8f1edf2 --- /dev/null +++ b/src/app/[orgId]/settings/sites/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create Site" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index cb6365b79..ab97197a3 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -161,16 +161,13 @@ export default function Page() { description: t("siteNewtTunnelDescription"), disabled: true }, - ...(env.flags.disableBasicWireguardSites + ...(env.flags.disableBasicWireguardSites || build == "saas" ? [] : [ { id: "wireguard" as SiteType, title: t("siteWg"), - description: - build == "saas" - ? t("siteWgDescriptionSaas") - : t("siteWgDescription"), + description: t("siteWgDescription"), disabled: true } ]), @@ -426,9 +423,22 @@ export default function Page() { })); setRemoteExitNodeOptions(exitNodeOptions); + + if (exitNodeOptions.length === 0) { + // No remote exit nodes available - remove local option and default to newt + setTunnelTypes((prev: any) => + prev.filter((item: any) => item.id !== "local") + ); + form.setValue("method", "newt"); + } } } catch (error) { console.error("Failed to fetch remote exit nodes:", error); + // If fetch fails, no remote exit nodes available - remove local option and default to newt + setTunnelTypes((prev: any) => + prev.filter((item: any) => item.id !== "local") + ); + form.setValue("method", "newt"); } } diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 38083325b..631baee41 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -5,8 +5,13 @@ import { AxiosResponse } from "axios"; import SitesTable, { SiteRow } from "@app/components/SitesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SitesBanner from "@app/components/SitesBanner"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; +export const metadata: Metadata = { + title: "Sites" +}; + type SitesPageProps = { params: Promise<{ orgId: string }>; searchParams: Promise>; @@ -59,6 +64,7 @@ export default async function SitesPage(props: SitesPageProps) { address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type), + resourceCount: Number(site.resourceCount ?? 0), orgId: params.orgId, type: site.type as any, online: site.online, diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index 60e8a094a..e9438da33 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -20,7 +20,6 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -63,7 +62,7 @@ import { SettingsSectionForm } from "@app/components/Settings"; import { useTranslations } from "next-intl"; -import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; +import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import { compileRoleMappingExpression, createMappingBuilderRule, @@ -499,9 +498,17 @@ export default function PoliciesPage() { id="policy-default-mappings-form" className="space-y-6" > - {}} + orgMappingField={{ + control: defaultMappingsForm.control, + name: "defaultOrgMapping", + labelKey: "defaultMappingsOrg" + }} + roleMappingFieldIdPrefix="admin-idp-default-role" + showFreeformRoleNamesHint roleMappingMode={defaultRoleMappingMode} onRoleMappingModeChange={ setDefaultRoleMappingMode @@ -528,27 +535,6 @@ export default function PoliciesPage() { setDefaultRawRoleExpression } /> - - ( - - - {t("defaultMappingsOrg")} - - - - - - {t( - "defaultMappingsOrgDescription" - )} - - - - )} - /> @@ -687,9 +673,15 @@ export default function PoliciesPage() { )} /> - {}} + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} + roleMappingFieldIdPrefix="admin-idp-policy-role" roleMappingMode={policyRoleMappingMode} onRoleMappingModeChange={ setPolicyRoleMappingMode @@ -716,27 +708,6 @@ export default function PoliciesPage() { setPolicyRawRoleExpression } /> - - ( - - - {t("orgMappingPathOptional")} - - - - - - {t( - "defaultMappingsOrgDescription" - )} - - - - )} - /> diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 82036c510..6e3270a55 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -24,7 +24,6 @@ import { import HeaderTitle from "@app/components/SettingsSectionTitle"; import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { SwitchInput } from "@app/components/SwitchInput"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; @@ -34,7 +33,6 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { applyOidcIdpProviderType } from "@app/lib/idp/oidcIdpProviderDefaults"; import { zodResolver } from "@hookform/resolvers/zod"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -220,23 +218,6 @@ export default function Page() { )} /> -
- { - form.setValue( - "autoProvision", - checked - ); - }} - /> -
@@ -244,6 +225,32 @@ export default function Page() {
+ + + + {t("idpAutoProvisionUsers")} + + + + + + +
+ { + form.setValue("autoProvision", checked); + }} + /> +

+ {t("idpAutoProvisionConfigureAfterCreate")} +

+
+
+
+
+ {!licenseStatus?.isLicenseValid && ( + + } + description={t("licenseBannerDescription")} + > + + + + + + + + )} + {/* */} {/* */} {/* */} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 2a000b34b..0cfaaf3b0 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,33 +1,70 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; +import type { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; +import type { ListIdpsResponse } from "@server/routers/idp/listIdps"; import UsersTable, { GlobalUserRow } from "@app/components/AdminUsersTable"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -type PageProps = { - params: Promise<{ orgId: string }>; +/** API JSON body shape for `response()` handlers (see `server/lib/response.ts`). */ +type ApiPayload = { + data: T; + success: boolean; + error: boolean; + message: string; + status: number; +}; + +type AdminUsersPageProps = { + searchParams: Promise>; }; export const dynamic = "force-dynamic"; -export default async function UsersPage(props: PageProps) { +export default async function UsersPage(props: AdminUsersPageProps) { + const searchParams = new URLSearchParams(await props.searchParams); + const cookieHeader = await authCookieHeader(); + let rows: AdminListUsersResponse["users"] = []; - try { - const res = await internal.get>( - `/users`, - await authCookieHeader() - ); - rows = res.data.data.users; - } catch (e) { - console.error(e); + let pagination: AdminListUsersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + const [usersRes, idpsRes] = await Promise.all([ + internal + .get< + ApiPayload + >(`/users?${searchParams.toString()}`, cookieHeader) + .catch(() => {}), + internal + .get< + ApiPayload + >(`/idp?limit=500&offset=0`, cookieHeader) + .catch(() => {}) + ]); + + if (usersRes && usersRes.status === 200) { + const list = usersRes.data.data; + rows = list.users; + pagination = list.pagination; } const t = await getTranslations(); + const globalIdps = + idpsRes && idpsRes.status === 200 ? (idpsRes.data.data.idps ?? []) : []; + const idpFilterOptions = [ + { value: "internal", label: t("idpNameInternal") }, + ...globalIdps.map((i: ListIdpsResponse["idps"][number]) => ({ + value: String(i.idpId), + label: i.name + })) + ]; + const userRows: GlobalUserRow[] = rows.map((row) => { return { id: row.id, @@ -59,7 +96,15 @@ export default async function UsersPage(props: PageProps) { {t("userAbountDescription")} - + ); } diff --git a/src/app/auth/2fa/setup/layout.tsx b/src/app/auth/2fa/setup/layout.tsx new file mode 100644 index 000000000..db9a76aa4 --- /dev/null +++ b/src/app/auth/2fa/setup/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Set Up 2FA" +}; + +export default function TwoFactorSetupLayout({ + children +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/auth/delete-account/page.tsx b/src/app/auth/delete-account/page.tsx index 5cbc8d738..4a67f268e 100644 --- a/src/app/auth/delete-account/page.tsx +++ b/src/app/auth/delete-account/page.tsx @@ -5,6 +5,11 @@ import { cache } from "react"; import DeleteAccountClient from "./DeleteAccountClient"; import { getTranslations } from "next-intl/server"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Delete Account" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx index 7b3ccabfc..12d161a1b 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -8,6 +8,11 @@ import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; import { LoadLoginPageResponse } from "@server/routers/loginPage/types"; import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Complete Login" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/initial-setup/layout.tsx b/src/app/auth/initial-setup/layout.tsx index 8407f0da6..590f24431 100644 --- a/src/app/auth/initial-setup/layout.tsx +++ b/src/app/auth/initial-setup/layout.tsx @@ -3,6 +3,11 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { InitialSetupCompleteResponse } from "@server/routers/auth"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Initial Setup" +}; export default async function Layout(props: { children: React.ReactNode }) { const setupRes = await internal.get< diff --git a/src/app/auth/initial-setup/page.tsx b/src/app/auth/initial-setup/page.tsx index 4a4438964..bf38eee9e 100644 --- a/src/app/auth/initial-setup/page.tsx +++ b/src/app/auth/initial-setup/page.tsx @@ -92,7 +92,7 @@ export default function InitialSetupPage() { />
-

+

{t("initialSetupTitle")}

diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 997ca3fb3..223c4e26c 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -10,7 +10,10 @@ import { getTranslations } from "next-intl/server"; import { cache } from "react"; export const metadata: Metadata = { - title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, + title: { + template: `%s - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, + default: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}` + }, description: "" }; @@ -66,18 +69,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { © {new Date().getFullYear()} Fossorial, Inc. - - - - {process.env.BRANDING_APP_NAME || "Pangolin"} - - + {build !== "saas" && ( + <> + + + + {process.env.BRANDING_APP_NAME || + "Pangolin"} + + + + )} {build === "oss" diff --git a/src/app/auth/login/device/page.tsx b/src/app/auth/login/device/page.tsx index 01c23c999..0fb77da89 100644 --- a/src/app/auth/login/device/page.tsx +++ b/src/app/auth/login/device/page.tsx @@ -4,6 +4,11 @@ import DeviceLoginForm from "@/components/DeviceLoginForm"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { cache } from "react"; import { cleanRedirect } from "@app/lib/cleanRedirect"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Authorize Device" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/login/device/success/layout.tsx b/src/app/auth/login/device/success/layout.tsx new file mode 100644 index 000000000..53a3c8af6 --- /dev/null +++ b/src/app/auth/login/device/success/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Device Authorized" +}; + +export default function DeviceAuthSuccessLayout({ + children +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/auth/login/device/success/page.tsx b/src/app/auth/login/device/success/page.tsx index dab609351..56f84c835 100644 --- a/src/app/auth/login/device/success/page.tsx +++ b/src/app/auth/login/device/success/page.tsx @@ -23,8 +23,10 @@ export default function DeviceAuthSuccessPage() { useEffect(() => { // Detect if we're on iOS or Android - const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; - const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; + const userAgent = + navigator.userAgent || navigator.vendor || (window as any).opera; + const isIOS = + /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; const isAndroid = /android/i.test(userAgent); if (isAndroid) { @@ -32,7 +34,8 @@ export default function DeviceAuthSuccessPage() { // This explicitly tells Chrome to send an intent to the app, which will bring // SignInCodeActivity back to the foreground (it has launchMode="singleTop") setTimeout(() => { - window.location.href = "intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end"; + window.location.href = + "intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end"; }, 500); } else if (isIOS) { // Wait 500ms then attempt to open the app @@ -41,7 +44,8 @@ export default function DeviceAuthSuccessPage() { window.location.href = "pangolin://"; setTimeout(() => { - window.location.href = "https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS"; + window.location.href = + "https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS"; }, 2000); }, 500); } @@ -64,7 +68,7 @@ export default function DeviceAuthSuccessPage() {
-

+

{t("deviceConnected")}

diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index bfb552df4..6373e334a 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -17,6 +17,11 @@ import { priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { LoginFormIDP } from "@app/components/LoginForm"; import { ListIdpsResponse } from "@server/routers/idp"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Log In" +}; export const dynamic = "force-dynamic"; @@ -130,7 +135,7 @@ export default async function Page(props: {

-

+

{t("inviteAlready")}

@@ -155,6 +160,18 @@ export default async function Page(props: { redirect={redirectUrl} forceLogin={forceLogin} defaultUser={defaultUser} + orgSignIn={ + !isInvite && + (build === "saas" || + env.app.identityProviderMode === "org") + ? { + href: `/auth/org${buildQueryString(searchParams)}`, + linkText: t("orgAuthSignInToOrg"), + descriptionText: + t("needToSignInToOrg") + } + : undefined + } /> @@ -190,7 +207,8 @@ export default async function Page(props: {

)} - {!isInvite && + {!useSmartLogin && + !isInvite && (build === "saas" || env.app.identityProviderMode === "org") ? ( { + if (!wildcardDomain.startsWith("*.")) return false; + const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io" + return host.endsWith(suffix) && host.length > suffix.length; + }; + if (serverResourceHost === redirectHost) { redirectUrl = searchParams.redirect; } else if (serverResourceHostWithPort === redirectHost) { redirectUrl = searchParams.redirect; + } else if ( + authInfo.wildcard && + authInfo.fullDomain && + wildcardMatchesRedirect(authInfo.fullDomain, redirectHost) + ) { + redirectUrl = searchParams.redirect; } } catch (e) {} } diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index f51ac9049..42829acfd 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -7,6 +7,11 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { cache } from "react"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create Account" +}; export const dynamic = "force-dynamic"; @@ -60,7 +65,7 @@ export default async function Page(props: {
-

+

{t("inviteAlready")}

diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index e4428370b..bb844057c 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -4,6 +4,11 @@ import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; import { redirect } from "next/navigation"; import { cache } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Verify Email" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/globals.css b/src/app/globals.css index bbb165c28..b42bf3dab 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,7 +6,7 @@ :root { --radius: 0.75rem; - --background: oklch(0.985 0 0); + --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); --card-foreground: oklch(0.141 0.005 285.823); @@ -22,30 +22,30 @@ --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.91 0.004 286.32); - --input: oklch(0.92 0.004 286.32); + --border: oklch(0.88 0.004 286.32); + --input: oklch(0.88 0.004 286.32); --ring: oklch(0.705 0.213 47.604); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); + --sidebar: #fafafa; --sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-primary: oklch(0.705 0.213 47.604); --sidebar-primary-foreground: oklch(0.98 0.016 73.684); - --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent: #eaeaea; --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.213 47.604); } .dark { - --background: oklch(0.19 0.006 285.885); + --background: #141415; --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); + --card: #141415; --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); + --popover: #141415; --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.6717 0.1946 41.93); --primary-foreground: oklch(0.98 0.016 73.684); @@ -57,7 +57,7 @@ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.5382 0.1949 22.216); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(1 0 0 / 13%); + --border: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 18%); --ring: oklch(0.646 0.222 41.116); --chart-1: oklch(0.488 0.243 264.376); @@ -65,11 +65,11 @@ --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); + --sidebar: #0C0C0D; --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.646 0.222 41.116); --sidebar-primary-foreground: oklch(0.98 0.016 73.684); - --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent: #171717; --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.646 0.222 41.116); @@ -110,6 +110,15 @@ --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); @@ -166,7 +175,9 @@ p { } @keyframes dot-pulse { - 0%, 80%, 100% { + 0%, + 80%, + 100% { opacity: 0.3; transform: scale(0.8); } @@ -189,7 +200,10 @@ p { /* Only apply custom viewport height on mobile */ @media (max-width: 767px) { .h-screen-safe { - height: var(--vh, 100vh); /* Use CSS variable set by ViewportHeightFix on mobile */ + height: var( + --vh, + 100vh + ); /* Use CSS variable set by ViewportHeightFix on mobile */ } } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0db1b49bf..d04cc0b6e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,7 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; import { TailwindIndicator } from "@app/components/TailwindIndicator"; import { ViewportHeightFix } from "@app/components/ViewportHeightFix"; import StoreInternalRedirect from "@app/components/StoreInternalRedirect"; -import { Inter } from "next/font/google"; +import localFont from "next/font/local"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -32,11 +32,33 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic"; -const inter = Inter({ - subsets: ["latin"] +const monaSans = localFont({ + src: [ + { + path: "../fonts/mona-sans/MonaSans-Regular.woff2", + weight: "400", + style: "normal" + }, + { + path: "../fonts/mona-sans/MonaSans-Medium.woff2", + weight: "500", + style: "normal" + }, + { + path: "../fonts/mona-sans/MonaSans-SemiBold.woff2", + weight: "600", + style: "normal" + }, + { + path: "../fonts/mona-sans/MonaSans-Bold.woff2", + weight: "700", + style: "normal" + } + ], + display: "swap" }); -const fontClassName = inter.className; +const fontClassName = monaSans.className; export default async function RootLayout({ children diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index ac7a4a10f..24dc02a19 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav"; import { Env } from "@app/lib/types/env"; import { build } from "@server/build"; import { + BellRing, Boxes, Building2, Cable, @@ -212,9 +213,9 @@ export const orgNavSections = ( icon: , items: [ { - title: "sidebarApiKeys", - href: "/{orgId}/settings/api-keys", - icon: + title: "sidebarAlerting", + href: "/{orgId}/settings/alerting", + icon: }, { title: "sidebarProvisioning", @@ -225,6 +226,11 @@ export const orgNavSections = ( title: "sidebarBluePrints", href: "/{orgId}/settings/blueprints", icon: + }, + { + title: "sidebarApiKeys", + href: "/{orgId}/settings/api-keys", + icon: } ] }, diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index d3ca37ccf..680962d23 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -5,7 +5,7 @@ export default async function NotFound() { return (

-

404

+

404

{t("pageNotFound")}

diff --git a/src/app/private-maintenance-screen/page.tsx b/src/app/private-maintenance-screen/page.tsx new file mode 100644 index 000000000..3f7959206 --- /dev/null +++ b/src/app/private-maintenance-screen/page.tsx @@ -0,0 +1,42 @@ +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@app/components/ui/card"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Private Placeholder" +}; + +export default async function MaintenanceScreen() { + const t = await getTranslations(); + + let title = t("privateMaintenanceScreenTitle"); + let message = t("privateMaintenanceScreenMessage"); + let steps = t("privateMaintenanceScreenSteps"); + + return ( +
+ + + {title} + + +

{message}

+

{steps}

+ + {t("learnMore")} + +
+
+
+ ); +} diff --git a/src/components/AccessToken.tsx b/src/components/AccessToken.tsx index 54f926433..802be769a 100644 --- a/src/components/AccessToken.tsx +++ b/src/components/AccessToken.tsx @@ -143,7 +143,7 @@ export default function AccessToken({ token, resourceId }: AccessTokenProps) { ) : ( - + {renderTitle()} diff --git a/src/components/AccessTokenUsage.tsx b/src/components/AccessTokenUsage.tsx index 4b1703717..b10f793d0 100644 --- a/src/components/AccessTokenUsage.tsx +++ b/src/components/AccessTokenUsage.tsx @@ -58,12 +58,12 @@ export default function AccessTokenSection({
-
{t("tokenId")}
+
{t("tokenId")}
-
{t("token")}
+
{t("token")}
diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index 09797a2e2..eabb6b468 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -1,19 +1,31 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { UsersDataTable } from "@app/components/AdminUsersDataTable"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; +import { Button } from "@app/components/ui/button"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowRight, + ArrowUp10Icon, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; import { DropdownMenu, DropdownMenuItem, @@ -31,7 +43,6 @@ import { CredenzaClose } from "@app/components/Credenza"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { AxiosResponse } from "axios"; export type GlobalUserRow = { id: string; @@ -44,10 +55,16 @@ export type GlobalUserRow = { dateCreated: string; twoFactorEnabled: boolean | null; twoFactorSetupRequested: boolean | null; + serverAdmin?: boolean; }; +type FilterOption = { value: string; label: string }; + type Props = { users: GlobalUserRow[]; + pagination: PaginationState; + rowCount: number; + idpFilterOptions: FilterOption[]; }; type AdminGeneratePasswordResetCodeResponse = { @@ -56,74 +73,103 @@ type AdminGeneratePasswordResetCodeResponse = { url: string; }; -export default function UsersTable({ users }: Props) { +export default function UsersTable({ + users, + pagination, + rowCount, + idpFilterOptions +}: Props) { const router = useRouter(); const t = useTranslations(); + const api = createApiClient(useEnvContext()); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selected, setSelected] = useState(null); - const [rows, setRows] = useState(users); - - const api = createApiClient(useEnvContext()); - - const [isRefreshing, setIsRefreshing] = useState(false); const [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] = useState(false); const [passwordResetCodeData, setPasswordResetCodeData] = useState(null); const [isGeneratingCode, setIsGeneratingCode] = useState(false); - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setRows(users); - }, [users]); + const [isRefreshing, startTransition] = useTransition(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams, + pathname + } = useNavigationContext(); + + const idpIdParamSchema = z + .union([z.literal("internal"), z.string().regex(/^\d+$/)]) + .optional() + .catch(undefined); + + const twoFactorFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } + startTransition(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); }; const deleteUser = (id: string) => { - api.delete(`/user/${id}`) - .catch((e) => { - console.error(t("userErrorDelete"), e); - toast({ - variant: "destructive", - title: t("userErrorDelete"), - description: formatAxiosError(e, t("userErrorDelete")) + startTransition(() => { + void api + .delete(`/user/${id}`) + .catch((e) => { + console.error(t("userErrorDelete"), e); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + setSelected(null); }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== id); - - setRows(newRows); - }); + }); }; const generatePasswordResetCode = async (userId: string) => { setIsGeneratingCode(true); try { - const res = await api.post< - AxiosResponse - >(`/user/${userId}/generate-password-reset-code`); + const res = await api.post( + `/user/${userId}/generate-password-reset-code` + ); - if (res.data?.data) { - setPasswordResetCodeData(res.data.data); + const envelope = res.data as { + data?: AdminGeneratePasswordResetCodeResponse; + }; + if (envelope?.data) { + setPasswordResetCodeData(envelope.data); setIsPasswordResetCodeDialogOpen(true); } } catch (e) { @@ -138,37 +184,55 @@ export default function UsersTable({ users }: Props) { } }; + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + const columns: ExtendedColumnDef[] = [ { accessorKey: "id", friendlyName: "ID", - header: ({ column }) => { - return ( - - ); - } + header: () => ID }, { accessorKey: "username", enableHiding: false, friendlyName: t("username"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("username", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -176,16 +240,22 @@ export default function UsersTable({ users }: Props) { { accessorKey: "email", friendlyName: t("email"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("email", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -193,16 +263,22 @@ export default function UsersTable({ users }: Props) { { accessorKey: "name", friendlyName: t("name"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("name", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -210,39 +286,45 @@ export default function UsersTable({ users }: Props) { { accessorKey: "idpName", friendlyName: t("identityProvider"), - header: ({ column }) => { - return ( - - ); - } + header: () => ( + + handleFilterChange("idp_id", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("identityProvider")} + className="p-3" + /> + ) }, { accessorKey: "twoFactorEnabled", friendlyName: t("twoFactor"), - header: ({ column }) => { - return ( - - ); - }, + header: () => ( + + handleFilterChange("two_factor", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("twoFactor")} + className="p-3" + /> + ), cell: ({ row }) => { const userRow = row.original; - return (
@@ -277,8 +359,11 @@ export default function UsersTable({ users }: Props) { {r.type === "internal" && ( { - generatePasswordResetCode(r.id); + void generatePasswordResetCode( + r.id + ); }} > {t("generatePasswordResetCode")} @@ -350,11 +435,21 @@ export default function UsersTable({ users }: Props) { /> )} - ) => string +) { + if (alertRuleAllSitesSelected(rule.eventType, rule.siteIds)) { + return t("alertingSummaryAllSites"); + } + if ( + rule.eventType === "site_online" || + rule.eventType === "site_offline" || + rule.eventType === "site_toggle" + ) { + return t("alertingSummarySites", { count: rule.siteIds.length }); + } + if (alertRuleAllResourcesSelected(rule.eventType, rule.resourceIds)) { + return t("alertingSummaryAllResources"); + } + if (rule.eventType.startsWith("resource_")) { + return t("alertingSummaryResources", { + count: rule.resourceIds.length + }); + } + if (alertRuleAllHealthChecksSelected(rule.eventType, rule.healthCheckIds)) { + return t("alertingSummaryAllHealthChecks"); + } + return t("alertingSummaryHealthChecks", { + count: rule.healthCheckIds.length + }); +} + +function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) { + switch (rule.eventType) { + case "site_online": + return t("alertingTriggerSiteOnline"); + case "site_offline": + return t("alertingTriggerSiteOffline"); + case "site_toggle": + return t("alertingTriggerSiteToggle"); + case "health_check_healthy": + return t("alertingTriggerHcHealthy"); + case "health_check_unhealthy": + return t("alertingTriggerHcUnhealthy"); + case "health_check_toggle": + return t("alertingTriggerHcToggle"); + case "resource_healthy": + return t("alertingTriggerResourceHealthy"); + case "resource_unhealthy": + return t("alertingTriggerResourceUnhealthy"); + case "resource_degraded": + return t("alertingTriggerResourceDegraded"); + case "resource_toggle": + return t("alertingTriggerResourceToggle"); + default: + return rule.eventType; + } +} + +export default function AlertingRulesTable({ + orgId, + alertRules, + rowCount +}: AlertingRulesTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isRefreshing, startRefresh] = useTransition(); + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.alertingRules); + + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [deleteOpen, setDeleteOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + const page = Math.max(1, Number(searchParams.get("page") ?? 1)); + const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20)); + const pageIndex = page - 1; + const query = searchParams.get("query") ?? undefined; + const sortBy = searchParams.get("sort_by") ?? undefined; + const order = searchParams.get("order") ?? undefined; + const enabledForQuery = alertRulesEnabledQuerySchema.parse( + searchParams.get("enabled") ?? undefined + ); + + const enabledFilterOptions = useMemo( + () => [ + { value: "true", label: t("enabled") }, + { value: "false", label: t("disabled") } + ], + [t] + ); + + const rows = alertRules; + const total = rowCount; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + function refreshList() { + startRefresh(() => { + router.refresh(); + }); + } + + const paginationState: DataTablePaginationState = { + pageIndex, + pageSize, + pageCount + }; + + const handlePaginationChange = (newState: PaginationState) => { + searchParams.set("page", (newState.pageIndex + 1).toString()); + searchParams.set("pageSize", newState.pageSize.toString()); + filter({ searchParams }); + }; + + const handleSearchChange = useDebouncedCallback((value: string) => { + if (value) { + searchParams.set("query", value); + } else { + searchParams.delete("query"); + } + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + + function toggleSort(column: string) { + filter({ + searchParams: getNextSortOrder(column, searchParams) + }); + } + + function handleEnabledFilter(value: string | undefined | null) { + const sp = new URLSearchParams(searchParams); + sp.delete("enabled"); + sp.delete("page"); + if (value) { + sp.set("enabled", value); + } + filter({ searchParams: sp }); + } + + const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => { + setTogglingId(rule.alertRuleId); + try { + await api.post(`/org/${orgId}/alert-rule/${rule.alertRuleId}`, { + enabled + }); + refreshList(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setTogglingId(null); + } + }; + + const confirmDelete = async () => { + if (!selected) return; + try { + await api.delete( + `/org/${orgId}/alert-rule/${selected.alertRuleId}` + ); + refreshList(); + toast({ title: t("alertingRuleDeleted") }); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setDeleteOpen(false); + setSelected(null); + } + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, + cell: ({ row }) => {row.original.name} + }, + { + id: "source", + friendlyName: t("alertingColumnSource"), + header: () => ( + {t("alertingColumnSource")} + ), + cell: ({ row }) => {sourceSummary(row.original, t)} + }, + { + id: "trigger", + friendlyName: t("alertingColumnTrigger"), + header: () => ( + {t("alertingColumnTrigger")} + ), + cell: ({ row }) => {triggerLabel(row.original, t)} + }, + { + accessorKey: "lastTriggeredAt", + friendlyName: t("lastTriggeredAt"), + header: () => { + const triggerOrder = getSortDirection( + "last_triggered_at", + searchParams + ); + const Icon = + triggerOrder === "asc" + ? ArrowDown01Icon + : triggerOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, + cell: ({ row }) => ( + + {row.original.lastTriggeredAt + ? moment(row.original.lastTriggeredAt).format("lll") + : "-"} + + ) + }, + { + accessorKey: "enabled", + friendlyName: t("alertingColumnEnabled"), + header: () => ( + + ), + cell: ({ row }) => { + const r = row.original; + return ( + setEnabled(r, v)} + /> + ); + } + }, + { + id: "rowActions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + + + + { + setSelected(r); + setDeleteOpen(true); + }} + > + + {t("delete")} + + + + + +
+ ); + } + } + ]; + + return ( + <> + {selected && ( + { + setDeleteOpen(val); + if (!val) setSelected(null); + }} + dialog={ +
+

{t("alertingDeleteQuestion")}

+
+ } + buttonText={t("delete")} + onConfirm={confirmDelete} + string={selected.name} + title={t("alertingDeleteRule")} + /> + )} + + + { + router.push(`/${orgId}/settings/alerting/create`); + }} + onRefresh={refreshList} + isRefreshing={isRefreshing || isFiltering} + addButtonText={t("alertingAddRule")} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="rowActions" + pagination={paginationState} + onPaginationChange={handlePaginationChange} + /> + + ); +} diff --git a/src/components/AuthPageSettings.tsx b/src/components/AuthPageSettings.tsx index 1e825fa1e..edd1e3a19 100644 --- a/src/components/AuthPageSettings.tsx +++ b/src/components/AuthPageSettings.tsx @@ -399,11 +399,10 @@ function AuthPageSettings({
)} - {env.flags.usePangolinDns && - (build === "enterprise" || - !isPaidUser( - tierMatrix.loginPageDomain - )) && + {build !== "oss" && (build === "enterprise" || + !isPaidUser( + tierMatrix.loginPageDomain + )) && loginPage?.domainId && loginPage?.fullDomain && !hasUnsavedChanges && ( diff --git a/src/components/AutoProvisionConfigWidget.tsx b/src/components/AutoProvisionConfigWidget.tsx index d4df3f50d..4767544d0 100644 --- a/src/components/AutoProvisionConfigWidget.tsx +++ b/src/components/AutoProvisionConfigWidget.tsx @@ -1,19 +1,33 @@ "use client"; -import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; -import { FormDescription } from "@app/components/ui/form"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; import { SwitchInput } from "@app/components/SwitchInput"; -import { useTranslations } from "next-intl"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; -import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; +import { useTranslations } from "next-intl"; +import type { Control } from "react-hook-form"; type Role = { roleId: number; name: string; }; +export type IdpOrgMappingFieldBinding = { + control: unknown; + name: string; + labelKey?: string; +}; + type AutoProvisionConfigWidgetProps = { autoProvision: boolean; onAutoProvisionChange: (checked: boolean) => void; @@ -28,6 +42,11 @@ type AutoProvisionConfigWidgetProps = { onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void; rawExpression: string; onRawExpressionChange: (expression: string) => void; + orgMappingField: IdpOrgMappingFieldBinding; + showAutoProvisionSwitch?: boolean; + roleMappingFieldIdPrefix?: string; + showFreeformRoleNamesHint?: boolean; + autoProvisionSwitchId?: string; }; export default function AutoProvisionConfigWidget({ @@ -43,41 +62,95 @@ export default function AutoProvisionConfigWidget({ mappingBuilderRules, onMappingBuilderRulesChange, rawExpression, - onRawExpressionChange + onRawExpressionChange, + orgMappingField, + showAutoProvisionSwitch = true, + roleMappingFieldIdPrefix = "org-idp-auto-provision", + showFreeformRoleNamesHint = false, + autoProvisionSwitchId = "auto-provision-toggle" }: AutoProvisionConfigWidgetProps) { const t = useTranslations(); const { isPaidUser } = usePaidStatus(); + const showMappingTabs = showAutoProvisionSwitch === false || autoProvision; + + const orgMappingLabelKey = + orgMappingField.labelKey ?? "orgMappingPathOptional"; + return (
-
- -
+ {showAutoProvisionSwitch && ( +
+ +
+ )} - {autoProvision && ( - + {showMappingTabs && ( + +
+ +
+
+
+

+ {t("defaultMappingsOrgDescription")} +

+ + } + name={orgMappingField.name} + render={({ field }) => ( + + + {t(orgMappingLabelKey)} + + + + + + + )} + /> +
+
+
)}
); diff --git a/src/components/BrandingLogo.tsx b/src/components/BrandingLogo.tsx index f6152f150..0216dae2e 100644 --- a/src/components/BrandingLogo.tsx +++ b/src/components/BrandingLogo.tsx @@ -48,6 +48,7 @@ export default function BrandingLogo(props: BrandingLogoProps) { // we use `img` tag here because the `logoPath` could be any URL // and next.js `Image` component only accepts a restricted number of domains const Component = props.logoPath ? "img" : Image; + const isNextImage = Component === Image; return ( path && ( diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index 65e0d19ae..cc22b1e88 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -1,43 +1,38 @@ "use client"; import { Button } from "@/components/ui/button"; -import { RotateCw } from "lucide-react"; +import { FileBadge, RotateCw } from "lucide-react"; import { useCertificate } from "@app/hooks/useCertificate"; +import type { GetCertificateResponse } from "@server/routers/certificates/types"; import { useTranslations } from "next-intl"; -type CertificateStatusProps = { - orgId: string; - domainId: string; - fullDomain: string; - autoFetch?: boolean; +export type CertificateStatusContentProps = { + cert: GetCertificateResponse | null; + certLoading: boolean; + certError: string | null; + refreshing: boolean; + refreshCert: () => Promise; showLabel?: boolean; className?: string; onRefresh?: () => void; - polling?: boolean; - pollingInterval?: number; }; -export default function CertificateStatus({ - orgId, - domainId, - fullDomain, - autoFetch = true, +/** Presentation-only certificate row (shared hook state possible via props). */ +export function CertificateStatusContent({ + cert, + certLoading, + certError, + refreshing, + refreshCert, showLabel = true, className = "", - onRefresh, - polling = false, - pollingInterval = 5000 -}: CertificateStatusProps) { + onRefresh +}: CertificateStatusContentProps) { const t = useTranslations(); - const { cert, certLoading, certError, refreshing, refreshCert } = - useCertificate({ - orgId, - domainId, - fullDomain, - autoFetch, - polling, - pollingInterval - }); + + const labelClass = + "inline-flex shrink-0 items-center self-center text-sm font-medium leading-none"; + const valueClass = "inline-flex items-center gap-2 text-sm leading-none"; const handleRefresh = async () => { await refreshCert(); @@ -74,11 +69,15 @@ export default function CertificateStatus({ return (
{showLabel && ( - + {t("certificateStatus")}: )} - + + {t("loading")}
@@ -89,11 +88,17 @@ export default function CertificateStatus({ return (
{showLabel && ( - + {t("certificateStatus")}: )} - {certError} + + + {certError} +
); } @@ -102,32 +107,64 @@ export default function CertificateStatus({ return (
{showLabel && ( - + {t("certificateStatus")}: )} - + + {t("none", { defaultValue: "None" })}
); } + const isPending = cert.status === "pending"; + const disableRestartButton = cert.domainType === "wildcard"; + return (
{showLabel && ( - - {t("certificateStatus")}: - + {t("certificateStatus")}: )} - - + {isPending && !disableRestartButton ? ( + + ) : ( + + {cert.status.charAt(0).toUpperCase() + cert.status.slice(1)} - {shouldShowRefreshButton(cert.status, cert.updatedAt) && ( + {shouldShowRefreshButton(cert.status, cert.updatedAt) && + !disableRestartButton ? ( - )} + ) : null} - + )}
); } + +type CertificateStatusProps = { + orgId: string; + domainId: string; + fullDomain: string; + autoFetch?: boolean; + showLabel?: boolean; + className?: string; + onRefresh?: () => void; + polling?: boolean; + pollingInterval?: number; +}; + +export default function CertificateStatus({ + orgId, + domainId, + fullDomain, + autoFetch = true, + showLabel = true, + className = "", + onRefresh, + polling = false, + pollingInterval = 5000 +}: CertificateStatusProps) { + const hook = useCertificate({ + orgId, + domainId, + fullDomain, + autoFetch, + polling, + pollingInterval + }); + + return ( + + ); +} diff --git a/src/components/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx index ece2309e2..4815c85fb 100644 --- a/src/components/ClientInfoCard.tsx +++ b/src/components/ClientInfoCard.tsx @@ -8,6 +8,7 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { useTranslations } from "next-intl"; @@ -36,7 +37,24 @@ export default function SiteInfoCard({}: ClientInfoCardProps) { {userDisplayName ? t("user") : t("identifier")} - {userDisplayName || client.niceId} +
+ {userDisplayName || client.niceId} + {userDisplayName && + (client.userType ?? "internal") !== + "internal" && ( + + )} +
@@ -49,7 +67,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
) : (
-
+
{t("offline")}
)} diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5066f273d..88b1e938e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -4,6 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { DataTable } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { DropdownMenu, @@ -12,6 +13,11 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; @@ -21,38 +27,52 @@ import { ArrowUp10Icon, ArrowUpDown, ArrowUpRight, + ChevronDown, ChevronsUpDownIcon, + Funnel, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; - +import { Selectedsite, SitesSelector } from "@app/components/site-selector"; +import { useEffect, useMemo, useState, useTransition } from "react"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; -import { orgQueries } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess"; +import { + ResourceSitesStatusCell, + type ResourceSiteRow +} from "@app/components/ResourceSitesStatusCell"; +import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; +import { build } from "@server/build"; + +export type InternalResourceSiteRow = ResourceSiteRow; export type InternalResourceRow = { id: number; name: string; orgId: string; - siteName: string; - siteAddress: string | null; + sites: InternalResourceSiteRow[]; + siteNames: string[]; + siteAddresses: (string | null)[]; + siteIds: number[]; + siteNiceIds: string[]; // mode: "host" | "cidr" | "port"; - mode: "host" | "cidr"; + mode: "host" | "cidr" | "http"; + scheme: "http" | "https" | null; + ssl: boolean; // protocol: string | null; // proxyPort: number | null; - siteId: number; - siteNiceId: string; destination: string; - // destinationPort: number | null; + httpHttpsPort: number | null; alias: string | null; aliasAddress: string | null; niceId: string; @@ -61,20 +81,43 @@ export type InternalResourceRow = { disableIcmp: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + subdomain?: string | null; + domainId?: string | null; + fullDomain?: string | null; }; +function formatDestinationDisplay(row: InternalResourceRow): string { + return formatSiteResourceDestinationDisplay({ + mode: row.mode, + destination: row.destination, + httpHttpsPort: row.httpHttpsPort, + scheme: row.scheme + }); +} + +function isSafeUrlForLink(href: string): boolean { + try { + void new URL(href); + return true; + } catch { + return false; + } +} + type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; pagination: PaginationState; rowCount: number; + initialFilterSite?: Selectedsite | null; }; export default function ClientResourcesTable({ internalResources, orgId, pagination, - rowCount + rowCount, + initialFilterSite = null }: ClientResourcesTableProps) { const router = useRouter(); const { @@ -96,11 +139,33 @@ export default function ClientResourcesTable({ const [editingResource, setEditingResource] = useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - - const { data: sites = [] } = useQuery(orgQueries.sites({ orgId })); + const [siteFilterOpen, setSiteFilterOpen] = useState(false); const [isRefreshing, startTransition] = useTransition(); + useEffect(() => { + const interval = setInterval(() => { + router.refresh(); + }, 30_000); + return () => clearInterval(interval); + }, [router]); + + const siteIdQ = searchParams.get("siteId"); + const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; + const selectedSite: Selectedsite | null = useMemo(() => { + if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) { + return null; + } + if (initialFilterSite && initialFilterSite.siteId === siteIdNum) { + return initialFilterSite; + } + return { + siteId: siteIdNum, + name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }), + type: "newt" + }; + }, [initialFilterSite, siteIdQ, siteIdNum, t]); + const refreshData = () => { startTransition(() => { try { @@ -136,6 +201,59 @@ export default function ClientResourcesTable({ } }; + function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) { + const { siteNames, siteNiceIds, orgId } = resourceRow; + + if (!siteNames || siteNames.length === 0) { + return ( + + {t("noSites", { defaultValue: "No sites" })} + + ); + } + + if (siteNames.length === 1) { + return ( + + + + ); + } + + return ( + + + + + + {siteNames.map((siteName, idx) => ( + + + {siteName} + + + + ))} + + + ); + } + const internalColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -185,20 +303,65 @@ export default function ClientResourcesTable({ } }, { - accessorKey: "siteName", - friendlyName: t("site"), - header: () => {t("site")}, + id: "sites", + accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), + friendlyName: t("sites"), + header: () => ( + + + + + +
+ +
+ +
+
+ ), cell: ({ row }) => { const resourceRow = row.original; return ( - - - + ); } }, @@ -215,6 +378,10 @@ export default function ClientResourcesTable({ { value: "cidr", label: t("editInternalResourceDialogModeCidr") + }, + { + value: "http", + label: t("editInternalResourceDialogModeHttp") } ]} selectedValue={searchParams.get("mode") ?? undefined} @@ -227,10 +394,14 @@ export default function ClientResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - const modeLabels: Record<"host" | "cidr" | "port", string> = { + const modeLabels: Record< + "host" | "cidr" | "port" | "http", + string + > = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), - port: t("editInternalResourceDialogModePort") + port: t("editInternalResourceDialogModePort"), + http: t("editInternalResourceDialogModeHttp") }; return {modeLabels[resourceRow.mode]}; } @@ -243,11 +414,12 @@ export default function ClientResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; + const display = formatDestinationDisplay(resourceRow); return ( ); } @@ -260,15 +432,47 @@ export default function ClientResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - return resourceRow.mode === "host" && resourceRow.alias ? ( - - ) : ( - - - ); + if (resourceRow.mode === "host" && resourceRow.alias) { + return ( + + ); + } + 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 ( +
+ {did ? ( + + ) : null} +
+ +
+
+ ); + } + return -; } }, { @@ -356,6 +560,16 @@ export default function ClientResourcesTable({ }); } + const clearSiteFilter = () => { + handleFilterChange("siteId", undefined); + setSiteFilterOpen(false); + }; + + const onPickSite = (site: Selectedsite) => { + handleFilterChange("siteId", String(site.siteId)); + setSiteFilterOpen(false); + }; + function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); @@ -399,7 +613,7 @@ export default function ClientResourcesTable({ onConfirm={async () => deleteInternalResource( selectedInternalResource!.id, - selectedInternalResource!.siteId + selectedInternalResource!.siteIds[0] ) } string={selectedInternalResource.name} @@ -412,6 +626,7 @@ export default function ClientResourcesTable({ rows={internalResources} tableId="internal-resources" searchPlaceholder={t("resourcesSearch")} + searchQuery={searchParams.get("query") ?? ""} onAdd={() => setIsCreateDialogOpen(true)} addButtonText={t("resourceAdd")} onSearch={handleSearchChange} @@ -435,7 +650,6 @@ export default function ClientResourcesTable({ setOpen={setIsEditDialogOpen} resource={editingResource} orgId={orgId} - sites={sites} onSuccess={() => { // Delay refresh to allow modal to close smoothly setTimeout(() => { @@ -450,7 +664,6 @@ export default function ClientResourcesTable({ open={isCreateDialogOpen} setOpen={setIsCreateDialogOpen} orgId={orgId} - sites={sites} onSuccess={() => { // Delay refresh to allow modal to close smoothly setTimeout(() => { diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx index 3e7b585b8..5a944cd88 100644 --- a/src/components/ColumnFilter.tsx +++ b/src/components/ColumnFilter.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; interface FilterOption { @@ -74,7 +75,10 @@ export function ColumnFilter({ - + diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx index 7d17066cb..689f78983 100644 --- a/src/components/ColumnFilterButton.tsx +++ b/src/components/ColumnFilterButton.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; interface FilterOption { @@ -75,7 +76,10 @@ export function ColumnFilterButton({
- + diff --git a/src/components/ColumnMultiFilterButton.tsx b/src/components/ColumnMultiFilterButton.tsx new file mode 100644 index 000000000..787a306b2 --- /dev/null +++ b/src/components/ColumnMultiFilterButton.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { CheckIcon, Funnel } from "lucide-react"; +import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { Badge } from "./ui/badge"; + +type FilterOption = { + value: string; + label: string; +}; + +type ColumnMultiFilterButtonProps = { + options: FilterOption[]; + selectedValues: string[]; + onSelectedValuesChange: (values: string[]) => void; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; + label: string; +}; + +export function ColumnMultiFilterButton({ + options, + selectedValues, + onSelectedValuesChange, + searchPlaceholder = "Search...", + emptyMessage = "No options found", + className, + label +}: ColumnMultiFilterButtonProps) { + const [open, setOpen] = useState(false); + const t = useTranslations(); + + const selectedSet = useMemo( + () => new Set(selectedValues), + [selectedValues] + ); + + const summary = useMemo(() => { + if (selectedValues.length === 0) { + return null; + } + if (selectedValues.length === 1) { + return ( + options.find((o) => o.value === selectedValues[0])?.label ?? + selectedValues[0] + ); + } + return t("accessUsersRoleFilterCount", { + count: selectedValues.length + }); + }, [selectedValues, options, t]); + + function toggle(value: string) { + const next = selectedSet.has(value) + ? selectedValues.filter((v) => v !== value) + : [...selectedValues, value]; + onSelectedValuesChange(next); + } + + return ( + + + + + + + + + {emptyMessage} + + {selectedValues.length > 0 && ( + { + onSelectedValuesChange([]); + setOpen(false); + }} + className="text-muted-foreground" + > + {t("accessUsersRoleFilterClear")} + + )} + {options.map((option) => ( + { + toggle(option.value); + }} + > + + {option.label} + + ))} + + + + + + ); +} diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx index 4c5c6ad63..32fc83179 100644 --- a/src/components/ConfirmDeleteDialog.tsx +++ b/src/components/ConfirmDeleteDialog.tsx @@ -92,7 +92,7 @@ export default function ConfirmDeleteDialog({
{dialog} -
+
{warningText || t("cannotbeUndone")}
@@ -142,7 +142,9 @@ export default function ConfirmDeleteDialog({ form="confirm-delete-form" loading={loading} disabled={loading || !isConfirmed} - className={!isConfirmed && !loading ? "opacity-50" : ""} + className={ + !isConfirmed && !loading ? "opacity-50" : "" + } > {buttonText} diff --git a/src/components/ContactSalesBanner.tsx b/src/components/ContactSalesBanner.tsx new file mode 100644 index 000000000..e5cb87d83 --- /dev/null +++ b/src/components/ContactSalesBanner.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { KeyRound, ExternalLink } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +export function ContactSalesBanner() { + const t = useTranslations(); + + return ( +
+
+
+ + + {t("contactSalesEnable")}{" "} + + {t("contactSalesBookDemo")} + + + {" " + t("contactSalesOr") + " "} + + {t("contactSalesContactUs")} + + + . + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index d5ca61acc..4d2bc0916 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -14,7 +14,6 @@ import { Button } from "@app/components/ui/button"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -25,13 +24,10 @@ import { type InternalResourceFormValues } from "./InternalResourceForm"; -type Site = ListSitesResponse["sites"][0]; - type CreateInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; orgId: string; - sites: Site[]; onSuccess?: () => void; }; @@ -39,18 +35,21 @@ export default function CreateInternalResourceDialog({ open, setOpen, orgId, - sites, onSuccess }: CreateInternalResourceDialogProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const [isSubmitting, setIsSubmitting] = useState(false); + const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false); async function handleSubmit(values: InternalResourceFormValues) { setIsSubmitting(true); try { let data = { ...values }; - if (data.mode === "host" && isHostname(data.destination)) { + if ( + (data.mode === "host" || data.mode === "http") && + isHostname(data.destination) + ) { const currentAlias = data.alias?.trim() || ""; if (!currentAlias) { let aliasValue = data.destination; @@ -65,25 +64,56 @@ export default function CreateInternalResourceDialog({ `/org/${orgId}/site-resource`, { name: data.name, - siteId: data.siteId, + siteIds: data.siteIds, mode: data.mode, destination: data.destination, enabled: true, - alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined, - tcpPortRangeString: data.tcpPortRangeString, - udpPortRangeString: data.udpPortRangeString, - disableIcmp: data.disableIcmp ?? false, - ...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }), - ...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }), - roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [], + ...(data.mode === "http" && { + scheme: data.scheme, + ssl: data.ssl ?? false, + destinationPort: data.httpHttpsPort ?? undefined, + domainId: data.httpConfigDomainId + ? data.httpConfigDomainId + : undefined, + subdomain: data.httpConfigSubdomain + ? data.httpConfigSubdomain + : undefined + }), + ...(data.mode === "host" && { + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : undefined, + ...(data.authDaemonMode != null && { + authDaemonMode: data.authDaemonMode + }), + ...(data.authDaemonMode === "remote" && + data.authDaemonPort != null && { + authDaemonPort: data.authDaemonPort + }) + }), + ...((data.mode === "host" || data.mode == "cidr") && { + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + disableIcmp: data.disableIcmp ?? false + }), + roleIds: data.roles + ? data.roles.map((r) => parseInt(r.id)) + : [], userIds: data.users ? data.users.map((u) => u.id) : [], - clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : [] + clientIds: data.clients + ? data.clients.map((c) => parseInt(c.id)) + : [] } ); toast({ title: t("createInternalResourceDialogSuccess"), - description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"), + description: t( + "createInternalResourceDialogInternalResourceCreatedSuccessfully" + ), variant: "default" }); setOpen(false); @@ -93,7 +123,9 @@ export default function CreateInternalResourceDialog({ title: t("createInternalResourceDialogError"), description: formatAxiosError( error, - t("createInternalResourceDialogFailedToCreateInternalResource") + t( + "createInternalResourceDialogFailedToCreateInternalResource" + ) ), variant: "destructive" }); @@ -106,31 +138,39 @@ export default function CreateInternalResourceDialog({ - {t("createInternalResourceDialogCreateClientResource")} + + {t("createInternalResourceDialogCreateClientResource")} + - {t("createInternalResourceDialogCreateClientResourceDescription")} + {t( + "createInternalResourceDialogCreateClientResourceDescription" + )} - + + + + + ); +} diff --git a/src/components/DeleteAccountConfirmDialog.tsx b/src/components/DeleteAccountConfirmDialog.tsx index 7a54f9a04..904b49149 100644 --- a/src/components/DeleteAccountConfirmDialog.tsx +++ b/src/components/DeleteAccountConfirmDialog.tsx @@ -252,7 +252,7 @@ export default function DeleteAccountConfirmDialog({ )}
-

+

{t("cannotbeUndone")}

diff --git a/src/components/DomainPageClient.tsx b/src/components/DomainPageClient.tsx new file mode 100644 index 000000000..31527c5b8 --- /dev/null +++ b/src/components/DomainPageClient.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { domainQueries } from "@app/lib/queries"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { GetDNSRecordsResponse } from "@server/routers/domain"; +import DomainInfoCard from "@app/components/DomainInfoCard"; +import DNSRecordsTable from "@app/components/DNSRecordTable"; +import RestartDomainButton from "@app/components/RestartDomainButton"; +import RefreshButton from "@app/components/RefreshButton"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainCertForm from "@app/components/DomainCertForm"; +import { build } from "@server/build"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; + +interface DomainPageClientProps { + initialDomain: GetDomainResponse; + initialDnsRecords: GetDNSRecordsResponse; + orgId: string; + domainId: string; +} + +export default function DomainPageClient({ + initialDomain, + initialDnsRecords, + orgId, + domainId +}: DomainPageClientProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + + const { data: domain, refetch: refetchDomain } = useQuery({ + ...domainQueries.getDomain({ orgId, domainId }), + initialData: initialDomain + }); + + const { data: dnsRecords, refetch: refetchDnsRecords } = useQuery({ + ...domainQueries.getDNSRecords({ orgId, domainId }), + initialData: initialDnsRecords + }); + + const refetchAll = () => { + refetchDomain(); + refetchDnsRecords(); + }; + + return ( + <> +
+ + {env.flags.usePangolinDns && domain.failed ? ( + + ) : ( + + )} +
+
+ {build !== "oss" && env.flags.usePangolinDns ? ( + + ) : null} + + ({ + ...r, + id: String(r.id) + }))} + type={domain.type} + /> + + {domain.type === "wildcard" && !domain.configManaged && ( + + )} +
+ + ); +} \ No newline at end of file diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index e1ec1062e..daacf892b 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -27,6 +27,7 @@ import { cn } from "@/lib/cn"; import { finalizeSubdomainSanitize, isValidSubdomainStructure, + isWildcardSubdomain, sanitizeInputRaw, validateByDomainType } from "@/lib/subdomain-utils"; @@ -41,14 +42,17 @@ import { Check, CheckCircle2, ChevronsUpDown, + ExternalLink, KeyRound, Zap } from "lucide-react"; import { useTranslations } from "next-intl"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@/hooks/usePaidStatus"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { toUnicode } from "punycode"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useUserContext } from "@app/hooks/useUserContext"; type AvailableOption = { domainNamespaceId: string; @@ -76,6 +80,7 @@ interface DomainPickerProps { subdomain?: string; fullDomain: string; baseDomain: string; + wildcard?: boolean; } | null ) => void; cols?: number; @@ -84,6 +89,7 @@ interface DomainPickerProps { defaultSubdomain?: string | null; defaultDomainId?: string | null; warnOnProvidedDomain?: boolean; + allowWildcard?: boolean; } export default function DomainPicker({ @@ -94,17 +100,30 @@ export default function DomainPicker({ defaultSubdomain, defaultFullDomain, defaultDomainId, - warnOnProvidedDomain = false + warnOnProvidedDomain = false, + allowWildcard = false }: DomainPickerProps) { const { env } = useEnvContext(); + const { user } = useUserContext(); const api = createApiClient({ env }); const t = useTranslations(); - const { hasSaasSubscription } = usePaidStatus(); + const { hasSaasSubscription, isPaidUser } = usePaidStatus(); + + const requiresPaywall = + build === "saas" && + !hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) && + new Date(user.dateCreated) > new Date("2026-04-13"); + + const wildcardAllowed = + allowWildcard && isPaidUser(tierMatrix[TierFeature.WildcardSubdomain]); const { data = [], isLoading: loadingDomains } = useQuery( orgQueries.domains({ orgId }) ); + // Wildcard mode is derived from the input itself — if the user types a + // wildcard subdomain (e.g. *.foo) and allowWildcard is enabled, it's active. + if (!env.flags.usePangolinDns) { hideFreeDomain = true; } @@ -168,15 +187,21 @@ export default function DomainPicker({ domainId: firstOrExistingDomain.domainId }; + const base = firstOrExistingDomain.baseDomain; + const sub = + firstOrExistingDomain.type !== "cname" + ? defaultSubdomain?.trim() || undefined + : undefined; + const isWc = + allowWildcard && !!sub && isWildcardSubdomain(sub); + onDomainChange?.({ domainId: firstOrExistingDomain.domainId, type: "organization", - subdomain: - firstOrExistingDomain.type !== "cname" - ? defaultSubdomain || undefined - : undefined, - fullDomain: firstOrExistingDomain.baseDomain, - baseDomain: firstOrExistingDomain.baseDomain + subdomain: sub, + fullDomain: sub ? `${sub}.${base}` : base, + baseDomain: base, + wildcard: isWc }); } } @@ -275,7 +300,8 @@ export default function DomainPicker({ }, [userInput, debouncedCheckAvailability, selectedBaseDomain]); const finalizeSubdomain = (sub: string, base: DomainOption): string => { - const sanitized = finalizeSubdomainSanitize(sub); + const wildcardMode = wildcardAllowed && isWildcardSubdomain(sub); + const sanitized = finalizeSubdomainSanitize(sub, wildcardMode); if (!sanitized) { toast({ @@ -291,7 +317,8 @@ export default function DomainPicker({ base.type === "provided-search" ? "provided-search" : "organization", - domainType: base.domainType + domainType: base.domainType, + allowWildcard: wildcardMode }); if (!ok) { @@ -320,7 +347,7 @@ export default function DomainPicker({ }; const handleSubdomainChange = (value: string) => { - const raw = sanitizeInputRaw(value); + const raw = sanitizeInputRaw(value, allowWildcard); setSubdomainInput(raw); setSelectedProvidedDomain(null); @@ -328,13 +355,15 @@ export default function DomainPicker({ const fullDomain = raw ? `${raw}.${selectedBaseDomain.domain}` : selectedBaseDomain.domain; + const isWc = wildcardAllowed && isWildcardSubdomain(raw); onDomainChange?.({ domainId: selectedBaseDomain.domainId!, type: "organization", subdomain: raw || undefined, fullDomain, - baseDomain: selectedBaseDomain.domain + baseDomain: selectedBaseDomain.domain, + wildcard: isWc }); } }; @@ -356,6 +385,17 @@ export default function DomainPicker({ const handleBaseDomainSelect = (option: DomainOption) => { let sub = subdomainInput; + // If the selected domain doesn't support wildcards, strip any wildcard prefix. + const supportsWildcard = + wildcardAllowed && + option.type === "organization" && + option.domainType !== "cname"; + + if (!supportsWildcard && isWildcardSubdomain(sub)) { + sub = sub.replace(/^\*\./, ""); + setSubdomainInput(sub); + } + if (sub && sub.trim() !== "") { sub = finalizeSubdomain(sub, option) || ""; setSubdomainInput(sub); @@ -379,6 +419,7 @@ export default function DomainPicker({ } const fullDomain = sub ? `${sub}.${option.domain}` : option.domain; + const isWc = wildcardAllowed && !!sub && isWildcardSubdomain(sub); if (option.type === "provided-search") { onDomainChange?.(null); // prevent the modal from closing with `.Free Provided domain` @@ -392,7 +433,8 @@ export default function DomainPicker({ ? sub || undefined : undefined, fullDomain, - baseDomain: option.domain + baseDomain: option.domain, + wildcard: isWc }); } }; @@ -421,7 +463,9 @@ export default function DomainPicker({ selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization", - domainType: selectedBaseDomain.domainType + domainType: selectedBaseDomain.domainType, + allowWildcard: + wildcardAllowed && isWildcardSubdomain(subdomainInput) }) : true; @@ -429,6 +473,7 @@ export default function DomainPicker({ selectedBaseDomain && selectedBaseDomain.type === "organization" && selectedBaseDomain.domainType !== "cname"; + const showProvidedDomainSearch = selectedBaseDomain?.type === "provided-search"; @@ -453,9 +498,11 @@ export default function DomainPicker({
- +
+ +
{showSubdomainInput && subdomainInput && - !isValidSubdomainStructure(subdomainInput) && ( + !isValidSubdomainStructure( + subdomainInput, + wildcardAllowed && + isWildcardSubdomain(subdomainInput) + ) && (

{t("domainPickerInvalidSubdomainStructure")}

)} + {allowWildcard && + !wildcardAllowed && + showSubdomainInput && + isWildcardSubdomain(subdomainInput) && ( + <> +

+ {t( + "domainPickerWildcardSubdomainNotAllowed" + )} +

+ + + )}
@@ -582,23 +655,23 @@ export default function DomainPicker({ {orgDomain.type === - "wildcard" - ? t( - "domainPickerManual" - ) - : ( - <> - {orgDomain.type.toUpperCase()}{" "} - •{" "} - {orgDomain.verified - ? t( - "domainPickerVerified" - ) - : t( - "domainPickerUnverified" - )} - - )} + "wildcard" ? ( + t( + "domainPickerManual" + ) + ) : ( + <> + {orgDomain.type.toUpperCase()}{" "} + •{" "} + {orgDomain.verified + ? t( + "domainPickerVerified" + ) + : t( + "domainPickerUnverified" + )} + + )}
@@ -696,22 +770,18 @@ export default function DomainPicker({
- {build === "saas" && - !hasSaasSubscription( - tierMatrix[TierFeature.DomainNamespaces] - ) && - !hideFreeDomain && ( - - -
- - - {t("domainPickerFreeDomainsPaidFeature")} - -
-
-
- )} + {requiresPaywall && !hideFreeDomain && ( + + +
+ + + {t("domainPickerFreeDomainsPaidFeature")} + +
+
+
+ )} {/*showProvidedDomainSearch && build === "saas" && ( @@ -838,6 +908,22 @@ export default function DomainPicker({ )}
)} + {selectedBaseDomain?.domainType === "wildcard" && + isWildcardSubdomain(subdomainInput) && ( +

+ {t("domainPickerWildcardCertWarning")}{" "} + + {t("domainPickerWildcardCertWarningLink")} + + + . +

+ )}
); } diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index f5cb1ae74..2c3abeb1a 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -10,13 +10,12 @@ import { MoreHorizontal, RefreshCw } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Badge } from "@app/components/ui/badge"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import CreateDomainForm from "@app/components/CreateDomainForm"; import { useToast } from "@app/hooks/useToast"; @@ -34,6 +33,10 @@ import { TooltipTrigger } from "./ui/tooltip"; import Link from "next/link"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { orgQueries } from "@app/lib/queries"; +import { toUnicode } from "punycode"; +import { durationToMs } from "@app/lib/durationToMs"; export type DomainRow = { domainId: string; @@ -59,32 +62,32 @@ export default function DomainsTable({ domains, orgId }: Props) { const [selectedDomain, setSelectedDomain] = useState( null ); - const [isRefreshing, setIsRefreshing] = useState(false); const [restartingDomains, setRestartingDomains] = useState>( new Set() ); const env = useEnvContext(); const api = createApiClient(env); - const router = useRouter(); const t = useTranslations(); const { toast } = useToast(); const { org } = useOrgContext(); + const queryClient = useQueryClient(); - const refreshData = async () => { - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } - }; + const { data: rawDomains, isRefetching, refetch } = useQuery({ + ...orgQueries.domains({ orgId }), + initialData: domains as any, + refetchInterval: durationToMs(10, "seconds") + }); + + const tableData = useMemo( + () => + (rawDomains ?? []).map((d) => ({ + ...d, + baseDomain: toUnicode(d.baseDomain), + type: d.type ?? "", + errorMessage: d.errorMessage ?? null + } as DomainRow)), + [rawDomains] + ); const deleteDomain = async (domainId: string) => { try { @@ -94,7 +97,7 @@ export default function DomainsTable({ domains, orgId }: Props) { description: t("domainDeletedDescription") }); setIsDeleteModalOpen(false); - refreshData(); + refetch(); } catch (e) { toast({ title: t("error"), @@ -114,7 +117,7 @@ export default function DomainsTable({ domains, orgId }: Props) { fallback: "Domain verification restarted successfully" }) }); - refreshData(); + refetch(); } catch (e) { toast({ title: t("error"), @@ -361,16 +364,16 @@ export default function DomainsTable({ domains, orgId }: Props) { open={isCreateModalOpen} setOpen={setIsCreateModalOpen} onCreated={(domain) => { - refreshData(); + refetch(); }} /> setIsCreateModalOpen(true)} - onRefresh={refreshData} - isRefreshing={isRefreshing} + onRefresh={refetch} + isRefreshing={isRefetching} /> ); diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 690ad405d..859981f7d 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -15,7 +15,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { resourceQueries } from "@app/lib/queries"; -import { ListSitesResponse } from "@server/routers/site"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useState, useTransition } from "react"; @@ -27,14 +26,11 @@ import { isHostname } from "./InternalResourceForm"; -type Site = ListSitesResponse["sites"][0]; - type EditInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; resource: InternalResourceData; orgId: string; - sites: Site[]; onSuccess?: () => void; }; @@ -43,18 +39,21 @@ export default function EditInternalResourceDialog({ setOpen, resource, orgId, - sites, onSuccess }: EditInternalResourceDialogProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const queryClient = useQueryClient(); const [isSubmitting, startTransition] = useTransition(); + const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false); async function handleSubmit(values: InternalResourceFormValues) { try { let data = { ...values }; - if (data.mode === "host" && isHostname(data.destination)) { + if ( + (data.mode === "host" || data.mode === "http") && + isHostname(data.destination) + ) { const currentAlias = data.alias?.trim() || ""; if (!currentAlias) { let aliasValue = data.destination; @@ -67,24 +66,39 @@ export default function EditInternalResourceDialog({ await api.post(`/site-resource/${resource.id}`, { name: data.name, - siteId: data.siteId, + siteIds: data.siteIds, mode: data.mode, niceId: data.niceId, destination: data.destination, - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : null, - tcpPortRangeString: data.tcpPortRangeString, - udpPortRangeString: data.udpPortRangeString, - disableIcmp: data.disableIcmp ?? false, - ...(data.authDaemonMode != null && { - authDaemonMode: data.authDaemonMode + ...(data.mode === "http" && { + scheme: data.scheme, + ssl: data.ssl ?? false, + destinationPort: data.httpHttpsPort ?? null, + domainId: data.httpConfigDomainId + ? data.httpConfigDomainId + : undefined, + subdomain: data.httpConfigSubdomain + ? data.httpConfigSubdomain + : undefined }), - ...(data.authDaemonMode === "remote" && { - authDaemonPort: data.authDaemonPort || null + ...(data.mode === "host" && { + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : null, + ...(data.authDaemonMode != null && { + authDaemonMode: data.authDaemonMode + }), + ...(data.authDaemonMode === "remote" && { + authDaemonPort: data.authDaemonPort || null + }) + }), + ...((data.mode === "host" || data.mode === "cidr") && { + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + disableIcmp: data.disableIcmp ?? false }), roleIds: (data.roles || []).map((r) => parseInt(r.id)), userIds: (data.users || []).map((u) => u.id), @@ -156,13 +170,13 @@ export default function EditInternalResourceDialog({ variant="edit" open={open} resource={resource} - sites={sites} orgId={orgId} siteResourceId={resource.id} formId="edit-internal-resource-form" onSubmit={(values) => startTransition(() => handleSubmit(values)) } + onSubmitDisabledChange={setIsHttpModeDisabled} />
@@ -178,7 +192,7 @@ export default function EditInternalResourceDialog({
) : (
-
+
{t("offline")}
)} diff --git a/src/components/ExitNodesTable.tsx b/src/components/ExitNodesTable.tsx index 5c39f409e..73e96a96c 100644 --- a/src/components/ExitNodesTable.tsx +++ b/src/components/ExitNodesTable.tsx @@ -21,6 +21,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import { Badge } from "@app/components/ui/badge"; +import { InfoPopup } from "@app/components/ui/info-popup"; export type RemoteExitNodeRow = { id: string; @@ -33,6 +34,7 @@ export type RemoteExitNodeRow = { online: boolean; dateCreated: string; version?: string; + updateAvailable?: boolean; }; type ExitNodesTableProps = { @@ -146,7 +148,7 @@ export default function ExitNodesTable({ } else { return ( -
+
{t("offline")}
); @@ -233,13 +235,18 @@ export default function ExitNodesTable({ const originalRow = row.original; return (
- {originalRow.version && originalRow.version ? ( + {originalRow.version ? ( {"v" + originalRow.version} ) : ( "-" )} + {originalRow.updateAvailable && ( + + )}
); } diff --git a/src/components/GenerateLicenseKeysTable.tsx b/src/components/GenerateLicenseKeysTable.tsx index 48eeb045e..e6961251b 100644 --- a/src/components/GenerateLicenseKeysTable.tsx +++ b/src/components/GenerateLicenseKeysTable.tsx @@ -4,7 +4,7 @@ import { useTranslations } from "next-intl"; import { ColumnDef } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "./ui/button"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import CopyToClipboard from "./CopyToClipboard"; import { Badge } from "./ui/badge"; import moment from "moment"; @@ -16,6 +16,12 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import NewPricingLicenseForm from "./NewPricingLicenseForm"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; type GnerateLicenseKeysTableProps = { licenseKeys: GeneratedLicenseKey[]; @@ -44,6 +50,7 @@ export default function GenerateLicenseKeysTable({ const [isRefreshing, setIsRefreshing] = useState(false); const [showGenerateForm, setShowGenerateForm] = useState(false); + const [isClearingInstanceName, setIsClearingInstanceName] = useState(false); useEffect(() => { if (searchParams.get(GENERATE_QUERY) !== null) { @@ -63,6 +70,28 @@ export default function GenerateLicenseKeysTable({ refreshData(); }; + const clearInstanceName = async (licenseKey: string) => { + setIsClearingInstanceName(true); + try { + await api.post( + `/org/${orgId}/license/${encodeURIComponent(licenseKey)}/clear-instance-name` + ); + toast({ + title: t("success"), + description: "Instance name cleared successfully" + }); + await refreshData(); + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError(error, "Failed to clear instance name"), + variant: "destructive" + }); + } finally { + setIsClearingInstanceName(false); + } + }; + const refreshData = async () => { console.log("Data refreshed"); setIsRefreshing(true); @@ -236,6 +265,39 @@ export default function GenerateLicenseKeysTable({ const termianteAt = row.original.expiresAt; return moment(termianteAt).format("lll"); } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const key = row.original; + return ( +
+ + + + + + + clearInstanceName(key.licenseKey) + } + > + Clear Instance Name + + + +
+ ); + } } ]; @@ -254,6 +316,7 @@ export default function GenerateLicenseKeysTable({ onAdd={() => { setShowGenerateForm(true); }} + stickyRightColumn="actions" /> void; + orgId?: string; + targetAddress: string; + targetMethod?: string; + initialConfig?: Partial; + onChanges: (config: HealthCheckConfig) => Promise; + } + | { + mode: "submit"; + open: boolean; + setOpen: (v: boolean) => void; + orgId: string; + initialValues?: HealthCheckRow | null; + onSaved: () => void; + }; + +const DEFAULT_VALUES = { + name: "", + hcEnabled: true, + hcMode: "http", + hcScheme: "http", + hcMethod: "GET", + hcHostname: "", + hcPort: "", + hcPath: "/", + hcInterval: 30, + hcUnhealthyInterval: 30, + hcTimeout: 5, + hcHealthyThreshold: 1, + hcUnhealthyThreshold: 1, + hcFollowRedirects: true, + hcTlsServerName: "", + hcStatus: null as number | null, + hcHeaders: [] as { name: string; value: string }[] +}; + +export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { + const { mode, open, setOpen, orgId } = props; + + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + const [selectedSite, setSelectedSite] = useState(null); + + const healthCheckSchema = z + .object({ + ...(mode === "submit" + ? { + name: z + .string() + .min(1, { message: t("standaloneHcNameLabel") }) + } + : {}), + hcEnabled: z.boolean(), + hcPath: z.string().optional(), + hcMethod: z.string().optional(), + hcInterval: z + .int() + .positive() + .min(5, { message: t("healthCheckIntervalMin") }), + hcTimeout: z + .int() + .positive() + .min(1, { message: t("healthCheckTimeoutMin") }), + hcStatus: z.int().positive().min(100).optional().nullable(), + hcHeaders: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable() + .optional(), + hcScheme: z.string().optional(), + hcHostname: z.string(), + hcPort: z + .string() + .min(1, { message: t("healthCheckPortInvalid") }) + .refine( + (val) => { + const port = parseInt(val); + return port > 0 && port <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ), + hcFollowRedirects: z.boolean(), + hcMode: z.string(), + hcUnhealthyInterval: z.int().positive().min(5), + hcTlsServerName: z.string(), + hcHealthyThreshold: z + .int() + .positive() + .min(1, { message: t("healthCheckHealthyThresholdMin") }), + hcUnhealthyThreshold: z + .int() + .positive() + .min(1, { message: t("healthCheckUnhealthyThresholdMin") }) + }) + .superRefine((data, ctx) => { + if (data.hcMode !== "tcp") { + if (!data.hcPath || data.hcPath.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("healthCheckPathRequired"), + path: ["hcPath"] + }); + } + if (!data.hcMethod || data.hcMethod.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("healthCheckMethodRequired"), + path: ["hcMethod"] + }); + } + } + }); + + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(healthCheckSchema), + defaultValues: mode === "submit" ? DEFAULT_VALUES : {} + }); + + const watchedEnabled = form.watch("hcEnabled"); + const watchedMode = form.watch("hcMode"); + + useEffect(() => { + if (!open) return; + + if (mode === "autoSave") { + const { initialConfig, targetMethod } = props; + + const getDefaultScheme = () => { + if (initialConfig?.hcScheme) return initialConfig.hcScheme; + if (targetMethod === "https") return "https"; + return "http"; + }; + + form.reset({ + hcEnabled: initialConfig?.hcEnabled, + hcPath: initialConfig?.hcPath, + hcMethod: initialConfig?.hcMethod, + hcInterval: initialConfig?.hcInterval, + hcTimeout: initialConfig?.hcTimeout, + hcStatus: initialConfig?.hcStatus, + hcHeaders: initialConfig?.hcHeaders, + hcScheme: getDefaultScheme(), + hcHostname: initialConfig?.hcHostname, + hcPort: initialConfig?.hcPort + ? initialConfig.hcPort.toString() + : "", + hcFollowRedirects: initialConfig?.hcFollowRedirects, + hcMode: initialConfig?.hcMode ?? "http", + hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval, + hcTlsServerName: initialConfig?.hcTlsServerName ?? "", + hcHealthyThreshold: initialConfig?.hcHealthyThreshold ?? 1, + hcUnhealthyThreshold: initialConfig?.hcUnhealthyThreshold ?? 1 + }); + } else { + const { initialValues } = props; + + if (initialValues) { + let parsedHeaders: { name: string; value: string }[] = []; + if (initialValues.hcHeaders) { + try { + parsedHeaders = JSON.parse(initialValues.hcHeaders); + } catch { + parsedHeaders = []; + } + } + + form.reset({ + name: initialValues.name, + hcEnabled: initialValues.hcEnabled, + hcMode: initialValues.hcMode ?? "http", + hcScheme: initialValues.hcScheme ?? "http", + hcMethod: initialValues.hcMethod ?? "GET", + hcHostname: initialValues.hcHostname ?? "", + hcPort: initialValues.hcPort + ? initialValues.hcPort.toString() + : "", + hcPath: initialValues.hcPath ?? "/", + hcInterval: initialValues.hcInterval ?? 30, + hcUnhealthyInterval: + initialValues.hcUnhealthyInterval ?? 30, + hcTimeout: initialValues.hcTimeout ?? 5, + hcHealthyThreshold: initialValues.hcHealthyThreshold ?? 1, + hcUnhealthyThreshold: + initialValues.hcUnhealthyThreshold ?? 1, + hcFollowRedirects: initialValues.hcFollowRedirects ?? true, + hcTlsServerName: initialValues.hcTlsServerName ?? "", + hcStatus: initialValues.hcStatus ?? null, + hcHeaders: parsedHeaders + }); + if (initialValues.siteId && initialValues.siteName) { + setSelectedSite({ siteId: initialValues.siteId, name: initialValues.siteName, type: "" }); + } else { + setSelectedSite(null); + } + } else { + form.reset(DEFAULT_VALUES); + setSelectedSite(null); + } + } + }, [open]); + + const handleFieldChange = async (fieldName: string, value: any) => { + if (mode !== "autoSave") return; + try { + const currentValues = form.getValues(); + const updatedValues = { ...currentValues, [fieldName]: value }; + + const configToSend: HealthCheckConfig = { + ...updatedValues, + hcPath: updatedValues.hcPath ?? "", + hcMethod: updatedValues.hcMethod ?? "", + hcPort: parseInt(updatedValues.hcPort), + hcStatus: updatedValues.hcStatus || null, + hcHealthyThreshold: updatedValues.hcHealthyThreshold, + hcUnhealthyThreshold: updatedValues.hcUnhealthyThreshold + }; + + await props.onChanges(configToSend); + } catch (error) { + toast({ + title: t("healthCheckError"), + description: t("healthCheckErrorDescription"), + variant: "destructive" + }); + } + }; + + const handleChange = ( + fieldName: string, + value: any, + fieldOnChange: (v: any) => void + ) => { + fieldOnChange(value); + if (mode === "autoSave") { + handleFieldChange(fieldName, value); + } + }; + + const onSubmit = async (values: FormValues) => { + if (mode !== "submit") return; + const { initialValues, onSaved } = props; + + setLoading(true); + try { + const payload = { + name: (values as any).name, + siteId: selectedSite?.siteId, + hcEnabled: values.hcEnabled, + hcMode: values.hcMode, + hcScheme: values.hcScheme, + hcMethod: values.hcMethod, + hcHostname: values.hcHostname, + hcPort: parseInt(values.hcPort), + hcPath: values.hcPath ?? "", + hcInterval: values.hcInterval, + hcUnhealthyInterval: values.hcUnhealthyInterval, + hcTimeout: values.hcTimeout, + hcHealthyThreshold: values.hcHealthyThreshold, + hcUnhealthyThreshold: values.hcUnhealthyThreshold, + hcFollowRedirects: values.hcFollowRedirects, + hcTlsServerName: values.hcTlsServerName, + hcStatus: values.hcStatus || null, + hcHeaders: + values.hcHeaders && values.hcHeaders.length > 0 + ? JSON.stringify(values.hcHeaders) + : null + }; + + if (initialValues) { + await api.post( + `/org/${orgId}/health-check/${initialValues.targetHealthCheckId}`, + payload + ); + } else { + await api.put(`/org/${orgId}/health-check`, payload); + } + + toast({ title: t("standaloneHcSaved") }); + onSaved(); + setOpen(false); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setLoading(false); + } + }; + + const isEditing = mode === "submit" && !!(props as any).initialValues; + + const title = + mode === "autoSave" + ? t("configureHealthCheck") + : isEditing + ? t("standaloneHcEditTitle") + : t("standaloneHcCreateTitle"); + + const description = + mode === "autoSave" + ? t("configureHealthCheckDescription", { + target: (props as any).targetAddress + }) + : t("standaloneHcDescription"); + + const disableTabInputs = mode === "autoSave" && !watchedEnabled; + const isSnmpOrIcmp = watchedMode === "snmp" || watchedMode === "icmp"; + const isTcp = watchedMode === "tcp"; + + return ( + + + + {title} + {description} + + +
+ + {/* Name (submit mode only) */} + {mode === "submit" && ( + ( + + + {t("standaloneHcNameLabel")} + + + + + + + )} + /> + )} + + {/* Site picker (submit mode only) */} + {mode === "submit" && ( +
+ + {t("site")} + + + + + + { + setSelectedSite(site); + }} + filterTypes={["newt"]} + /> + + + +
+ )} + + {mode === "autoSave" && ( +
+ ( + + + + handleChange( + "hcEnabled", + value, + field.onChange + ) + } + /> + + + )} + /> +
+ )} + +
+ + {/* ── Strategy tab ──────────────────────── */} +
+
+ {/* Strategy picker */} + ( + + + + handleChange( + "hcMode", + value, + field.onChange + ) + } + /> + + + + )} + /> +
+
+ + {/* ── Connection tab ────────────────────── */} +
+
+ {/* Contact-sales banner for SNMP / ICMP */} + {isSnmpOrIcmp && } + + {!isSnmpOrIcmp && ( + <> + {/* Scheme / Hostname / Port */} + {isTcp ? ( +
+ ( + + + {t( + "healthHostname" + )} + + + + handleChange( + "hcHostname", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t( + "healthPort" + )} + + + + handleChange( + "hcPort", + e + .target + .value, + field.onChange + ) + } + /> + + + + )} + /> +
+ ) : ( +
+ ( + + + {t( + "healthScheme" + )} + + + + + )} + /> + ( + + + {t( + "healthHostname" + )} + + + + handleChange( + "hcHostname", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t( + "healthPort" + )} + + + + handleChange( + "hcPort", + e + .target + .value, + field.onChange + ) + } + /> + + + + )} + /> +
+ )} + + {/* Method / Path / Timeout (HTTP) */} + {!isTcp && ( +
+ ( + + + {t( + "httpMethod" + )} + + + + + )} + /> + ( + + + {t( + "healthCheckPath" + )} + + + + handleChange( + "hcPath", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t( + "timeoutSeconds" + )} + + + + handleChange( + "hcTimeout", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> +
+ )} + + {/* Timeout for TCP */} + {isTcp && ( + ( + + + {t( + "timeoutSeconds" + )} + + + + handleChange( + "hcTimeout", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> + )} + + )} +
+
+ + {/* ── Advanced tab ──────────────────────── */} +
+
+ {/* Contact-sales banner for SNMP / ICMP */} + {isSnmpOrIcmp && } + + {!isSnmpOrIcmp && ( + <> + {/* Healthy interval + threshold */} +
+ ( + + + {t( + "healthyIntervalSeconds" + )} + + + + handleChange( + "hcInterval", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> + ( + + + {t( + "healthyThreshold" + )} + + + + handleChange( + "hcHealthyThreshold", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> +
+ + {/* Unhealthy interval + threshold */} +
+ ( + + + {t( + "unhealthyIntervalSeconds" + )} + + + + handleChange( + "hcUnhealthyInterval", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> + ( + + + {t( + "unhealthyThreshold" + )} + + + + handleChange( + "hcUnhealthyThreshold", + parseInt( + e + .target + .value + ), + field.onChange + ) + } + /> + + + + )} + /> +
+ + {/* HTTP-only advanced fields */} + {!isTcp && ( + <> + {/* Expected status + TLS server name */} +
+ ( + + + {t( + "expectedResponseCodes" + )} + + + { + const val = + e + .target + .value; + const value = + val + ? parseInt( + val + ) + : null; + handleChange( + "hcStatus", + value, + field.onChange + ); + }} + /> + + + + )} + /> + ( + + + {t( + "tlsServerName" + )} + + + + handleChange( + "hcTlsServerName", + e + .target + .value, + () => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> +
+ + {/* Follow redirects */} + ( + + + {t( + "followRedirects" + )} + + + + handleChange( + "hcFollowRedirects", + value, + field.onChange + ) + } + /> + + + )} + /> + + {/* Custom headers */} + ( + + + {t( + "customHeaders" + )} + + + + handleChange( + "hcHeaders", + value, + field.onChange + ) + } + rows={ + 4 + } + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> + + )} + + )} +
+
+
+
+ + +
+ + {mode === "autoSave" ? ( + + ) : ( + <> + + + + + + )} + +
+
+ ); +} + +export default HealthCheckCredenza; diff --git a/src/components/HealthCheckDialog.tsx b/src/components/HealthCheckDialog.tsx deleted file mode 100644 index c95908025..000000000 --- a/src/components/HealthCheckDialog.tsx +++ /dev/null @@ -1,635 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { HeadersInput } from "@app/components/HeadersInput"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@/components/ui/form"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@/components/Credenza"; -import { toast } from "@/hooks/useToast"; -import { useTranslations } from "next-intl"; - -type HealthCheckConfig = { - hcEnabled: boolean; - hcPath: string; - hcMethod: string; - hcInterval: number; - hcTimeout: number; - hcStatus: number | null; - hcHeaders?: { name: string; value: string }[] | null; - hcScheme?: string; - hcHostname: string; - hcPort: number; - hcFollowRedirects: boolean; - hcMode: string; - hcUnhealthyInterval: number; - hcTlsServerName: string; -}; - -type HealthCheckDialogProps = { - open: boolean; - setOpen: (val: boolean) => void; - targetId: number; - targetAddress: string; - targetMethod?: string; - initialConfig?: Partial; - onChanges: (config: HealthCheckConfig) => Promise; -}; - -export default function HealthCheckDialog({ - open, - setOpen, - targetId, - targetAddress, - targetMethod, - initialConfig, - onChanges -}: HealthCheckDialogProps) { - const t = useTranslations(); - - const healthCheckSchema = z.object({ - hcEnabled: z.boolean(), - hcPath: z.string().min(1, { message: t("healthCheckPathRequired") }), - hcMethod: z - .string() - .min(1, { message: t("healthCheckMethodRequired") }), - hcInterval: z - .int() - .positive() - .min(5, { message: t("healthCheckIntervalMin") }), - hcTimeout: z - .int() - .positive() - .min(1, { message: t("healthCheckTimeoutMin") }), - hcStatus: z.int().positive().min(100).optional().nullable(), - hcHeaders: z - .array(z.object({ name: z.string(), value: z.string() })) - .nullable() - .optional(), - hcScheme: z.string().optional(), - hcHostname: z.string(), - hcPort: z - .string() - .min(1, { message: t("healthCheckPortInvalid") }) - .refine( - (val) => { - const port = parseInt(val); - return port > 0 && port <= 65535; - }, - { - message: t("healthCheckPortInvalid") - } - ), - hcFollowRedirects: z.boolean(), - hcMode: z.string(), - hcUnhealthyInterval: z.int().positive().min(5), - hcTlsServerName: z.string() - }); - - const form = useForm>({ - resolver: zodResolver(healthCheckSchema), - defaultValues: {} - }); - - useEffect(() => { - if (!open) return; - - // Determine default scheme from target method - const getDefaultScheme = () => { - if (initialConfig?.hcScheme) { - return initialConfig.hcScheme; - } - // Default to target method if it's http or https, otherwise default to http - if (targetMethod === "https") { - return "https"; - } - return "http"; - }; - - form.reset({ - hcEnabled: initialConfig?.hcEnabled, - hcPath: initialConfig?.hcPath, - hcMethod: initialConfig?.hcMethod, - hcInterval: initialConfig?.hcInterval, - hcTimeout: initialConfig?.hcTimeout, - hcStatus: initialConfig?.hcStatus, - hcHeaders: initialConfig?.hcHeaders, - hcScheme: getDefaultScheme(), - hcHostname: initialConfig?.hcHostname, - hcPort: initialConfig?.hcPort - ? initialConfig.hcPort.toString() - : "", - hcFollowRedirects: initialConfig?.hcFollowRedirects, - hcMode: initialConfig?.hcMode, - hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval, - hcTlsServerName: initialConfig?.hcTlsServerName ?? "" - }); - }, [open]); - - const watchedEnabled = form.watch("hcEnabled"); - - const handleFieldChange = async (fieldName: string, value: any) => { - try { - const currentValues = form.getValues(); - const updatedValues = { ...currentValues, [fieldName]: value }; - - // Convert hcPort from string to number before passing to parent - const configToSend: HealthCheckConfig = { - ...updatedValues, - hcPort: parseInt(updatedValues.hcPort), - hcStatus: updatedValues.hcStatus || null - }; - - await onChanges(configToSend); - } catch (error) { - toast({ - title: t("healthCheckError"), - description: t("healthCheckErrorDescription"), - variant: "destructive" - }); - } - }; - - return ( - - - - {t("configureHealthCheck")} - - {t("configureHealthCheckDescription", { - target: targetAddress - })} - - - -
- - {/* Enable Health Checks */} - ( - -
- - {t("enableHealthChecks")} - - - {t( - "enableHealthChecksDescription" - )} - -
- - { - field.onChange(value); - handleFieldChange( - "hcEnabled", - value - ); - }} - /> - -
- )} - /> - - {watchedEnabled && ( -
-
- ( - - - {t("healthScheme")} - - - - - )} - /> - ( - - - {t("healthHostname")} - - - { - field.onChange( - e - ); - handleFieldChange( - "hcHostname", - e.target - .value - ); - }} - /> - - - - )} - /> - ( - - - {t("healthPort")} - - - { - const value = - e.target - .value; - field.onChange( - value - ); - handleFieldChange( - "hcPort", - value - ); - }} - /> - - - - )} - /> - ( - - - {t("healthCheckPath")} - - - { - field.onChange( - e - ); - handleFieldChange( - "hcPath", - e.target - .value - ); - }} - /> - - - - )} - /> -
- - {/* HTTP Method */} - ( - - - {t("httpMethod")} - - - - - )} - /> - - {/* Check Interval, Timeout, and Retry Attempts */} -
- ( - - - {t( - "healthyIntervalSeconds" - )} - - - { - const value = - parseInt( - e.target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcInterval", - value - ); - }} - /> - - - - )} - /> - - ( - - - {t( - "unhealthyIntervalSeconds" - )} - - - { - const value = - parseInt( - e.target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcUnhealthyInterval", - value - ); - }} - /> - - - - )} - /> - - ( - - - {t("timeoutSeconds")} - - - { - const value = - parseInt( - e.target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcTimeout", - value - ); - }} - /> - - - - )} - /> -
- - {/* Expected Response Codes */} - ( - - - {t("expectedResponseCodes")} - - - { - const value = - parseInt( - e.target - .value - ); - field.onChange( - value - ); - handleFieldChange( - "hcStatus", - value - ); - }} - /> - - - {t( - "expectedResponseCodesDescription" - )} - - - - )} - /> - - {/*TLS Server Name (SNI)*/} - ( - - - {t("tlsServerName")} - - - { - field.onChange(e); - handleFieldChange( - "hcTlsServerName", - e.target.value - ); - }} - /> - - - {t( - "tlsServerNameDescription" - )} - - - - )} - /> - - {/* Custom Headers */} - ( - - - {t("customHeaders")} - - - { - field.onChange( - value - ); - handleFieldChange( - "hcHeaders", - value - ); - }} - rows={4} - /> - - - {t( - "customHeadersDescription" - )} - - - - )} - /> -
- )} - - -
- - - -
-
- ); -} diff --git a/src/components/HealthCheckFormFields.tsx b/src/components/HealthCheckFormFields.tsx new file mode 100644 index 000000000..6f5d528db --- /dev/null +++ b/src/components/HealthCheckFormFields.tsx @@ -0,0 +1,768 @@ +"use client"; + +import { UseFormReturn } from "react-hook-form"; +import { useTranslations } from "next-intl"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { Switch } from "@/components/ui/switch"; +import { HeadersInput } from "@app/components/HeadersInput"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { ExternalLink, KeyRound } from "lucide-react"; +import Link from "next/link"; + +type HealthCheckFormFieldsProps = { + form: UseFormReturn; + onFieldChange?: (fieldName: string, value: any) => void; + showNameField?: boolean; + hideEnabledField?: boolean; + watchedEnabled?: boolean; + watchedMode?: string; +}; + +export function HealthCheckFormFields({ + form, + onFieldChange, + showNameField, + hideEnabledField, + watchedEnabled, + watchedMode +}: HealthCheckFormFieldsProps) { + const t = useTranslations(); + + const showFields = hideEnabledField || watchedEnabled; + + const handleChange = ( + fieldName: string, + value: any, + fieldOnChange: (v: any) => void + ) => { + fieldOnChange(value); + if (onFieldChange) { + onFieldChange(fieldName, value); + } + }; + + return ( + <> + {/* Name */} + {showNameField && ( + ( + + {t("standaloneHcNameLabel")} + + + + + + )} + /> + )} + + {/* Enable Health Checks */} + {!hideEnabledField && ( + ( + +
+ {t("enableHealthChecks")} +
+ + + handleChange( + "hcEnabled", + value, + field.onChange + ) + } + /> + +
+ )} + /> + )} + + {showFields && ( +
+ {/* Strategy */} + ( + + + + handleChange( + "hcMode", + value, + field.onChange + ) + } + /> + + + + )} + /> + + {/* Inline contact-sales banner for SNMP / ICMP */} + {(watchedMode === "snmp" || watchedMode === "icmp") && ( +
+
+
+ + + Contact sales to enable this feature.{" "} + + Book a demo + + + {" or "} + + contact us + + + . + +
+
+
+ )} + + {/* Connection fields + all remaining config — hidden for SNMP / ICMP */} + {watchedMode !== "snmp" && watchedMode !== "icmp" && ( + <> + {/* Connection fields */} + {watchedMode === "tcp" ? ( +
+ ( + + + {t("healthHostname")} + + + + handleChange( + "hcHostname", + e.target.value, + (v) => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t("healthPort")} + + + { + const value = + e.target.value; + handleChange( + "hcPort", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
+ ) : ( +
+ ( + + + {t("healthScheme")} + + + + + )} + /> + ( + + + {t("healthHostname")} + + + + handleChange( + "hcHostname", + e.target.value, + (v) => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t("healthPort")} + + + { + const value = + e.target.value; + handleChange( + "hcPort", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
+ )} + + {/* HTTP Method + Path + Timeout (shown when not TCP) */} + {watchedMode !== "tcp" && ( +
+ ( + + + {t("httpMethod")} + + + + + )} + /> + ( + + + {t("healthCheckPath")} + + + + handleChange( + "hcPath", + e.target.value, + (v) => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> + ( + + + {t("timeoutSeconds")} + + + { + const value = + parseInt( + e.target + .value + ); + handleChange( + "hcTimeout", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
+ )} + + {/* TCP timeout (shown only for TCP) */} + {watchedMode === "tcp" && ( + ( + + + {t("timeoutSeconds")} + + + { + const value = parseInt( + e.target.value + ); + handleChange( + "hcTimeout", + value, + field.onChange + ); + }} + /> + + + + )} + /> + )} + + {/* Healthy interval + healthy threshold */} +
+ ( + + + {t("healthyIntervalSeconds")} + + + { + const value = parseInt( + e.target.value + ); + handleChange( + "hcInterval", + value, + field.onChange + ); + }} + /> + + + + )} + /> + ( + + + {t("healthyThreshold")} + + + { + const value = parseInt( + e.target.value + ); + handleChange( + "hcHealthyThreshold", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
+ + {/* Unhealthy interval + unhealthy threshold */} +
+ ( + + + {t("unhealthyIntervalSeconds")} + + + { + const value = parseInt( + e.target.value + ); + handleChange( + "hcUnhealthyInterval", + value, + field.onChange + ); + }} + /> + + + + )} + /> + ( + + + {t("unhealthyThreshold")} + + + { + const value = parseInt( + e.target.value + ); + handleChange( + "hcUnhealthyThreshold", + value, + field.onChange + ); + }} + /> + + + + )} + /> +
+ + {/* HTTP-only fields */} + {watchedMode !== "tcp" && ( + <> + {/* Expected Response Codes + TLS Server Name */} +
+ ( + + + {t( + "expectedResponseCodes" + )} + + + { + const val = + e.target + .value; + const value = + val + ? parseInt( + val + ) + : null; + handleChange( + "hcStatus", + value, + field.onChange + ); + }} + /> + + + + )} + /> + ( + + + {t("tlsServerName")} + + + + handleChange( + "hcTlsServerName", + e.target + .value, + (v) => + field.onChange( + e + ) + ) + } + /> + + + + )} + /> +
+ + {/* Follow Redirects inline toggle */} + ( + + + {t("followRedirects")} + + + + handleChange( + "hcFollowRedirects", + value, + field.onChange + ) + } + /> + + + )} + /> + + {/* Custom Headers */} + ( + + + {t("customHeaders")} + + + + handleChange( + "hcHeaders", + value, + field.onChange + ) + } + rows={4} + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> + + )} + + )} +
+ )} + + ); +} diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx new file mode 100644 index 000000000..68976bf40 --- /dev/null +++ b/src/components/HealthChecksTable.tsx @@ -0,0 +1,712 @@ +"use client"; + +import UptimeMiniBar from "@app/components/UptimeMiniBar"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import HealthCheckCredenza, { + HealthCheckRow +} from "@app/components/HealthCheckCredenza"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { Switch } from "@app/components/ui/switch"; +import { toast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { Selectedsite, SitesSelector } from "@app/components/site-selector"; +import { + ResourceSelector, + SelectedResource +} from "@app/components/resource-selector"; +import { + ArrowUpDown, + ArrowUpRight, + Funnel, + MoreHorizontal +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState, useTransition, useEffect, useMemo } from "react"; +import type { PaginationState } from "@tanstack/react-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useDebouncedCallback } from "use-debounce"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; + +type StandaloneHealthChecksTableProps = { + orgId: string; + healthChecks: HealthCheckRow[]; + rowCount: number; + pagination: PaginationState; + initialFilterSite?: Selectedsite | null; + initialFilterResource?: SelectedResource | null; +}; + +function formatTarget(row: HealthCheckRow): string { + if (!row.hcHostname) return "-"; + if (row.hcMode === "tcp") { + if (!row.hcPort) return row.hcHostname; + return `${row.hcHostname}:${row.hcPort}`; + } + if (row.hcMode === "snmp" || row.hcMode === "ping") { + if (row.hcPort) { + return `${row.hcHostname}:${row.hcPort}`; + } + return row.hcHostname; + } + // HTTP / default + const scheme = row.hcScheme ?? "http"; + const host = row.hcHostname; + const port = row.hcPort ? `:${row.hcPort}` : ""; + const path = row.hcPath ?? "/"; + return `${scheme}://${host}${port}${path}`; +} + +export default function HealthChecksTable({ + orgId, + healthChecks, + rowCount, + pagination, + initialFilterSite = null, + initialFilterResource = null +}: StandaloneHealthChecksTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isRefreshing, startRefresh] = useTransition(); + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks); + + const [credenzaOpen, setCredenzaOpen] = useState(false); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [deleteOpen, setDeleteOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [togglingId, setTogglingId] = useState(null); + const [siteFilterOpen, setSiteFilterOpen] = useState(false); + const [resourceFilterOpen, setResourceFilterOpen] = useState(false); + + const pageSize = pagination.pageSize; + const query = searchParams.get("query") ?? undefined; + + const siteIdQ = searchParams.get("siteId"); + const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; + const selectedSite: Selectedsite | null = useMemo(() => { + if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) { + return null; + } + if (initialFilterSite && initialFilterSite.siteId === siteIdNum) { + return initialFilterSite; + } + return { + siteId: siteIdNum, + name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }), + type: "newt" + }; + }, [initialFilterSite, siteIdQ, siteIdNum, t]); + + const resourceIdQ = searchParams.get("resourceId"); + const resourceIdNum = resourceIdQ ? parseInt(resourceIdQ, 10) : NaN; + const selectedResource: SelectedResource | null = useMemo(() => { + if ( + !resourceIdQ || + !Number.isInteger(resourceIdNum) || + resourceIdNum <= 0 + ) { + return null; + } + if ( + initialFilterResource && + initialFilterResource.resourceId === resourceIdNum + ) { + return initialFilterResource; + } + return { + name: t("standaloneHcFilterResourceIdFallback", { + id: resourceIdNum + }), + resourceId: resourceIdNum, + fullDomain: null, + niceId: "", + ssl: false, + wildcard: false + }; + }, [initialFilterResource, resourceIdQ, resourceIdNum, t]); + + const rows = healthChecks; + + function refreshList() { + startRefresh(() => { + router.refresh(); + }); + } + + useEffect(() => { + const interval = setInterval(() => { + router.refresh(); + }, 30_000); + return () => clearInterval(interval); + }, [router]); + + const handlePaginationChange = (newState: PaginationState) => { + searchParams.set("page", (newState.pageIndex + 1).toString()); + searchParams.set("pageSize", newState.pageSize.toString()); + filter({ searchParams }); + }; + + const handleSearchChange = useDebouncedCallback((value: string) => { + if (value) { + searchParams.set("query", value); + } else { + searchParams.delete("query"); + } + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + if (value) { + sp.set(column, value); + } + filter({ searchParams: sp }); + } + + const clearSiteFilter = () => { + handleFilterChange("siteId", undefined); + setSiteFilterOpen(false); + }; + + const clearResourceFilter = () => { + handleFilterChange("resourceId", undefined); + setResourceFilterOpen(false); + }; + + const onPickSite = (site: Selectedsite) => { + handleFilterChange("siteId", String(site.siteId)); + setSiteFilterOpen(false); + }; + + const onPickResource = (resource: SelectedResource) => { + handleFilterChange("resourceId", String(resource.resourceId)); + setResourceFilterOpen(false); + }; + + const handleToggleEnabled = async ( + row: HealthCheckRow, + enabled: boolean + ) => { + setTogglingId(row.targetHealthCheckId); + try { + await api.post( + `/org/${orgId}/health-check/${row.targetHealthCheckId}`, + { hcEnabled: enabled } + ); + refreshList(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setTogglingId(null); + } + }; + + const handleDelete = async () => { + if (!selected) return; + try { + await api.delete( + `/org/${orgId}/health-check/${selected.targetHealthCheckId}` + ); + refreshList(); + toast({ title: t("standaloneHcDeleted") }); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setDeleteOpen(false); + setSelected(null); + } + }; + + const modeParam = searchParams.get("hcMode"); + const selectedHcMode = + modeParam === "http" || + modeParam === "tcp" || + modeParam === "snmp" || + modeParam === "ping" + ? modeParam + : undefined; + const healthParam = searchParams.get("hcHealth"); + const selectedHcHealth = + healthParam === "healthy" || + healthParam === "unhealthy" || + healthParam === "unknown" + ? healthParam + : undefined; + const enabledParam = searchParams.get("hcEnabled"); + const selectedHcEnabled = + enabledParam === "true" || enabledParam === "false" + ? enabledParam + : undefined; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.name ? row.original.name : "-"} + ) + }, + { + id: "mode", + friendlyName: t("standaloneHcColumnMode"), + header: () => ( + + handleFilterChange("hcMode", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("standaloneHcColumnMode")} + className="p-3" + /> + ), + cell: ({ row }) => ( + {row.original.hcMode?.toUpperCase() ?? "-"} + ) + }, + { + id: "target", + friendlyName: t("standaloneHcColumnTarget"), + header: () => ( + {t("standaloneHcColumnTarget")} + ), + cell: ({ row }) => {formatTarget(row.original)} + }, + { + id: "resource", + friendlyName: t("resource"), + header: () => ( + + + + + +
+ +
+ +
+
+ ), + cell: ({ row }) => { + const r = row.original; + if (!r.resourceId || !r.resourceName || !r.resourceNiceId) { + return -; + } + return ( + + + + ); + } + }, + { + id: "site", + friendlyName: t("site"), + header: () => ( + + + + + +
+ +
+ +
+
+ ), + cell: ({ row }) => { + const r = row.original; + if (!r.siteId || !r.siteName || !r.siteNiceId) { + return -; + } + return ( + + + + ); + } + }, + { + id: "health", + friendlyName: t("standaloneHcColumnHealth"), + header: () => ( + + handleFilterChange("hcHealth", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("standaloneHcColumnHealth")} + className="p-3" + /> + ), + cell: ({ row }) => { + const health = row.original.hcHealth; + if (health === "healthy") { + return ( + +
+ {t("standaloneHcHealthStateHealthy")} + + ); + } else if (health === "unhealthy") { + return ( + +
+ {t("standaloneHcHealthStateUnhealthy")} + + ); + } else { + return ( + +
+ {t("standaloneHcHealthStateUnknown")} + + ); + } + } + }, + { + id: "uptime", + friendlyName: t("uptime30d"), + header: () => {t("uptime30d")}, + cell: ({ row }) => { + return ( + + ); + } + }, + { + accessorKey: "hcEnabled", + friendlyName: t("alertingColumnEnabled"), + header: () => ( + + handleFilterChange("hcEnabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("alertingColumnEnabled")} + className="p-3" + /> + ), + cell: ({ row }) => { + const r = row.original; + return ( + handleToggleEnabled(r, v)} + /> + ); + } + }, + { + id: "rowActions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + + + + { + setSelected(r); + setDeleteOpen(true); + }} + > + + {t("delete")} + + + + + {r.resourceId && r.resourceName && r.resourceNiceId ? ( + + + + ) : ( + + )} +
+ ); + } + } + ]; + + return ( + <> + {selected && deleteOpen && ( + { + setDeleteOpen(val); + if (!val) setSelected(null); + }} + dialog={ +
+

{t("standaloneHcDeleteQuestion")}

+
+ } + buttonText={t("delete")} + onConfirm={handleDelete} + string={selected.name} + title={t("standaloneHcDeleteTitle")} + /> + )} + + { + setCredenzaOpen(val); + if (!val) setSelected(null); + }} + orgId={orgId} + initialValues={selected} + onSaved={refreshList} + /> + + + + { + setSelected(null); + setCredenzaOpen(true); + }} + addButtonDisabled={!isPaid} + onRefresh={refreshList} + isRefreshing={isRefreshing || isFiltering} + addButtonText={t("standaloneHcAddButton")} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="rowActions" + pagination={pagination} + onPaginationChange={handlePaginationChange} + rowCount={rowCount} + /> + + ); +} diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 717a3c120..bffaadeba 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -11,6 +11,8 @@ import { useTranslations } from "next-intl"; export type TabItem = { title: string; href: string; + /** When set, active tab detection uses this path instead of `href` (link target unchanged). */ + activePrefix?: string; icon?: React.ReactNode; showProfessional?: boolean; exact?: boolean; @@ -115,18 +117,33 @@ export function HorizontalTabs({ } // Server-side mode: original behavior with routing + const activeIndex: number | null = (() => { + if (pathname.includes("create")) return null; + let best: number | null = null; + let bestLen = -1; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const matchBase = hydrateHref(item.activePrefix ?? item.href); + const matched = item.exact + ? pathname === matchBase + : pathname === matchBase || + pathname.startsWith(`${matchBase}/`); + if (matched && matchBase.length > bestLen) { + bestLen = matchBase.length; + best = i; + } + } + return best; + })(); + return (
- {items.map((item) => { + {items.map((item, index) => { const hydratedHref = hydrateHref(item.href); - const isActive = - (item.exact - ? pathname === hydratedHref - : pathname.startsWith(hydratedHref)) && - !pathname.includes("create"); + const isActive = activeIndex === index; const isProfessional = item.showProfessional && !isUnlocked(); @@ -135,7 +152,7 @@ export function HorizontalTabs({ return ( - {effectiveType === "google" && ( - Google - )} - {effectiveType === "azure" && ( - Azure - )} + {idp.name} ); diff --git a/src/components/IdpTypeBadge.tsx b/src/components/IdpTypeBadge.tsx index b0e90660b..d18c96d9b 100644 --- a/src/components/IdpTypeBadge.tsx +++ b/src/components/IdpTypeBadge.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@app/components/ui/badge"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; type IdpTypeBadgeProps = { type: string; @@ -29,34 +29,8 @@ export default function IdpTypeBadge({ variant="secondary" className="inline-flex items-center space-x-1 w-fit" > - {effectiveType === "google" && ( - <> - Google - {effectiveName} - - )} - {effectiveType === "azure" && ( - <> - Azure - {effectiveName} - - )} - {effectiveType === "oidc" && {effectiveName}} - {!["google", "azure", "oidc"].includes(effectiveType) && ( - {effectiveName} - )} + + {effectiveName} ); } diff --git a/src/components/IdpTypeIcon.tsx b/src/components/IdpTypeIcon.tsx new file mode 100644 index 000000000..be49f9654 --- /dev/null +++ b/src/components/IdpTypeIcon.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import Image from "next/image"; +import { ReactNode } from "react"; + +type Props = { + type?: string | null; + variant?: string | null; + size?: number; + className?: string; + alt?: string; + fallback?: ReactNode; +}; + +export default function IdpTypeIcon({ + type, + variant, + size = 16, + className, + alt, + fallback = null +}: Props) { + const effectiveType = (variant || type || "").toLowerCase(); + + let src: string | null = null; + let defaultAlt = ""; + + if (effectiveType === "google") { + src = "/idp/google.png"; + defaultAlt = "Google"; + } else if (effectiveType === "azure") { + src = "/idp/azure.png"; + defaultAlt = "Azure"; + } else if (effectiveType === "oidc") { + src = "/idp/openid.png"; + defaultAlt = "OAuth2/OIDC"; + } + + if (!src) { + return <>{fallback}; + } + + return ( + {alt + ); +} diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index b00503c3d..f680a51ca 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -4,18 +4,29 @@ import { cn } from "@app/lib/cn"; export function InfoSections({ children, - cols + cols, + columnSizing = "content" }: { children: React.ReactNode; cols?: number; + /** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */ + columnSizing?: "fill" | "content"; }) { + const n = cols || 1; + const track = + columnSizing === "fill" ? "minmax(0, 1fr)" : "minmax(0, max-content)"; + return (
{children} diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index a4a793753..62e4fc611 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1,6 +1,10 @@ "use client"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { + OptionSelect, + type OptionSelectOption +} from "@app/components/OptionSelect"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { StrategySelect } from "@app/components/StrategySelect"; import { Tag, TagInput } from "@app/components/tags/tag-input"; @@ -34,7 +38,6 @@ import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { ListSitesResponse } from "@server/routers/site"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { ChevronsUpDown, ExternalLink } from "lucide-react"; @@ -42,9 +45,17 @@ import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { SitesSelector, type Selectedsite } from "./site-selector"; +import { + MultiSitesSelector, + formatMultiSitesSelectorLabel +} from "./multi-site-selector"; +import type { Selectedsite } from "./site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; +import DomainPicker from "@app/components/DomainPicker"; +import { SwitchInput } from "@app/components/SwitchInput"; +import CertificateStatus from "@app/components/CertificateStatus"; +import { build } from "@server/build"; // --- Helpers (shared) --- @@ -118,15 +129,15 @@ export const cleanForFQDN = (name: string): string => // --- Types --- -type Site = ListSitesResponse["sites"][0]; +export type InternalResourceMode = "host" | "cidr" | "http"; export type InternalResourceData = { id: number; name: string; orgId: string; - siteName: string; - mode: "host" | "cidr"; - siteId: number; + siteNames: string[]; + mode: InternalResourceMode; + siteIds: number[]; niceId: string; destination: string; alias?: string | null; @@ -135,14 +146,30 @@ export type InternalResourceData = { disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + httpHttpsPort?: number | null; + scheme?: "http" | "https" | null; + ssl?: boolean; + subdomain?: string | null; + domainId?: string | null; + fullDomain?: string | null; }; const tagSchema = z.object({ id: z.string(), text: z.string() }); +function buildSelectedSitesForResource( + resource: InternalResourceData +): Selectedsite[] { + return resource.siteIds.map((siteId, idx) => ({ + name: resource.siteNames[idx] ?? "", + siteId, + type: "newt" as const + })); +} + export type InternalResourceFormValues = { name: string; - siteId: number; - mode: "host" | "cidr"; + siteIds: number[]; + mode: InternalResourceMode; destination: string; alias?: string | null; niceId?: string; @@ -151,6 +178,12 @@ export type InternalResourceFormValues = { disableIcmp?: boolean; authDaemonMode?: "site" | "remote" | null; authDaemonPort?: number | null; + httpHttpsPort?: number | null; + scheme?: "http" | "https"; + ssl?: boolean; + httpConfigSubdomain?: string | null; + httpConfigDomainId?: string | null; + httpConfigFullDomain?: string | null; roles?: z.infer[]; users?: z.infer[]; clients?: z.infer[]; @@ -160,28 +193,29 @@ type InternalResourceFormProps = { variant: "create" | "edit"; resource?: InternalResourceData; open?: boolean; - sites: Site[]; orgId: string; siteResourceId?: number; formId: string; onSubmit: (values: InternalResourceFormValues) => void | Promise; + onSubmitDisabledChange?: (disabled: boolean) => void; }; export function InternalResourceForm({ variant, resource, open, - sites, orgId, siteResourceId, formId, - onSubmit + onSubmit, + onSubmitDisabledChange }: InternalResourceFormProps) { const t = useTranslations(); const { env } = useEnvContext(); const { isPaidUser } = usePaidStatus(); const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam); + const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources); const nameRequiredKey = variant === "create" @@ -211,6 +245,22 @@ export function InternalResourceForm({ variant === "create" ? "createInternalResourceDialogModeCidr" : "editInternalResourceDialogModeCidr"; + const modeHttpKey = + variant === "create" + ? "createInternalResourceDialogModeHttp" + : "editInternalResourceDialogModeHttp"; + const schemeLabelKey = + variant === "create" + ? "createInternalResourceDialogScheme" + : "editInternalResourceDialogScheme"; + const enableSslLabelKey = + variant === "create" + ? "createInternalResourceDialogEnableSsl" + : "editInternalResourceDialogEnableSsl"; + const enableSslDescriptionKey = + variant === "create" + ? "createInternalResourceDialogEnableSslDescription" + : "editInternalResourceDialogEnableSslDescription"; const destinationLabelKey = variant === "create" ? "createInternalResourceDialogDestination" @@ -223,50 +273,95 @@ export function InternalResourceForm({ variant === "create" ? "createInternalResourceDialogAlias" : "editInternalResourceDialogAlias"; + const httpHttpsPortLabelKey = + variant === "create" + ? "createInternalResourceDialogModePort" + : "editInternalResourceDialogModePort"; + const httpConfigurationTitleKey = + variant === "create" + ? "createInternalResourceDialogHttpConfiguration" + : "editInternalResourceDialogHttpConfiguration"; + const httpConfigurationDescriptionKey = + variant === "create" + ? "createInternalResourceDialogHttpConfigurationDescription" + : "editInternalResourceDialogHttpConfigurationDescription"; - const formSchema = z.object({ - name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), - siteId: z - .number() - .int() - .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), - mode: z.enum(["host", "cidr"]), - destination: z - .string() - .min( - 1, - destinationRequiredKey - ? { message: t(destinationRequiredKey) } - : undefined - ), - alias: z.string().nullish(), - niceId: z - .string() - .min(1) - .max(255) - .regex(/^[a-zA-Z0-9-]+$/) - .optional(), - tcpPortRangeString: createPortRangeStringSchema(t), - udpPortRangeString: createPortRangeStringSchema(t), - disableIcmp: z.boolean().optional(), - authDaemonMode: z.enum(["site", "remote"]).optional().nullable(), - authDaemonPort: z.number().int().positive().optional().nullable(), - roles: z.array(tagSchema).optional(), - users: z.array(tagSchema).optional(), - clients: z - .array( - z.object({ - clientId: z.number(), - name: z.string() - }) - ) - .optional() - }); + const siteIdsSchema = siteRequiredKey + ? z.array(z.number().int().positive()).min(1, t(siteRequiredKey)) + : z.array(z.number().int().positive()).min(1); + + const formSchema = z + .object({ + name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), + siteIds: siteIdsSchema, + mode: z.enum(["host", "cidr", "http"]), + destination: z + .string() + .min( + 1, + destinationRequiredKey + ? { message: t(destinationRequiredKey) } + : undefined + ), + alias: z.string().nullish(), + httpHttpsPort: z + .number() + .int() + .min(1) + .max(65535) + .optional() + .nullable(), + scheme: z.enum(["http", "https"]).optional(), + ssl: z.boolean().optional(), + httpConfigSubdomain: z.string().nullish(), + httpConfigDomainId: z.string().nullish(), + httpConfigFullDomain: z.string().nullish(), + niceId: z + .string() + .min(1) + .max(255) + .regex(/^[a-zA-Z0-9-]+$/) + .optional(), + tcpPortRangeString: createPortRangeStringSchema(t), + udpPortRangeString: createPortRangeStringSchema(t), + disableIcmp: z.boolean().optional(), + authDaemonMode: z.enum(["site", "remote"]).optional().nullable(), + authDaemonPort: z.number().int().positive().optional().nullable(), + roles: z.array(tagSchema).optional(), + users: z.array(tagSchema).optional(), + clients: z + .array( + z.object({ + clientId: z.number(), + name: z.string() + }) + ) + .optional() + }) + .superRefine((data, ctx) => { + if (data.mode !== "http") return; + if (!data.scheme) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("internalResourceDownstreamSchemeRequired"), + path: ["scheme"] + }); + } + if ( + data.httpHttpsPort == null || + !Number.isFinite(data.httpHttpsPort) || + data.httpHttpsPort < 1 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("internalResourceHttpPortRequired"), + path: ["httpHttpsPort"] + }); + } + }); type FormData = z.infer; - const availableSites = sites.filter((s) => s.type === "newt"); - const rolesQuery = useQuery(orgQueries.roles({ orgId })); const usersQuery = useQuery(orgQueries.users({ orgId })); const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); @@ -385,7 +480,7 @@ export function InternalResourceForm({ variant === "edit" && resource ? { name: resource.name, - siteId: resource.siteId, + siteIds: resource.siteIds, mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, @@ -394,6 +489,12 @@ export function InternalResourceForm({ disableIcmp: resource.disableIcmp ?? false, authDaemonMode: resource.authDaemonMode ?? "site", authDaemonPort: resource.authDaemonPort ?? null, + httpHttpsPort: resource.httpHttpsPort ?? null, + scheme: resource.scheme ?? "http", + ssl: resource.ssl ?? false, + httpConfigSubdomain: resource.subdomain ?? null, + httpConfigDomainId: resource.domainId ?? null, + httpConfigFullDomain: resource.fullDomain ?? null, niceId: resource.niceId, roles: [], users: [], @@ -401,10 +502,16 @@ export function InternalResourceForm({ } : { name: "", - siteId: availableSites[0]?.siteId ?? 0, + siteIds: [], mode: "host", destination: "", alias: null, + httpHttpsPort: null, + scheme: "http", + ssl: true, + httpConfigSubdomain: null, + httpConfigDomainId: null, + httpConfigFullDomain: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, @@ -415,8 +522,10 @@ export function InternalResourceForm({ clients: [] }; - const [selectedSite, setSelectedSite] = useState( - availableSites[0] + const [selectedSites, setSelectedSites] = useState(() => + variant === "edit" && resource + ? buildSelectedSitesForResource(resource) + : [] ); const form = useForm({ @@ -425,6 +534,10 @@ export function InternalResourceForm({ }); const mode = form.watch("mode"); + const httpConfigSubdomain = form.watch("httpConfigSubdomain"); + const httpConfigDomainId = form.watch("httpConfigDomainId"); + const httpConfigFullDomain = form.watch("httpConfigFullDomain"); + const isHttpMode = mode === "http"; const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const hasInitialized = useRef(false); const previousResourceId = useRef(null); @@ -444,10 +557,16 @@ export function InternalResourceForm({ if (variant === "create" && open) { form.reset({ name: "", - siteId: availableSites[0]?.siteId ?? 0, + siteIds: [], mode: "host", destination: "", alias: null, + httpHttpsPort: null, + scheme: "http", + ssl: true, + httpConfigSubdomain: null, + httpConfigDomainId: null, + httpConfigFullDomain: null, tcpPortRangeString: "*", udpPortRangeString: "*", disableIcmp: false, @@ -457,12 +576,13 @@ export function InternalResourceForm({ users: [], clients: [] }); + setSelectedSites([]); setTcpPortMode("all"); setUdpPortMode("all"); setTcpCustomPorts(""); setUdpCustomPorts(""); } - }, [variant, open]); + }, [variant, open, form]); // Reset when edit dialog opens / resource changes useEffect(() => { @@ -471,10 +591,16 @@ export function InternalResourceForm({ if (resourceChanged) { form.reset({ name: resource.name, - siteId: resource.siteId, + siteIds: resource.siteIds, mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, + httpHttpsPort: resource.httpHttpsPort ?? null, + scheme: resource.scheme ?? "http", + ssl: resource.ssl ?? false, + httpConfigSubdomain: resource.subdomain ?? null, + httpConfigDomainId: resource.domainId ?? null, + httpConfigFullDomain: resource.fullDomain ?? null, tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, @@ -484,6 +610,7 @@ export function InternalResourceForm({ users: [], clients: [] }); + setSelectedSites(buildSelectedSitesForResource(resource)); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) ); @@ -537,12 +664,18 @@ export function InternalResourceForm({ form ]); + useEffect(() => { + onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled); + }, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]); + return (
{ + const siteIds = values.siteIds; onSubmit({ ...values, + siteIds, clients: (values.clients ?? []).map((c) => ({ id: c.clientId.toString(), text: c.name @@ -581,51 +714,6 @@ export function InternalResourceForm({ )} /> )} - ( - - {t("site")} - - - - - - - - { - setSelectedSite(site); - field.onChange(site.siteId); - }} - /> - - - - - )} - />
+
+
+
+ ( + + + {t("sites")} + + + + + + + + + { + setSelectedSites( + sites + ); + field.onChange( + sites.map( + ( + s + ) => + s.siteId + ) + ); + }} + /> + + + + + )} + /> +
+
+ { + const modeOptions: OptionSelectOption[] = + [ + { + value: "host", + label: t( + modeHostKey + ) + }, + { + value: "cidr", + label: t( + modeCidrKey + ) + }, + { + value: "http", + label: t( + modeHttpKey + ) + } + ]; + return ( + + + {t(modeLabelKey)} + + + options={ + modeOptions + } + value={field.value} + onChange={ + field.onChange + } + cols={3} + /> + + + ); + }} + /> +
+
+ {selectedSites.length > 1 && ( +

+ {t( + "internalResourceFormMultiSiteRoutingHelp" + )}{" "} + + {t( + "internalResourceFormMultiSiteRoutingHelpLearnMore" + )} + + + . +

+ )} +
+ {mode === "http" && ( +
+ ( + + + {t(schemeLabelKey)} + + + + + )} + /> +
+ )}
- ( - - - {t(modeLabelKey)} - - - - - )} - /> -
-
- + )} />
- {mode !== "cidr" && ( -
+ {mode === "host" && ( +
)} + {mode === "http" && ( +
+ ( + + + {t( + httpHttpsPortLabelKey + )} + + + { + const raw = + e.target + .value; + if ( + raw === "" + ) { + field.onChange( + null + ); + return; + } + const n = + Number(raw); + field.onChange( + Number.isFinite( + n + ) + ? n + : null + ); + }} + /> + + + + )} + /> +
+ )}
-
-
- -
- {t( - "editInternalResourceDialogPortRestrictionsDescription" - )} -
-
-
-
- - {t("editInternalResourceDialogTcp")} - + {isHttpMode && ( + + )} + + {isHttpMode ? ( +
+
+ +
+ {t(httpConfigurationDescriptionKey)} +
- ( - -
- - {tcpPortMode === - "custom" ? ( - - - setTcpCustomPorts( - e.target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} + { + if (res === null) { + form.setValue( + "httpConfigSubdomain", + null + ); + form.setValue( + "httpConfigDomainId", + null + ); + form.setValue( + "httpConfigFullDomain", + null + ); + return; + } + form.setValue( + "httpConfigSubdomain", + res.subdomain ?? null + ); + form.setValue( + "httpConfigDomainId", + res.domainId + ); + form.setValue( + "httpConfigFullDomain", + res.fullDomain + ); + }} />
-
-
-
- - {t("editInternalResourceDialogUdp")} - -
-
+
( - -
- - {udpPortMode === - "custom" ? ( - - - setUdpCustomPorts( - e.target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
-
-
- - {t("editInternalResourceDialogIcmp")} - -
-
- ( - -
- - + + + + + )} + /> + {variant === "edit" && + resource?.domainId && + httpConfigFullDomain && + httpConfigDomainId === + resource.domainId && + httpConfigFullDomain === + resource.fullDomain && + build != "oss" && + form.watch("ssl") && ( +
+ + {t("certificateStatus")}: + + +
+ )} +
+
+ ) : ( +
+
+ +
+ {t( + "editInternalResourceDialogPortRestrictionsDescription" + )} +
+
+
+
+ + {t("editInternalResourceDialogTcp")} + +
+
+ ( + +
+ + {tcpPortMode === + "custom" ? ( + + + setTcpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+
+
+ + {t("editInternalResourceDialogUdp")} + +
+
+ ( + +
+ + {udpPortMode === + "custom" ? ( + + + setUdpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+
+
+ + {t( + "editInternalResourceDialogIcmp" + )} + +
+
+ ( + +
+ + + field.onChange( + !checked + ) + } + /> + + + {field.value + ? t("blocked") + : t("allowed")} + +
+ +
+ )} + /> +
-
+ )}
@@ -1066,7 +1509,7 @@ export function InternalResourceForm({ ] ) } - enableAutocomplete={true} + enableAutocomplete autocompleteOptions={ allRoles } @@ -1176,7 +1619,7 @@ export function InternalResourceForm({ ) )} - + {t( "accessClientSelect" )} @@ -1213,8 +1656,8 @@ export function InternalResourceForm({ )}
- {/* SSH Access tab */} - {!disableEnterpriseFeatures && mode !== "cidr" && ( + {/* SSH Access tab (host mode only) */} + {!disableEnterpriseFeatures && mode === "host" && (
diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx index 5de8f25fd..f35f47629 100644 --- a/src/components/InviteStatusCard.tsx +++ b/src/components/InviteStatusCard.tsx @@ -204,7 +204,7 @@ export default function InviteStatusCard({
- + {loading ? t("checkingInvite") : t("inviteNotAccepted")} diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index bef016853..29850f115 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -49,7 +49,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { return (
-
+
diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 854cad6db..13efdd564 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -101,7 +101,6 @@ export function LayoutMobileMenu({ "serverAdmin" )} -
)} diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 1cd2131f7..69149c9f4 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -13,6 +13,7 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useUserContext } from "@app/hooks/useUserContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; import { cn } from "@app/lib/cn"; import { approvalQueries } from "@app/lib/queries"; import { build } from "@server/build"; @@ -24,15 +25,17 @@ import dynamic from "next/dynamic"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; -import { FaGithub } from "react-icons/fa"; import SidebarLicenseButton from "./SidebarLicenseButton"; import { SidebarSupportButton } from "./SidebarSupportButton"; -import { is } from "drizzle-orm"; const ProductUpdates = dynamic(() => import("./ProductUpdates"), { ssr: false }); +const ShowTrialCard = dynamic(() => import("./ShowTrialCard"), { + ssr: false +}); + interface LayoutSidebarProps { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; @@ -57,6 +60,7 @@ export function LayoutSidebar({ const { user } = useUserContext(); const { isUnlocked, licenseStatus } = useLicenseStatusContext(); const { env } = useEnvContext(); + const subscriptionContext = useSubscriptionStatusContext(); const t = useTranslations(); // Fetch pending approval count if we have an orgId and it's not an admin page @@ -124,10 +128,15 @@ export function LayoutSidebar({ const canShowProductUpdates = user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin); + const showTrial = + build === "saas" && + Boolean(orgId) && + subscriptionContext?.isTrial; + return (
@@ -156,7 +165,7 @@ export function LayoutSidebar({ {t("serverAdmin")} - )} @@ -193,7 +201,7 @@ export function LayoutSidebar({ />
{/* Fade gradient at bottom to indicate scrollable content */} -
+
{isSidebarCollapsed && ( @@ -208,7 +216,7 @@ export function LayoutSidebar({ setHasManualToggle(true); setSidebarStateCookie(false); }} - className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors" + className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 transition-colors" aria-label={t("sidebarExpand")} > @@ -222,11 +230,22 @@ export function LayoutSidebar({
)} -
- {canShowProductUpdates && ( +
+ {canShowProductUpdates ? (
+ ) :
} + + {showTrial && ( +
+ +
)} {build === "enterprise" && ( @@ -248,6 +267,7 @@ export function LayoutSidebar({ />
)} + {!isSidebarCollapsed && (
{loadFooterLinks() ? ( @@ -291,7 +311,6 @@ export function LayoutSidebar({ : build === "enterprise" ? t("enterpriseEdition") : "Pangolin Cloud"} -
{build === "enterprise" && diff --git a/src/components/LicenseKeysDataTable.tsx b/src/components/LicenseKeysDataTable.tsx index 1e39c9225..a3e6f3ce5 100644 --- a/src/components/LicenseKeysDataTable.tsx +++ b/src/components/LicenseKeysDataTable.tsx @@ -1,6 +1,5 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DataTable } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; diff --git a/src/components/LocaleSwitcherSelect.tsx b/src/components/LocaleSwitcherSelect.tsx index e647f7dd1..b6f65aa7c 100644 --- a/src/components/LocaleSwitcherSelect.tsx +++ b/src/components/LocaleSwitcherSelect.tsx @@ -36,7 +36,7 @@ export default function LocaleSwitcherSelect({ }); // Persist locale to the database (fire-and-forget) api.post("/user/locale", { locale }).catch(() => { - // Silently ignore errors — cookie is already set as fallback + // Silently ignore errors - cookie is already set as fallback }); } @@ -53,7 +53,7 @@ export default function LocaleSwitcherSelect({ )} aria-label={label} > - + {selected?.label ?? label} diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 3a53a859f..14e87ff75 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -405,7 +405,11 @@ export function LogDataTable({ onClick={() => !disabled && onExport() } - disabled={isExporting || disabled || isExportDisabled} + disabled={ + isExporting || + disabled || + isExportDisabled + } > {isExporting ? ( diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index c3b1fc384..e87a8b1a8 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -27,7 +27,6 @@ import { LockIcon } from "lucide-react"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import { createApiClient } from "@app/lib/api"; import Link from "next/link"; -import Image from "next/image"; import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { Separator } from "./ui/separator"; import { useTranslations } from "next-intl"; @@ -37,6 +36,7 @@ import { } from "@app/actions/server"; import { redirect as redirectTo } from "next/navigation"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; // @ts-ignore import { loadReoScript } from "reodotdev"; import { build } from "@server/build"; @@ -393,24 +393,7 @@ export default function LoginForm({ loginWithIdp(idp.idpId); }} > - {effectiveType === "google" && ( - Google - )} - {effectiveType === "azure" && ( - Azure - )} + {idp.name} ); diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 9c1da5b4d..4ef22c83d 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -293,7 +293,7 @@ export default function MachineClientsTable({ } else { return ( -
+
{t("disconnected")}
); diff --git a/src/components/MemberResourcesPortal.tsx b/src/components/MemberResourcesPortal.tsx index 8ce721c88..0ca6c550b 100644 --- a/src/components/MemberResourcesPortal.tsx +++ b/src/components/MemberResourcesPortal.tsx @@ -67,7 +67,7 @@ type SiteResource = { enabled: boolean; alias: string | null; aliasAddress: string | null; - type: 'site'; + type: "site"; }; type MemberResourcesPortalProps = { @@ -130,7 +130,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => { resource.whitelist; const hasAnyInfo = - Boolean(resource.siteName) || Boolean(hasAuthMethods) || !resource.enabled; + Boolean(resource.siteName) || + Boolean(hasAuthMethods) || + !resource.enabled; if (!hasAnyInfo) return null; @@ -353,7 +355,9 @@ export default function MemberResourcesPortal({ const [resources, setResources] = useState([]); const [siteResources, setSiteResources] = useState([]); const [filteredResources, setFilteredResources] = useState([]); - const [filteredSiteResources, setFilteredSiteResources] = useState([]); + const [filteredSiteResources, setFilteredSiteResources] = useState< + SiteResource[] + >([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -381,7 +385,9 @@ export default function MemberResourcesPortal({ setResources(response.data.data.resources); setSiteResources(response.data.data.siteResources || []); setFilteredResources(response.data.data.resources); - setFilteredSiteResources(response.data.data.siteResources || []); + setFilteredSiteResources( + response.data.data.siteResources || [] + ); } else { setError("Failed to load resources"); } @@ -459,9 +465,10 @@ export default function MemberResourcesPortal({ case "domain-asc": case "domain-desc": // Sort by destination for site resources - const destCompare = sortBy === "domain-asc" - ? a.destination.localeCompare(b.destination) - : b.destination.localeCompare(a.destination); + const destCompare = + sortBy === "domain-asc" + ? a.destination.localeCompare(b.destination) + : b.destination.localeCompare(a.destination); return destCompare; case "status-enabled": return b.enabled ? 1 : -1; @@ -487,12 +494,14 @@ export default function MemberResourcesPortal({ startIndex + itemsPerPage ); const remainingSlots = itemsPerPage - paginatedResources.length; - const paginatedSiteResources = remainingSlots > 0 - ? filteredSiteResources.slice( - Math.max(0, startIndex - filteredResources.length), - Math.max(0, startIndex - filteredResources.length) + remainingSlots - ) - : []; + const paginatedSiteResources = + remainingSlots > 0 + ? filteredSiteResources.slice( + Math.max(0, startIndex - filteredResources.length), + Math.max(0, startIndex - filteredResources.length) + + remainingSlots + ) + : []; const handleOpenResource = (resource: Resource) => { // Open the resource in a new tab @@ -640,7 +649,8 @@ export default function MemberResourcesPortal({
{/* Resources Content */} - {filteredResources.length === 0 && filteredSiteResources.length === 0 ? ( + {filteredResources.length === 0 && + filteredSiteResources.length === 0 ? ( /* Enhanced Empty State */ @@ -697,87 +707,96 @@ export default function MemberResourcesPortal({ Public Resources

- Web applications and services accessible via browser + Web applications and services accessible via + browser

{paginatedResources.map((resource) => ( - -
-
-
- - - - - {resource.name} - - - -

- {resource.name} -

-
-
-
+ +
+
+
+ + + + + { + resource.name + } + + + +

+ { + resource.name + } +

+
+
+
+
+ +
+ +
+
+ +
+ + +
-
- +
+
-
- -
- - -
-
- -
- -
- - ))} -
+ + ))} +
)} @@ -790,7 +809,8 @@ export default function MemberResourcesPortal({ Private Resources

- Internal network resources accessible via client + Internal network resources accessible via + client

@@ -802,13 +822,17 @@ export default function MemberResourcesPortal({ - - {siteResource.name} + + { + siteResource.name + }

- {siteResource.name} + { + siteResource.name + }

@@ -818,39 +842,63 @@ export default function MemberResourcesPortal({
-
Resource Details
+
+ Resource Details +
- Mode: + + Mode: + - {siteResource.mode} + { + siteResource.mode + }
{siteResource.protocol && (
- Protocol: + + Protocol: + - {siteResource.protocol} + { + siteResource.protocol + }
)}
- Destination: + + Destination: + - {siteResource.destination} + { + siteResource.destination + }
{siteResource.alias && (
- Alias: + + Alias: + - {siteResource.alias} + { + siteResource.alias + }
)}
- Status: - - {siteResource.enabled ? 'Enabled' : 'Disabled'} + + Status: + + + {siteResource.enabled + ? "Enabled" + : "Disabled"}
@@ -864,7 +912,9 @@ export default function MemberResourcesPortal({ {/* Alias as primary */}
- {siteResource.alias} + { + siteResource.alias + }
- ); - } - }, { accessorKey: "name", friendlyName: t("name"), @@ -142,6 +252,14 @@ export default function IdpTable({ idps, orgId }: Props) { {t("viewSettings")} + { + setSelectedUnassociateIdp(siteRow); + setIsUnassociateModalOpen(true); + }} + > + {t("idpUnassociateMenu")} + { setSelectedIdp(siteRow); @@ -149,7 +267,7 @@ export default function IdpTable({ idps, orgId }: Props) { }} > - {t("delete")} + {t("idpDeleteAllOrgsMenu")} @@ -179,8 +297,8 @@ export default function IdpTable({ idps, orgId }: Props) { }} dialog={
-

{t("idpQuestionRemove")}

-

{t("idpMessageRemove")}

+

{t("idpDeleteGlobalQuestion")}

+

{t("idpDeleteGlobalDescription")}

} buttonText={t("idpConfirmDelete")} @@ -189,11 +307,127 @@ export default function IdpTable({ idps, orgId }: Props) { title={t("idpDelete")} /> )} + {selectedUnassociateIdp && ( + { + setIsUnassociateModalOpen(val); + setSelectedUnassociateIdp(null); + }} + dialog={ +
+

{t("idpUnassociateQuestion")}

+

{t("idpUnassociateDescription")}

+
+ } + buttonText={t("idpUnassociateConfirm")} + onConfirm={async () => + unassociateIdp(selectedUnassociateIdp.idpId) + } + string={selectedUnassociateIdp.name} + title={t("idpUnassociateTitle")} + warningText={t("idpUnassociateWarning")} + /> + )} + + { + setImportDialogOpen(open); + if (!open) { + setImportSearchQuery(""); + } + }} + > + + + + {t("idpImportDialogTitle")} + + + {t("idpImportDialogDescription")} + + + + + + + + {t("idpImportEmpty")} + + + {shownImportIdps.map((row) => ( + { + if (!canImportOrgOidcIdp) { + return; + } + void importIdp(row); + }} + > +
+ +
+
+
+ {row.orgName} +
+
+ {row.name} +
+
+
+ ))} +
+
+
+
+ + + + + +
+
router.push(`/${orgId}/settings/idp/create`)} + addButtonDisabled={addIdpDisabled} + addActions={[ + { + label: t("idpAddActionCreateNew"), + onSelect: () => { + router.push(`/${orgId}/settings/idp/create`); + } + }, + { + label: t("idpAddActionImportFromOrg"), + onSelect: () => { + setImportDialogOpen(true); + } + } + ]} /> ); diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index fcbc700a2..79f2fad7d 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -76,8 +76,8 @@ export function OrgSelector({ className={cn( "cursor-pointer transition-colors", isCollapsed - ? "w-full h-16 flex items-center justify-center hover:bg-muted" - : "w-full px-5 py-4 hover:bg-muted" + ? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50" + : "w-full px-5 py-4 hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50" )} > {isCollapsed ? ( @@ -86,7 +86,7 @@ export function OrgSelector({
- + {t("org")} @@ -172,7 +172,7 @@ export function OrgSelector({ +
+ {primaryActionVariant === "button" ? ( + + ) : ( + + )}
); diff --git a/src/components/OrganizationLandingCard.tsx b/src/components/OrganizationLandingCard.tsx index 5b5ceba7b..75dd49872 100644 --- a/src/components/OrganizationLandingCard.tsx +++ b/src/components/OrganizationLandingCard.tsx @@ -70,7 +70,7 @@ export default function OrganizationLandingCard( return ( - + {orgData.overview.orgName} @@ -82,7 +82,7 @@ export default function OrganizationLandingCard( className="flex flex-col items-center p-4 bg-secondary rounded-lg" > {stat.icon} - + {stat.value} diff --git a/src/components/PaidFeaturesAlert.tsx b/src/components/PaidFeaturesAlert.tsx index 95179ea78..1ae31bb3f 100644 --- a/src/components/PaidFeaturesAlert.tsx +++ b/src/components/PaidFeaturesAlert.tsx @@ -10,7 +10,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { Tier } from "@server/types/Tiers"; import { useParams } from "next/navigation"; -const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"]; +// const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"]; +const TIER_ORDER: Tier[] = ["tier2", "tier3", "enterprise"]; const TIER_TRANSLATION_KEYS: Record< Tier, @@ -113,9 +114,10 @@ function getDocsLinkRenderer(href: string) { type Props = { tiers: Tier[]; + showBookADemo?: boolean; }; -export function PaidFeaturesAlert({ tiers }: Props) { +export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) { const t = useTranslations(); const params = useParams(); const orgId = params?.orgId as string | undefined; @@ -133,7 +135,9 @@ export function PaidFeaturesAlert({ tiers }: Props) { const tierLinkRenderer = getTierLinkRenderer(billingHref); const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer(); const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL); - const bookADemoLinkRenderer = getBookADemoLinkRenderer(); + const bookADemoLinkRenderer = showBookADemo + ? getBookADemoLinkRenderer() + : () => null; if (env.flags.disableEnterpriseFeatures) { return null; diff --git a/src/components/PathMatchRenameModal.tsx b/src/components/PathMatchRenameModal.tsx index 202cbc3ff..6474da6b9 100644 --- a/src/components/PathMatchRenameModal.tsx +++ b/src/components/PathMatchRenameModal.tsx @@ -14,6 +14,7 @@ import { Input } from "./ui/input"; import { Button } from "./ui/button"; import { Credenza, + CredenzaBody, CredenzaContent, CredenzaDescription, CredenzaFooter, @@ -88,7 +89,7 @@ export function PathMatchModal({ {t("pathMatchModalDescription")} -
+
setName(e.target.value)} + placeholder={t("uptimeAlertNamePlaceholder")} + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(userTags) + : newTags; + setUserTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allUsers} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(roleTags) + : newTags; + setRoleTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allRoles} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(emailTags) + : newTags; + setEmailTags(next as Tag[]); + }} + allowDuplicates={false} + sortTags + validateTag={(tag) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + } + delimiterList={[",", "Enter"]} + /> +
+
+
+ + + + + + + + + + + + ); +} diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx new file mode 100644 index 000000000..37e38bcaa --- /dev/null +++ b/src/components/UptimeBar.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { orgQueries } from "@app/lib/queries"; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { useTranslations } from "next-intl"; + +function formatDuration(seconds: number): string { + if (seconds === 0) return "0s"; + if (seconds < 60) return `${Math.round(seconds)}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.round(seconds % 60); + if (h > 0) return s > 0 ? `${h}h ${m}m ${s}s` : `${h}h ${m}m`; + if (m > 0 && s > 0) return `${m}m ${s}s`; + return `${m}m`; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr + "T00:00:00").toLocaleDateString([], { + month: "short", + day: "numeric", + year: "numeric" + }); +} + +function formatTime(ts: number): string { + return new Date(ts * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit" + }); +} + +const barColorClass: Record = { + good: "bg-green-500", + degraded: "bg-yellow-500", + bad: "bg-red-500", + no_data: "bg-neutral-200 dark:bg-neutral-700", + unknown: "bg-neutral-200 dark:bg-neutral-700" +}; + +type UptimeBarProps = { + orgId?: string; + siteId?: number; + resourceId?: number; + healthCheckId?: number; + days?: number; + title?: string; + className?: string; +}; + +export default function UptimeBar({ + orgId, + siteId, + resourceId, + healthCheckId, + days = 90, + title, + className +}: UptimeBarProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + + const siteQuery = useQuery({ + ...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }), + enabled: siteId != null, + meta: { api } + }); + + const hcQuery = useQuery({ + ...orgQueries.healthCheckStatusHistory({ + orgId: orgId ?? "", + healthCheckId: healthCheckId ?? 0, + days + }), + enabled: healthCheckId != null && siteId == null && resourceId == null, + meta: { api } + }); + + const resourceQuery = useQuery({ + ...orgQueries.resourceStatusHistory({ resourceId, days }), + enabled: resourceId != null && siteId == null && healthCheckId == null, + meta: { api } + }); + + const { data, isLoading } = + siteId != null + ? siteQuery + : resourceId != null + ? resourceQuery + : hcQuery; + + if (isLoading) { + return ( +
+
+ {title && ( + {title} + )} +
+ + +
+
+
+ {Array.from({ length: days }).map((_, i) => ( +
+ ))} +
+
+ {t("uptimeDaysAgo", { count: days })} + {t("uptimeToday")} +
+
+ ); + } + + if (!data) return null; + + const allNoData = data.days.every((d) => d.status === "no_data"); + + return ( +
+ {/* Header row */} +
+ {title && {title}} +
+ {!allNoData && ( + <> + + + {data.overallUptimePercent.toFixed(2)}% + {" "} + {t("uptimeSuffix")} + + {data.totalDowntimeSeconds > 0 && ( + + + {formatDuration( + data.totalDowntimeSeconds + )} + {" "} + {t("uptimeDowntimeSuffix")} + + )} + + )} + {allNoData && ( + + {t("uptimeNoDataAvailable")} + + )} +
+
+ + {/* Bar row */} +
+ {data.days.map((day, i) => ( + + +
+ + +
+ {formatDate(day.date)} +
+ {day.status !== "no_data" && day.status !== "unknown" && ( +
+ {t("uptimeTooltipUptimeLabel")}:{" "} + + {day.uptimePercent.toFixed(1)}% + +
+ )} + {day.totalDowntimeSeconds > 0 && ( +
+ {t("uptimeTooltipDowntimeLabel")}:{" "} + + {formatDuration( + day.totalDowntimeSeconds + )} + +
+ )} + {day.downtimeWindows.length > 0 && ( +
+ {day.downtimeWindows.map((w, wi) => ( +
+ {formatTime(w.start)} + {w.end + ? ` – ${formatTime(w.end)}` + : ` – ${t("uptimeOngoing")}`}{" "} + + ({w.status}) + +
+ ))} +
+ )} + {(day.status === "no_data" || day.status === "unknown") && ( +
+ {t("uptimeNoMonitoringData")} +
+ )} +
+ + ))} +
+ + {/* Date labels */} +
+ {t("uptimeDaysAgo", { count: days })} + {t("uptimeToday")} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx new file mode 100644 index 000000000..b7e684c8b --- /dev/null +++ b/src/components/UptimeMiniBar.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { orgQueries } from "@app/lib/queries"; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { useTranslations } from "next-intl"; + +function formatDuration(seconds: number): string { + if (seconds === 0) return "0s"; + if (seconds < 60) return `${Math.round(seconds)}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.round(seconds % 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0 && s > 0) return `${m}m ${s}s`; + return `${m}m`; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr + "T00:00:00").toLocaleDateString([], { + month: "short", + day: "numeric" + }); +} + +const barColorClass: Record = { + good: "bg-green-500", + degraded: "bg-yellow-500", + bad: "bg-red-500", + no_data: "bg-neutral-200 dark:bg-neutral-700", + unknown: "bg-neutral-200 dark:bg-neutral-700" +}; + +type UptimeMiniBarProps = { + orgId?: string; + siteId?: number; + resourceId?: number; + healthCheckId?: number; + days?: number; +}; + +export default function UptimeMiniBar({ + orgId, + siteId, + resourceId, + healthCheckId, + days = 30 +}: UptimeMiniBarProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + + const siteQuery = useQuery({ + ...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }), + enabled: siteId != null, + meta: { api }, + staleTime: 5 * 60 * 1000 + }); + + const hcQuery = useQuery({ + ...orgQueries.healthCheckStatusHistory({ + orgId: orgId ?? "", + healthCheckId: healthCheckId ?? 0, + days + }), + enabled: healthCheckId != null && siteId == null && resourceId == null, + meta: { api }, + staleTime: 5 * 60 * 1000 + }); + + const resourceQuery = useQuery({ + ...orgQueries.resourceStatusHistory({ resourceId, days }), + enabled: resourceId != null && siteId == null && healthCheckId == null, + meta: { api }, + staleTime: 5 * 60 * 1000 + }); + + const { data, isLoading } = + siteId != null + ? siteQuery + : resourceId != null + ? resourceQuery + : hcQuery; + + if (isLoading) { + return ( +
+
+ {Array.from({ length: days }).map((_, i) => ( +
+ ))} +
+ + + +
+ ); + } + + if (!data) return null; + + const allNoData = data.days.every((d) => d.status === "no_data"); + + return ( +
+
+ {data.days.map((day, i) => ( + + +
+ + +
+ {formatDate(day.date)} +
+
+ {day.status === "no_data" || day.status === "unknown" + ? t("uptimeNoData") + : `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`} +
+ {day.totalDowntimeSeconds > 0 && ( +
+ {t("uptimeMiniBarDown")}:{" "} + {formatDuration(day.totalDowntimeSeconds)} +
+ )} +
+ + ))} +
+ + {allNoData + ? t("uptimeNoData") + : `${data.overallUptimePercent.toFixed(1)}%`} + +
+ ); +} \ No newline at end of file diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 52f2d1384..0a130cc16 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -35,6 +35,7 @@ import { useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import ClientDownloadBanner from "./ClientDownloadBanner"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import IdpTypeBadge from "./IdpTypeBadge"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; @@ -52,6 +53,9 @@ export type ClientRow = { userId: string | null; username: string | null; userEmail: string | null; + userType: string | null; + idpName: string | null; + idpVariant: string | null; niceId: string; agent: string | null; approvalState: "approved" | "pending" | "denied" | null; @@ -370,17 +374,30 @@ export default function UserDevicesTable({ cell: ({ row }) => { const r = row.original; return r.userId ? ( - - - +
+ + + + {(r.userType ?? "internal") !== "internal" && ( + + )} +
) : ( "-" ); @@ -388,7 +405,7 @@ export default function UserDevicesTable({ }, { accessorKey: "online", - friendlyName: t("online"), + friendlyName: t("connected"), header: () => { return ( ); @@ -427,7 +444,7 @@ export default function UserDevicesTable({ } else { return ( -
+
{t("disconnected")}
); diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 3e2d4e578..50915c02b 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -1,29 +1,42 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react"; -import { UsersDataTable } from "@app/components/UsersDataTable"; -import { useState, useEffect } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowRight, + ArrowUp10Icon, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; +import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from "next-intl"; +import { useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "./ui/controlled-data-table"; import UserRoleBadges from "./UserRoleBadges"; export type UserRow = { @@ -41,41 +54,90 @@ export type UserRow = { isOwner: boolean; }; +type FilterOption = { value: string; label: string }; + type UsersTableProps = { users: UserRow[]; + pagination: PaginationState; + rowCount: number; + idpFilterOptions: FilterOption[]; + roleFilterOptions: FilterOption[]; }; -export default function UsersTable({ users: u }: UsersTableProps) { +export default function UsersTable({ + users, + pagination, + rowCount, + idpFilterOptions, + roleFilterOptions +}: UsersTableProps) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [users, setUsers] = useState(u); const router = useRouter(); const api = createApiClient(useEnvContext()); - const { user, updateUser } = useUserContext(); + const { user } = useUserContext(); const { org } = useOrgContext(); const t = useTranslations(); - const [isRefreshing, setIsRefreshing] = useState(false); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + const [isRefreshing, startTransition] = useTransition(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams, + pathname + } = useNavigationContext(); - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setUsers(u); - }, [u]); + const idpIdParamSchema = z + .union([z.literal("internal"), z.string().regex(/^\d+$/)]) + .optional() + .catch(undefined); + + const roleIdsFromSearchParams = useMemo(() => { + const sp = new URLSearchParams(searchParams); + return [ + ...new Set(sp.getAll("role_id").filter((id) => /^\d+$/.test(id))) + ]; + }, [searchParams.toString()]); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } + + function handleRoleIdsChange(values: string[]) { + const sp = new URLSearchParams(searchParams); + sp.delete("role_id"); + sp.delete("page"); + for (const id of values) { + if (/^\d+$/.test(id)) { + sp.append("role_id", id); + } + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } + startTransition(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); }; const columns: ExtendedColumnDef[] = [ @@ -84,15 +146,21 @@ export default function UsersTable({ users: u }: UsersTableProps) { enableHiding: false, friendlyName: t("username"), header: ({ column }) => { + const nameOrder = getSortDirection("username", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -100,17 +168,21 @@ export default function UsersTable({ users: u }: UsersTableProps) { { accessorKey: "idpName", friendlyName: t("identityProvider"), - header: ({ column }) => { + header: () => { return ( - + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("identityProvider")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -128,17 +200,17 @@ export default function UsersTable({ users: u }: UsersTableProps) { id: "role", accessorFn: (row) => row.roleLabels.join(", "), friendlyName: t("role"), - header: ({ column }) => { + header: () => { return ( - + ); }, cell: ({ row }) => { @@ -180,10 +252,8 @@ export default function UsersTable({ users: u }: UsersTableProps) { isDisabled && e.preventDefault() } > - - {t("accessUsersManage")} + + {t("accessUserManage")} {!isDisabled && ( @@ -214,10 +284,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { - @@ -252,15 +319,36 @@ export default function UsersTable({ users: u }: UsersTableProps) { email: selectedUser.email || "" }) }); - - setUsers((prev) => - prev.filter((u) => u.id !== selectedUser?.id) - ); } } + router.refresh(); setIsDeleteModalOpen(false); } + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + return ( <> } buttonText={t("userRemoveOrgConfirm")} - onConfirm={removeUser} + onConfirm={async () => startTransition(removeUser)} string={ selectedUser ? getUserDisplayName({ @@ -289,16 +377,27 @@ export default function UsersTable({ users: u }: UsersTableProps) { title={t("userRemoveOrg")} /> - { - router.push( - `/${org?.org.orgId}/settings/access/users/create` + pagination={pagination} + rowCount={rowCount} + isNavigatingToAddPage={isNavigatingToAddPage} + addButtonText={t("accessUserCreate")} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onPaginationChange={handlePaginationChange} + rows={users} + searchPlaceholder={t("accessUsersSearch")} + tableId="users-table" + onAdd={() => { + startNavigation(() => + router.push( + `/${org?.org.orgId}/settings/access/users/create` + ) ); }} onRefresh={refreshData} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} /> ); diff --git a/src/components/ViewDevicesDialog.tsx b/src/components/ViewDevicesDialog.tsx index 9c29b219c..b29cdcc75 100644 --- a/src/components/ViewDevicesDialog.tsx +++ b/src/components/ViewDevicesDialog.tsx @@ -27,7 +27,7 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { Loader2, RefreshCw } from "lucide-react"; import moment from "moment"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -58,7 +58,6 @@ export default function ViewDevicesDialog({ const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState<"available" | "archived">("available"); const fetchDevices = async () => { setLoading(true); @@ -177,34 +176,21 @@ export default function ViewDevicesDialog({
) : ( - - setActiveTab(value as "available" | "archived") - } - className="w-full" + !d.archived).length})`, + href: "#available" + }, + { + title: `${t("archived") || "Archived"} (${devices.filter((d) => d.archived).length})`, + href: "#archived" + } + ]} > - - - {t("available") || "Available"} ( - { - devices.filter( - (d) => !d.archived - ).length - } - ) - - - {t("archived") || "Archived"} ( - { - devices.filter( - (d) => d.archived - ).length - } - ) - - - +
{devices.filter((d) => !d.archived) .length === 0 ? (
@@ -271,8 +257,8 @@ export default function ViewDevicesDialog({
)} - - +
+
{devices.filter((d) => d.archived) .length === 0 ? (
@@ -336,8 +322,8 @@ export default function ViewDevicesDialog({
)} - - +
+
)} diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 9dbf6de80..09548400b 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -1,6 +1,4 @@ -/** - * Inspired from plausible: https://github.com/plausible/analytics/blob/1df08a25b4a536c9cc1e03855ddcfeac1d1cf6e5/assets/js/dashboard/stats/locations/map.tsx - */ +// Inspired from plausible: https://github.com/plausible/analytics/blob/1df08a25b4a536c9cc1e03855ddcfeac1d1cf6e5/assets/js/dashboard/stats/locations/map.tsx import { cn } from "@app/lib/cn"; import worldJson from "visionscarto-world-atlas/world/110m.json"; import * as topojson from "topojson-client"; @@ -220,7 +218,7 @@ function drawInteractiveCountries( }); hoverPath .datum(country) - .attr("d", path(country) as string) + .attr("d", path(country as any) as string) .style("display", null); }) @@ -237,7 +235,7 @@ function drawInteractiveCountries( return svg; } -type WorldJsonCountryData = { properties: { name: string; a3: string } }; +type WorldJsonCountryData = d3.ExtendedFeature; function parseWorldTopoJsonToGeoJsonFeatures(): Array { const collection = topojson.feature( diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx new file mode 100644 index 000000000..b374df5f8 --- /dev/null +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -0,0 +1,1461 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { Switch } from "@app/components/ui/switch"; +import { Textarea } from "@app/components/ui/textarea"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { Label } from "@app/components/ui/label"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { + type AlertRuleFormAction, + type AlertRuleFormValues +} from "@app/lib/alertRuleForm"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; +import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { Control, UseFormReturn } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; +import { useDebounce } from "use-debounce"; + +export function AddActionPanel({ + onAdd +}: { + onAdd: (type: AlertRuleFormAction["type"]) => void; +}) { + const t = useTranslations(); + + const EXTERNAL_INTEGRATIONS = [ + { + id: "pagerduty", + name: "PagerDuty", + logo: "/third-party/pgd.png", + description: "Send alerts to PagerDuty for incident management", + descriptionKey: t("alertingExternalPagerDutyDescription") + }, + { + id: "opsgenie", + name: "Opsgenie", + logo: "/third-party/opsgenie.png", + description: "Route alerts to Opsgenie for on-call management", + descriptionKey: t("alertingExternalOpsgenieDescription") + }, + { + id: "servicenow", + name: "ServiceNow", + logo: "/third-party/servicenow.png", + description: "Create ServiceNow incidents from alert events", + descriptionKey: t("alertingExternalServiceNowDescription") + }, + { + id: "incidentio", + name: "Incident.io", + logo: "/third-party/incidentio.png", + description: "Trigger Incident.io workflows from alert events", + descriptionKey: t("alertingExternalIncidentIoDescription") + } + ] as const; + + const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id); + + const [selected, setSelected] = useState("notify"); + + const isPremiumSelected = + selected !== null && EXTERNAL_IDS.includes(selected as any); + const isBuiltInSelected = selected !== null && !isPremiumSelected; + + const actionTypeOptions = [ + { + id: "notify", + title: t("alertingActionNotify"), + description: t("alertingActionNotifyDescription"), + icon: + }, + { + id: "webhook", + title: t("alertingActionWebhook"), + description: t("alertingActionWebhookDescription"), + icon: + }, + ...EXTERNAL_INTEGRATIONS.map((integration) => ({ + id: integration.id, + title: integration.name, + description: integration.description, + icon: ( + {integration.name} + ) + })) + ]; + + const handleAdd = () => { + if (!isBuiltInSelected) return; + onAdd(selected as AlertRuleFormAction["type"]); + setSelected(null); + }; + + return ( +
+ setSelected(v)} + /> + {isPremiumSelected && } + {!isPremiumSelected && ( + + )} +
+ ); +} + +function SiteMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + const { data: sites = [] } = useQuery( + orgQueries.sites({ orgId, query: debounced, perPage: 500 }) + ); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectSites") + : t("alertingSitesSelected", { count: value.length }); + return ( + + + + + + + + + {t("siteNotFound")} + + {sites.map((s) => ( + toggle(s.siteId)} + className="cursor-pointer" + > + + {s.name} + + ))} + + + + + + ); +} + +function HealthCheckMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + + const { data: healthChecks = [] } = useQuery( + orgQueries.healthChecks({ orgId }) + ); + + const shown = useMemo(() => { + const query = debounced.trim().toLowerCase(); + const base = query + ? healthChecks.filter((hc) => hc.name.toLowerCase().includes(query)) + : healthChecks; + // Always keep already-selected items visible even if they fall outside the search + if (query && value.length > 0) { + const selectedNotInBase = healthChecks.filter( + (hc) => + value.includes(hc.targetHealthCheckId) && + !base.some( + (b) => b.targetHealthCheckId === hc.targetHealthCheckId + ) + ); + return [...selectedNotInBase, ...base]; + } + return base; + }, [healthChecks, debounced, value]); + + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + + const summary = + value.length === 0 + ? t("alertingSelectHealthChecks") + : t("alertingHealthChecksSelected", { count: value.length }); + + return ( + + + + + + + + + + {t("alertingHealthChecksEmpty")} + + + {shown.map((hc) => ( + + toggle(hc.targetHealthCheckId) + } + className="cursor-pointer" + > + + {hc.name} + + ))} + + + + + + ); +} + +function ResourceMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + + const { data: resources = [] } = useQuery( + orgQueries.resources({ orgId, query: debounced, perPage: 10 }) + ); + + const shown = useMemo(() => { + return resources; + }, [resources]); + + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + + const summary = + value.length === 0 + ? t("alertingSelectResources") + : t("alertingResourcesSelected", { count: value.length }); + + return ( + + + + + + + + + + {t("alertingResourcesEmpty")} + + + {shown.map((r) => ( + toggle(r.resourceId)} + className="cursor-pointer" + > + + {r.name} + + ))} + + + + + + ); +} + +export function ActionBlock({ + orgId, + index, + control, + form, + onRemove, + onUpdate, + canRemove +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; + onRemove: () => void; + onUpdate: (val: AlertRuleFormAction) => void; + canRemove: boolean; +}) { + const t = useTranslations(); + const type = useWatch({ control, name: `actions.${index}.type` }); + + const typeHeader = + type === "notify" ? ( +
+ + {t("alertingActionNotify")} +
+ ) : ( +
+ + {t("alertingActionWebhook")} +
+ ); + + return ( +
+ {canRemove && ( + + )} + {typeHeader} + {type === "notify" && ( + + )} + {type === "webhook" && ( + + )} +
+ ); +} + +function NotifyActionFields({ + orgId, + index, + control, + form +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + + const [emailActiveIdx, setEmailActiveIdx] = useState(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + + const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery( + orgQueries.users({ orgId }) + ); + const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery( + orgQueries.roles({ orgId }) + ); + + const allUsers = useMemo( + () => + orgUsers.map((u) => ({ + id: String(u.id), + text: getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + }) + })), + [orgUsers] + ); + + const allRoles = useMemo( + () => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })), + [orgRoles] + ); + + const hasResolvedTagsRef = useRef(false); + + useEffect(() => { + if (isLoadingUsers || isLoadingRoles) return; + if (hasResolvedTagsRef.current) return; + + const currentUserTags = form.getValues( + `actions.${index}.userTags` + ) as Tag[]; + const currentRoleTags = form.getValues( + `actions.${index}.roleTags` + ) as Tag[]; + + const resolvedUserTags = currentUserTags.map((tag) => { + const match = allUsers.find((u) => u.id === tag.id); + return match ? { id: tag.id, text: match.text } : tag; + }); + + const resolvedRoleTags = currentRoleTags.map((tag) => { + const match = allRoles.find((r) => r.id === tag.id); + return match ? { id: tag.id, text: match.text } : tag; + }); + + const userTagsNeedUpdate = resolvedUserTags.some( + (t, i) => t.text !== currentUserTags[i]?.text + ); + const roleTagsNeedUpdate = resolvedRoleTags.some( + (t, i) => t.text !== currentRoleTags[i]?.text + ); + + if (userTagsNeedUpdate) { + form.setValue(`actions.${index}.userTags`, resolvedUserTags, { + shouldDirty: false + }); + } + if (roleTagsNeedUpdate) { + form.setValue(`actions.${index}.roleTags`, resolvedRoleTags, { + shouldDirty: false + }); + } + + hasResolvedTagsRef.current = true; + }, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]); + + const userTags = (useWatch({ + control, + name: `actions.${index}.userTags` + }) ?? []) as Tag[]; + const roleTags = (useWatch({ + control, + name: `actions.${index}.roleTags` + }) ?? []) as Tag[]; + const emailTags = (useWatch({ + control, + name: `actions.${index}.emailTags` + }) ?? []) as Tag[]; + + return ( +
+ ( + + {t("alertingNotifyUsers")} + + { + const next = + typeof newTags === "function" + ? newTags(userTags) + : newTags; + form.setValue( + `actions.${index}.userTags`, + next as Tag[], + { shouldDirty: true } + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + + + )} + /> + ( + + {t("alertingNotifyRoles")} + + { + const next = + typeof newTags === "function" + ? newTags(roleTags) + : newTags; + form.setValue( + `actions.${index}.roleTags`, + next as Tag[], + { shouldDirty: true } + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + + + )} + /> + ( + + {t("alertingNotifyEmails")} + + { + const next = + typeof updater === "function" + ? updater(emailTags) + : updater; + form.setValue( + `actions.${index}.emailTags`, + next as Tag[], + { shouldDirty: true } + ); + }} + activeTagIndex={emailActiveIdx} + setActiveTagIndex={setEmailActiveIdx} + placeholder={t("alertingEmailPlaceholder")} + size="sm" + allowDuplicates={false} + sortTags={true} + validateTag={(tag) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + } + delimiterList={[",", "Enter"]} + /> + + + + )} + /> +
+ ); +} + +function WebhookActionFields({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + return ( +
+ ( + + {t("webhookUrlLabel")} + + + + + + )} + /> + ( + + {t("alertingWebhookMethod")} + + + + )} + /> + {/* Authentication */} +
+
+ +

+ {t("httpDestAuthDescription")} +

+
+ ( + + + + {/* None */} +
+ +
+ +

+ {t( + "httpDestAuthNoneDescription" + )} +

+
+
+ + {/* Bearer */} +
+ +
+
+ +

+ {t( + "httpDestAuthBearerDescription" + )} +

+
+ {field.value === "bearer" && ( + ( + + + + + + + )} + /> + )} +
+
+ + {/* Basic */} +
+ +
+
+ +

+ {t( + "httpDestAuthBasicDescription" + )} +

+
+ {field.value === "basic" && ( + ( + + + + + + + )} + /> + )} +
+
+ + {/* Custom */} +
+ +
+
+ +

+ {t( + "httpDestAuthCustomDescription" + )} +

+
+ {field.value === "custom" && ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> +
+ )} +
+
+
+
+ +
+ )} + /> +
+ + {/* Body Template */} +
+
+ +

+ {t("httpDestBodyTemplateDescription")} +

+
+ ( + +
+ + + + +
+
+ )} + /> + {useWatch({ + control, + name: `actions.${index}.useBodyTemplate` + }) && ( + ( + + + {t("httpDestBodyTemplateLabel")} + + +