From 76fd9eedbf0f7e853d5ffe40717cac126ffe6a98 Mon Sep 17 00:00:00 2001 From: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:47:40 -0500 Subject: [PATCH] release-2.7.1 (#1296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan Co-authored-by: Codebuff * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms Co-authored-by: ZacharyZcR Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan Co-authored-by: Codebuff Co-authored-by: ZacharyZcR Co-authored-by: dropafterfree Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> --- .github/workflows/deploy-helm.yml | 76 + .github/workflows/helm.yml | 75 + .gitlab-ci.yml | 2 + .prettierignore | 3 + README.md | 4 + RELEASE_NOTES.md | 145 +- charts/termix/.helmignore | 6 + charts/termix/Chart.yaml | 16 + charts/termix/templates/NOTES.txt | 17 + charts/termix/templates/_helpers.tpl | 45 + charts/termix/templates/deployment.yaml | 148 ++ charts/termix/templates/hpa.yaml | 22 + charts/termix/templates/ingress.yaml | 35 + charts/termix/templates/networkpolicy.yaml | 19 + charts/termix/templates/pdb.yaml | 13 + charts/termix/templates/pvc.yaml | 21 + charts/termix/templates/secret.yaml | 15 + charts/termix/templates/service.yaml | 19 + charts/termix/templates/serviceaccount.yaml | 13 + .../templates/traefik-ingressroute.yaml | 34 + charts/termix/templates/validate.yaml | 12 + charts/termix/values-gitops-example.yaml | 28 + charts/termix/values-traefik.yaml | 32 + charts/termix/values.yaml | 147 ++ deploy/argocd/application-traefik.yaml | 25 + deploy/argocd/application.yaml | 25 + deploy/gitlab/.gitlab-ci.yml | 70 + docker/Dockerfile | 9 +- docker/entrypoint.sh | 24 + docker/nginx-https.conf | 2 + electron-builder.json | 2 +- electron/local-shell.cjs | 21 + electron/main.cjs | 36 +- package-lock.json | 4 +- package.json | 2 +- scripts/patch-xterm-android-ime.cjs | 34 +- src/backend/automations/engine.ts | 28 +- src/backend/automations/notify.ts | 10 + src/backend/database/database.ts | 5 + .../repositories/sqlite-foreign-keys.ts | 14 +- src/backend/database/routes/credentials.ts | 159 ++ .../database/routes/proxmox-import-auth.ts | 2 +- src/backend/database/routes/proxmox.ts | 22 +- .../database/routes/sso-provider-routes.ts | 42 +- .../database/routes/tailscale-routes.ts | 5 +- .../database/routes/user-oidc-utils.ts | 19 +- src/backend/database/routes/users.ts | 22 +- src/backend/hosts/file-manager/index.ts | 28 +- .../hosts/file-manager/sftp-promisify.ts | 120 ++ .../hosts/file-manager/transfer-engine.ts | 609 +------ .../hosts/file-manager/transfer-errors.ts | 52 + .../hosts/file-manager/transfer-host-utils.ts | 59 + .../hosts/file-manager/transfer-scan.ts | 89 + .../file-manager/transfer-segment-copy.ts | 40 + .../hosts/file-manager/transfer-sftp-dir.ts | 74 + .../hosts/file-manager/transfer-stats.ts | 157 ++ src/backend/hosts/jump-host-chain.ts | 62 +- src/backend/hosts/metrics/index.ts | 26 +- src/backend/hosts/serial.ts | 182 +- src/backend/hosts/terminal-auth-helpers.ts | 118 +- src/backend/hosts/terminal/host-identity.ts | 9 + src/backend/hosts/terminal/index.ts | 1610 +++++++++-------- src/backend/hosts/tmux/monitor-helpers.ts | 18 +- src/backend/starter.ts | 22 +- src/backend/tests/automations/engine.test.ts | 35 + src/backend/tests/automations/notify.test.ts | 44 + .../repositories/host-repository.test.ts | 100 +- .../repositories/sqlite-foreign-keys.test.ts | 51 + .../routes/proxmox-import-auth.test.ts | 6 +- .../routes/sso-provider-routes.test.ts | 24 + .../database/routes/user-oidc-utils.test.ts | 8 + .../tests/electron/local-shell.test.ts | 35 + .../tests/hosts/terminal-agent-auth.test.ts | 129 +- .../hosts/terminal/host-identity.test.ts | 15 + .../tests/hosts/tmux/monitor-helpers.test.ts | 47 + src/backend/tests/utils/proxy-agent.test.ts | 41 + src/backend/tests/utils/ssh-key-utils.test.ts | 17 + src/backend/tests/utils/ws-message.test.ts | 99 + src/backend/utils/proxy-agent.ts | 12 +- src/backend/utils/ssh-key-utils.ts | 4 + src/backend/utils/ws-message.ts | 76 + src/types/electron.d.ts | 6 +- src/types/index.ts | 1 + src/ui/api/credentials-api.ts | 15 + src/ui/api/guacamole-api.ts | 11 +- src/ui/api/ssh-file-operations-api.ts | 14 +- src/ui/api/user-management-api.ts | 1 + src/ui/api/webauthn-api.ts | 55 +- src/ui/auth/Auth.tsx | 103 ++ .../proxmox/ProxmoxDiscoverDialog.tsx | 5 +- .../components/proxmox/proxmox-import-auth.ts | 2 +- src/ui/features/ai/AiProviderSettings.tsx | 237 ++- src/ui/features/file-manager/FileManager.tsx | 164 +- .../file-manager/FileManagerToolbar.tsx | 107 +- .../components/DownloadProgressToast.tsx | 87 + .../file-manager/hooks/useDragAndDrop.ts | 27 +- src/ui/features/guacamole/GuacamoleApp.tsx | 16 +- .../features/guacamole/GuacamoleDisplay.tsx | 25 +- .../features/guacamole/guacamole-file-drop.ts | 14 + .../features/local-terminal/LocalTerminal.tsx | 35 +- src/ui/features/terminal/Terminal.tsx | 27 +- .../features/terminal/terminal-key-event.ts | 10 + src/ui/locales/en.json | 32 +- src/ui/locales/translated/af_ZA.json | 32 +- src/ui/locales/translated/ar_SA.json | 32 +- src/ui/locales/translated/bg_BG.json | 32 +- src/ui/locales/translated/bn_BD.json | 32 +- src/ui/locales/translated/ca_ES.json | 32 +- src/ui/locales/translated/cs_CZ.json | 32 +- src/ui/locales/translated/da_DK.json | 32 +- src/ui/locales/translated/de_DE.json | 32 +- src/ui/locales/translated/el_GR.json | 32 +- src/ui/locales/translated/es_ES.json | 32 +- src/ui/locales/translated/fi_FI.json | 32 +- src/ui/locales/translated/fr_FR.json | 32 +- src/ui/locales/translated/he_IL.json | 32 +- src/ui/locales/translated/hi_IN.json | 32 +- src/ui/locales/translated/hu_HU.json | 32 +- src/ui/locales/translated/id_ID.json | 32 +- src/ui/locales/translated/it_IT.json | 32 +- src/ui/locales/translated/ja_JP.json | 32 +- src/ui/locales/translated/ko_KR.json | 32 +- src/ui/locales/translated/nl_NL.json | 32 +- src/ui/locales/translated/no_NO.json | 32 +- src/ui/locales/translated/pl_PL.json | 32 +- src/ui/locales/translated/pt_BR.json | 32 +- src/ui/locales/translated/pt_PT.json | 32 +- src/ui/locales/translated/ro_RO.json | 32 +- src/ui/locales/translated/ru_RU.json | 32 +- src/ui/locales/translated/sr_SP.json | 32 +- src/ui/locales/translated/sv_SE.json | 32 +- src/ui/locales/translated/th_TH.json | 32 +- src/ui/locales/translated/tr_TR.json | 32 +- src/ui/locales/translated/uk_UA.json | 32 +- src/ui/locales/translated/vi_VN.json | 32 +- src/ui/locales/translated/zh_CN.json | 32 +- src/ui/locales/translated/zh_TW.json | 32 +- src/ui/main-axios.ts | 1 + src/ui/settings/RemoteSyncServerPicker.tsx | 1 + src/ui/sidebar/AdminImageStorageSection.tsx | 41 +- src/ui/sidebar/AdminSettingsPanel.tsx | 8 +- src/ui/sidebar/AdminSettingsSections.tsx | 9 +- src/ui/sidebar/CredentialEditorView.tsx | 92 +- src/ui/sidebar/HostEditor.tsx | 98 +- src/ui/sidebar/HostEditorData.ts | 9 +- src/ui/sidebar/HostManager.tsx | 77 +- src/ui/sidebar/HostsPanel.tsx | 2 +- .../CredentialItem/CredentialItem.tsx | 25 +- .../credential-tree/CredentialSidebarTree.tsx | 62 +- .../credential-tree/credential-row-height.ts | 55 + src/ui/sidebar/tree/HostItem/HostItem.tsx | 32 +- src/ui/sidebar/tree/SidebarTree.tsx | 102 +- src/ui/sidebar/tree/row-metrics.ts | 21 + src/ui/ssh/dialogs/TOTPDialog.tsx | 3 +- src/ui/tests/api/guacamole-api.test.ts | 34 +- src/ui/tests/api/webauthn-api.test.ts | 121 ++ .../proxmox/proxmox-import-auth.test.ts | 6 +- .../features/ai/AiProviderSettings.test.tsx | 85 + .../file-manager/hooks/useDragAndDrop.test.ts | 92 + .../guacamole/guacamole-file-drop.test.ts | 17 + .../features/terminal/android-ime.test.ts | 28 + .../terminal/terminal-key-event.test.ts | 28 +- src/ui/tests/sidebar/HostEditorData.test.ts | 68 + .../credential-row-height.test.ts | 58 + src/ui/tests/sidebar/tree/row-metrics.test.ts | 64 + 165 files changed, 6778 insertions(+), 2090 deletions(-) create mode 100644 .github/workflows/deploy-helm.yml create mode 100644 .github/workflows/helm.yml create mode 100644 .gitlab-ci.yml create mode 100644 charts/termix/.helmignore create mode 100644 charts/termix/Chart.yaml create mode 100644 charts/termix/templates/NOTES.txt create mode 100644 charts/termix/templates/_helpers.tpl create mode 100644 charts/termix/templates/deployment.yaml create mode 100644 charts/termix/templates/hpa.yaml create mode 100644 charts/termix/templates/ingress.yaml create mode 100644 charts/termix/templates/networkpolicy.yaml create mode 100644 charts/termix/templates/pdb.yaml create mode 100644 charts/termix/templates/pvc.yaml create mode 100644 charts/termix/templates/secret.yaml create mode 100644 charts/termix/templates/service.yaml create mode 100644 charts/termix/templates/serviceaccount.yaml create mode 100644 charts/termix/templates/traefik-ingressroute.yaml create mode 100644 charts/termix/templates/validate.yaml create mode 100644 charts/termix/values-gitops-example.yaml create mode 100644 charts/termix/values-traefik.yaml create mode 100644 charts/termix/values.yaml create mode 100644 deploy/argocd/application-traefik.yaml create mode 100644 deploy/argocd/application.yaml create mode 100644 deploy/gitlab/.gitlab-ci.yml create mode 100644 electron/local-shell.cjs create mode 100644 src/backend/hosts/file-manager/sftp-promisify.ts create mode 100644 src/backend/hosts/file-manager/transfer-errors.ts create mode 100644 src/backend/hosts/file-manager/transfer-host-utils.ts create mode 100644 src/backend/hosts/file-manager/transfer-scan.ts create mode 100644 src/backend/hosts/file-manager/transfer-segment-copy.ts create mode 100644 src/backend/hosts/file-manager/transfer-sftp-dir.ts create mode 100644 src/backend/hosts/file-manager/transfer-stats.ts create mode 100644 src/backend/tests/automations/notify.test.ts create mode 100644 src/backend/tests/database/repositories/sqlite-foreign-keys.test.ts create mode 100644 src/backend/tests/database/routes/sso-provider-routes.test.ts create mode 100644 src/backend/tests/electron/local-shell.test.ts create mode 100644 src/backend/tests/utils/proxy-agent.test.ts create mode 100644 src/backend/tests/utils/ws-message.test.ts create mode 100644 src/backend/utils/ws-message.ts create mode 100644 src/ui/features/file-manager/components/DownloadProgressToast.tsx create mode 100644 src/ui/features/guacamole/guacamole-file-drop.ts create mode 100644 src/ui/sidebar/credential-tree/credential-row-height.ts create mode 100644 src/ui/sidebar/tree/row-metrics.ts create mode 100644 src/ui/tests/api/webauthn-api.test.ts create mode 100644 src/ui/tests/features/ai/AiProviderSettings.test.tsx create mode 100644 src/ui/tests/features/file-manager/hooks/useDragAndDrop.test.ts create mode 100644 src/ui/tests/features/guacamole/guacamole-file-drop.test.ts create mode 100644 src/ui/tests/sidebar/credential-tree/credential-row-height.test.ts create mode 100644 src/ui/tests/sidebar/tree/row-metrics.test.ts diff --git a/.github/workflows/deploy-helm.yml b/.github/workflows/deploy-helm.yml new file mode 100644 index 00000000..27b44f20 --- /dev/null +++ b/.github/workflows/deploy-helm.yml @@ -0,0 +1,76 @@ +name: Deploy Helm + +on: + workflow_dispatch: + inputs: + namespace: + description: "Kubernetes namespace" + required: true + default: termix + release: + description: "Helm release name" + required: true + default: termix + values_file: + description: "Values file to use" + required: true + default: charts/termix/values-gitops-example.yaml + image_tag: + description: "Image tag to deploy" + required: false + default: "" + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Helm + uses: azure/setup-helm@v5 + with: + version: v3.15.4 + + - name: Configure kubeconfig + env: + KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }} + run: | + test -n "$KUBE_CONFIG" + echo "$KUBE_CONFIG" | base64 -d > "$RUNNER_TEMP/kubeconfig" + chmod 600 "$RUNNER_TEMP/kubeconfig" + + - name: Lint chart + run: helm lint charts/termix + + - name: Deploy + env: + KUBECONFIG: ${{ runner.temp }}/kubeconfig + IMAGE_TAG: ${{ inputs.image_tag }} + RELEASE_NAME: ${{ inputs.release }} + TARGET_NAMESPACE: ${{ inputs.namespace }} + VALUES_FILE: ${{ inputs.values_file }} + run: | + set -euo pipefail + [[ "$RELEASE_NAME" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]] + [[ "$TARGET_NAMESPACE" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]] + case "$VALUES_FILE" in + charts/termix/*.yaml) ;; + *) echo "values_file must be a YAML file under charts/termix" >&2; exit 1 ;; + esac + test -f "$VALUES_FILE" + ARGS=() + if [ -n "$IMAGE_TAG" ]; then + ARGS+=(--set "image.tag=$IMAGE_TAG") + fi + helm upgrade --install "$RELEASE_NAME" charts/termix \ + --namespace "$TARGET_NAMESPACE" \ + --create-namespace \ + --values "$VALUES_FILE" \ + --atomic \ + --timeout 10m \ + "${ARGS[@]}" diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml new file mode 100644 index 00000000..a7de8014 --- /dev/null +++ b/.github/workflows/helm.yml @@ -0,0 +1,75 @@ +name: Helm + +on: + pull_request: + paths: + - "charts/**" + - ".github/workflows/helm.yml" + - "deploy/**" + push: + branches: [main] + paths: + - "charts/**" + - ".github/workflows/helm.yml" + - "deploy/**" + workflow_dispatch: + inputs: + publish: + description: "Publish chart to GHCR OCI registry" + required: true + type: boolean + default: false + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Helm + uses: azure/setup-helm@v5 + with: + version: v3.15.4 + + - name: Lint chart + run: helm lint charts/termix + + - name: Render default chart + run: helm template termix charts/termix --namespace termix + + - name: Render GitOps example + run: helm template termix charts/termix --values charts/termix/values-gitops-example.yaml --namespace termix + + - name: Render Traefik example + run: helm template termix charts/termix --values charts/termix/values-traefik.yaml --namespace termix + + publish: + needs: lint + if: github.event_name == 'workflow_dispatch' && inputs.publish + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Helm + uses: azure/setup-helm@v5 + with: + version: v3.15.4 + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin + + - name: Package chart + run: helm package charts/termix --destination .helm-packages + + - name: Push chart + run: | + OWNER="$(echo "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" + helm push .helm-packages/termix-*.tgz "oci://ghcr.io/$OWNER/charts" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..2d7e7746 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,2 @@ +include: + - local: deploy/gitlab/.gitlab-ci.yml diff --git a/.prettierignore b/.prettierignore index 8cf32b0d..9d9f404b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -20,3 +20,6 @@ openapi.json # Generated by drizzle-kit; formatting is the tool's own drizzle/ + +# Helm templates contain Go template syntax, not plain YAML +charts/*/templates/ diff --git a/README.md b/README.md index 95c175bf..b7a8e592 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,10 @@ Around 30 languages built in, managed through [Crowdin](https://docs.termix.site Visit the [Termix Docs](https://docs.termix.site/install) for full installation instructions across all platforms. +Deploying to Kubernetes? The Helm chart is in `charts/termix`, and setup instructions +covering Ingress, Traefik, Argo CD, GitHub Actions, and GitLab CI are at +[docs.termix.site/install/server/kubernetes](https://docs.termix.site/install/server/kubernetes). + Sample Docker Compose file (you can omit `guacd` and the network if you don't plan on using remote desktop features): ```yaml diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 66230107..f6a23545 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,6 @@ -Termix AI, automations, fleets, workspaces, subhosts, Proxmox metrics, a context aware terminal toolbar, split screen tabs, onboarding, PostgreSQL/MySQL support, and a large batch of fixes. +Credential cloning, a WSL local terminal, Helm and GitOps deployment, an editable file manager path bar, download progress bars, and a large batch of connection, sync, and remote desktop fixes. @@ -12,114 +12,49 @@ https://youtu.be/lngaePO96tM -- Added completely optional and disabled/removed by default Termix AI, an assistant that can work with your hosts and terminals - - This feature was added based off a 60% (yes) to 40% (no) Discord vote. - - The assistant cannot change anything on its own. It can only read a limited set of your Termix data and propose actions, and every change or command runs only after you approve it, using your own account and permissions. - - It has no access to credentials, SSH keys, vaults, users, roles, sessions, SSO, certificates, audit logs, or instance settings. Secrets are also stripped from anything sent to a model provider. - - Both an admin and each user must turn it on before it does anything, and it stays off after upgrading. -- Added automations with events, channels, and steps -- Added a fleet system with snippets, packages, files, and inventory -- Added workspaces to save and restore your tab layout -- Added subhosts so hosts can be organized under a parent host -- Added Proxmox metrics integration -- Added a context aware terminal toolbar with quick links, host info, image pasting, and a movable desktop layout -- Added interactive terminal macros -- Added first-class split screen tabs -- Added a file manager trash instead of permanent deletes -- Added a local terminal to the desktop app -- Added inheritable connection defaults so hosts can share settings -- Added an onboarding flow with an interface simplicity system -- Added PostgreSQL and MySQL support alongside SQLite -- Added a redesigned host and credential sidebar with synced preferences and drag-to-reorder -- Added the option to open some app rail tabs as their own tab or in a right sidebar -- Added folder select to host multi select -- Added connection logs for RDP, VNC, and Telnet hosts -- Added native RDP launching on Windows desktop -- Added a drive file browser and drag-and-drop upload for RDP -- Added terminal image handoff so images open on your local machine -- Added custom terminal font selection -- Added trusted proxy authentication -- Added global touch input settings -- Added adaptive transfers that pick the fastest route and verify integrity -- Added adaptive polling and preloading that respond to activity and network cost -- Added adaptive SSH local echo for high latency connections -- Added custom disk and network metric options -- Added the ability to exclude specific mounts from disk usage metrics -- Added expanded snippet options -- Added downloadable session logs as text files -- Added keyboard shortcuts to move between open tabs -- Added Discord webhook notification channels -- Added paste support when not running over HTTPS -- Added Headscale API key and custom API endpoint support -- Added a Meta key option for terminals -- Added BE-AZERTY keyboard layout for remote desktop -- Added PKCE to the OIDC login flow -- Added Proxmox VMID and Docker tags to discovered guests -- Added custom SSL certificate support in admin settings -- Greatly improved performance across metrics polling and host management for large setups +- Added the ability to clone an existing credential +- Added a WSL option for the local terminal +- Added Helm charts and a GitOps deployment setup +- Added an editable path bar to the file manager +- Added a progress bar for file downloads in the file manager +- Added an identity file option so agent authentication stops after the right key +- Added an environment variable to turn on silent OIDC login +- Added editable model settings for AI providers +- Improved tmux monitor performance when aggregating sessions +- Improved Linux packaging with standard icon sizes -- Periodic SSH terminal stalls caused by SQLite telemetry writes -- Missing OPKSSH binary breaking installs without internet access -- Session recording writes slowing down terminals -- SGR mouse tracking escape codes printing as text -- Terminal display distortion with special characters -- Windows Ctrl+W not closing the active tab -- Tray Quit not terminating the desktop app -- Mobile terminal scrollback not matching xterm wheel behavior -- tmux breaking on UTF-8 paths -- Sudo password auto-fill not persisting -- SSH and sudo passwords not being saved or auto-filled -- Switching SSH authentication away from Vault failing -- Host edits being discarded without a warning -- Quick-created credentials not being selected -- Saved RDP connection settings not being preserved -- RDP domain credentials not being prompted for -- Windows key mapping in remote desktop sessions -- VNC failing to connect to macOS screen sharing -- Mouse input breaking on touch-capable devices in RDP and VNC -- Docker runtime selection not persisting, plus Docker manager UI issues -- Desktop Docker console WebSocket not being authenticated -- Folders intermittently disappearing from duplicate requests -- Folder deletion not refreshing the host list -- Proxmox guest identity being lost on edit -- Long host names shifting dashboard metrics -- Host list rows resizing unexpectedly -- Metrics collection all firing at once on startup -- Session activity writes hitting the database too often -- Reachable and available hosts being treated the same -- SSH keepalives could not be disabled -- OIDC group claims from multiple sources not being merged -- OIDC discovery issuers with trailing slashes failing -- LDAP logins not using preferred_username -- Trusted MFA devices not being bound to a specific client install -- 2FA could not be disabled with a single credential -- Profile API keys not being shown after creation -- SSH agent authentication being unclear in the host editor -- Tunnel status stream not requiring authentication -- SFTP and Docker console accepting a mismatched host id -- SSH connections whose host id resolved elsewhere being accepted -- User-managed CA certificates not being applied over SFTP -- Already-shared hosts losing their SSH authentication -- Real client IP not being captured for SSH login alerts behind a reverse proxy -- Audit log IPs not using the real client IP -- Homepage System Overview update indicator never firing -- Webhook notification channels not working -- Remote sync failing behind an nginx proxy -- First server sync not refreshing the UI -- Desktop app not showing update prompts and hiding the version badge -- Desktop Tailscale configuration being lost -- Command palette not loading new activity, plus Enter now opens the first result -- Database connection failures during login not being reported clearly -- audit_logs.user_id not being nullable on fresh SQLite installs -- Sync upserts writing to the wrong row -- Database migration failures on tables without an id column -- ssh_credentials rebuilds not matching the live schema -- Sidebar reset and fullscreen buttons sharing the same icon -- Host list icons not matching the tab bar icons -- Keep Linux credential storage working on unrecognized desktops +- Passkey sign in not showing up on the login screen +- Connections through jump hosts failing +- Jump hosts and remote desktop hosts not resolving after a sync +- Malformed websocket messages crashing the server +- Encrypted file manager keys not prompting for a passphrase +- SSH agent forwarding not working with the in-memory agent +- Two factor prompts rejecting codes longer than six digits +- OIDC lockout with no way to recover from environment settings +- Tailscale requests failing on some setups +- Terminal clipboard shortcuts not working on non-QWERTY layouts +- Rapid mobile terminal input being sent one keystroke at a time +- HTTPS not being able to share the configured port +- Portable imports failing on remote databases +- Host status not showing when metrics collection is off +- Proxmox guest credential usernames being wrong +- File drops not working for RDP in the browser +- Duplicate Docker HTTPS listener on startup +- Remote sync server probe ignoring the certificate setting +- Runtime SSL settings not being preserved +- Automation notifications missing host details +- Connection screens crashing outside the connection log provider +- better-sqlite3 failing in Docker on some platforms +- Host action rows shifting at large font sizes +- Sidebar height jumping when hosts or credentials have tags +- Gaps between host rows in the sidebar list +- Rounded corners on the host list search bar +- Image storage settings text wrapping to one word per line +- Unclear wording on the click-to-expand host setting +- Dragging a folder into the file manager failing to upload diff --git a/charts/termix/.helmignore b/charts/termix/.helmignore new file mode 100644 index 00000000..e4a6797e --- /dev/null +++ b/charts/termix/.helmignore @@ -0,0 +1,6 @@ +.git/ +.github/ +node_modules/ +dist/ +coverage/ +*.tgz diff --git a/charts/termix/Chart.yaml b/charts/termix/Chart.yaml new file mode 100644 index 00000000..5fd44049 --- /dev/null +++ b/charts/termix/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: termix +description: Self-hosted SSH and remote desktop management for Kubernetes. +type: application +version: 0.1.0 +appVersion: 2.7.0 +home: https://github.com/Termix-SSH/Termix +sources: + - https://github.com/Termix-SSH/Termix +maintainers: + - name: Termix maintainers +keywords: + - ssh + - remote-desktop + - guacamole + - terminal diff --git a/charts/termix/templates/NOTES.txt b/charts/termix/templates/NOTES.txt new file mode 100644 index 00000000..c2eea984 --- /dev/null +++ b/charts/termix/templates/NOTES.txt @@ -0,0 +1,17 @@ +Termix has been installed. + +Service: + {{ include "termix.fullname" . }}:{{ .Values.service.port }} + +{{- if .Values.ingress.enabled }} +Ingress hosts: +{{- range .Values.ingress.hosts }} + - {{ .host }} +{{- end }} +{{- else if .Values.traefik.ingressRoute.enabled }} +Traefik IngressRoute: + https://{{ .Values.traefik.ingressRoute.host }}{{ .Values.traefik.ingressRoute.pathPrefix }} +{{- else }} +Port-forward for local testing: + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "termix.fullname" . }} 8080:{{ .Values.service.port }} +{{- end }} diff --git a/charts/termix/templates/_helpers.tpl b/charts/termix/templates/_helpers.tpl new file mode 100644 index 00000000..65b38907 --- /dev/null +++ b/charts/termix/templates/_helpers.tpl @@ -0,0 +1,45 @@ +{{- define "termix.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "termix.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "termix.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "termix.labels" -}} +helm.sh/chart: {{ include "termix.chart" . }} +app.kubernetes.io/name: {{ include "termix.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "termix.selectorLabels" -}} +app.kubernetes.io/name: {{ include "termix.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "termix.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "termix.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} + +{{- define "termix.secretName" -}} +{{- default (printf "%s-secret" (include "termix.fullname" .)) .Values.secrets.name -}} +{{- end -}} diff --git a/charts/termix/templates/deployment.yaml b/charts/termix/templates/deployment.yaml new file mode 100644 index 00000000..52487425 --- /dev/null +++ b/charts/termix/templates/deployment.yaml @@ -0,0 +1,148 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "termix.fullname" . }} + labels: + {{- include "termix.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + strategy: + {{- toYaml .Values.strategy | nindent 4 }} + selector: + matchLabels: + {{- include "termix.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "termix.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "termix.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: termix + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + {{- if .Values.guacd.enabled }} + - name: GUACD_HOST + value: "127.0.0.1" + {{- else if .Values.guacd.host }} + - name: GUACD_HOST + value: {{ .Values.guacd.host | quote }} + {{- end }} + - name: GUACD_TUNNEL_HOST + value: {{ include "termix.fullname" . | quote }} + {{- if ne .Values.database.dialect "sqlite" }} + - name: DATABASE_DIALECT + value: {{ .Values.database.dialect | quote }} + {{- end }} + {{- if .Values.database.existingSecret.name }} + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.database.existingSecret.name }} + key: {{ .Values.database.existingSecret.urlKey }} + {{- end }} + {{- if .Values.secrets.create }} + {{- range $key, $value := .Values.secrets.data }} + {{- if $value }} + - name: {{ $key }} + valueFrom: + secretKeyRef: + name: {{ include "termix.secretName" $ }} + key: {{ $key }} + {{- end }} + {{- end }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.extraEnvFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.probes.liveness.enabled }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + {{- end }} + {{- if .Values.probes.readiness.enabled }} + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /app/data + {{- if .Values.guacd.enabled }} + - name: guacd + image: "{{ .Values.guacd.image.repository }}:{{ .Values.guacd.image.tag }}" + imagePullPolicy: {{ .Values.guacd.image.pullPolicy }} + securityContext: + {{- toYaml .Values.guacd.securityContext | nindent 12 }} + ports: + - name: guacd + containerPort: {{ .Values.guacd.service.port }} + protocol: TCP + resources: + {{- toYaml .Values.guacd.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /termix-data + {{- end }} + volumes: + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ default (printf "%s-data" (include "termix.fullname" .)) .Values.persistence.existingClaim }} + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/termix/templates/hpa.yaml b/charts/termix/templates/hpa.yaml new file mode 100644 index 00000000..016a9e87 --- /dev/null +++ b/charts/termix/templates/hpa.yaml @@ -0,0 +1,22 @@ +{{- if .Values.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "termix.fullname" . }} + labels: + {{- include "termix.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "termix.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/charts/termix/templates/ingress.yaml b/charts/termix/templates/ingress.yaml new file mode 100644 index 00000000..6c6b1fbe --- /dev/null +++ b/charts/termix/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "termix.fullname" . }} + labels: + {{- include "termix.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "termix.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/termix/templates/networkpolicy.yaml b/charts/termix/templates/networkpolicy.yaml new file mode 100644 index 00000000..c0e508be --- /dev/null +++ b/charts/termix/templates/networkpolicy.yaml @@ -0,0 +1,19 @@ +{{- if .Values.networkPolicy.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "termix.fullname" . }} + labels: + {{- include "termix.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "termix.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + {{- toYaml .Values.networkPolicy.ingress | nindent 4 }} + egress: + {{- toYaml .Values.networkPolicy.egress | nindent 4 }} +{{- end }} diff --git a/charts/termix/templates/pdb.yaml b/charts/termix/templates/pdb.yaml new file mode 100644 index 00000000..fc22ede1 --- /dev/null +++ b/charts/termix/templates/pdb.yaml @@ -0,0 +1,13 @@ +{{- if .Values.podDisruptionBudget.enabled -}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "termix.fullname" . }} + labels: + {{- include "termix.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "termix.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/termix/templates/pvc.yaml b/charts/termix/templates/pvc.yaml new file mode 100644 index 00000000..ac7a11a4 --- /dev/null +++ b/charts/termix/templates/pvc.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "termix.fullname" . }}-data + labels: + {{- include "termix.labels" . | nindent 4 }} + {{- with .Values.persistence.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/charts/termix/templates/secret.yaml b/charts/termix/templates/secret.yaml new file mode 100644 index 00000000..278119ee --- /dev/null +++ b/charts/termix/templates/secret.yaml @@ -0,0 +1,15 @@ +{{- if .Values.secrets.create -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "termix.secretName" . }} + labels: + {{- include "termix.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- range $key, $value := .Values.secrets.data }} + {{- if $value }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/termix/templates/service.yaml b/charts/termix/templates/service.yaml new file mode 100644 index 00000000..c2d2f4db --- /dev/null +++ b/charts/termix/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "termix.fullname" . }} + labels: + {{- include "termix.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "termix.selectorLabels" . | nindent 4 }} diff --git a/charts/termix/templates/serviceaccount.yaml b/charts/termix/templates/serviceaccount.yaml new file mode 100644 index 00000000..365c382b --- /dev/null +++ b/charts/termix/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "termix.serviceAccountName" . }} + labels: + {{- include "termix.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/termix/templates/traefik-ingressroute.yaml b/charts/termix/templates/traefik-ingressroute.yaml new file mode 100644 index 00000000..4d5f29a7 --- /dev/null +++ b/charts/termix/templates/traefik-ingressroute.yaml @@ -0,0 +1,34 @@ +{{- if .Values.traefik.ingressRoute.enabled -}} +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: {{ include "termix.fullname" . }} + labels: + {{- include "termix.labels" . | nindent 4 }} + {{- with .Values.traefik.ingressRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + entryPoints: + {{- toYaml .Values.traefik.ingressRoute.entryPoints | nindent 4 }} + routes: + - match: Host(`{{ .Values.traefik.ingressRoute.host }}`) && PathPrefix(`{{ .Values.traefik.ingressRoute.pathPrefix }}`) + kind: Rule + services: + - name: {{ include "termix.fullname" . }} + port: {{ .Values.service.port }} + {{- with .Values.traefik.ingressRoute.middlewares }} + middlewares: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.traefik.ingressRoute.tls.enabled }} + tls: + {{- if .Values.traefik.ingressRoute.tls.secretName }} + secretName: {{ .Values.traefik.ingressRoute.tls.secretName }} + {{- end }} + {{- if .Values.traefik.ingressRoute.tls.certResolver }} + certResolver: {{ .Values.traefik.ingressRoute.tls.certResolver }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/termix/templates/validate.yaml b/charts/termix/templates/validate.yaml new file mode 100644 index 00000000..2faf999a --- /dev/null +++ b/charts/termix/templates/validate.yaml @@ -0,0 +1,12 @@ +{{- if and .Values.ingress.enabled .Values.traefik.ingressRoute.enabled -}} +{{- fail "ingress and traefik.ingressRoute cannot both be enabled" -}} +{{- end -}} +{{- if and (eq .Values.database.dialect "sqlite") (or (gt (int .Values.replicaCount) 1) .Values.autoscaling.enabled) -}} +{{- fail "SQLite supports only one replica; use Postgres or MySQL before enabling replicas or autoscaling" -}} +{{- end -}} +{{- if and (not .Values.guacd.enabled) (not .Values.guacd.host) -}} +{{- fail "guacd.host is required when the bundled guacd sidecar is disabled" -}} +{{- end -}} +{{- if and .Values.database.existingSecret.name .Values.secrets.create .Values.secrets.data.DATABASE_URL -}} +{{- fail "DATABASE_URL must come from either database.existingSecret or secrets.data, not both" -}} +{{- end -}} diff --git a/charts/termix/values-gitops-example.yaml b/charts/termix/values-gitops-example.yaml new file mode 100644 index 00000000..9f3d769b --- /dev/null +++ b/charts/termix/values-gitops-example.yaml @@ -0,0 +1,28 @@ +image: + repository: ghcr.io/termix-ssh/termix + tag: "2.7.0" + +ingress: + enabled: true + className: nginx + hosts: + - host: termix.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: termix-tls + hosts: + - termix.example.com + +persistence: + enabled: true + size: 20Gi + +secrets: + create: false + +database: + dialect: sqlite + +extraEnvFrom: [] diff --git a/charts/termix/values-traefik.yaml b/charts/termix/values-traefik.yaml new file mode 100644 index 00000000..d4b79942 --- /dev/null +++ b/charts/termix/values-traefik.yaml @@ -0,0 +1,32 @@ +ingress: + enabled: false + +traefik: + ingressRoute: + enabled: true + entryPoints: + - websecure + host: termix.example.com + pathPrefix: / + tls: + enabled: true + certResolver: letsencrypt + +persistence: + enabled: true + size: 20Gi + +resources: + requests: + cpu: 250m + memory: 512Mi + limits: + memory: 2Gi + +guacd: + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 512Mi diff --git a/charts/termix/values.yaml b/charts/termix/values.yaml new file mode 100644 index 00000000..043499c9 --- /dev/null +++ b/charts/termix/values.yaml @@ -0,0 +1,147 @@ +replicaCount: 1 + +image: + repository: ghcr.io/termix-ssh/termix + pullPolicy: IfNotPresent + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + automount: false + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + +securityContext: {} + +service: + type: ClusterIP + port: 8080 + annotations: {} + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: termix.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +traefik: + ingressRoute: + enabled: false + entryPoints: + - websecure + host: termix.example.com + pathPrefix: / + tls: + enabled: true + secretName: "" + certResolver: "" + middlewares: [] + annotations: {} + +resources: {} + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 + +persistence: + enabled: true + accessModes: + - ReadWriteOnce + size: 10Gi + storageClass: "" + annotations: {} + existingClaim: "" + +env: + PORT: "8080" + DATA_DIR: /app/data + NODE_ENV: production + GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole + GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole + +extraEnv: [] +extraEnvFrom: [] + +secrets: + create: false + name: "" + data: + JWT_SECRET: "" + DATABASE_URL: "" + GUACAMOLE_ENCRYPTION_KEY: "" + +database: + dialect: sqlite + existingSecret: + name: "" + urlKey: DATABASE_URL + +guacd: + enabled: true + host: "" + image: + repository: guacamole/guacd + tag: 1.6.0 + pullPolicy: IfNotPresent + resources: {} + podAnnotations: {} + podLabels: {} + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + service: + port: 4822 + annotations: {} + +nodeSelector: {} +tolerations: [] +affinity: {} + +strategy: + type: Recreate + +probes: + liveness: + enabled: true + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + readiness: + enabled: true + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + +podDisruptionBudget: + enabled: false + minAvailable: 1 + +networkPolicy: + enabled: false + ingress: [] + egress: [] diff --git a/deploy/argocd/application-traefik.yaml b/deploy/argocd/application-traefik.yaml new file mode 100644 index 00000000..901cfe3d --- /dev/null +++ b/deploy/argocd/application-traefik.yaml @@ -0,0 +1,25 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: termix-traefik + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Termix-SSH/Termix.git + targetRevision: main + path: charts/termix + helm: + releaseName: termix + valueFiles: + - values-traefik.yaml + destination: + server: https://kubernetes.default.svc + namespace: termix + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - PruneLast=true diff --git a/deploy/argocd/application.yaml b/deploy/argocd/application.yaml new file mode 100644 index 00000000..391c2e27 --- /dev/null +++ b/deploy/argocd/application.yaml @@ -0,0 +1,25 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: termix + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/Termix-SSH/Termix.git + targetRevision: main + path: charts/termix + helm: + releaseName: termix + valueFiles: + - values-gitops-example.yaml + destination: + server: https://kubernetes.default.svc + namespace: termix + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - PruneLast=true diff --git a/deploy/gitlab/.gitlab-ci.yml b/deploy/gitlab/.gitlab-ci.yml new file mode 100644 index 00000000..a84c4013 --- /dev/null +++ b/deploy/gitlab/.gitlab-ci.yml @@ -0,0 +1,70 @@ +stages: + - test + - build + - package + - deploy + +variables: + DOCKER_TLS_CERTDIR: "/certs" + IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + HELM_EXPERIMENTAL_OCI: "1" + +helm-lint: + image: + name: alpine/helm:3.15.4 + entrypoint: [""] + stage: test + script: + - helm lint charts/termix + - helm template termix charts/termix --values charts/termix/values-gitops-example.yaml >/tmp/termix.yaml + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH + +docker-build-push: + image: docker:27 + stage: build + services: + - docker:27-dind + before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin + script: + - docker build -f docker/Dockerfile -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" . + - docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG" + - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" + rules: + - if: $CI_COMMIT_BRANCH + +helm-package-push: + image: + name: alpine/helm:3.15.4 + entrypoint: [""] + stage: package + before_script: + - echo "$CI_REGISTRY_PASSWORD" | helm registry login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin + script: + - helm dependency update charts/termix + - helm package charts/termix --destination .helm-packages + - helm push .helm-packages/termix-*.tgz "oci://$CI_REGISTRY_IMAGE/charts" + artifacts: + paths: + - .helm-packages/ + rules: + - if: $CI_COMMIT_TAG + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +deploy: + image: + name: alpine/helm:3.15.4 + entrypoint: [""] + stage: deploy + environment: + name: production + before_script: + - echo "$KUBE_CONFIG" | base64 -d > kubeconfig + - chmod 600 kubeconfig + script: + - helm upgrade --install termix charts/termix --namespace termix --create-namespace --kubeconfig kubeconfig --set image.repository="$CI_REGISTRY_IMAGE" --set image.tag="$IMAGE_TAG" --values charts/termix/values-gitops-example.yaml --atomic --timeout 10m + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual diff --git a/docker/Dockerfile b/docker/Dockerfile index 85071241..cb9e0ee3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -31,7 +31,9 @@ WORKDIR /app COPY . . -RUN npm rebuild better-sqlite3 +RUN rm -rf node_modules/better-sqlite3/prebuilds && \ + npm run build-release --prefix node_modules/better-sqlite3 && \ + test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node RUN npm run build:backend @@ -67,7 +69,10 @@ COPY scripts/patch-guacamole-lite.cjs ./scripts/ RUN npm ci --omit=dev --ignore-scripts && \ node scripts/patch-guacamole-lite.cjs && \ - npm rebuild better-sqlite3 bcryptjs ssh2 && \ + rm -rf node_modules/better-sqlite3/prebuilds && \ + npm run build-release --prefix node_modules/better-sqlite3 && \ + test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \ + npm rebuild bcryptjs ssh2 && \ npm cache clean --force # Stage 6: Final optimized image diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 02480b72..c7099c74 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -23,6 +23,18 @@ if [ "$(id -u)" = "0" ]; then fi DATA_DIR=${DATA_DIR:-/app/data} + +RUNTIME_ENABLE_SSL_SET=${ENABLE_SSL+x} +RUNTIME_ENABLE_SSL=${ENABLE_SSL-} +RUNTIME_SSL_PORT_SET=${SSL_PORT+x} +RUNTIME_SSL_PORT=${SSL_PORT-} +RUNTIME_SSL_CERT_PATH_SET=${SSL_CERT_PATH+x} +RUNTIME_SSL_CERT_PATH=${SSL_CERT_PATH-} +RUNTIME_SSL_KEY_PATH_SET=${SSL_KEY_PATH+x} +RUNTIME_SSL_KEY_PATH=${SSL_KEY_PATH-} +RUNTIME_SSL_DOMAIN_SET=${SSL_DOMAIN+x} +RUNTIME_SSL_DOMAIN=${SSL_DOMAIN-} + if [ -f "$DATA_DIR/.env" ]; then echo "Loading persisted SSL settings from $DATA_DIR/.env" set -a @@ -30,11 +42,18 @@ if [ -f "$DATA_DIR/.env" ]; then set +a fi +[ "$RUNTIME_ENABLE_SSL_SET" = "x" ] && ENABLE_SSL=$RUNTIME_ENABLE_SSL +[ "$RUNTIME_SSL_PORT_SET" = "x" ] && SSL_PORT=$RUNTIME_SSL_PORT +[ "$RUNTIME_SSL_CERT_PATH_SET" = "x" ] && SSL_CERT_PATH=$RUNTIME_SSL_CERT_PATH +[ "$RUNTIME_SSL_KEY_PATH_SET" = "x" ] && SSL_KEY_PATH=$RUNTIME_SSL_KEY_PATH +[ "$RUNTIME_SSL_DOMAIN_SET" = "x" ] && SSL_DOMAIN=$RUNTIME_SSL_DOMAIN + export PORT=${PORT:-8080} export ENABLE_SSL=${ENABLE_SSL:-false} export SSL_PORT=${SSL_PORT:-8443} export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt} export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key} +export TERMIX_SSL_TERMINATED_BY_NGINX=true echo "Configuring web UI to run on port: $PORT" @@ -49,6 +68,11 @@ fi mkdir -p /tmp/nginx envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf +if [ "$ENABLE_SSL" = "true" ] && [ "$PORT" = "$SSL_PORT" ]; then + echo "HTTP and HTTPS use port $SSL_PORT; disabling the HTTP redirect listener" + sed -i '/# BEGIN HTTP_REDIRECT_SERVER/,/# END HTTP_REDIRECT_SERVER/d' /tmp/nginx/nginx.conf +fi + mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 6245ce50..689c7ed6 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -69,12 +69,14 @@ http { ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; + # BEGIN HTTP_REDIRECT_SERVER server { listen ${PORT}; server_name _; return 301 https://$host:${SSL_PORT}$request_uri; } + # END HTTP_REDIRECT_SERVER server { listen ${SSL_PORT} ssl; diff --git a/electron-builder.json b/electron-builder.json index 9ee5beb7..1bd39332 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -82,7 +82,7 @@ "arch": ["x64", "arm64", "armv7l"] } ], - "icon": "public/icon.png", + "icon": "public/icons", "category": "Development", "executableName": "termix", "maintainer": "Termix ", diff --git a/electron/local-shell.cjs b/electron/local-shell.cjs new file mode 100644 index 00000000..ac633fec --- /dev/null +++ b/electron/local-shell.cjs @@ -0,0 +1,21 @@ +function resolveLocalShell(platform, requestedShell, env = process.env) { + if (platform === "win32") { + if (requestedShell === "wsl") { + return { file: "wsl.exe", args: [] }; + } + return { + file: env.TERMIX_LOCAL_SHELL || "powershell.exe", + args: ["-NoLogo"], + }; + } + + return { + file: + env.TERMIX_LOCAL_SHELL || + env.SHELL || + (platform === "darwin" ? "/bin/zsh" : "/bin/bash"), + args: ["-l"], + }; +} + +module.exports = { resolveLocalShell }; diff --git a/electron/main.cjs b/electron/main.cjs index 651723bc..bd23c255 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -30,25 +30,10 @@ const { launchNativeRdp } = require("./native-rdp.cjs"); const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs"); const { quitApp } = require("./app-quit.cjs"); const { selectLinuxPasswordStore } = require("./linux-password-store.cjs"); +const { resolveLocalShell } = require("./local-shell.cjs"); const localTerminalSessions = new Map(); -function localShell() { - if (process.platform === "win32") { - return { - file: process.env.TERMIX_LOCAL_SHELL || "powershell.exe", - args: ["-NoLogo"], - }; - } - return { - file: - process.env.TERMIX_LOCAL_SHELL || - process.env.SHELL || - (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"), - args: ["-l"], - }; -} - function ownedLocalTerminal(event, sessionId) { if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) { return null; @@ -537,7 +522,11 @@ function httpFetch(url, options = {}) { method: options.method || "GET", headers: options.headers || {}, timeout: options.timeout || 10000, - ...(isHttps ? getTlsVerificationOptions(url) : {}), + ...(isHttps + ? options.allowInvalidCertificate + ? { rejectUnauthorized: false } + : getTlsVerificationOptions(url) + : {}), }; const req = client.request(url, requestOptions, (res) => { @@ -2928,7 +2917,7 @@ ipcMain.handle("local-terminal-start", (event, dimensions = {}) => { const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80)); const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24)); const sessionId = crypto.randomUUID(); - const shellConfig = localShell(); + const shellConfig = resolveLocalShell(process.platform, dimensions.shell); const child = pty.spawn(shellConfig.file, shellConfig.args, { name: "xterm-256color", cols, @@ -3134,7 +3123,11 @@ ipcMain.handle("close-external-editor", (_event, editId) => { } }); -ipcMain.handle("test-server-connection", async (event, serverUrl) => { +async function testServerConnection( + _event, + serverUrl, + allowInvalidCertificate = false, +) { try { const normalizedServerUrl = serverUrl.replace(/\/$/, ""); const healthUrl = `${normalizedServerUrl}/health`; @@ -3154,6 +3147,7 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => { const response = await httpFetch(healthUrl, { method: "GET", timeout: 10000, + allowInvalidCertificate, }); const data = await response.text(); @@ -3207,7 +3201,9 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => { } catch (error) { return { success: false, error: error.message }; } -}); +} + +ipcMain.handle("test-server-connection", testServerConnection); function createMenu() { if (process.platform === "darwin") { diff --git a/package-lock.json b/package-lock.json index 734f113f..21d6ced6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "2.7.0", + "version": "2.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "2.7.0", + "version": "2.7.1", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.116.0", diff --git a/package.json b/package.json index b4b7b25f..bf168a0f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "2.7.0", + "version": "2.7.1", "description": "Self-hosted SSH and remote desktop management.", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/scripts/patch-xterm-android-ime.cjs b/scripts/patch-xterm-android-ime.cjs index 4bbff4a3..c527748d 100644 --- a/scripts/patch-xterm-android-ime.cjs +++ b/scripts/patch-xterm-android-ime.cjs @@ -30,8 +30,11 @@ const patches = [ file: "xterm.mjs", replacements: [ [ - 'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""', - 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""', + [ + 'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""', + 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""', + ], + 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""', ], [ 'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""', @@ -46,8 +49,11 @@ const patches = [ "e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length0&&", ], [ - '_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}},0)}", + [ + '_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}},0)}", + ], + "_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout(()=>{const t=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}},0)}", ], ], }, @@ -55,8 +61,11 @@ const patches = [ file: "xterm.js", replacements: [ [ - 'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""', - 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""', + [ + 'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""', + 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""', + ], + 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""', ], [ 'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""', @@ -71,8 +80,11 @@ const patches = [ "e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length0&&", ], [ - '_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}}),0)}", + [ + '_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}}),0)}", + ], + "_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout((()=>{const e=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}}),0)}", ], ], }, @@ -91,12 +103,14 @@ for (const { file, replacements } of patches) { if (source.includes(patched)) { continue; } - if (!source.includes(original)) { + const originals = Array.isArray(original) ? original : [original]; + const matched = originals.find((candidate) => source.includes(candidate)); + if (!matched) { throw new Error( `[patch-xterm-android-ime] Expected source not found in ${file}`, ); } - source = source.replace(original, patched); + source = source.replace(matched, patched); changed = true; } diff --git a/src/backend/automations/engine.ts b/src/backend/automations/engine.ts index 813a2cf6..244c1f69 100644 --- a/src/backend/automations/engine.ts +++ b/src/backend/automations/engine.ts @@ -10,6 +10,7 @@ import { } from "../../types/automations.js"; import { createCurrentAutomationRepository } from "../database/repositories/factory.js"; import { statsLogger } from "../utils/logger.js"; +import { resolveHostById } from "../hosts/host-resolver.js"; import { executeStep } from "./actions/index.js"; import type { StepExecutionContext, StepResult } from "./actions/types.js"; import { compare } from "./conditions.js"; @@ -175,8 +176,33 @@ export class AutomationEngine { if (!claimed) this.running.add(automation.id); + const trigger = { ...(request.triggerContext ?? {}) }; + trigger.type ??= request.triggerType; + let host: TemplateContext["host"]; + if (request.triggerHostId) { + try { + const resolved = await resolveHostById( + request.triggerHostId, + automation.userId, + ); + if (resolved) { + host = { + id: request.triggerHostId, + name: resolved.name || resolved.ip, + ip: resolved.ip, + username: resolved.username, + port: resolved.port, + }; + trigger.hostName ??= host.name; + } + } catch { + // A notification should still run with its numeric host id. + } + } + const template: TemplateContext = { - trigger: request.triggerContext ?? {}, + host, + trigger, steps: {}, vars: {}, run: { diff --git a/src/backend/automations/notify.ts b/src/backend/automations/notify.ts index ce446254..747685c7 100644 --- a/src/backend/automations/notify.ts +++ b/src/backend/automations/notify.ts @@ -88,6 +88,16 @@ async function sendWebhook( headers, body: JSON.stringify({ title: notification.title, + hostName: + notification.context?.host?.name ?? + notification.context?.trigger?.hostName, + hostId: + notification.context?.host?.id ?? notification.context?.trigger?.hostId, + ruleName: notification.title, + ruleId: notification.context?.run?.automationId, + triggerType: notification.context?.trigger?.type, + value: notification.context?.trigger?.value, + threshold: notification.context?.trigger?.threshold, message: notification.body, severity: notification.severity, timestamp: new Date().toISOString(), diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index efec1837..d4caec28 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -2052,7 +2052,12 @@ if (sslConfig.enabled) { ssl_port: sslConfig.port, backend_http_port: HTTP_PORT, }); +} +if ( + sslConfig.enabled && + process.env.TERMIX_SSL_TERMINATED_BY_NGINX !== "true" +) { try { const httpsServer = https.createServer( { diff --git a/src/backend/database/repositories/sqlite-foreign-keys.ts b/src/backend/database/repositories/sqlite-foreign-keys.ts index 8b55d755..cf4f4e5d 100644 --- a/src/backend/database/repositories/sqlite-foreign-keys.ts +++ b/src/backend/database/repositories/sqlite-foreign-keys.ts @@ -23,21 +23,17 @@ export async function withSqliteForeignKeysDisabled( * Backup restore writes tables in an order that is not dependency-safe, so the * constraints have to stand down for the duration. * - * **This has no equivalent on Postgres or MySQL here.** Postgres needs - * superuser to disable triggers, and MySQL's `SET FOREIGN_KEY_CHECKS = 0` is - * per-connection, which a pool does not guarantee. Rather than run the import - * with constraints enforced and have it fail partway through — leaving a - * half-restored database — it refuses with a message that says why. + * Postgres and MySQL keep their constraints enabled. The portable importer + * writes through repositories and handles individual row failures, so it must + * still be allowed to run there; only SQLite needs this connection-local + * relaxation for legacy backups whose rows are not dependency ordered. */ export async function withCurrentSqliteForeignKeysDisabled( operation: () => Promise, ): Promise { const dialect = resolveDatabaseDialect(); if (!needsExplicitPersist(dialect)) { - throw new Error( - `Importing a backup is only supported on SQLite; this deployment uses ${dialect}. ` + - `Restore into the database directly with its own tooling instead.`, - ); + return operation(); } return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation); diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 0a761648..ab6ad60e 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -388,6 +388,165 @@ router.get( }, ); +/** + * @openapi + * /credentials/{id}/duplicate: + * post: + * summary: Duplicate a credential + * description: Creates a new credential from an existing one, optionally overriding fields (e.g. password), leaving the original credential untouched. + * tags: + * - Credentials + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * username: + * type: string + * password: + * type: string + * key: + * type: string + * keyPassword: + * type: string + * responses: + * 201: + * description: New credential created from the duplicate. + * 400: + * description: Invalid request. + * 404: + * description: Credential not found. + * 500: + * description: Failed to duplicate credential. + */ +router.post( + "/:id/duplicate", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const { name, username, password, key, keyPassword, certPublicKey } = + req.body ?? {}; + + if (!isNonEmptyString(userId) || !id) { + authLogger.warn("Invalid request for credential duplicate"); + return res.status(400).json({ error: "Invalid request" }); + } + if (!isNonEmptyString(name)) { + return res.status(400).json({ error: "Name is required" }); + } + + const credentialId = parseInt(id); + + try { + const credentialRepository = createCurrentCredentialRepository(); + const source = await credentialRepository.findDecryptedByIdForUser( + userId, + credentialId, + ); + + if (!source) { + return res.status(404).json({ error: "Credential not found" }); + } + + const authType = source.authType; + const plainPassword = + password !== undefined ? password || null : source.password; + const plainKey = key !== undefined ? key || null : source.key; + const plainKeyPassword = + keyPassword !== undefined ? keyPassword || null : source.keyPassword; + + let keyInfo = null; + if (authType === "key" && plainKey) { + keyInfo = parseSSHKey(plainKey, plainKeyPassword); + if (!keyInfo.success) { + return res.status(400).json({ + error: keyInfo.error + ? `Invalid SSH key: ${keyInfo.error}` + : "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.", + }); + } + } + + const credentialData = { + userId, + name: name.trim(), + description: source.description, + folder: source.folder, + tags: source.tags, + authType, + username: + username !== undefined ? username?.trim() || null : source.username, + password: authType === "password" ? plainPassword : null, + key: authType === "key" ? plainKey : null, + privateKey: authType === "key" ? keyInfo?.privateKey || plainKey : null, + publicKey: authType === "key" ? keyInfo?.publicKey || null : null, + keyPassword: authType === "key" ? plainKeyPassword : null, + keyType: source.keyType, + detectedKeyType: authType === "key" ? keyInfo?.keyType || null : null, + certPublicKey: + authType === "key" + ? certPublicKey !== undefined + ? certPublicKey?.trim() || null + : source.certPublicKey + : null, + usageCount: 0, + lastUsed: null, + }; + + const created = await credentialRepository.createEncryptedForUser( + userId, + credentialData, + ); + + const { ipAddress: dupIp, userAgent: dupUa } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: "duplicate_credential", + resourceType: "credential", + resourceId: String(created.id), + resourceName: name, + ipAddress: dupIp, + userAgent: dupUa, + success: true, + }); + + authLogger.success( + `SSH credential duplicated: ${name} (from ${credentialId}) by user ${userId}`, + { + operation: "credential_duplicate_success", + userId, + sourceCredentialId: credentialId, + credentialId: created.id, + }, + ); + + res.status(201).json(formatCredentialOutput(created)); + } catch (err) { + authLogger.error("Failed to duplicate credential", err, { + operation: "credential_duplicate", + userId, + credentialId, + }); + res.status(500).json({ + error: getErrorMessage(err, "Failed to duplicate credential"), + }); + } + }, +); + /** * @openapi * /credentials/{id}: diff --git a/src/backend/database/routes/proxmox-import-auth.ts b/src/backend/database/routes/proxmox-import-auth.ts index 00306c8e..5a1adeca 100644 --- a/src/backend/database/routes/proxmox-import-auth.ts +++ b/src/backend/database/routes/proxmox-import-auth.ts @@ -32,7 +32,7 @@ export function resolveProxmoxImportAuth( return { authType: "credential", credentialId, - overrideCredentialUsername: 1, + overrideCredentialUsername: 0, }; } diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts index 3dcfc5e1..3cc7c70d 100644 --- a/src/backend/database/routes/proxmox.ts +++ b/src/backend/database/routes/proxmox.ts @@ -689,6 +689,12 @@ async function syncProxmoxHost( ? existing.connectionType : null; const connectionType = existingConnectionType ?? guest.connectionType; + const usesImportCredential = + connectionType === "ssh" && importAuth.authType === "credential"; + const existingUsesImportCredential = + usesImportCredential && + existing?.authType === "credential" && + existing?.credentialId === importAuth.credentialId; const port = typeof existing?.port === "number" ? existing.port @@ -696,11 +702,13 @@ async function syncProxmoxHost( ? 3389 : 22; const username = - typeof existing?.username === "string" && existing.username - ? existing.username - : connectionType === "rdp" - ? "" - : "root"; + usesImportCredential && (!existing || existingUsesImportCredential) + ? "" + : typeof existing?.username === "string" && existing.username + ? existing.username + : connectionType === "rdp" + ? "" + : "root"; const update: Record = { name: guest.name, ip: guest.ip || existing?.ip || "0.0.0.0", @@ -714,6 +722,10 @@ async function syncProxmoxHost( }; if (existing) { + if (existingUsesImportCredential) { + update.credentialId = importAuth.credentialId; + update.overrideCredentialUsername = false; + } await createCurrentHostRepository().updateEncryptedForUser( userId, existing.id as number, diff --git a/src/backend/database/routes/sso-provider-routes.ts b/src/backend/database/routes/sso-provider-routes.ts index c2a51132..b2c5663f 100644 --- a/src/backend/database/routes/sso-provider-routes.ts +++ b/src/backend/database/routes/sso-provider-routes.ts @@ -7,7 +7,10 @@ import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { SSOProviderType } from "../../../types/index.js"; import { createCurrentSsoProviderRepository } from "../repositories/factory.js"; -import { getOIDCConfigFromEnv } from "./user-oidc-utils.js"; +import { + getOIDCConfigFromEnv, + isOIDCEnvOverrideEnabled, +} from "./user-oidc-utils.js"; import { decryptSsoConfigSecrets, encryptSsoConfigSecrets, @@ -18,6 +21,19 @@ function isOidcLike(type: SSOProviderType): boolean { return type === "oidc" || type === "github" || type === "google"; } +export function isValidOidcIssuer(value: unknown): boolean { + if (typeof value !== "string") return false; + try { + const url = new URL(value); + return ( + ["http:", "https:"].includes(url.protocol) && + !/\/userinfo\/?$/i.test(url.pathname) + ); + } catch { + return false; + } +} + const authManager = AuthManager.getInstance(); /** @@ -95,13 +111,19 @@ export function registerSSOProviderRoutes(router: Router): void { */ router.get("/sso-providers", async (_req, res) => { try { + const envConfig = getOIDCConfigFromEnv(); + if (envConfig && isOIDCEnvOverrideEnabled()) { + return res.json([ + { id: 0, name: "SSO", type: "oidc", displayOrder: 0 }, + ]); + } + const providers = await createCurrentSsoProviderRepository().listEnabledPublic(); // If no DB providers exist, synthesize one from env vars so SSO login // remains available when configured purely via environment variables. if (providers.length === 0) { - const envConfig = getOIDCConfigFromEnv(); if (envConfig) { providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }); } @@ -222,6 +244,12 @@ export function registerSSOProviderRoutes(router: Router): void { error: `Missing required OIDC fields: ${missing.join(", ")}`, }); } + if (c.issuer_url && !isValidOidcIssuer(c.issuer_url)) { + return res.status(400).json({ + error: + "Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint", + }); + } if ( (type === "github" || type === "google") && (!c.client_id || !c.client_secret) @@ -353,6 +381,16 @@ export function registerSSOProviderRoutes(router: Router): void { ), ...rawConfig, }; + if ( + isOidcLike(effectiveType) && + mergedConfig.issuer_url && + !isValidOidcIssuer(mergedConfig.issuer_url) + ) { + return res.status(400).json({ + error: + "Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint", + }); + } encryptedConfig = await encryptProviderConfig( mergedConfig, userId, diff --git a/src/backend/database/routes/tailscale-routes.ts b/src/backend/database/routes/tailscale-routes.ts index a2e3aed4..1340cc28 100644 --- a/src/backend/database/routes/tailscale-routes.ts +++ b/src/backend/database/routes/tailscale-routes.ts @@ -4,7 +4,7 @@ import { type Router as ExpressRouter, } from "express"; import { apiLogger } from "../../utils/logger.js"; -import { getFetchDispatcher } from "../../utils/proxy-agent.js"; +import { fetchWithProxy } from "../../utils/proxy-agent.js"; import { createCurrentSettingsRepository } from "../repositories/factory.js"; interface TailscaleDevice { @@ -74,12 +74,11 @@ export function registerTailscaleRoutes( ); const url = `${apiBase}/tailnet/-/devices?fields=all`; - const response = await fetch(url, { + const response = await fetchWithProxy(url, { headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": "Termix/1.0", }, - dispatcher: getFetchDispatcher(url), }); if (!response.ok) { diff --git a/src/backend/database/routes/user-oidc-utils.ts b/src/backend/database/routes/user-oidc-utils.ts index 158e37c7..8098d5db 100644 --- a/src/backend/database/routes/user-oidc-utils.ts +++ b/src/backend/database/routes/user-oidc-utils.ts @@ -105,6 +105,10 @@ export function getOIDCConfigFromEnv(): OIDCConfig | null { }; } +export function isOIDCEnvOverrideEnabled(): boolean { + return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true"; +} + /** * Normalizes a group name for comparison. Providers are inconsistent about * whether they emit bare names (`devops-interns`) or full paths @@ -438,6 +442,11 @@ export async function loadProviderConfig( providerType: SSOProviderType; providerDbId: number | null; } | null> { + const envConfig = getOIDCConfigFromEnv(); + if (envConfig && isOIDCEnvOverrideEnabled()) { + return { config: envConfig, providerType: "oidc", providerDbId: null }; + } + if (providerId != null) { try { const row = @@ -485,7 +494,6 @@ export async function loadProviderConfig( } // Fallback: env vars - const envConfig = getOIDCConfigFromEnv(); if (envConfig) { return { config: envConfig, providerType: "oidc", providerDbId: null }; } @@ -542,6 +550,14 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{ providerDbId: number | null; } | null> { const target = normalizeIssuer(issuer); + const envConfig = getOIDCConfigFromEnv(); + if ( + envConfig?.issuer_url && + isOIDCEnvOverrideEnabled() && + normalizeIssuer(envConfig.issuer_url) === target + ) { + return { config: envConfig, providerType: "oidc", providerDbId: null }; + } try { const rows = await createCurrentSsoProviderRepository().listEnabled(); @@ -569,7 +585,6 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{ }); } - const envConfig = getOIDCConfigFromEnv(); if ( envConfig?.issuer_url && normalizeIssuer(envConfig.issuer_url) === target diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index e0b8aeef..26763fbc 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -137,6 +137,12 @@ function isPasswordResetAllowed(): boolean { } } +function getOidcSilentLoginDefaultFromEnv(): boolean | undefined { + const envVal = process.env.OIDC_SILENT_LOGIN_DEFAULT; + if (envVal === undefined) return undefined; + return envVal.trim().toLowerCase() === "true"; +} + function isNativeAppRequest(req: Request): boolean { return ( (req.get("User-Agent") || "").startsWith("Termix-Mobile/") || @@ -2460,7 +2466,7 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { * /users/oidc-silent-login-default: * get: * summary: Get OIDC silent login default setting - * description: Returns whether silent OIDC login is enabled as the default behavior. + * description: Returns whether silent OIDC login is enabled as the default behavior. Can be pinned via the OIDC_SILENT_LOGIN_DEFAULT env var. * tags: * - Users * responses: @@ -2471,11 +2477,17 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { */ router.get("/oidc-silent-login-default", async (_req, res) => { try { + const envVal = getOidcSilentLoginDefaultFromEnv(); + if (envVal !== undefined) { + res.json({ enabled: envVal, locked: true }); + return; + } res.json({ enabled: await createCurrentSettingsRepository().getBoolean( "oidc_silent_login_default", false, ), + locked: false, }); } catch (err) { authLogger.error("Failed to get OIDC silent login default", err); @@ -2507,6 +2519,8 @@ router.get("/oidc-silent-login-default", async (_req, res) => { * description: Invalid value. * 403: * description: Not authorized. + * 409: + * description: Setting is pinned by the OIDC_SILENT_LOGIN_DEFAULT env var. * 500: * description: Failed to update setting. */ @@ -2520,6 +2534,12 @@ router.patch( if (!user) { return res.status(403).json({ error: "Not authorized" }); } + if (getOidcSilentLoginDefaultFromEnv() !== undefined) { + return res.status(409).json({ + error: + "OIDC silent login default is set via the OIDC_SILENT_LOGIN_DEFAULT env var and cannot be changed here", + }); + } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "Invalid value for enabled" }); diff --git a/src/backend/hosts/file-manager/index.ts b/src/backend/hosts/file-manager/index.ts index 03d10a32..f7cdf52b 100644 --- a/src/backend/hosts/file-manager/index.ts +++ b/src/backend/hosts/file-manager/index.ts @@ -9,7 +9,7 @@ import { createCorsMiddleware } from "../../utils/cors-config.js"; import { createCompressionMiddleware } from "../../utils/compression-config.js"; import cookieParser from "cookie-parser"; import axios from "axios"; -import { Client as SSHClient } from "ssh2"; +import ssh2Pkg, { Client as SSHClient } from "ssh2"; import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; import { fileLogger } from "../../utils/logger.js"; @@ -42,8 +42,11 @@ import { } from "./transfer-engine.js"; import { registerFileContentRoutes } from "./content-routes.js"; import { createConnectionLog } from "../connection-log.js"; -import { createJumpHostChain } from "../jump-host-chain.js"; -import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; +import { createJumpHostChain, JumpHostChainError } from "../jump-host-chain.js"; +import { + isPrivateKeyPassphraseError, + preparePrivateKeyForSSH2, +} from "../../utils/ssh-key-utils.js"; import { ChannelOpenSerializer, execChannel, @@ -840,7 +843,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { resolvedCredentials = { password: resolvedHost.password, sshKey: resolvedHost.key, - keyPassword: resolvedHost.keyPassword, + keyPassword: keyPassword || resolvedHost.keyPassword, authType: resolvedHost.authType, sudoPassword: resolvedHost.sudoPassword as string | undefined, certPublicKey: (resolvedHost as { certPublicKey?: string }) @@ -909,7 +912,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { resolvedCredentials = { password: resolvedHost.password, sshKey: resolvedHost.key, - keyPassword: resolvedHost.keyPassword, + keyPassword: keyPassword || resolvedHost.keyPassword, authType: resolvedHost.authType, sudoPassword: resolvedHost.sudoPassword as string | undefined, certPublicKey: (resolvedHost as { certPublicKey?: string }) @@ -1049,6 +1052,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { resolvedCredentials.keyPassword, ); + const parsedKey = ssh2Pkg.utils.parseKey( + config.privateKey as Buffer, + resolvedCredentials.keyPassword, + ); + if (parsedKey instanceof Error) throw parsedKey; + if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword; @@ -1071,6 +1080,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { ), ); } catch (keyError) { + if (isPrivateKeyPassphraseError(keyError)) { + return res.json({ status: "passphrase_required", connectionLogs }); + } + fileLogger.error("SSH key format error for file manager", { operation: "file_connect", sessionId, @@ -1832,7 +1845,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { ), ); return res.status(500).json({ - error: "Failed to connect through jump hosts", + error: + error instanceof JumpHostChainError + ? `Failed to connect through jump hosts: ${error.message}` + : "Failed to connect through jump hosts", connectionLogs, }); } diff --git a/src/backend/hosts/file-manager/sftp-promisify.ts b/src/backend/hosts/file-manager/sftp-promisify.ts new file mode 100644 index 00000000..697b6b44 --- /dev/null +++ b/src/backend/hosts/file-manager/sftp-promisify.ts @@ -0,0 +1,120 @@ +type SFTPWrapper = import("ssh2").SFTPWrapper; + +export const SFTP_OPEN_READ = 0x00000001; +export const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010; +export const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008; + +export function promisifySftpStat( + sftp: SFTPWrapper, + path: string, +): Promise { + return new Promise((resolve, reject) => { + sftp.stat(path, (err, stats) => { + if (err) reject(err); + else resolve(stats); + }); + }); +} + +export function promisifySftpUnlink( + sftp: SFTPWrapper, + path: string, +): Promise { + return new Promise((resolve, reject) => { + sftp.unlink(path, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +export function promisifySftpRmdir( + sftp: SFTPWrapper, + path: string, +): Promise { + return new Promise((resolve, reject) => { + sftp.rmdir(path, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +export function promisifySftpMkdir( + sftp: SFTPWrapper, + path: string, + mode: number, +): Promise { + return new Promise((resolve, reject) => { + sftp.mkdir(path, { mode }, (err) => { + if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") { + reject(err); + } else { + resolve(); + } + }); + }); +} + +export function promisifySftpChmod( + sftp: SFTPWrapper, + path: string, + mode: number, +): Promise { + return new Promise((resolve, reject) => { + sftp.chmod(path, mode, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +export function promisifySftpReaddir( + sftp: SFTPWrapper, + path: string, +): Promise> { + return new Promise((resolve, reject) => { + sftp.readdir(path, (err, list) => { + if (err) reject(err); + else resolve(list); + }); + }); +} + +export function promisifySftpOpen( + sftp: SFTPWrapper, + path: string, + flags: number, + mode: number, +): Promise { + return new Promise((resolve, reject) => { + sftp.open(path, flags, mode, (err, handle) => { + if (err) reject(err); + else resolve(handle); + }); + }); +} + +export function promisifySftpClose( + sftp: SFTPWrapper, + handle: Buffer, +): Promise { + return new Promise((resolve, reject) => { + sftp.close(handle, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +export function promisifySftpFstat( + sftp: SFTPWrapper, + handle: Buffer, +): Promise { + return new Promise((resolve, reject) => { + sftp.fstat(handle, (err, stats) => { + if (err) reject(err); + else resolve(stats); + }); + }); +} diff --git a/src/backend/hosts/file-manager/transfer-engine.ts b/src/backend/hosts/file-manager/transfer-engine.ts index 5323cc6b..4b52484b 100644 --- a/src/backend/hosts/file-manager/transfer-engine.ts +++ b/src/backend/hosts/file-manager/transfer-engine.ts @@ -1,12 +1,10 @@ import { getErrorMessage } from "../../utils/error-message.js"; import { randomUUID } from "crypto"; -import { networkInterfaces } from "os"; import { performance } from "node:perf_hooks"; import type { ClientChannel } from "ssh2"; import { fileLogger } from "../../utils/logger.js"; import { basename, - buildPathFromSegments, dirname, getWorkingDir, inferPlatformFromPath, @@ -14,7 +12,6 @@ import { normalizeSftpPath, pathsOverlap, sftpPathToLocalPath, - splitPathSegments, type TransferPlatform, } from "../transfer-paths.js"; import { @@ -26,6 +23,60 @@ import { type TransferScanSummary, } from "./transfer-routing.js"; import { verifySftpFileIntegrity } from "./transfer-integrity.js"; +import { + promisifySftpChmod, + promisifySftpClose, + promisifySftpFstat, + promisifySftpOpen, + promisifySftpStat, + promisifySftpUnlink, +} from "./sftp-promisify.js"; +import { + buildTransferHopTimings, + computeTransferMbPerSec, + createEmptyXferStats, + createHopWallClock, + createThrottledProgress, + elapsedMs, + hopSpanMs, + mergeXferStats, + noteHopEnd, + noteHopStart, + type PipelinedXferStats, + type TransferTimings, +} from "./transfer-stats.js"; +import { + TransferCancelledError, + TransferStalledError, + isRecoverableTransferError, +} from "./transfer-errors.js"; +import { + escapeShell, + isLocalSshEndpoint, + isPermissionError, + isRootOnlyPath, +} from "./transfer-host-utils.js"; +import { + SFTP_OPEN_READ, + SFTP_OPEN_WRITE, + SFTP_OPEN_WRITE_RESUME, +} from "./sftp-promisify.js"; +import { + collectFileWorkItems, + readSftpSample, + type FileWorkItem, +} from "./transfer-scan.js"; +import { + deletePathSftp, + ensureDirectoryTreeSftp, +} from "./transfer-sftp-dir.js"; +import { + DEFAULT_PARALLEL_SEGMENT_COUNT, + SFTP_XFER_SEGMENT_SIZE, + buildSegmentCopyJobs, + clampParallelSegmentCount, + type SegmentCopyJob, +} from "./transfer-segment-copy.js"; import { buildDirectProbeCommand, buildDirectRsyncCommand, @@ -107,31 +158,11 @@ export type TransferStatus = "running" | "success" | "partial" | "error" | "cancelled"; export type TransferMethod = "stream" | "tar" | "item_sftp" | "direct_rsync"; -export type TransferHopId = - "source_read" | "dest_sftp_write" | "dest_local_write"; - -export interface TransferHopMetrics { - id: TransferHopId; - bytes: number; - /** Wall-clock span from first I/O on this hop to last I/O complete. */ - spanMs: number; - mbPerSec: number; -} - -export interface TransferTimings { - prepareDestMs?: number; - compressMs?: number; - transferMs?: number; - extractMs?: number; - verifyMs?: number; - directBenchmarkMs?: number; - relayBenchmarkMs?: number; - sourceDeleteMs?: number; - totalMs?: number; - transferBytes?: number; - endToEndMbPerSec?: number; - hops?: TransferHopMetrics[]; -} +export type { + TransferHopId, + TransferHopMetrics, + TransferTimings, +} from "./transfer-stats.js"; export interface TransferProgress { transferId: string; @@ -204,34 +235,6 @@ interface ActiveXferControl { const activeXferControls = new Map(); const cancelWatchdogs = new Map>(); -class TransferCancelledError extends Error { - constructor() { - super("Transfer cancelled"); - this.name = "TransferCancelledError"; - } -} - -class TransferStalledError extends Error { - readonly byteOffset?: number; - readonly segmentIndex?: number; - - constructor(byteOffset?: number, segmentIndex?: number) { - const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : ""; - const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : ""; - super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`); - this.name = "TransferStalledError"; - this.byteOffset = byteOffset; - this.segmentIndex = segmentIndex; - } -} - -class TransferConnectionLostError extends Error { - constructor(message = "Transfer SSH connection lost") { - super(message); - this.name = "TransferConnectionLostError"; - } -} - function throwIfCancelled(transferId: string): void { if (cancelRequestedTransfers.has(transferId)) { throw new TransferCancelledError(); @@ -315,8 +318,6 @@ const SMALL_FILE_SYNC_THRESHOLD = 10 * 1024 * 1024; const SFTP_XFER_CHUNK_SIZE = 256 * 1024; /** Pipelined in-flight READ requests per leg (ssh2 fastGet/fastPut default is 64). */ const SFTP_XFER_CONCURRENCY = 32; -/** Reset pipelined scheduler every segment to avoid long-run deadlocks at GiB boundaries. */ -const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024; /** Files above this size use segmented copy; smaller files use a single scheduler run. */ const SFTP_XFER_SEGMENT_THRESHOLD = 32 * 1024 * 1024; /** Per-segment attempts before giving up (sequential and parallel). */ @@ -328,17 +329,9 @@ const SFTP_SEQUENTIAL_COPY_MAX_ATTEMPTS = 2; const SFTP_PARALLEL_COPY_MAX_ATTEMPTS = 2; /** Short backoff before opening fresh dedicated SSH sessions. */ const TRANSFER_SESSION_RESET_DELAYS_MS = [1000, 2000, 3000]; -const DEFAULT_PARALLEL_SEGMENT_COUNT = 2; -const MAX_PARALLEL_SEGMENT_COUNT = 8; const TRANSFER_HANDLE_CLOSE_TIMEOUT_MS = 2500; const HUNG_TRANSFER_MS = 90_000; const HUNG_RECONNECTING_MS = 180_000; -const TRANSFER_PROGRESS_INTERVAL_MS = 200; -const SFTP_OPEN_READ = 0x00000001; -/** WRITE | CREATE | TRUNCATE — new file or overwrite from start. */ -const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010; -/** READ | WRITE | CREATE — resume into an existing partial file without truncating. */ -const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008; interface TransferReconnectContext { deps: HostTransferDeps; @@ -364,31 +357,6 @@ function buildTransferReconnectContext( return { deps, transferId, ...meta }; } -function isRecoverableTransferConnectionError(err: unknown): boolean { - if (!(err instanceof Error)) return false; - const msg = err.message.toLowerCase(); - return ( - msg.includes("no response from server") || - msg.includes("connection lost") || - msg.includes("not connected") || - msg.includes("econnreset") || - msg.includes("econnrefused") || - msg.includes("etimedout") || - msg.includes("socket hang up") || - msg.includes("protocol error") || - msg.includes("connection closed") || - msg.includes("channel open failure") - ); -} - -function isRecoverableTransferError(err: unknown): boolean { - return ( - err instanceof TransferStalledError || - err instanceof TransferConnectionLostError || - isRecoverableTransferConnectionError(err) - ); -} - async function probeDestResumeOffset( destSftp: SFTPWrapper, destPath: string, @@ -560,69 +528,6 @@ async function resetDedicatedTransferSessions( return { sourceSession, destSession, sourceSftp, destSftp }; } -let cachedLocalAddresses: Set | null = null; - -function normalizeHostAddress(host: string): string { - const trimmed = host.trim().toLowerCase(); - if (trimmed.startsWith("[") && trimmed.endsWith("]")) { - return trimmed.slice(1, -1); - } - return trimmed.split(":")[0] ?? trimmed; -} - -function getLocalAddresses(): Set { - if (cachedLocalAddresses) return cachedLocalAddresses; - - const addresses = new Set(["127.0.0.1", "::1", "localhost"]); - for (const ifaces of Object.values(networkInterfaces())) { - if (!ifaces) continue; - for (const iface of ifaces) { - if (!iface.internal && iface.family === "IPv4") { - addresses.add(iface.address.toLowerCase()); - } - } - } - cachedLocalAddresses = addresses; - return addresses; -} - -export function isLocalSshEndpoint(ip?: string): boolean { - if (!ip) return false; - const bare = normalizeHostAddress(ip); - if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") { - return true; - } - return getLocalAddresses().has(bare); -} - -function createThrottledProgress(onProgress?: (bytes: number) => void) { - let pending = 0; - let lastFlush = 0; - - const flush = () => { - if (pending > 0) { - onProgress?.(pending); - pending = 0; - lastFlush = Date.now(); - } - }; - - return { - add(bytes: number) { - pending += bytes; - const now = Date.now(); - if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) { - flush(); - } - }, - flush, - }; -} - -function escapeShell(s: string): string { - return s.replace(/'/g, "'\"'\"'"); -} - async function detectTransferPlatform( deps: HostTransferDeps, session: SSHSessionLike, @@ -680,150 +585,6 @@ async function detectTransferPlatform( return "unix"; } -function isRootOnlyPath(path: string): boolean { - const normalized = normalizeSftpPath(path); - return ( - normalized === "/" || - /^\/[A-Za-z]:$/.test(normalized) || - /^[A-Za-z]:$/.test(normalized) - ); -} - -function isPermissionError(err: Error): boolean { - const msg = err.message.toLowerCase(); - return ( - msg.includes("permission denied") || - msg.includes("eacces") || - msg.includes("access denied") - ); -} - -function promisifySftpStat( - sftp: SFTPWrapper, - path: string, -): Promise { - return new Promise((resolve, reject) => { - sftp.stat(path, (err, stats) => { - if (err) reject(err); - else resolve(stats); - }); - }); -} - -function promisifySftpUnlink(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve, reject) => { - sftp.unlink(path, (err) => { - if (err) reject(err); - else resolve(); - }); - }); -} - -function promisifySftpRmdir(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve, reject) => { - sftp.rmdir(path, (err) => { - if (err) reject(err); - else resolve(); - }); - }); -} - -async function ensureDirectoryTreeSftp( - sftp: SFTPWrapper, - dirPath: string, - created: Set = new Set(), -): Promise { - const normalized = normalizeSftpPath(dirPath); - if (!normalized || isRootOnlyPath(normalized)) return; - - const { root, segments } = splitPathSegments(normalized); - if (segments.length === 0) return; - - for (let i = 0; i < segments.length; i++) { - const current = buildPathFromSegments(root, segments, i + 1); - if (created.has(current)) continue; - - try { - await promisifySftpMkdir(sftp, current, 0o755); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code !== "EEXIST") { - try { - const stats = await promisifySftpStat(sftp, current); - if (!stats.isDirectory()) throw err; - } catch { - throw err; - } - } - } - created.add(current); - } -} - -async function deletePathSftp(sftp: SFTPWrapper, path: string): Promise { - let stats: import("ssh2").Stats; - try { - stats = await promisifySftpStat(sftp, path); - } catch { - return; - } - - if (stats.isDirectory()) { - const entries = await promisifySftpReaddir(sftp, path); - for (const entry of entries) { - if (entry.filename === "." || entry.filename === "..") continue; - await deletePathSftp(sftp, joinPath(path, entry.filename)); - } - await promisifySftpRmdir(sftp, path); - return; - } - - if (stats.isFile()) { - await promisifySftpUnlink(sftp, path); - } -} - -function promisifySftpMkdir( - sftp: SFTPWrapper, - path: string, - mode: number, -): Promise { - return new Promise((resolve, reject) => { - sftp.mkdir(path, { mode }, (err) => { - if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") { - reject(err); - } else { - resolve(); - } - }); - }); -} - -function promisifySftpChmod( - sftp: SFTPWrapper, - path: string, - mode: number, -): Promise { - return new Promise((resolve, reject) => { - sftp.chmod(path, mode, (err) => { - if (err) reject(err); - else resolve(); - }); - }); -} - -function promisifySftpReaddir( - sftp: SFTPWrapper, - path: string, -): Promise> { - return new Promise((resolve, reject) => { - sftp.readdir(path, (err, list) => { - if (err) reject(err); - else resolve(list); - }); - }); -} - function execCommand( deps: HostTransferDeps, session: SSHSessionLike, @@ -1157,10 +918,6 @@ function finalizeTransfer( return result; } -function elapsedMs(start: number): number { - return Date.now() - start; -} - async function verifyTransferredFile( deps: HostTransferDeps, transferId: string, @@ -1208,103 +965,6 @@ async function verifyTransferredFile( }); } -export function computeTransferMbPerSec( - bytes: number, - ms: number, -): number | undefined { - if (ms <= 0 || bytes <= 0) return undefined; - return ((bytes / ms) * 1000) / (1024 * 1024); -} - -interface HopWallClock { - firstAt: number | null; - lastAt: number | null; -} - -function createHopWallClock(): HopWallClock { - return { firstAt: null, lastAt: null }; -} - -function noteHopStart( - clock: HopWallClock, - t: number = performance.now(), -): void { - if (clock.firstAt === null) clock.firstAt = t; -} - -function noteHopEnd(clock: HopWallClock, t: number = performance.now()): void { - clock.lastAt = t; -} - -function hopSpanMs(clock: HopWallClock): number { - if (clock.firstAt === null || clock.lastAt === null) return 0; - return Math.max(0, clock.lastAt - clock.firstAt); -} - -interface PipelinedXferStats { - bytes: number; - sourceReadSpanMs: number; - destWriteSpanMs: number; - destWriteKind: "sftp" | "local"; -} - -function createEmptyXferStats(): PipelinedXferStats { - return { - bytes: 0, - sourceReadSpanMs: 0, - destWriteSpanMs: 0, - destWriteKind: "sftp", - }; -} - -function mergeXferStats( - target: PipelinedXferStats, - source: PipelinedXferStats, -): void { - target.bytes += source.bytes; - target.sourceReadSpanMs += source.sourceReadSpanMs; - target.destWriteSpanMs += source.destWriteSpanMs; - target.destWriteKind = source.destWriteKind; -} - -function buildTransferHopTimings( - stats: PipelinedXferStats, - transferMs: number, -): Pick { - const hops: TransferHopMetrics[] = []; - - const sourceRate = computeTransferMbPerSec( - stats.bytes, - stats.sourceReadSpanMs, - ); - if (sourceRate !== undefined) { - hops.push({ - id: "source_read", - bytes: stats.bytes, - spanMs: stats.sourceReadSpanMs, - mbPerSec: sourceRate, - }); - } - - const destHopId: TransferHopId = - stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write"; - const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs); - if (destRate !== undefined) { - hops.push({ - id: destHopId, - bytes: stats.bytes, - spanMs: stats.destWriteSpanMs, - mbPerSec: destRate, - }); - } - - return { - transferBytes: stats.bytes, - endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs), - hops, - }; -} - async function deleteSourcePathsAfterSuccess( deps: HostTransferDeps, transferId: string, @@ -1334,63 +994,6 @@ async function ensureDestParentForFile( await ensureDestDirectory(deps, destSession, parent); } -interface FileWorkItem { - sourcePath: string; - destPath: string; - mode: number; - size: number; -} - -async function collectFileWorkItems( - sftp: SFTPWrapper, - sourcePath: string, - destRoot: string, - destBaseName?: string, -): Promise { - const stats = await promisifySftpStat(sftp, sourcePath); - const name = destBaseName ?? basename(sourcePath); - const destPath = joinPath(destRoot, name); - - if (stats.isFile()) { - return [ - { - sourcePath, - destPath, - mode: stats.mode & 0o7777, - size: stats.size, - }, - ]; - } - - if (!stats.isDirectory()) { - return []; - } - - const items: FileWorkItem[] = []; - const walk = async (srcDir: string, dstDir: string) => { - const entries = await promisifySftpReaddir(sftp, srcDir); - for (const entry of entries) { - if (entry.filename === "." || entry.filename === "..") continue; - const srcChild = joinPath(srcDir, entry.filename); - const dstChild = joinPath(dstDir, entry.filename); - - if (entry.attrs.isDirectory()) { - await walk(srcChild, dstChild); - } else if (entry.attrs.isFile()) { - items.push({ - sourcePath: srcChild, - destPath: dstChild, - mode: entry.attrs.mode & 0o7777, - size: entry.attrs.size, - }); - } - } - }; - - await walk(sourcePath, destPath); - return items; -} - async function scanSourcePathsForRouting( sftp: SFTPWrapper, sourcePaths: string[], @@ -1430,63 +1033,6 @@ async function scanSourcePathsForRouting( return summary; } -async function readSftpSample( - sftp: SFTPWrapper, - path: string, - fileSize: number, -): Promise { - const sampleSize = Math.min(64 * 1024, fileSize); - const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2)); - const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666); - try { - const buffer = Buffer.alloc(sampleSize); - const bytesRead = await new Promise((resolve, reject) => { - sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => { - if (err) reject(err); - else resolve(count); - }); - }); - return buffer.subarray(0, bytesRead); - } finally { - await promisifySftpClose(sftp, handle).catch(() => {}); - } -} - -function promisifySftpOpen( - sftp: SFTPWrapper, - path: string, - flags: number, - mode: number, -): Promise { - return new Promise((resolve, reject) => { - sftp.open(path, flags, mode, (err, handle) => { - if (err) reject(err); - else resolve(handle); - }); - }); -} - -function promisifySftpClose(sftp: SFTPWrapper, handle: Buffer): Promise { - return new Promise((resolve, reject) => { - sftp.close(handle, (err) => { - if (err) reject(err); - else resolve(); - }); - }); -} - -function promisifySftpFstat( - sftp: SFTPWrapper, - handle: Buffer, -): Promise { - return new Promise((resolve, reject) => { - sftp.fstat(handle, (err, stats) => { - if (err) reject(err); - else resolve(stats); - }); - }); -} - interface PipelinedXferOptions { fileSize?: number; initialOffset?: number; @@ -1500,43 +1046,6 @@ interface PipelinedXferOptions { onResumeOffset?: (offset: number) => void; } -interface SegmentCopyJob { - offset: number; - length: number; - segmentIndex: number; -} - -function clampParallelSegmentCount(value?: number): number { - const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT; - return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n))); -} - -function buildSegmentCopyJobs( - fileSize: number, - initialOffset: number, - destResumeSize: number, -): SegmentCopyJob[] { - const jobs: SegmentCopyJob[] = []; - for ( - let offset = initialOffset; - offset < fileSize; - offset += SFTP_XFER_SEGMENT_SIZE - ) { - const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset); - const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE); - if (destResumeSize >= offset + length) { - continue; - } - const start = destResumeSize > offset ? destResumeSize : offset; - jobs.push({ - offset: start, - length: offset + length - start, - segmentIndex, - }); - } - return jobs; -} - function closeAllTransferSessions( deps: HostTransferDeps, ctx: TransferReconnectContext, diff --git a/src/backend/hosts/file-manager/transfer-errors.ts b/src/backend/hosts/file-manager/transfer-errors.ts new file mode 100644 index 00000000..2180e1ff --- /dev/null +++ b/src/backend/hosts/file-manager/transfer-errors.ts @@ -0,0 +1,52 @@ +export class TransferCancelledError extends Error { + constructor() { + super("Transfer cancelled"); + this.name = "TransferCancelledError"; + } +} + +export class TransferStalledError extends Error { + readonly byteOffset?: number; + readonly segmentIndex?: number; + + constructor(byteOffset?: number, segmentIndex?: number) { + const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : ""; + const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : ""; + super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`); + this.name = "TransferStalledError"; + this.byteOffset = byteOffset; + this.segmentIndex = segmentIndex; + } +} + +export class TransferConnectionLostError extends Error { + constructor(message = "Transfer SSH connection lost") { + super(message); + this.name = "TransferConnectionLostError"; + } +} + +export function isRecoverableTransferConnectionError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + return ( + msg.includes("no response from server") || + msg.includes("connection lost") || + msg.includes("not connected") || + msg.includes("econnreset") || + msg.includes("econnrefused") || + msg.includes("etimedout") || + msg.includes("socket hang up") || + msg.includes("protocol error") || + msg.includes("connection closed") || + msg.includes("channel open failure") + ); +} + +export function isRecoverableTransferError(err: unknown): boolean { + return ( + err instanceof TransferStalledError || + err instanceof TransferConnectionLostError || + isRecoverableTransferConnectionError(err) + ); +} diff --git a/src/backend/hosts/file-manager/transfer-host-utils.ts b/src/backend/hosts/file-manager/transfer-host-utils.ts new file mode 100644 index 00000000..ad76ce66 --- /dev/null +++ b/src/backend/hosts/file-manager/transfer-host-utils.ts @@ -0,0 +1,59 @@ +import { networkInterfaces } from "os"; +import { normalizeSftpPath } from "../transfer-paths.js"; + +let cachedLocalAddresses: Set | null = null; + +export function normalizeHostAddress(host: string): string { + const trimmed = host.trim().toLowerCase(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return trimmed.slice(1, -1); + } + return trimmed.split(":")[0] ?? trimmed; +} + +function getLocalAddresses(): Set { + if (cachedLocalAddresses) return cachedLocalAddresses; + + const addresses = new Set(["127.0.0.1", "::1", "localhost"]); + for (const ifaces of Object.values(networkInterfaces())) { + if (!ifaces) continue; + for (const iface of ifaces) { + if (!iface.internal && iface.family === "IPv4") { + addresses.add(iface.address.toLowerCase()); + } + } + } + cachedLocalAddresses = addresses; + return addresses; +} + +export function isLocalSshEndpoint(ip?: string): boolean { + if (!ip) return false; + const bare = normalizeHostAddress(ip); + if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") { + return true; + } + return getLocalAddresses().has(bare); +} + +export function escapeShell(s: string): string { + return s.replace(/'/g, "'\"'\"'"); +} + +export function isRootOnlyPath(path: string): boolean { + const normalized = normalizeSftpPath(path); + return ( + normalized === "/" || + /^\/[A-Za-z]:$/.test(normalized) || + /^[A-Za-z]:$/.test(normalized) + ); +} + +export function isPermissionError(err: Error): boolean { + const msg = err.message.toLowerCase(); + return ( + msg.includes("permission denied") || + msg.includes("eacces") || + msg.includes("access denied") + ); +} diff --git a/src/backend/hosts/file-manager/transfer-scan.ts b/src/backend/hosts/file-manager/transfer-scan.ts new file mode 100644 index 00000000..03473acc --- /dev/null +++ b/src/backend/hosts/file-manager/transfer-scan.ts @@ -0,0 +1,89 @@ +import { basename, joinPath } from "../transfer-paths.js"; +import { + SFTP_OPEN_READ, + promisifySftpClose, + promisifySftpOpen, + promisifySftpReaddir, + promisifySftpStat, +} from "./sftp-promisify.js"; + +type SFTPWrapper = import("ssh2").SFTPWrapper; + +export interface FileWorkItem { + sourcePath: string; + destPath: string; + mode: number; + size: number; +} + +export async function collectFileWorkItems( + sftp: SFTPWrapper, + sourcePath: string, + destRoot: string, + destBaseName?: string, +): Promise { + const stats = await promisifySftpStat(sftp, sourcePath); + const name = destBaseName ?? basename(sourcePath); + const destPath = joinPath(destRoot, name); + + if (stats.isFile()) { + return [ + { + sourcePath, + destPath, + mode: stats.mode & 0o7777, + size: stats.size, + }, + ]; + } + + if (!stats.isDirectory()) { + return []; + } + + const items: FileWorkItem[] = []; + const walk = async (srcDir: string, dstDir: string) => { + const entries = await promisifySftpReaddir(sftp, srcDir); + for (const entry of entries) { + if (entry.filename === "." || entry.filename === "..") continue; + const srcChild = joinPath(srcDir, entry.filename); + const dstChild = joinPath(dstDir, entry.filename); + + if (entry.attrs.isDirectory()) { + await walk(srcChild, dstChild); + } else if (entry.attrs.isFile()) { + items.push({ + sourcePath: srcChild, + destPath: dstChild, + mode: entry.attrs.mode & 0o7777, + size: entry.attrs.size, + }); + } + } + }; + + await walk(sourcePath, destPath); + return items; +} + +export async function readSftpSample( + sftp: SFTPWrapper, + path: string, + fileSize: number, +): Promise { + const sampleSize = Math.min(64 * 1024, fileSize); + const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2)); + const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666); + try { + const buffer = Buffer.alloc(sampleSize); + const bytesRead = await new Promise((resolve, reject) => { + sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => { + if (err) reject(err); + else resolve(count); + }); + }); + return buffer.subarray(0, bytesRead); + } finally { + await promisifySftpClose(sftp, handle).catch(() => {}); + } +} diff --git a/src/backend/hosts/file-manager/transfer-segment-copy.ts b/src/backend/hosts/file-manager/transfer-segment-copy.ts new file mode 100644 index 00000000..36a494db --- /dev/null +++ b/src/backend/hosts/file-manager/transfer-segment-copy.ts @@ -0,0 +1,40 @@ +export const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024; +export const DEFAULT_PARALLEL_SEGMENT_COUNT = 2; +export const MAX_PARALLEL_SEGMENT_COUNT = 8; + +export interface SegmentCopyJob { + offset: number; + length: number; + segmentIndex: number; +} + +export function clampParallelSegmentCount(value?: number): number { + const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT; + return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n))); +} + +export function buildSegmentCopyJobs( + fileSize: number, + initialOffset: number, + destResumeSize: number, +): SegmentCopyJob[] { + const jobs: SegmentCopyJob[] = []; + for ( + let offset = initialOffset; + offset < fileSize; + offset += SFTP_XFER_SEGMENT_SIZE + ) { + const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset); + const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE); + if (destResumeSize >= offset + length) { + continue; + } + const start = destResumeSize > offset ? destResumeSize : offset; + jobs.push({ + offset: start, + length: offset + length - start, + segmentIndex, + }); + } + return jobs; +} diff --git a/src/backend/hosts/file-manager/transfer-sftp-dir.ts b/src/backend/hosts/file-manager/transfer-sftp-dir.ts new file mode 100644 index 00000000..cba11ac7 --- /dev/null +++ b/src/backend/hosts/file-manager/transfer-sftp-dir.ts @@ -0,0 +1,74 @@ +import { + buildPathFromSegments, + joinPath, + normalizeSftpPath, + splitPathSegments, +} from "../transfer-paths.js"; +import { isRootOnlyPath } from "./transfer-host-utils.js"; +import { + promisifySftpMkdir, + promisifySftpReaddir, + promisifySftpRmdir, + promisifySftpStat, + promisifySftpUnlink, +} from "./sftp-promisify.js"; + +type SFTPWrapper = import("ssh2").SFTPWrapper; + +export async function ensureDirectoryTreeSftp( + sftp: SFTPWrapper, + dirPath: string, + created: Set = new Set(), +): Promise { + const normalized = normalizeSftpPath(dirPath); + if (!normalized || isRootOnlyPath(normalized)) return; + + const { root, segments } = splitPathSegments(normalized); + if (segments.length === 0) return; + + for (let i = 0; i < segments.length; i++) { + const current = buildPathFromSegments(root, segments, i + 1); + if (created.has(current)) continue; + + try { + await promisifySftpMkdir(sftp, current, 0o755); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + try { + const stats = await promisifySftpStat(sftp, current); + if (!stats.isDirectory()) throw err; + } catch { + throw err; + } + } + } + created.add(current); + } +} + +export async function deletePathSftp( + sftp: SFTPWrapper, + path: string, +): Promise { + let stats: import("ssh2").Stats; + try { + stats = await promisifySftpStat(sftp, path); + } catch { + return; + } + + if (stats.isDirectory()) { + const entries = await promisifySftpReaddir(sftp, path); + for (const entry of entries) { + if (entry.filename === "." || entry.filename === "..") continue; + await deletePathSftp(sftp, joinPath(path, entry.filename)); + } + await promisifySftpRmdir(sftp, path); + return; + } + + if (stats.isFile()) { + await promisifySftpUnlink(sftp, path); + } +} diff --git a/src/backend/hosts/file-manager/transfer-stats.ts b/src/backend/hosts/file-manager/transfer-stats.ts new file mode 100644 index 00000000..14e43fe2 --- /dev/null +++ b/src/backend/hosts/file-manager/transfer-stats.ts @@ -0,0 +1,157 @@ +import { performance } from "node:perf_hooks"; + +const TRANSFER_PROGRESS_INTERVAL_MS = 200; + +export type TransferHopId = + "source_read" | "dest_sftp_write" | "dest_local_write"; + +export interface TransferHopMetrics { + id: TransferHopId; + bytes: number; + /** Wall-clock span from first I/O on this hop to last I/O complete. */ + spanMs: number; + mbPerSec: number; +} + +export interface TransferTimings { + prepareDestMs?: number; + compressMs?: number; + transferMs?: number; + extractMs?: number; + verifyMs?: number; + directBenchmarkMs?: number; + relayBenchmarkMs?: number; + sourceDeleteMs?: number; + totalMs?: number; + transferBytes?: number; + endToEndMbPerSec?: number; + hops?: TransferHopMetrics[]; +} + +export function elapsedMs(start: number): number { + return Date.now() - start; +} + +export function computeTransferMbPerSec( + bytes: number, + ms: number, +): number | undefined { + if (ms <= 0 || bytes <= 0) return undefined; + return ((bytes / ms) * 1000) / (1024 * 1024); +} + +export interface HopWallClock { + firstAt: number | null; + lastAt: number | null; +} + +export function createHopWallClock(): HopWallClock { + return { firstAt: null, lastAt: null }; +} + +export function noteHopStart( + clock: HopWallClock, + t: number = performance.now(), +): void { + if (clock.firstAt === null) clock.firstAt = t; +} + +export function noteHopEnd( + clock: HopWallClock, + t: number = performance.now(), +): void { + clock.lastAt = t; +} + +export function hopSpanMs(clock: HopWallClock): number { + if (clock.firstAt === null || clock.lastAt === null) return 0; + return Math.max(0, clock.lastAt - clock.firstAt); +} + +export function createThrottledProgress(onProgress?: (bytes: number) => void) { + let pending = 0; + let lastFlush = 0; + + const flush = () => { + if (pending > 0) { + onProgress?.(pending); + pending = 0; + lastFlush = Date.now(); + } + }; + + return { + add(bytes: number) { + pending += bytes; + const now = Date.now(); + if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) { + flush(); + } + }, + flush, + }; +} + +export interface PipelinedXferStats { + bytes: number; + sourceReadSpanMs: number; + destWriteSpanMs: number; + destWriteKind: "sftp" | "local"; +} + +export function createEmptyXferStats(): PipelinedXferStats { + return { + bytes: 0, + sourceReadSpanMs: 0, + destWriteSpanMs: 0, + destWriteKind: "sftp", + }; +} + +export function mergeXferStats( + target: PipelinedXferStats, + source: PipelinedXferStats, +): void { + target.bytes += source.bytes; + target.sourceReadSpanMs += source.sourceReadSpanMs; + target.destWriteSpanMs += source.destWriteSpanMs; + target.destWriteKind = source.destWriteKind; +} + +export function buildTransferHopTimings( + stats: PipelinedXferStats, + transferMs: number, +): Pick { + const hops: TransferHopMetrics[] = []; + + const sourceRate = computeTransferMbPerSec( + stats.bytes, + stats.sourceReadSpanMs, + ); + if (sourceRate !== undefined) { + hops.push({ + id: "source_read", + bytes: stats.bytes, + spanMs: stats.sourceReadSpanMs, + mbPerSec: sourceRate, + }); + } + + const destHopId: TransferHopId = + stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write"; + const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs); + if (destRate !== undefined) { + hops.push({ + id: destHopId, + bytes: stats.bytes, + spanMs: stats.destWriteSpanMs, + mbPerSec: destRate, + }); + } + + return { + transferBytes: stats.bytes, + endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs), + hops, + }; +} diff --git a/src/backend/hosts/jump-host-chain.ts b/src/backend/hosts/jump-host-chain.ts index b7441552..f4167a3c 100644 --- a/src/backend/hosts/jump-host-chain.ts +++ b/src/backend/hosts/jump-host-chain.ts @@ -2,6 +2,8 @@ import { Client as SSHClient } from "ssh2"; import { fileLogger } from "../utils/logger.js"; import { createSocks5Connection } from "../utils/socks5-helper.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; +import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; +import { getErrorMessage } from "../utils/error-message.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; import { applyAgentAuth } from "./terminal-auth-helpers.js"; @@ -46,6 +48,17 @@ async function resolveJumpHost( } } +export class JumpHostChainError extends Error { + constructor( + message: string, + readonly hopIndex: number, + readonly totalHops: number, + ) { + super(message); + this.name = "JumpHostChainError"; + } +} + export async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, @@ -76,7 +89,11 @@ export async function createJumpHostChain( totalHops, }); clients.forEach((c) => c.end()); - return null; + throw new JumpHostChainError( + `Jump host ${i + 1} of ${totalHops} was not found`, + i, + totalHops, + ); } } @@ -106,11 +123,20 @@ export async function createJumpHostChain( true, ); + let lastError: Error | null = null; + // eslint-disable-next-line no-async-promise-executor const connected = await new Promise(async (resolve) => { + const readyTimeoutMs = 60000; const timeout = setTimeout(() => { + lastError = new Error( + `Timed out waiting for jump host ${i + 1}/${totalHops} to authenticate`, + ); resolve(false); - }, 30000); + // ssh2 has no explicit cancel; ending the client stops it from + // firing "ready"/"error" after we've already resolved. + jumpClient.end(); + }, readyTimeoutMs + 5000); jumpClient.on("ready", () => { clearTimeout(timeout); @@ -119,6 +145,7 @@ export async function createJumpHostChain( jumpClient.on("error", (err) => { clearTimeout(timeout); + lastError = err; fileLogger.error( `Jump host ${i + 1}/${totalHops} connection failed`, err, @@ -145,7 +172,7 @@ export async function createJumpHostChain( port: jumpHostConfig.port || 22, username: jumpHostConfig.username, tryKeyboard: jumpHostConfig.authType !== "none", - readyTimeout: 60000, + readyTimeout: readyTimeoutMs, hostVerifier: jumpHostVerifier, algorithms: { kex: [ @@ -190,11 +217,19 @@ export async function createJumpHostChain( if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { connectConfig.password = jumpHostConfig.password; } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { - const cleanKey = jumpHostConfig.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + try { + connectConfig.privateKey = preparePrivateKeyForSSH2( + jumpHostConfig.key, + jumpHostConfig.keyPassword, + ); + } catch (keyError) { + clearTimeout(timeout); + lastError = new Error( + `Jump host ${i + 1}/${totalHops} key error: ${getErrorMessage(keyError, "Invalid private key format")}`, + ); + resolve(false); + return; + } if (jumpHostConfig.keyPassword) { connectConfig.passphrase = jumpHostConfig.keyPassword; } @@ -237,6 +272,7 @@ export async function createJumpHostChain( (err, stream) => { if (err) { clearTimeout(timeout); + lastError = err; resolve(false); return; } @@ -254,7 +290,14 @@ export async function createJumpHostChain( if (!connected) { clients.forEach((c) => c.end()); - return null; + throw new JumpHostChainError( + getErrorMessage( + lastError, + `Jump host ${i + 1} of ${totalHops} failed to connect`, + ), + i, + totalHops, + ); } currentClient = jumpClient; @@ -262,6 +305,7 @@ export async function createJumpHostChain( return currentClient; } catch (error) { + if (error instanceof JumpHostChainError) throw error; fileLogger.error("Failed to create jump host chain", error, { operation: "jump_host_chain", }); diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index d04f0387..941bdae4 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -576,16 +576,32 @@ class PollingManager { } else { isOnline = await tcpPing(refreshedHost.ip, pingPort, 5000); } + const config = this.pollingConfigs.get(refreshedHost.id); + let authenticated: boolean | undefined; + if ( + isOnline && + supportsMetrics(refreshedHost) && + !config?.statsConfig.metricsEnabled + ) { + try { + await withSshConnection(refreshedHost, async () => undefined); + authenticated = true; + } catch { + authenticated = false; + } + } const statusEntry: StatusEntry = { - status: statusAfterReachabilityCheck( - isOnline, - this.statusStore.get(refreshedHost.id)?.status, - ), + status: + authenticated === undefined + ? statusAfterReachabilityCheck( + isOnline, + this.statusStore.get(refreshedHost.id)?.status, + ) + : statusAfterAuthentication(authenticated), lastChecked: new Date().toISOString(), }; this.statusStore.set(refreshedHost.id, statusEntry); if (isOnline && this.activeViewers.has(refreshedHost.id)) { - const config = this.pollingConfigs.get(refreshedHost.id); if (config?.statsConfig.metricsEnabled) { this.scheduleInitialMetricsPoll(config.host, config.viewerUserId); } diff --git a/src/backend/hosts/serial.ts b/src/backend/hosts/serial.ts index a03a25e2..9030d2f6 100644 --- a/src/backend/hosts/serial.ts +++ b/src/backend/hosts/serial.ts @@ -4,6 +4,7 @@ import { SerialPort } from "serialport"; import { AuthManager } from "../utils/auth-manager.js"; import { DataCrypto } from "../utils/data-crypto.js"; import { sshLogger } from "../utils/logger.js"; +import { parseWsMessage } from "../utils/ws-message.js"; interface SerialConnectData { path: string; @@ -13,11 +14,6 @@ interface SerialConnectData { parity?: "none" | "even" | "odd"; } -interface WebSocketMessage { - type: string; - data?: SerialConnectData | string | unknown; -} - const authManager = AuthManager.getInstance(); const wss = new WebSocketServer({ port: 30011 }); @@ -93,109 +89,117 @@ wss.on("connection", async (ws: WebSocket, req) => { }; ws.on("message", async (raw: RawData) => { - let parsed: WebSocketMessage; + let type: string; + let data: unknown; try { - parsed = JSON.parse(raw.toString()) as WebSocketMessage; + ({ type, data } = parseWsMessage(raw)); } catch { return; } - const { type, data } = parsed; - - switch (type) { - case "list_ports": { - try { - const ports = await SerialPort.list(); - send({ type: "ports_list", data: ports }); - } catch (err) { - send({ - type: "error", - data: getErrorMessage(err, "Failed to list ports"), - }); - } - break; - } - - case "connect": { - if (port?.isOpen) { - port.close(); - port = null; - } - - const cfg = data as SerialConnectData; - if (!cfg?.path || !cfg?.baudRate) { - send({ type: "error", data: "Missing port path or baud rate" }); + try { + switch (type) { + case "list_ports": { + try { + const ports = await SerialPort.list(); + send({ type: "ports_list", data: ports }); + } catch (err) { + send({ + type: "error", + data: getErrorMessage(err, "Failed to list ports"), + }); + } break; } - try { - port = new SerialPort({ - path: cfg.path, - baudRate: cfg.baudRate, - dataBits: cfg.dataBits ?? 8, - stopBits: cfg.stopBits ?? 1, - parity: cfg.parity ?? "none", - autoOpen: false, - }); + case "connect": { + if (port?.isOpen) { + port.close(); + port = null; + } - port.open((err) => { - if (err) { - sshLogger.error("Serial port open failed", err, { - operation: "serial_open", - path: cfg.path, - userId, - }); - send({ type: "error", data: err.message }); - port = null; - return; - } - sshLogger.info("Serial port opened", { - operation: "serial_open", + const cfg = data as SerialConnectData; + if (!cfg?.path || !cfg?.baudRate) { + send({ type: "error", data: "Missing port path or baud rate" }); + break; + } + + try { + port = new SerialPort({ path: cfg.path, baudRate: cfg.baudRate, - userId, + dataBits: cfg.dataBits ?? 8, + stopBits: cfg.stopBits ?? 1, + parity: cfg.parity ?? "none", + autoOpen: false, }); - send({ type: "connected" }); - }); - port.on("data", (chunk: Buffer) => { - send({ type: "data", data: chunk.toString("binary") }); - }); + port.open((err) => { + if (err) { + sshLogger.error("Serial port open failed", err, { + operation: "serial_open", + path: cfg.path, + userId, + }); + send({ type: "error", data: err.message }); + port = null; + return; + } + sshLogger.info("Serial port opened", { + operation: "serial_open", + path: cfg.path, + baudRate: cfg.baudRate, + userId, + }); + send({ type: "connected" }); + }); - port.on("error", (err) => { - send({ type: "error", data: err.message }); - }); + port.on("data", (chunk: Buffer) => { + send({ type: "data", data: chunk.toString("binary") }); + }); - port.on("close", () => { - send({ type: "disconnected" }); - port = null; - }); - } catch (err) { - send({ - type: "error", - data: getErrorMessage(err, "Failed to open serial port"), - }); - } - break; - } + port.on("error", (err) => { + send({ type: "error", data: err.message }); + }); - case "input": { - if (!port?.isOpen) break; - const input = typeof data === "string" ? data : ""; - if (!input) break; - port.write(Buffer.from(input, "binary"), (err) => { - if (err) { - send({ type: "error", data: err.message }); + port.on("close", () => { + send({ type: "disconnected" }); + port = null; + }); + } catch (err) { + send({ + type: "error", + data: getErrorMessage(err, "Failed to open serial port"), + }); } - }); - break; - } + break; + } - case "disconnect": { - cleanup(); - send({ type: "disconnected" }); - break; + case "input": { + if (!port?.isOpen) break; + const input = typeof data === "string" ? data : ""; + if (!input) break; + port.write(Buffer.from(input, "binary"), (err) => { + if (err) { + send({ type: "error", data: err.message }); + } + }); + break; + } + + case "disconnect": { + cleanup(); + send({ type: "disconnected" }); + break; + } } + } catch (err) { + sshLogger.error("Error handling serial WebSocket message", err, { + operation: "serial_message_handler_error", + userId, + messageType: type, + }); + send({ type: "error", data: "Failed to process message" }); } }); diff --git a/src/backend/hosts/terminal-auth-helpers.ts b/src/backend/hosts/terminal-auth-helpers.ts index 8bbf3cc8..6197aa61 100644 --- a/src/backend/hosts/terminal-auth-helpers.ts +++ b/src/backend/hosts/terminal-auth-helpers.ts @@ -1,13 +1,18 @@ import dgram from "dgram"; import net from "net"; import ssh2Pkg, { + type BaseAgent as BaseAgentType, + type GetStreamCallback, type IdentityCallback, + type KnownPublicKeys, type ParsedKey, type SignCallback, type SigningRequestOptions, } from "ssh2"; -const { BaseAgent } = ssh2Pkg; +type KnownPublicKey = KnownPublicKeys[number]; + +const { AgentProtocol, BaseAgent } = ssh2Pkg; const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000; type Sleep = (ms: number) => Promise; @@ -34,6 +39,23 @@ export class MemoryAgent extends BaseAgent { cb(null, [this.key]); } + getStream(cb: GetStreamCallback): void { + const protocol = new AgentProtocol(false); + + protocol.on("identities", (request) => { + protocol.getIdentitiesReply(request, [this.key]); + }); + + protocol.on("sign", (request, publicKey, data, options) => { + this.sign(publicKey, data, options, (error, signature) => { + if (error || !signature) return protocol.failureReply(request); + protocol.signReply(request, signature); + }); + }); + + cb(null, protocol); + } + sign( _pubKey: ParsedKey | Buffer | string, data: Buffer, @@ -85,6 +107,84 @@ export async function resolveAgentSocket( return { socketPath: resolved }; } +/** + * Wraps an agent so only identities matching a specific public key are + * offered to the server, mirroring ssh_config's IdentityFile + IdentitiesOnly + * for agent auth. Prevents exhausting the server's MaxAuthTries when the + * agent holds many keys. + */ +export class FilteredAgent extends BaseAgent { + private inner: BaseAgentType; + private publicKeyBlob: Buffer; + + constructor(inner: BaseAgentType, publicKeyBlob: Buffer) { + super(); + this.inner = inner; + this.publicKeyBlob = publicKeyBlob; + } + + private matches(key: KnownPublicKey): boolean { + try { + const blob = + typeof key === "string" + ? Buffer.from(key) + : Buffer.isBuffer(key) + ? key + : "getPublicSSH" in key + ? key.getPublicSSH() + : null; + return ( + Buffer.isBuffer(blob) && Buffer.compare(blob, this.publicKeyBlob) === 0 + ); + } catch { + return false; + } + } + + getIdentities(cb: IdentityCallback): void { + this.inner.getIdentities((err, keys) => { + if (err || !keys) return cb(err, keys); + cb( + null, + keys.filter((key) => this.matches(key)), + ); + }); + } + + getStream(cb: GetStreamCallback): void { + if (typeof this.inner.getStream !== "function") { + return cb(new Error("Agent does not support forwarding.")); + } + this.inner.getStream(cb); + } + + sign( + pubKey: ParsedKey | Buffer | string, + data: Buffer, + optionsOrCb: SigningRequestOptions | SignCallback, + cb?: SignCallback, + ): void { + this.inner.sign( + pubKey, + data, + optionsOrCb as SigningRequestOptions, + cb as SignCallback, + ); + } +} + +function parseAgentIdentityBlob(agentIdentity: string): Buffer | null { + const { utils } = ssh2Pkg; + const parsed = utils.parseKey(agentIdentity.trim()); + if (parsed instanceof Error || !parsed) return null; + const key = Array.isArray(parsed) ? parsed[0] : parsed; + try { + return key.getPublicSSH(); + } catch { + return null; + } +} + export async function applyAgentAuth( connectConfig: Record, terminalConfig: Record | undefined, @@ -93,7 +193,21 @@ export async function applyAgentAuth( if ("error" in result) return result; const { createAgent } = ssh2Pkg; - connectConfig.agent = createAgent(result.socketPath); + const agent = createAgent(result.socketPath); + + const agentIdentity = ( + terminalConfig?.agentIdentity as string | undefined + )?.trim(); + if (agentIdentity) { + const publicKeyBlob = parseAgentIdentityBlob(agentIdentity); + if (!publicKeyBlob) { + return { error: "Invalid public key provided for agent identity." }; + } + connectConfig.agent = new FilteredAgent(agent, publicKeyBlob); + } else { + connectConfig.agent = agent; + } + return result; } diff --git a/src/backend/hosts/terminal/host-identity.ts b/src/backend/hosts/terminal/host-identity.ts index 74f649af..92cf9188 100644 --- a/src/backend/hosts/terminal/host-identity.ts +++ b/src/backend/hosts/terminal/host-identity.ts @@ -55,6 +55,15 @@ export const HOST_NOT_ON_THIS_SERVER_MESSAGE = "This host does not exist on the sync server, so the connection was refused. " + 'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.'; +export function resolveServerJumpHosts( + clientJumpHosts: Array<{ hostId: number }> | undefined, + serverJumpHosts: Array<{ hostId: number }> | undefined, + hostSyncId?: string | null, +): Array<{ hostId: number }> | undefined { + if (hostSyncId) return serverJumpHosts ?? []; + return clientJumpHosts?.length ? clientJumpHosts : serverJumpHosts; +} + /** * Thrown where a mismatch is reported by rejecting rather than by messaging * the socket. Callers whose host-resolution is wrapped in a "failed to resolve diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index 526db5cc..7ab60c67 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -1,4 +1,10 @@ import { getErrorMessage } from "../../utils/error-message.js"; +import { + parseWsMessage, + asObject, + asString, + toTerminalDimension, +} from "../../utils/ws-message.js"; import { StringDecoder } from "string_decoder"; import { WebSocketServer, WebSocket, type RawData } from "ws"; import ssh2Pkg, { @@ -21,7 +27,7 @@ import { import { SSHAuthManager } from "../auth-manager.js"; import type { ProxyNode } from "../../../types/index.js"; import { SSHHostKeyVerifier } from "../host-key-verifier.js"; -import { createJumpHostChain } from "../jump-host-chain.js"; +import { createJumpHostChain, JumpHostChainError } from "../jump-host-chain.js"; import { parseTailscaleCheckBanner, isTailscaleCheckCompleteBanner, @@ -42,7 +48,7 @@ import { import { MemoryAgent, performPortKnocking, - resolveAgentSocket, + applyAgentAuth, } from "../terminal-auth-helpers.js"; import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js"; import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; @@ -54,6 +60,7 @@ import { hostAddressMismatch, HOST_ADDRESS_MISMATCH_MESSAGE, HOST_NOT_ON_THIS_SERVER_MESSAGE, + resolveServerJumpHosts, } from "./host-identity.js"; interface ConnectToHostData { @@ -112,13 +119,6 @@ interface TOTPResponseData { code?: string; } -interface WebSocketMessage { - type: string; - data?: ConnectToHostData | ResizeData | TOTPResponseData | string | unknown; - code?: string; - [key: string]: unknown; -} - const authManager = AuthManager.getInstance(); // Tailscale holds a check-mode connection open for up to 30 minutes while the @@ -236,13 +236,13 @@ async function handleShareTokenConnection( }); ws.on("message", (msg: RawData) => { - let parsed: WebSocketMessage; + let type: string; + let data: unknown; try { - parsed = JSON.parse(msg.toString()) as WebSocketMessage; + ({ type, data } = parseWsMessage(msg)); } catch { return; } - const { type, data } = parsed; const liveSession = sessionManager.getSession(currentSessionId); const participant = liveSession @@ -254,7 +254,8 @@ async function handleShareTokenConnection( switch (type) { case "input": { - const inputData = data as string; + if (typeof data !== "string") break; + const inputData = data; sessionManager.bufferInput(currentSessionId, inputData); const inputStream = liveSession?.sshStream; if (inputStream) { @@ -476,21 +477,20 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - let parsed: WebSocketMessage; + let type: string; + let data: unknown; try { - parsed = JSON.parse(msg.toString()) as WebSocketMessage; + ({ type, data } = parseWsMessage(msg)); } catch (e) { - sshLogger.error("Invalid JSON received", e, { - operation: "websocket_message_invalid_json", + sshLogger.warn("Rejected malformed WebSocket message", { + operation: "websocket_message_invalid", userId, - messageLength: msg.toString().length, + error: getErrorMessage(e), }); - ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); + ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); return; } - const { type, data } = parsed; - // Server-side gate: non-owner participants (read-only or read-write // guests/joiners) may only send input/ping/disconnect - everything else // (auth flows, tmux, resize, etc.) is owner-only and silently ignored. @@ -506,824 +506,867 @@ wss.on("connection", async (ws: WebSocket, req) => { } } - switch (type) { - case "connectToHost": { - const connectData = data as ConnectToHostData; - if (connectData.hostConfig) { - connectData.hostConfig.userId = userId; - } - handleConnectToHost(connectData).catch((error) => { - const errMsg = getErrorMessage(error); - if ( - errMsg.includes("Cannot parse privateKey") && - errMsg.includes("no passphrase") - ) { - isAwaitingAuthCredentials = true; + try { + switch (type) { + case "connectToHost": { + const connectData = data as ConnectToHostData; + if (!connectData?.hostConfig) { ws.send( JSON.stringify({ - type: "passphrase_required", - message: - "The SSH key is encrypted. Please enter the passphrase to unlock it.", + type: "error", + message: "Missing host configuration", }), ); - return; + break; } - sshLogger.error("Failed to connect to host", error, { - operation: "ssh_connect", - userId, - hostId: connectData.hostConfig?.id, - ip: connectData.hostConfig?.ip, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "Failed to connect to host: " + errMsg, - }), - ); - }); - break; - } - - case "attachSession": { - const attachData = data as { - sessionId: string; - cols: number; - rows: number; - tabInstanceId?: string; - }; - sshLogger.info("Attempting to attach session", { - operation: "terminal_attach_session", - sessionId: attachData.sessionId, - tabInstanceId: attachData.tabInstanceId, - userId, - requestedCols: attachData.cols, - requestedRows: attachData.rows, - }); - const session = sessionManager.attachWs( - attachData.sessionId, - userId, - ws, - attachData.tabInstanceId, - ); - if (session) { - sshLogger.success("Session attached successfully", { - operation: "terminal_attach_success", - sessionId: attachData.sessionId, - sessionCreatedAt: session.createdAt, - wasDetached: !!session.lastDetachedAt, - detachedDuration: session.lastDetachedAt - ? Date.now() - session.lastDetachedAt - : 0, - }); - currentSessionId = attachData.sessionId; - sshStream = session.sshStream; - sshConn = session.sshConn; - isConnecting = false; - isConnected = true; - const buffered = sessionManager.getBuffer(session); - if (buffered) { - ws.send(JSON.stringify({ type: "data", data: buffered })); - } - if ( - attachData.cols !== session.cols || - attachData.rows !== session.rows - ) { - session.sshStream?.setWindow( - attachData.rows, - attachData.cols, - attachData.rows, - attachData.cols, - ); - session.cols = attachData.cols; - session.rows = attachData.rows; - } - - ws.send( - JSON.stringify({ - type: "sessionAttached", - sessionId: attachData.sessionId, - }), - ); - ws.send( - JSON.stringify({ - type: "connected", - message: "Session reattached", - }), - ); - } else { - sshLogger.warn( - "Session attachment failed - will create new connection", - { - operation: "terminal_attach_failed", - sessionId: attachData.sessionId, - tabInstanceId: attachData.tabInstanceId, - userId, - reason: "session_not_found_or_invalid", - }, - ); - ws.send( - JSON.stringify({ - type: "sessionExpired", - sessionId: attachData.sessionId, - }), - ); - } - break; - } - - case "listSessions": { - const sessions = sessionManager.getUserSessions(userId); - ws.send( - JSON.stringify({ - type: "sessionList", - sessions: sessions.map((s) => ({ - id: s.id, - hostId: s.hostId, - hostName: s.hostName, - createdAt: s.createdAt, - lastDetachedAt: s.lastDetachedAt, - tmuxSessionName: s.tmuxSessionName, - })), - }), - ); - break; - } - - case "resize": { - const resizeData = data as ResizeData; - handleResize(resizeData); - break; - } - - case "disconnect": { - const disconnectSession = currentSessionId - ? sessionManager.getSession(currentSessionId) - : null; - const disconnectParticipant = disconnectSession - ? sessionManager.getParticipantForWs(disconnectSession, ws) - : null; - if (disconnectParticipant && !disconnectParticipant.isOwner) { - if (currentSessionId) { - sessionManager.removeParticipant(currentSessionId, ws); - currentSessionId = null; - } - break; - } - if (currentSessionId) { - sessionManager.destroySession(currentSessionId); - currentSessionId = null; - } - cleanupAuthState(); - sshConn = null; - sshStream = null; - break; - } - - case "get_cwd": { - const activeConn = - sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn; - if (!activeConn) { - ws.send(JSON.stringify({ type: "cwd", path: "/" })); - break; - } - activeConn.exec("pwd", (err, execStream) => { - if (err) { - ws.send(JSON.stringify({ type: "cwd", path: "/" })); - return; - } - let stdout = ""; - execStream.on("data", (chunk: Buffer) => { - stdout += chunk.toString("utf-8"); - }); - execStream.stderr.on("data", () => {}); - execStream.on("close", () => { - const cwd = stdout.trim() || "/"; - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "cwd", path: cwd })); - } - }); - }); - break; - } - - case "open_file_in_editor": { - const { path: requestedPath } = data as { path: string }; - const activeConn = - sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn; - if (!activeConn || !requestedPath) { - ws.send( - JSON.stringify({ - type: "open_file_in_editor", - path: requestedPath || "/", - }), - ); - break; - } - const escapedPath = requestedPath.replace(/'/g, "'\\''"); - activeConn.exec( - `realpath '${escapedPath}' 2>/dev/null || echo '${escapedPath}'`, - (err, execStream) => { - if (err) { + connectData.hostConfig.userId = userId; + handleConnectToHost(connectData).catch((error) => { + const errMsg = getErrorMessage(error); + if ( + errMsg.includes("Cannot parse privateKey") && + errMsg.includes("no passphrase") + ) { + isAwaitingAuthCredentials = true; ws.send( JSON.stringify({ - type: "open_file_in_editor", - path: requestedPath, + type: "passphrase_required", + message: + "The SSH key is encrypted. Please enter the passphrase to unlock it.", }), ); return; } + sshLogger.error("Failed to connect to host", error, { + operation: "ssh_connect", + userId, + hostId: connectData.hostConfig?.id, + ip: connectData.hostConfig?.ip, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to connect to host: " + errMsg, + }), + ); + }); + break; + } + + case "attachSession": { + const attachData = data as { + sessionId: string; + cols: number; + rows: number; + tabInstanceId?: string; + }; + sshLogger.info("Attempting to attach session", { + operation: "terminal_attach_session", + sessionId: attachData.sessionId, + tabInstanceId: attachData.tabInstanceId, + userId, + requestedCols: attachData.cols, + requestedRows: attachData.rows, + }); + const session = sessionManager.attachWs( + attachData.sessionId, + userId, + ws, + attachData.tabInstanceId, + ); + if (session) { + sshLogger.success("Session attached successfully", { + operation: "terminal_attach_success", + sessionId: attachData.sessionId, + sessionCreatedAt: session.createdAt, + wasDetached: !!session.lastDetachedAt, + detachedDuration: session.lastDetachedAt + ? Date.now() - session.lastDetachedAt + : 0, + }); + currentSessionId = attachData.sessionId; + sshStream = session.sshStream; + sshConn = session.sshConn; + isConnecting = false; + isConnected = true; + const buffered = sessionManager.getBuffer(session); + if (buffered) { + ws.send(JSON.stringify({ type: "data", data: buffered })); + } + const attachCols = toTerminalDimension(attachData.cols); + const attachRows = toTerminalDimension(attachData.rows); + if ( + attachCols && + attachRows && + (attachCols !== session.cols || attachRows !== session.rows) + ) { + session.sshStream?.setWindow( + attachRows, + attachCols, + attachRows, + attachCols, + ); + session.cols = attachCols; + session.rows = attachRows; + } + + ws.send( + JSON.stringify({ + type: "sessionAttached", + sessionId: attachData.sessionId, + }), + ); + ws.send( + JSON.stringify({ + type: "connected", + message: "Session reattached", + }), + ); + } else { + sshLogger.warn( + "Session attachment failed - will create new connection", + { + operation: "terminal_attach_failed", + sessionId: attachData.sessionId, + tabInstanceId: attachData.tabInstanceId, + userId, + reason: "session_not_found_or_invalid", + }, + ); + ws.send( + JSON.stringify({ + type: "sessionExpired", + sessionId: attachData.sessionId, + }), + ); + } + break; + } + + case "listSessions": { + const sessions = sessionManager.getUserSessions(userId); + ws.send( + JSON.stringify({ + type: "sessionList", + sessions: sessions.map((s) => ({ + id: s.id, + hostId: s.hostId, + hostName: s.hostName, + createdAt: s.createdAt, + lastDetachedAt: s.lastDetachedAt, + tmuxSessionName: s.tmuxSessionName, + })), + }), + ); + break; + } + + case "resize": { + const resizeData = data as ResizeData; + handleResize(resizeData); + break; + } + + case "disconnect": { + const disconnectSession = currentSessionId + ? sessionManager.getSession(currentSessionId) + : null; + const disconnectParticipant = disconnectSession + ? sessionManager.getParticipantForWs(disconnectSession, ws) + : null; + if (disconnectParticipant && !disconnectParticipant.isOwner) { + if (currentSessionId) { + sessionManager.removeParticipant(currentSessionId, ws); + currentSessionId = null; + } + break; + } + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(); + sshConn = null; + sshStream = null; + break; + } + + case "get_cwd": { + const activeConn = + sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn; + if (!activeConn) { + ws.send(JSON.stringify({ type: "cwd", path: "/" })); + break; + } + activeConn.exec("pwd", (err, execStream) => { + if (err) { + ws.send(JSON.stringify({ type: "cwd", path: "/" })); + return; + } let stdout = ""; execStream.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf-8"); }); execStream.stderr.on("data", () => {}); execStream.on("close", () => { - const resolvedPath = stdout.trim() || requestedPath; + const cwd = stdout.trim() || "/"; if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "cwd", path: cwd })); + } + }); + }); + break; + } + + case "open_file_in_editor": { + const requestedPath = asString(asObject(data).path); + const activeConn = + sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn; + if (!activeConn || !requestedPath) { + ws.send( + JSON.stringify({ + type: "open_file_in_editor", + path: requestedPath || "/", + }), + ); + break; + } + const escapedPath = requestedPath.replace(/'/g, "'\\''"); + activeConn.exec( + `realpath '${escapedPath}' 2>/dev/null || echo '${escapedPath}'`, + (err, execStream) => { + if (err) { ws.send( JSON.stringify({ type: "open_file_in_editor", - path: resolvedPath, + path: requestedPath, }), ); + return; } - }); - }, - ); - break; - } - - case "input": { - const inputData = data as string; - if (currentSessionId) { - sessionManager.bufferInput(currentSessionId, inputData); - } - const inputStream = - sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; - if (inputStream) { - if (inputData === "\t") { - inputStream.write(inputData); - } else if ( - typeof inputData === "string" && - inputData.startsWith("\x1b") - ) { - inputStream.write(inputData); - } else { - try { - inputStream.write(Buffer.from(inputData, "utf8")); - } catch (error) { - sshLogger.error("Error writing input to SSH stream", error, { - operation: "ssh_input_encoding", - userId, - dataLength: inputData.length, + let stdout = ""; + execStream.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf-8"); }); - inputStream.write(Buffer.from(inputData, "latin1")); + execStream.stderr.on("data", () => {}); + execStream.on("close", () => { + const resolvedPath = stdout.trim() || requestedPath; + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "open_file_in_editor", + path: resolvedPath, + }), + ); + } + }); + }, + ); + break; + } + + case "input": { + if (typeof data !== "string") break; + const inputData = data; + if (currentSessionId) { + sessionManager.bufferInput(currentSessionId, inputData); + } + const inputStream = + sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; + if (inputStream) { + if (inputData === "\t") { + inputStream.write(inputData); + } else if ( + typeof inputData === "string" && + inputData.startsWith("\x1b") + ) { + inputStream.write(inputData); + } else { + try { + inputStream.write(Buffer.from(inputData, "utf8")); + } catch (error) { + sshLogger.error("Error writing input to SSH stream", error, { + operation: "ssh_input_encoding", + userId, + dataLength: inputData.length, + }); + inputStream.write(Buffer.from(inputData, "latin1")); + } } } + break; } - break; - } - case "ping": - ws.send(JSON.stringify({ type: "pong" })); - break; + case "ping": + ws.send(JSON.stringify({ type: "pong" })); + break; - case "tmux_attach": { - const tmuxData = data as { sessionName: string }; - const session = currentSessionId - ? sessionManager.getSession(currentSessionId) - : null; - if (session?.sshStream) { - const existingName = tmuxData.sessionName || undefined; - if (existingName) { - attachOrCreateTmuxSession(session.sshStream, existingName); - session.tmuxSessionName = existingName; - sshLogger.info("User selected tmux session to attach", { - operation: "tmux_user_attach", - sessionName: existingName, + case "tmux_attach": { + const tmuxData = data as { sessionName: string }; + const session = currentSessionId + ? sessionManager.getSession(currentSessionId) + : null; + if (session?.sshStream) { + const existingName = tmuxData.sessionName || undefined; + if (existingName) { + attachOrCreateTmuxSession(session.sshStream, existingName); + session.tmuxSessionName = existingName; + sshLogger.info("User selected tmux session to attach", { + operation: "tmux_user_attach", + sessionName: existingName, + hostId: session.hostId, + }); + ws.send( + JSON.stringify({ + type: "tmux_session_attached", + sessionName: existingName, + }), + ); + } else { + const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`; + attachOrCreateTmuxSession(session.sshStream, undefined, newName); + const sshConn = session.sshConn; + if (sshConn) { + (async () => { + const confirmed = await waitForTmuxSession(sshConn, newName); + session.tmuxSessionName = confirmed; + sshLogger.info("User requested new tmux session", { + operation: "tmux_user_create", + sessionName: confirmed, + hostId: session.hostId, + }); + ws.send( + JSON.stringify({ + type: "tmux_session_created", + sessionName: confirmed, + }), + ); + })(); + } + } + } + break; + } + + case "tmux_detach": { + const session = currentSessionId + ? sessionManager.getSession(currentSessionId) + : null; + if (session?.sshConn && session.tmuxSessionName) { + const tmuxName = session.tmuxSessionName; + session.sshStream?.write("\x02d"); + session.tmuxSessionName = null; + sshLogger.info("User detached from tmux session", { + operation: "tmux_user_detach", + sessionName: tmuxName, hostId: session.hostId, }); ws.send( - JSON.stringify({ - type: "tmux_session_attached", - sessionName: existingName, - }), + JSON.stringify({ type: "tmux_detached", sessionName: tmuxName }), ); - } else { - const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`; - attachOrCreateTmuxSession(session.sshStream, undefined, newName); - const sshConn = session.sshConn; - if (sshConn) { - (async () => { - const confirmed = await waitForTmuxSession(sshConn, newName); - session.tmuxSessionName = confirmed; - sshLogger.info("User requested new tmux session", { - operation: "tmux_user_create", - sessionName: confirmed, - hostId: session.hostId, - }); - ws.send( - JSON.stringify({ - type: "tmux_session_created", - sessionName: confirmed, - }), - ); - })(); + } + break; + } + + case "totp_response": { + const totpData = data as TOTPResponseData; + if (keyboardInteractiveFinish && totpData?.code) { + if (totpTimeout) { + clearTimeout(totpTimeout); + totpTimeout = null; } - } - } - break; - } - - case "tmux_detach": { - const session = currentSessionId - ? sessionManager.getSession(currentSessionId) - : null; - if (session?.sshConn && session.tmuxSessionName) { - const tmuxName = session.tmuxSessionName; - session.sshStream?.write("\x02d"); - session.tmuxSessionName = null; - sshLogger.info("User detached from tmux session", { - operation: "tmux_user_detach", - sessionName: tmuxName, - hostId: session.hostId, - }); - ws.send( - JSON.stringify({ type: "tmux_detached", sessionName: tmuxName }), - ); - } - break; - } - - case "totp_response": { - const totpData = data as TOTPResponseData; - if (keyboardInteractiveFinish && totpData?.code) { - if (totpTimeout) { - clearTimeout(totpTimeout); - totpTimeout = null; - } - const totpCode = totpData.code; - keyboardInteractiveFinish([totpCode]); - keyboardInteractiveFinish = null; - totpPromptSent = false; - } else { - sshLogger.warn("TOTP response received but no callback available", { - operation: "totp_response_error", - userId, - hasCallback: !!keyboardInteractiveFinish, - hasCode: !!totpData?.code, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "TOTP authentication state lost. Please reconnect.", - }), - ); - } - break; - } - - case "password_response": { - const passwordData = data as TOTPResponseData; - if (keyboardInteractiveFinish && passwordData?.code) { - if (totpTimeout) { - clearTimeout(totpTimeout); - totpTimeout = null; - } - const password = passwordData.code; - keyboardInteractiveFinish([password]); - keyboardInteractiveFinish = null; - } else { - sshLogger.warn( - "Password response received but no callback available", - { - operation: "password_response_error", + const totpCode = totpData.code; + keyboardInteractiveFinish([totpCode]); + keyboardInteractiveFinish = null; + totpPromptSent = false; + } else { + sshLogger.warn("TOTP response received but no callback available", { + operation: "totp_response_error", userId, hasCallback: !!keyboardInteractiveFinish, - hasCode: !!passwordData?.code, - }, - ); - ws.send( - JSON.stringify({ - type: "error", - message: "Password authentication state lost. Please reconnect.", - }), - ); - } - break; - } - - case "warpgate_auth_continue": { - if (keyboardInteractiveFinish) { - if (warpgateAuthTimeout) { - clearTimeout(warpgateAuthTimeout); - warpgateAuthTimeout = null; - } - keyboardInteractiveFinish([""]); - keyboardInteractiveFinish = null; - warpgateAuthPromptSent = false; - } - break; - } - - case "reconnect_with_credentials": { - const credentialsData = data as { - cols: number; - rows: number; - hostConfig: ConnectToHostData["hostConfig"]; - password?: string; - sshKey?: string; - keyPassword?: string; - }; - - if (credentialsData.password) { - credentialsData.hostConfig.password = credentialsData.password; - credentialsData.hostConfig.authType = "password"; - ( - credentialsData.hostConfig as Record - ).userProvidedPassword = true; - } else if (credentialsData.sshKey) { - credentialsData.hostConfig.key = credentialsData.sshKey; - credentialsData.hostConfig.keyPassword = credentialsData.keyPassword; - credentialsData.hostConfig.authType = "key"; - } else if (credentialsData.keyPassword) { - credentialsData.hostConfig.keyPassword = credentialsData.keyPassword; - } - - isAwaitingAuthCredentials = false; - if (currentSessionId) { - sessionManager.destroySession(currentSessionId); - currentSessionId = null; - } - cleanupAuthState(); - sshConn = null; - sshStream = null; - - const reconnectData: ConnectToHostData = { - cols: credentialsData.cols, - rows: credentialsData.rows, - hostConfig: credentialsData.hostConfig, - }; - - handleConnectToHost(reconnectData).catch((error) => { - const errMsg = getErrorMessage(error); - if ( - errMsg.includes("Cannot parse privateKey") && - errMsg.includes("no passphrase") - ) { - isAwaitingAuthCredentials = true; + hasCode: !!totpData?.code, + }); ws.send( JSON.stringify({ - type: "passphrase_required", - message: - "The SSH key is encrypted. Please enter the passphrase to unlock it.", + type: "error", + message: "TOTP authentication state lost. Please reconnect.", }), ); - return; } - sshLogger.error("Failed to reconnect with credentials", error, { - operation: "ssh_reconnect_with_credentials", - userId, - hostId: credentialsData.hostConfig?.id, - ip: credentialsData.hostConfig?.ip, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "Failed to connect with provided credentials: " + errMsg, - }), - ); - }); - break; - } + break; + } - case "opkssh_start_auth": { - const opksshData = data as { hostId: number }; - try { - const { startOPKSSHAuth } = await import("../opkssh-auth.js"); - const { getRequestOrigin } = - await import("../../utils/request-origin.js"); - const host = - await createCurrentHostResolutionRepository().findHostById( - opksshData.hostId, - userId, - ); - if (!host) { - sshLogger.error( - `Host ${opksshData.hostId} not found for OPKSSH auth`, + case "password_response": { + const passwordData = data as TOTPResponseData; + if (keyboardInteractiveFinish && passwordData?.code) { + if (totpTimeout) { + clearTimeout(totpTimeout); + totpTimeout = null; + } + const password = passwordData.code; + keyboardInteractiveFinish([password]); + keyboardInteractiveFinish = null; + } else { + sshLogger.warn( + "Password response received but no callback available", { - operation: "opkssh_start_auth_host_not_found", + operation: "password_response_error", userId, - hostId: opksshData.hostId, + hasCallback: !!keyboardInteractiveFinish, + hasCode: !!passwordData?.code, }, ); ws.send( JSON.stringify({ - type: "opkssh_error", - requestId: "", - error: "Host not found", + type: "error", + message: + "Password authentication state lost. Please reconnect.", + }), + ); + } + break; + } + + case "warpgate_auth_continue": { + if (keyboardInteractiveFinish) { + if (warpgateAuthTimeout) { + clearTimeout(warpgateAuthTimeout); + warpgateAuthTimeout = null; + } + keyboardInteractiveFinish([""]); + keyboardInteractiveFinish = null; + warpgateAuthPromptSent = false; + } + break; + } + + case "reconnect_with_credentials": { + const credentialsData = data as { + cols: number; + rows: number; + hostConfig: ConnectToHostData["hostConfig"]; + password?: string; + sshKey?: string; + keyPassword?: string; + }; + + if (!credentialsData?.hostConfig) { + ws.send( + JSON.stringify({ + type: "error", + message: "Missing host configuration", }), ); break; } - const hostname = host.name || host.ip; - const requestOrigin = getRequestOrigin(req); - await startOPKSSHAuth( - userId, - opksshData.hostId, - hostname, - ws, - requestOrigin, - ); - } catch (error) { - sshLogger.error("Failed to start OPKSSH auth", error, { - operation: "opkssh_start_auth_error", - userId, - hostId: opksshData.hostId, - }); - ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId: "", - error: "Failed to start OPKSSH authentication", - }), - ); - } - break; - } - case "opkssh_cancel": { - const cancelData = data as { requestId: string }; - try { - const { cancelAuthSession } = await import("../opkssh-auth.js"); - cancelAuthSession(cancelData.requestId); - resetConnectionState(); - } catch (error) { - sshLogger.error("Failed to cancel OPKSSH auth", error, { - operation: "opkssh_cancel_error", - userId, - }); - } - break; - } + if (credentialsData.password) { + credentialsData.hostConfig.password = credentialsData.password; + credentialsData.hostConfig.authType = "password"; + ( + credentialsData.hostConfig as Record + ).userProvidedPassword = true; + } else if (credentialsData.sshKey) { + credentialsData.hostConfig.key = credentialsData.sshKey; + credentialsData.hostConfig.keyPassword = + credentialsData.keyPassword; + credentialsData.hostConfig.authType = "key"; + } else if (credentialsData.keyPassword) { + credentialsData.hostConfig.keyPassword = + credentialsData.keyPassword; + } - case "opkssh_browser_opened": { - break; - } + isAwaitingAuthCredentials = false; + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(); + sshConn = null; + sshStream = null; - case "opkssh_auth_completed": { - const completedData = data as { - hostId: number; - cols?: number; - rows?: number; - hostConfig?: ConnectToHostData["hostConfig"]; - }; + const reconnectData: ConnectToHostData = { + cols: credentialsData.cols, + rows: credentialsData.rows, + hostConfig: credentialsData.hostConfig, + }; - resetConnectionState(); - - const reconnectConfig: ConnectToHostData = { - cols: completedData.cols || 80, - rows: completedData.rows || 24, - hostConfig: - completedData.hostConfig || - ({ - id: completedData.hostId, - ip: "", - port: 22, - username: "", + handleConnectToHost(reconnectData).catch((error) => { + const errMsg = getErrorMessage(error); + if ( + errMsg.includes("Cannot parse privateKey") && + errMsg.includes("no passphrase") + ) { + isAwaitingAuthCredentials = true; + ws.send( + JSON.stringify({ + type: "passphrase_required", + message: + "The SSH key is encrypted. Please enter the passphrase to unlock it.", + }), + ); + return; + } + sshLogger.error("Failed to reconnect with credentials", error, { + operation: "ssh_reconnect_with_credentials", userId, - } as ConnectToHostData["hostConfig"]), - }; - - handleConnectToHost(reconnectConfig).catch((error) => { - sshLogger.error("Failed to reconnect after OPKSSH auth", error, { - operation: "opkssh_reconnect_error", - userId, - hostId: completedData.hostId, + hostId: credentialsData.hostConfig?.id, + ip: credentialsData.hostConfig?.ip, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Failed to connect with provided credentials: " + errMsg, + }), + ); }); - ws.send( - JSON.stringify({ - type: "error", - message: - "Failed to connect after authentication: " + - getErrorMessage(error), - }), - ); - }); - break; - } + break; + } - case "vault_start_auth": { - const vaultData = data as { hostId: number }; - try { - const { loadVaultProfileForHost, startVaultAuth } = - await import("../vault-oidc-auth.js"); - const { getRequestOrigin } = - await import("../../utils/request-origin.js"); - const profile = await loadVaultProfileForHost( - vaultData.hostId, - userId, - ); - if (!profile) { + case "opkssh_start_auth": { + const opksshData = data as { hostId: number }; + try { + const { startOPKSSHAuth } = await import("../opkssh-auth.js"); + const { getRequestOrigin } = + await import("../../utils/request-origin.js"); + const host = + await createCurrentHostResolutionRepository().findHostById( + opksshData.hostId, + userId, + ); + if (!host) { + sshLogger.error( + `Host ${opksshData.hostId} not found for OPKSSH auth`, + { + operation: "opkssh_start_auth_host_not_found", + userId, + hostId: opksshData.hostId, + }, + ); + ws.send( + JSON.stringify({ + type: "opkssh_error", + requestId: "", + error: "Host not found", + }), + ); + break; + } + const hostname = host.name || host.ip; + const requestOrigin = getRequestOrigin(req); + await startOPKSSHAuth( + userId, + opksshData.hostId, + hostname, + ws, + requestOrigin, + ); + } catch (error) { + sshLogger.error("Failed to start OPKSSH auth", error, { + operation: "opkssh_start_auth_error", + userId, + hostId: opksshData.hostId, + }); + ws.send( + JSON.stringify({ + type: "opkssh_error", + requestId: "", + error: "Failed to start OPKSSH authentication", + }), + ); + } + break; + } + + case "opkssh_cancel": { + const cancelData = data as { requestId: string }; + try { + const { cancelAuthSession } = await import("../opkssh-auth.js"); + cancelAuthSession(cancelData.requestId); + resetConnectionState(); + } catch (error) { + sshLogger.error("Failed to cancel OPKSSH auth", error, { + operation: "opkssh_cancel_error", + userId, + }); + } + break; + } + + case "opkssh_browser_opened": { + break; + } + + case "opkssh_auth_completed": { + const completedData = data as { + hostId: number; + cols?: number; + rows?: number; + hostConfig?: ConnectToHostData["hostConfig"]; + }; + + resetConnectionState(); + + const reconnectConfig: ConnectToHostData = { + cols: completedData.cols || 80, + rows: completedData.rows || 24, + hostConfig: + completedData.hostConfig || + ({ + id: completedData.hostId, + ip: "", + port: 22, + username: "", + userId, + } as ConnectToHostData["hostConfig"]), + }; + + handleConnectToHost(reconnectConfig).catch((error) => { + sshLogger.error("Failed to reconnect after OPKSSH auth", error, { + operation: "opkssh_reconnect_error", + userId, + hostId: completedData.hostId, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Failed to connect after authentication: " + + getErrorMessage(error), + }), + ); + }); + break; + } + + case "vault_start_auth": { + const vaultData = data as { hostId: number }; + try { + const { loadVaultProfileForHost, startVaultAuth } = + await import("../vault-oidc-auth.js"); + const { getRequestOrigin } = + await import("../../utils/request-origin.js"); + const profile = await loadVaultProfileForHost( + vaultData.hostId, + userId, + ); + if (!profile) { + ws.send( + JSON.stringify({ + type: "vault_error", + hostId: vaultData.hostId, + error: "No Vault signer profile configured for this host", + }), + ); + break; + } + const requestOrigin = getRequestOrigin(req); + await startVaultAuth( + userId, + vaultData.hostId, + profile, + ws, + requestOrigin, + ); + } catch (error) { + sshLogger.error("Failed to start Vault auth", error, { + operation: "vault_start_auth_error", + userId, + hostId: vaultData.hostId, + }); ws.send( JSON.stringify({ type: "vault_error", hostId: vaultData.hostId, - error: "No Vault signer profile configured for this host", + error: getErrorMessage( + error, + "Failed to start Vault authentication", + ), }), ); - break; } - const requestOrigin = getRequestOrigin(req); - await startVaultAuth( - userId, - vaultData.hostId, - profile, - ws, - requestOrigin, - ); - } catch (error) { - sshLogger.error("Failed to start Vault auth", error, { - operation: "vault_start_auth_error", - userId, - hostId: vaultData.hostId, - }); - ws.send( - JSON.stringify({ - type: "vault_error", - hostId: vaultData.hostId, - error: getErrorMessage( - error, - "Failed to start Vault authentication", - ), - }), - ); + break; } - break; - } - case "vault_cancel": { - const cancelData = data as { hostId: number }; - try { - const { cancelVaultAuthByHost } = - await import("../vault-oidc-auth.js"); - cancelVaultAuthByHost(userId, cancelData.hostId); + case "vault_cancel": { + const cancelData = data as { hostId: number }; + try { + const { cancelVaultAuthByHost } = + await import("../vault-oidc-auth.js"); + cancelVaultAuthByHost(userId, cancelData.hostId); + resetConnectionState(); + } catch (error) { + sshLogger.error("Failed to cancel Vault auth", error, { + operation: "vault_cancel_error", + userId, + }); + } + break; + } + + case "vault_auth_completed": { + const completedData = data as { + hostId: number; + cols?: number; + rows?: number; + hostConfig?: ConnectToHostData["hostConfig"]; + }; + resetConnectionState(); - } catch (error) { - sshLogger.error("Failed to cancel Vault auth", error, { - operation: "vault_cancel_error", - userId, - }); - } - break; - } - case "vault_auth_completed": { - const completedData = data as { - hostId: number; - cols?: number; - rows?: number; - hostConfig?: ConnectToHostData["hostConfig"]; - }; + const reconnectConfig: ConnectToHostData = { + cols: completedData.cols || 80, + rows: completedData.rows || 24, + hostConfig: + completedData.hostConfig || + ({ + id: completedData.hostId, + ip: "", + port: 22, + username: "", + userId, + } as ConnectToHostData["hostConfig"]), + }; - resetConnectionState(); - - const reconnectConfig: ConnectToHostData = { - cols: completedData.cols || 80, - rows: completedData.rows || 24, - hostConfig: - completedData.hostConfig || - ({ - id: completedData.hostId, - ip: "", - port: 22, - username: "", + handleConnectToHost(reconnectConfig).catch((error) => { + sshLogger.error("Failed to reconnect after Vault auth", error, { + operation: "vault_reconnect_error", userId, - } as ConnectToHostData["hostConfig"]), - }; - - handleConnectToHost(reconnectConfig).catch((error) => { - sshLogger.error("Failed to reconnect after Vault auth", error, { - operation: "vault_reconnect_error", - userId, - hostId: completedData.hostId, + hostId: completedData.hostId, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Failed to connect after authentication: " + + getErrorMessage(error), + }), + ); }); - ws.send( - JSON.stringify({ - type: "error", - message: - "Failed to connect after authentication: " + - getErrorMessage(error), - }), - ); - }); - break; - } - - case "joinSharedSession": { - const joinData = data as { shareId: string; tabInstanceId?: string }; - try { - const shareRepo = createCurrentSessionShareRepository(); - const share = await shareRepo.findActiveById(joinData.shareId); - if ( - !share || - share.shareType !== "user" || - share.targetUserId !== userId || - share.protocol !== "ssh" - ) { - ws.send( - JSON.stringify({ - type: "error", - message: "Share not found or not accessible", - }), - ); - break; - } - - const { PermissionManager } = - await import("../../utils/permission-manager.js"); - const access = await PermissionManager.getInstance().canAccessHost( - userId, - share.hostId, - "connect", - ); - if (!access.hasAccess) { - ws.send( - JSON.stringify({ - type: "error", - message: "Share not found or not accessible", - }), - ); - break; - } - - const joinedSession = sessionManager.joinAsParticipant( - share.sessionId, - ws, - { - userId, - permissionLevel: share.permissionLevel as - "read-write" | "read-only", - tabInstanceId: joinData.tabInstanceId, - shareId: share.id, - }, - ); - if (!joinedSession) { - ws.send( - JSON.stringify({ - type: "error", - message: "Shared session is no longer active", - }), - ); - break; - } - - currentSessionId = share.sessionId; - sshStream = joinedSession.sshStream; - sshConn = joinedSession.sshConn; - isConnecting = false; - isConnected = true; - - shareRepo.touchShareUsage(share.id).catch(() => {}); - shareRepo - .recordParticipantJoin(share.id, userId, null) - .catch(() => {}); - - const buffered = sessionManager.getBuffer(joinedSession); - if (buffered) { - ws.send(JSON.stringify({ type: "data", data: buffered })); - } - ws.send( - JSON.stringify({ - type: "sessionAttached", - sessionId: share.sessionId, - }), - ); - ws.send( - JSON.stringify({ type: "connected", message: "Joined session" }), - ); - } catch (error) { - sshLogger.error("Failed to join shared session", error, { - operation: "terminal_join_shared_session_error", - userId, - shareId: joinData.shareId, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "Failed to join shared session", - }), - ); + break; } - break; - } - default: - sshLogger.warn("Unknown message type received", { - operation: "websocket_message_unknown_type", - userId, - messageType: type, - }); + case "joinSharedSession": { + const joinData = data as { shareId: string; tabInstanceId?: string }; + try { + const shareRepo = createCurrentSessionShareRepository(); + const share = await shareRepo.findActiveById(joinData.shareId); + if ( + !share || + share.shareType !== "user" || + share.targetUserId !== userId || + share.protocol !== "ssh" + ) { + ws.send( + JSON.stringify({ + type: "error", + message: "Share not found or not accessible", + }), + ); + break; + } + + const { PermissionManager } = + await import("../../utils/permission-manager.js"); + const access = await PermissionManager.getInstance().canAccessHost( + userId, + share.hostId, + "connect", + ); + if (!access.hasAccess) { + ws.send( + JSON.stringify({ + type: "error", + message: "Share not found or not accessible", + }), + ); + break; + } + + const joinedSession = sessionManager.joinAsParticipant( + share.sessionId, + ws, + { + userId, + permissionLevel: share.permissionLevel as + "read-write" | "read-only", + tabInstanceId: joinData.tabInstanceId, + shareId: share.id, + }, + ); + if (!joinedSession) { + ws.send( + JSON.stringify({ + type: "error", + message: "Shared session is no longer active", + }), + ); + break; + } + + currentSessionId = share.sessionId; + sshStream = joinedSession.sshStream; + sshConn = joinedSession.sshConn; + isConnecting = false; + isConnected = true; + + shareRepo.touchShareUsage(share.id).catch(() => {}); + shareRepo + .recordParticipantJoin(share.id, userId, null) + .catch(() => {}); + + const buffered = sessionManager.getBuffer(joinedSession); + if (buffered) { + ws.send(JSON.stringify({ type: "data", data: buffered })); + } + ws.send( + JSON.stringify({ + type: "sessionAttached", + sessionId: share.sessionId, + }), + ); + ws.send( + JSON.stringify({ type: "connected", message: "Joined session" }), + ); + } catch (error) { + sshLogger.error("Failed to join shared session", error, { + operation: "terminal_join_shared_session_error", + userId, + shareId: joinData.shareId, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to join shared session", + }), + ); + } + break; + } + + default: + sshLogger.warn("Unknown message type received", { + operation: "websocket_message_unknown_type", + userId, + messageType: type, + }); + } + } catch (error) { + // A malformed payload must never escape into the process-level + // unhandledRejection handler, which exits the server. + sshLogger.error("Error handling WebSocket message", error, { + operation: "websocket_message_handler_error", + userId, + messageType: type, + }); + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to process message", + }), + ); + } } }); @@ -1542,16 +1585,17 @@ wss.on("connection", async (ws: WebSocket, req) => { } if (resolvedHostData) { - if ( - (!hostConfig.jumpHosts || hostConfig.jumpHosts.length === 0) && - resolvedHostData.jumpHosts && - resolvedHostData.jumpHosts.length > 0 - ) { - hostConfig.jumpHosts = resolvedHostData.jumpHosts; + const resolvedJumpHosts = resolveServerJumpHosts( + hostConfig.jumpHosts, + resolvedHostData.jumpHosts, + hostSyncId, + ); + if (resolvedJumpHosts !== hostConfig.jumpHosts) { + hostConfig.jumpHosts = resolvedJumpHosts; sendLog( "jump", "info", - `Loaded ${resolvedHostData.jumpHosts.length} jump host(s) from server-side host data`, + `Loaded ${resolvedJumpHosts?.length ?? 0} jump host(s) from server-side host data`, ); } @@ -3019,15 +3063,14 @@ wss.on("connection", async (ws: WebSocket, req) => { } } else if (resolvedCredentials.authType === "agent") { sendLog("auth", "info", "Using SSH agent authentication"); - const result = await resolveAgentSocket( + const result = await applyAgentAuth( + connectConfig, hostConfig.terminalConfig as Record | undefined, ); if ("error" in result) { ws.send(JSON.stringify({ type: "error", message: result.error })); return; } - const { createAgent } = ssh2Pkg; - connectConfig.agent = createAgent(result.socketPath); sendLog( "auth", "info", @@ -3245,7 +3288,10 @@ wss.on("connection", async (ws: WebSocket, req) => { ws.send( JSON.stringify({ type: "error", - message: "Failed to connect through jump hosts", + message: + error instanceof JumpHostChainError + ? `Failed to connect through jump hosts: ${error.message}` + : "Failed to connect through jump hosts", }), ); if (currentSessionId) { @@ -3314,19 +3360,21 @@ wss.on("connection", async (ws: WebSocket, req) => { } function handleResize(data: ResizeData) { + const cols = toTerminalDimension(data?.cols); + const rows = toTerminalDimension(data?.rows); + if (!cols || !rows) return; + const resizeStream = sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; if (resizeStream && resizeStream.setWindow) { - resizeStream.setWindow(data.rows, data.cols, data.rows, data.cols); + resizeStream.setWindow(rows, cols, rows, cols); const session = sessionManager.getSession(currentSessionId); if (session) { - session.cols = data.cols; - session.rows = data.rows; - sessionManager.bufferResize(session.id, data.cols, data.rows); + session.cols = cols; + session.rows = rows; + sessionManager.bufferResize(session.id, cols, rows); } - ws.send( - JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }), - ); + ws.send(JSON.stringify({ type: "resized", cols, rows })); } } diff --git a/src/backend/hosts/tmux/monitor-helpers.ts b/src/backend/hosts/tmux/monitor-helpers.ts index b1b12e9b..e2197d50 100644 --- a/src/backend/hosts/tmux/monitor-helpers.ts +++ b/src/backend/hosts/tmux/monitor-helpers.ts @@ -177,8 +177,8 @@ export function buildPaneMetrics( const treePids: number[] = []; const queue = [pane.pid]; const seen = new Set(); - while (queue.length > 0) { - const pid = queue.shift()!; + for (let cursor = 0; cursor < queue.length; cursor++) { + const pid = queue[cursor]; if (seen.has(pid)) continue; seen.add(pid); if (byPid.has(pid)) treePids.push(pid); @@ -225,9 +225,19 @@ export function attachPanesToWindows( windows: Map, panes: RawPane[], ): void { + const windowsBySessionAndIndex = new Map>(); + for (const [sessionName, sessionWindows] of windows) { + const byIndex = new Map(); + for (const window of sessionWindows) { + if (!byIndex.has(window.index)) byIndex.set(window.index, window); + } + windowsBySessionAndIndex.set(sessionName, byIndex); + } + for (const pane of panes) { - const sessionWindows = windows.get(pane.sessionName) || []; - const window = sessionWindows.find((w) => w.index === pane.windowIndex); + const window = windowsBySessionAndIndex + .get(pane.sessionName) + ?.get(pane.windowIndex); if (window) { const { sessionName: _s, windowIndex: _w, ...paneFields } = pane; window.panes.push(paneFields); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 5f154114..aa78c636 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -384,18 +384,36 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { } }); + // A single bad request must not take the server down. Exit only on errors + // that leave the process genuinely unusable; log and keep serving + // otherwise, since these are almost always scoped to one connection. + const isFatalError = (error: unknown): boolean => { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === "ERR_WORKER_OUT_OF_MEMORY") return true; + if (error instanceof RangeError) { + return /call stack|heap out of memory/i.test(error.message); + } + return false; + }; + process.on("uncaughtException", (error) => { systemLogger.error("Uncaught exception occurred", error, { operation: "error_handling", + fatal: isFatalError(error), }); - process.exit(1); + if (isFatalError(error)) { + process.exit(1); + } }); process.on("unhandledRejection", (reason) => { systemLogger.error("Unhandled promise rejection", reason, { operation: "error_handling", + fatal: isFatalError(reason), }); - process.exit(1); + if (isFatalError(reason)) { + process.exit(1); + } }); } catch (error) { systemLogger.error("Failed to initialize backend services", error, { diff --git a/src/backend/tests/automations/engine.test.ts b/src/backend/tests/automations/engine.test.ts index c4dd3f0a..85782b77 100644 --- a/src/backend/tests/automations/engine.test.ts +++ b/src/backend/tests/automations/engine.test.ts @@ -47,6 +47,12 @@ const repository = { }), }; +const resolveHostById = vi.fn(); + +vi.mock("../../hosts/host-resolver.js", () => ({ + resolveHostById: (...args: unknown[]) => resolveHostById(...args), +})); + vi.mock("../../database/repositories/factory.js", () => ({ createCurrentAutomationRepository: () => repository, })); @@ -93,12 +99,41 @@ beforeEach(() => { nextRunId = 1; nextStepRowId = 1; vi.clearAllMocks(); + resolveHostById.mockResolvedValue(null); executeStep.mockResolvedValue({ success: true, output: "ok" }); // The singleton carries in-flight state between tests. (AutomationEngine as unknown as { instance?: unknown }).instance = undefined; }); describe("AutomationEngine.run", () => { + it("adds the trigger host name to the template context", async () => { + defineAutomation([step({ id: "notify", type: "notify" })]); + resolveHostById.mockResolvedValue({ + name: "Proxmox Node", + ip: "10.0.0.11", + username: "root", + port: 22, + }); + + await AutomationEngine.getInstance().run({ + automationId: 1, + triggerType: "metric_threshold", + triggerHostId: 11, + triggerContext: { hostId: 11, value: 97 }, + }); + + const context = executeStep.mock.calls[0][1] as { + template: { + host: { id: number; name: string }; + trigger: { hostName: string }; + }; + }; + expect(context.template.host).toMatchObject({ + id: 11, + name: "Proxmox Node", + }); + expect(context.template.trigger.hostName).toBe("Proxmox Node"); + }); it("runs steps in order and records each one", async () => { defineAutomation([ step({ id: "a", type: "run_command" }), diff --git a/src/backend/tests/automations/notify.test.ts b/src/backend/tests/automations/notify.test.ts new file mode 100644 index 00000000..d481dfd0 --- /dev/null +++ b/src/backend/tests/automations/notify.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const automationFetch = vi.fn(); + +vi.mock("../../automations/http.js", () => ({ + automationFetch: (...args: unknown[]) => automationFetch(...args), +})); + +const { sendAutomationNotification } = + await import("../../automations/notify.js"); + +beforeEach(() => { + automationFetch.mockReset(); + automationFetch.mockResolvedValue({ ok: true }); +}); + +describe("sendAutomationNotification", () => { + it("keeps alert-compatible host and rule fields in webhook payloads", async () => { + await sendAutomationNotification( + { id: 1, type: "webhook", config: '{"url":"https://example.com"}' }, + { + title: "CPU warning", + body: "cpu.percent is at 97", + severity: "warning", + context: { + host: { id: 11, name: "Proxmox Node" }, + trigger: { value: 97, threshold: 90 }, + run: { automationId: 42 }, + }, + }, + ); + + const options = automationFetch.mock.calls[0][1] as RequestInit; + expect(JSON.parse(options.body as string)).toMatchObject({ + hostName: "Proxmox Node", + hostId: 11, + ruleName: "CPU warning", + ruleId: 42, + value: 97, + threshold: 90, + message: "cpu.percent is at 97", + }); + }); +}); diff --git a/src/backend/tests/database/repositories/host-repository.test.ts b/src/backend/tests/database/repositories/host-repository.test.ts index 468c481c..1ff9922b 100644 --- a/src/backend/tests/database/repositories/host-repository.test.ts +++ b/src/backend/tests/database/repositories/host-repository.test.ts @@ -1,7 +1,8 @@ import { sql } from "drizzle-orm"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { HostRepository } from "../../../database/repositories/host-repository.js"; +import { DataCrypto } from "../../../utils/data-crypto.js"; describe("HostRepository.reorderForUser", () => { let adapter: TestSqliteDatabase | null = null; @@ -72,3 +73,100 @@ describe("HostRepository.reorderForUser", () => { await expect(repo.reorderForUser("user-1", [])).resolves.toBe(0); }); }); + +describe("HostRepository Proxmox sync inserts", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + vi.restoreAllMocks(); + await adapter?.close(); + adapter = null; + }); + + it("creates a discovered guest using the scheduled-sync payload", async () => { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + await adapter.exec(` + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'); + INSERT INTO ssh_credentials (id, user_id, name, auth_type, username) + VALUES (7, 'user-1', 'guest key', 'key', 'alice'); + `); + const repository = new HostRepository(context); + const now = new Date().toISOString(); + vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue( + Buffer.alloc(32, 1), + ); + + const created = await repository.createEncryptedForUser("user-1", { + userId: "user-1", + name: "guest", + ip: "10.0.0.8", + port: 22, + username: "", + connectionType: "ssh", + folder: "Proxmox", + tags: "proxmox,qemu,node-1,vm-100", + proxmoxConfig: JSON.stringify({ + source: { + source: "proxmox", + sourceHostId: 1, + node: "node-1", + vmid: 100, + type: "qemu", + }, + }), + updatedAt: now, + createdAt: now, + pin: false, + authType: "credential", + credentialId: 7, + overrideCredentialUsername: 0, + password: null, + key: null, + keyPassword: null, + keyType: null, + enableTerminal: true, + enableFileManager: true, + enableTunnel: true, + enableDocker: false, + enableSsh: true, + enableRdp: false, + rdpUser: null, + rdpPassword: null, + rdpDomain: null, + rdpSecurity: null, + rdpIgnoreCert: 0, + rdpPort: null, + vncUser: null, + vncPassword: null, + vncPort: null, + telnetUser: null, + telnetPassword: null, + telnetPort: null, + defaultPath: "/", + tunnelConnections: "[]", + jumpHosts: null, + quickActions: null, + statsConfig: null, + dockerConfig: null, + terminalConfig: null, + forceKeyboardInteractive: "false", + useSocks5: 0, + socks5Host: null, + socks5Port: null, + socks5Username: null, + socks5Password: null, + socks5ProxyChain: null, + portKnockSequence: null, + showTerminalInSidebar: 0, + showFileManagerInSidebar: 0, + showTunnelInSidebar: 0, + showDockerInSidebar: 0, + showServerStatsInSidebar: 0, + }); + + expect(created.username).toBe(""); + expect(created.credentialId).toBe(7); + }); +}); diff --git a/src/backend/tests/database/repositories/sqlite-foreign-keys.test.ts b/src/backend/tests/database/repositories/sqlite-foreign-keys.test.ts new file mode 100644 index 00000000..9e674ea3 --- /dev/null +++ b/src/backend/tests/database/repositories/sqlite-foreign-keys.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const factory = vi.hoisted(() => ({ + getCurrentRepositorySqlite: vi.fn(), +})); + +vi.mock("../../../database/repositories/factory.js", () => factory); + +import { + withCurrentSqliteForeignKeysDisabled, + withSqliteForeignKeysDisabled, +} from "../../../database/repositories/sqlite-foreign-keys.js"; + +const previousDatabaseDialect = process.env.DATABASE_DIALECT; + +afterEach(() => { + if (previousDatabaseDialect === undefined) + delete process.env.DATABASE_DIALECT; + else process.env.DATABASE_DIALECT = previousDatabaseDialect; + vi.clearAllMocks(); +}); + +describe("withSqliteForeignKeysDisabled", () => { + it("restores foreign keys after an import", async () => { + const sqlite = { exec: vi.fn() }; + + await expect( + withSqliteForeignKeysDisabled(sqlite, async () => "imported"), + ).resolves.toBe("imported"); + expect(sqlite.exec.mock.calls).toEqual([ + ["PRAGMA foreign_keys = OFF"], + ["PRAGMA foreign_keys = ON"], + ]); + }); +}); + +describe("withCurrentSqliteForeignKeysDisabled", () => { + it.each(["postgres", "mysql"])( + "runs portable imports with constraints enabled on %s", + async (dialect) => { + process.env.DATABASE_DIALECT = dialect; + const operation = vi.fn().mockResolvedValue("imported"); + + await expect( + withCurrentSqliteForeignKeysDisabled(operation), + ).resolves.toBe("imported"); + expect(operation).toHaveBeenCalledOnce(); + expect(factory.getCurrentRepositorySqlite).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/backend/tests/database/routes/proxmox-import-auth.test.ts b/src/backend/tests/database/routes/proxmox-import-auth.test.ts index 0d40493f..47d62acc 100644 --- a/src/backend/tests/database/routes/proxmox-import-auth.test.ts +++ b/src/backend/tests/database/routes/proxmox-import-auth.test.ts @@ -9,7 +9,7 @@ describe("resolveProxmoxImportAuth", () => { expect(resolveProxmoxImportAuth("key", 7)).toEqual({ authType: "credential", credentialId: 7, - overrideCredentialUsername: 1, + overrideCredentialUsername: 0, }); }); @@ -17,7 +17,7 @@ describe("resolveProxmoxImportAuth", () => { expect(resolveProxmoxImportAuth("password", 7)).toEqual({ authType: "credential", credentialId: 7, - overrideCredentialUsername: 1, + overrideCredentialUsername: 0, }); }); @@ -35,7 +35,7 @@ describe("resolveProxmoxImportAuth", () => { expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({ authType: "credential", credentialId: 42, - overrideCredentialUsername: 1, + overrideCredentialUsername: 0, }); expect(resolveProxmoxImportAuth(undefined, null)).toEqual({ authType: "none", diff --git a/src/backend/tests/database/routes/sso-provider-routes.test.ts b/src/backend/tests/database/routes/sso-provider-routes.test.ts new file mode 100644 index 00000000..292433fe --- /dev/null +++ b/src/backend/tests/database/routes/sso-provider-routes.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ createAdminMiddleware: vi.fn() }), + }, +})); + +const { isValidOidcIssuer } = + await import("../../../database/routes/sso-provider-routes.js"); + +describe("isValidOidcIssuer", () => { + it("rejects userinfo endpoints used as issuer URLs", () => { + expect( + isValidOidcIssuer("https://auth.example/application/o/userinfo/"), + ).toBe(false); + }); + + it("accepts an Authentik application issuer", () => { + expect(isValidOidcIssuer("https://auth.example/application/o/termix")).toBe( + true, + ); + }); +}); diff --git a/src/backend/tests/database/routes/user-oidc-utils.test.ts b/src/backend/tests/database/routes/user-oidc-utils.test.ts index d56aeff7..55781a73 100644 --- a/src/backend/tests/database/routes/user-oidc-utils.test.ts +++ b/src/backend/tests/database/routes/user-oidc-utils.test.ts @@ -21,6 +21,7 @@ const { resolveOidcMappedRoles, verifyOIDCToken, describeFetchFailure, + isOIDCEnvOverrideEnabled, } = await import("../../../database/routes/user-oidc-utils.js"); const BACKCHANNEL_LOGOUT_EVENT = @@ -281,6 +282,7 @@ describe("getOIDCConfigFromEnv", () => { "OIDC_SCOPES", "OIDC_ALLOWED_USERS", "OIDC_ADMIN_GROUP", + "OIDC_ENV_OVERRIDE", ]; const saved: Record = {}; @@ -334,6 +336,12 @@ describe("getOIDCConfigFromEnv", () => { expect(config?.identifier_path).toBe("email"); expect(config?.scopes).toBe("openid"); }); + + it("only enables database recovery override when explicitly requested", () => { + expect(isOIDCEnvOverrideEnabled()).toBe(false); + process.env.OIDC_ENV_OVERRIDE = "true"; + expect(isOIDCEnvOverrideEnabled()).toBe(true); + }); }); describe("extractOidcGroups", () => { diff --git a/src/backend/tests/electron/local-shell.test.ts b/src/backend/tests/electron/local-shell.test.ts new file mode 100644 index 00000000..d8f84c12 --- /dev/null +++ b/src/backend/tests/electron/local-shell.test.ts @@ -0,0 +1,35 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { resolveLocalShell } = + require("../../../../electron/local-shell.cjs") as { + resolveLocalShell: ( + platform: NodeJS.Platform, + requestedShell?: string, + env?: NodeJS.ProcessEnv, + ) => { file: string; args: string[] }; + }; + +describe("resolveLocalShell", () => { + it("starts the default WSL distribution without PowerShell arguments", () => { + expect(resolveLocalShell("win32", "wsl", {})).toEqual({ + file: "wsl.exe", + args: [], + }); + }); + + it("keeps PowerShell as the default Windows shell", () => { + expect(resolveLocalShell("win32", "default", {})).toEqual({ + file: "powershell.exe", + args: ["-NoLogo"], + }); + }); + + it("preserves the configured shell on non-Windows platforms", () => { + expect(resolveLocalShell("linux", "wsl", { SHELL: "/bin/fish" })).toEqual({ + file: "/bin/fish", + args: ["-l"], + }); + }); +}); diff --git a/src/backend/tests/hosts/terminal-agent-auth.test.ts b/src/backend/tests/hosts/terminal-agent-auth.test.ts index 8e5587f5..61e6b1bf 100644 --- a/src/backend/tests/hosts/terminal-agent-auth.test.ts +++ b/src/backend/tests/hosts/terminal-agent-auth.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateKeyPairSync } from "crypto"; +import ssh2Pkg, { type ParsedKey } from "ssh2"; const mockAccess = vi.fn(); @@ -6,7 +8,57 @@ vi.mock("fs/promises", () => ({ access: mockAccess, })); -import { resolveAgentSocket } from "../../hosts/terminal-auth-helpers.js"; +import { + MemoryAgent, + FilteredAgent, + resolveAgentSocket, +} from "../../hosts/terminal-auth-helpers.js"; + +describe("MemoryAgent", () => { + it("serves identities and signatures over the agent protocol", async () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const parsed = ssh2Pkg.utils.parseKey( + privateKey.export({ type: "pkcs1", format: "pem" }), + ); + expect(parsed).not.toBeInstanceOf(Error); + + const agent = new MemoryAgent(parsed as ParsedKey); + const stream = await new Promise( + (resolve, reject) => { + agent.getStream((error, result) => { + if (error || !result) + reject(error ?? new Error("Missing agent stream")); + else resolve(result); + }); + }, + ); + const client = new ssh2Pkg.AgentProtocol(true); + client.pipe(stream).pipe(client); + + const identities = await new Promise((resolve, reject) => { + client.getIdentities((error, keys) => { + if (error || !keys) reject(error ?? new Error("Missing identities")); + else resolve(keys); + }); + }); + expect(identities).toHaveLength(1); + expect(identities[0].getPublicSSH()).toEqual( + (parsed as ParsedKey).getPublicSSH(), + ); + + const data = Buffer.from("forwarded-agent-test"); + const signature = await new Promise((resolve, reject) => { + client.sign(identities[0], data, (error, result) => { + if (error || !result) reject(error ?? new Error("Missing signature")); + else resolve(result); + }); + }); + expect((parsed as ParsedKey).verify(data, signature)).toBe(true); + + client.destroy(); + stream.destroy(); + }); +}); describe("resolveAgentSocket", () => { const originalEnv = process.env.SSH_AUTH_SOCK; @@ -101,3 +153,78 @@ describe("resolveAgentSocket", () => { expect(mockAccess).not.toHaveBeenCalled(); }); }); + +describe("FilteredAgent", () => { + it("only returns identities matching the configured public key", async () => { + const { privateKey: keyA } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + const { privateKey: keyB } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + const parsedA = ssh2Pkg.utils.parseKey( + keyA.export({ type: "pkcs1", format: "pem" }), + ) as ParsedKey; + const parsedB = ssh2Pkg.utils.parseKey( + keyB.export({ type: "pkcs1", format: "pem" }), + ) as ParsedKey; + + const inner = { + getIdentities: (cb: (err: Error | null, keys: ParsedKey[]) => void) => + cb(null, [parsedA, parsedB]), + getStream: vi.fn(), + sign: vi.fn(), + }; + + const filtered = new FilteredAgent( + inner as unknown as ConstructorParameters[0], + parsedB.getPublicSSH(), + ); + + const identities = await new Promise((resolve, reject) => { + filtered.getIdentities((err, keys) => { + if (err || !keys) reject(err ?? new Error("Missing identities")); + else resolve(keys); + }); + }); + + expect(identities).toHaveLength(1); + expect(identities[0].getPublicSSH()).toEqual(parsedB.getPublicSSH()); + }); + + it("returns no identities when nothing matches", async () => { + const { privateKey: keyA } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + const { privateKey: keyB } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + const parsedA = ssh2Pkg.utils.parseKey( + keyA.export({ type: "pkcs1", format: "pem" }), + ) as ParsedKey; + const parsedB = ssh2Pkg.utils.parseKey( + keyB.export({ type: "pkcs1", format: "pem" }), + ) as ParsedKey; + + const inner = { + getIdentities: (cb: (err: Error | null, keys: ParsedKey[]) => void) => + cb(null, [parsedA]), + getStream: vi.fn(), + sign: vi.fn(), + }; + + const filtered = new FilteredAgent( + inner as unknown as ConstructorParameters[0], + parsedB.getPublicSSH(), + ); + + const identities = await new Promise((resolve, reject) => { + filtered.getIdentities((err, keys) => { + if (err || !keys) reject(err ?? new Error("Missing identities")); + else resolve(keys); + }); + }); + + expect(identities).toHaveLength(0); + }); +}); diff --git a/src/backend/tests/hosts/terminal/host-identity.test.ts b/src/backend/tests/hosts/terminal/host-identity.test.ts index d9e1ac4c..31e2d9f8 100644 --- a/src/backend/tests/hosts/terminal/host-identity.test.ts +++ b/src/backend/tests/hosts/terminal/host-identity.test.ts @@ -6,6 +6,7 @@ import { HostAddressMismatchError, HostNotOnThisServerError, normalizeHostAddress, + resolveServerJumpHosts, } from "../../../hosts/terminal/host-identity.js"; /** @@ -59,6 +60,20 @@ describe("hostAddressMismatch", () => { }); }); +describe("resolveServerJumpHosts", () => { + it("uses server-side ids for a sync-delegated connection", () => { + expect( + resolveServerJumpHosts([{ hostId: 7 }], [{ hostId: 42 }], "host-sync-id"), + ).toEqual([{ hostId: 42 }]); + }); + + it("keeps client ids for a local id-based connection", () => { + expect(resolveServerJumpHosts([{ hostId: 7 }], [{ hostId: 42 }])).toEqual([ + { hostId: 7 }, + ]); + }); +}); + describe("HostAddressMismatchError", () => { it("survives the catch blocks that swallow resolution failures", () => { // SFTP host resolution sits inside "failed to resolve credentials, carry diff --git a/src/backend/tests/hosts/tmux/monitor-helpers.test.ts b/src/backend/tests/hosts/tmux/monitor-helpers.test.ts index a40059d5..9e0d06fe 100644 --- a/src/backend/tests/hosts/tmux/monitor-helpers.test.ts +++ b/src/backend/tests/hosts/tmux/monitor-helpers.test.ts @@ -9,6 +9,8 @@ import { buildPaneMetrics, attachPanesToWindows, shellEscape, + type ProcessInfo, + type TmuxWindow, } from "../../../hosts/tmux/monitor-helpers.js"; function join(...fields: (string | number)[]): string { @@ -194,6 +196,28 @@ describe("buildPaneMetrics", () => { const metrics = buildPaneMetrics(pane, cyclic, new Map()); expect(metrics[0].processCount).toBe(2); }); + + it("aggregates a wide process tree without dropping children", () => { + const childCount = 2_000; + const wideTree: ProcessInfo[] = [ + { pid: 1, ppid: 0, cpu: 0, mem: 0, rss: 1, comm: "bash" }, + ...Array.from({ length: childCount }, (_, index) => ({ + pid: index + 2, + ppid: 1, + cpu: 0.1, + mem: 0, + rss: 1, + comm: `worker-${index}`, + })), + ]; + const pane = parsePanes( + join("wide", 0, "%1", 0, 1, 1, 80, 24, "bash", "/", "t"), + ); + + const [metrics] = buildPaneMetrics(pane, wideTree, new Map()); + expect(metrics.processCount).toBe(childCount + 1); + expect(metrics.memRssKb).toBe(childCount + 1); + }); }); describe("attachPanesToWindows", () => { @@ -214,6 +238,29 @@ describe("attachPanesToWindows", () => { expect(windows.get("s1")![0].panes[0].id).toBe("%1"); expect(windows.get("s1")![1].panes[0].id).toBe("%2"); }); + + it("preserves first-match behavior for duplicate window indexes", () => { + const first: TmuxWindow = { + index: 0, + name: "first", + active: true, + panes: [], + }; + const duplicate: TmuxWindow = { + index: 0, + name: "duplicate", + active: false, + panes: [], + }; + const windows = new Map([["s1", [first, duplicate]]]); + const panes = parsePanes( + join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"), + ); + + attachPanesToWindows(windows, panes); + expect(first.panes).toHaveLength(1); + expect(duplicate.panes).toHaveLength(0); + }); }); describe("shellEscape", () => { diff --git a/src/backend/tests/utils/proxy-agent.test.ts b/src/backend/tests/utils/proxy-agent.test.ts new file mode 100644 index 00000000..7f94c66f --- /dev/null +++ b/src/backend/tests/utils/proxy-agent.test.ts @@ -0,0 +1,41 @@ +import { createServer } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { fetchWithProxy } from "../../utils/proxy-agent.js"; + +describe("fetchWithProxy", () => { + const savedProxies = { + HTTP_PROXY: process.env.HTTP_PROXY, + HTTPS_PROXY: process.env.HTTPS_PROXY, + http_proxy: process.env.http_proxy, + https_proxy: process.env.https_proxy, + }; + + afterEach(() => { + for (const [name, value] of Object.entries(savedProxies)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + it("uses a dispatcher compatible with the selected fetch implementation", async () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + const server = createServer((_request, response) => response.end("ok")); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No port"); + const response = await fetchWithProxy(`http://127.0.0.1:${address.port}`); + expect(await response.text()).toBe("ok"); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }); +}); diff --git a/src/backend/tests/utils/ssh-key-utils.test.ts b/src/backend/tests/utils/ssh-key-utils.test.ts index 756dc662..534eb6cc 100644 --- a/src/backend/tests/utils/ssh-key-utils.test.ts +++ b/src/backend/tests/utils/ssh-key-utils.test.ts @@ -4,6 +4,7 @@ import { parseSSHKey, parsePublicKey, preparePrivateKeyForSSH2, + isPrivateKeyPassphraseError, getFriendlyKeyTypeName, validateKeyPair, } from "../../utils/ssh-key-utils.js"; @@ -97,6 +98,22 @@ describe("parseSSHKey", () => { }); }); +describe("isPrivateKeyPassphraseError", () => { + it("recognizes missing and incorrect passphrase errors", () => { + expect( + isPrivateKeyPassphraseError( + new Error( + "Encrypted OpenSSH private key detected, but no passphrase given", + ), + ), + ).toBe(true); + expect(isPrivateKeyPassphraseError(new Error("Bad passphrase"))).toBe(true); + expect( + isPrivateKeyPassphraseError(new Error("Unsupported key format")), + ).toBe(false); + }); +}); + describe("getFriendlyKeyTypeName", () => { it("maps known key types to friendly names", () => { expect(getFriendlyKeyTypeName("ssh-rsa")).toBe("RSA"); diff --git a/src/backend/tests/utils/ws-message.test.ts b/src/backend/tests/utils/ws-message.test.ts new file mode 100644 index 00000000..510ade95 --- /dev/null +++ b/src/backend/tests/utils/ws-message.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { + parseWsMessage, + asObject, + asString, + toTerminalDimension, + WsMessageError, +} from "../../utils/ws-message.js"; + +const frame = (s: string) => Buffer.from(s, "utf8"); + +describe("parseWsMessage", () => { + it("parses a well-formed message", () => { + expect(parseWsMessage(frame('{"type":"ping"}'))).toEqual({ + type: "ping", + data: undefined, + }); + expect(parseWsMessage(frame('{"type":"input","data":"ls"}'))).toEqual({ + type: "input", + data: "ls", + }); + }); + + it("rejects JSON that parses but cannot be destructured", () => { + // The original DoS: JSON.parse("null") succeeds, so it escaped the + // try/catch and threw a TypeError on destructure. + for (const payload of ["null", "123", '"str"', "[1,2]", "true"]) { + expect(() => parseWsMessage(frame(payload))).toThrow(WsMessageError); + } + }); + + it("rejects invalid JSON", () => { + expect(() => parseWsMessage(frame("{oops"))).toThrow(WsMessageError); + expect(() => parseWsMessage(frame(""))).toThrow(WsMessageError); + }); + + it("rejects a missing or non-string type", () => { + expect(() => parseWsMessage(frame("{}"))).toThrow(WsMessageError); + expect(() => parseWsMessage(frame('{"type":5}'))).toThrow(WsMessageError); + expect(() => parseWsMessage(frame('{"type":null}'))).toThrow( + WsMessageError, + ); + }); + + it("rejects oversized frames", () => { + const huge = Buffer.alloc(1024 * 1024 + 1, 0x20); + expect(() => parseWsMessage(huge)).toThrow(WsMessageError); + }); + + it("never throws a TypeError for any malformed input", () => { + const payloads = [ + "null", + "0", + "[]", + "{}", + '{"type":{}}', + '{"data":"x"}', + "undefined", + '{"type":"a","data":null}', + ]; + for (const p of payloads) { + try { + parseWsMessage(frame(p)); + } catch (e) { + expect(e).toBeInstanceOf(WsMessageError); + } + } + }); +}); + +describe("asObject / asString", () => { + it("narrows without throwing", () => { + expect(asObject({ a: 1 })).toEqual({ a: 1 }); + expect(asObject(null)).toEqual({}); + expect(asObject([1])).toEqual({}); + expect(asObject("x")).toEqual({}); + expect(asString("x")).toBe("x"); + expect(asString(5)).toBe(""); + expect(asString(undefined)).toBe(""); + }); +}); + +describe("toTerminalDimension", () => { + it("accepts sane values", () => { + expect(toTerminalDimension(80)).toBe(80); + expect(toTerminalDimension("120")).toBe(120); + expect(toTerminalDimension(24.7)).toBe(24); + }); + + it("rejects values that would poison setWindow", () => { + for (const bad of [0, -1, NaN, Infinity, null, undefined, "abc", {}]) { + expect(toTerminalDimension(bad)).toBe(0); + } + }); + + it("clamps absurdly large values", () => { + expect(toTerminalDimension(1e9)).toBe(10000); + }); +}); diff --git a/src/backend/utils/proxy-agent.ts b/src/backend/utils/proxy-agent.ts index ec9b392e..5eeccf4c 100644 --- a/src/backend/utils/proxy-agent.ts +++ b/src/backend/utils/proxy-agent.ts @@ -1,4 +1,4 @@ -import { Agent, ProxyAgent } from "undici"; +import { Agent, ProxyAgent, fetch as undiciFetch } from "undici"; import type { Dispatcher } from "undici-types"; const directAgent = new Agent({ @@ -39,3 +39,13 @@ export function getProxyAgent(targetUrl?: string): Dispatcher | undefined { export function getFetchDispatcher(targetUrl: string): Dispatcher { return getProxyAgent(targetUrl) ?? (directAgent as unknown as Dispatcher); } + +export function fetchWithProxy( + url: string, + init: RequestInit = {}, +): Promise { + return undiciFetch(url, { + ...init, + dispatcher: getFetchDispatcher(url), + }); +} diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 2c1df5e0..f8f1303b 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -256,6 +256,10 @@ export function preparePrivateKeyForSSH2( return Buffer.from(cleanKey, "utf8"); } +export function isPrivateKeyPassphraseError(error: unknown): boolean { + return /passphrase/i.test(getErrorMessage(error, "")); +} + export function parseSSHKey( privateKeyData: string, passphrase?: string, diff --git a/src/backend/utils/ws-message.ts b/src/backend/utils/ws-message.ts new file mode 100644 index 00000000..483a8a81 --- /dev/null +++ b/src/backend/utils/ws-message.ts @@ -0,0 +1,76 @@ +import type { RawData } from "ws"; + +// Cap on a single decoded text frame. Anything larger is almost certainly +// abuse - the legitimate control messages here are tiny, and terminal input is +// bounded by what a user can type or paste. +const MAX_MESSAGE_BYTES = 1024 * 1024; + +export class WsMessageError extends Error {} + +function rawByteLength(raw: RawData): number { + if (Buffer.isBuffer(raw)) return raw.length; + if (Array.isArray(raw)) + return raw.reduce((sum, part) => sum + part.length, 0); + if (raw instanceof ArrayBuffer) return raw.byteLength; + return 0; +} + +/** + * Parse a WebSocket frame into a plain message object. + * + * Throws WsMessageError - never a TypeError - for anything malformed, so + * callers can reject the frame instead of the parse blowing up the handler. + * `JSON.parse` happily returns null, numbers and arrays, none of which are + * safe to destructure, so the shape is checked here rather than at each site. + */ +export function parseWsMessage(raw: RawData): { + type: string; + data: unknown; +} { + if (rawByteLength(raw) > MAX_MESSAGE_BYTES) { + throw new WsMessageError("Message too large"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw.toString()); + } catch { + throw new WsMessageError("Invalid JSON"); + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new WsMessageError("Message must be a JSON object"); + } + + const { type, data } = parsed as { type?: unknown; data?: unknown }; + if (typeof type !== "string") { + throw new WsMessageError("Message type must be a string"); + } + + return { type, data }; +} + +/** Narrow unknown payload data to a plain object without throwing. */ +export function asObject(data: unknown): Record { + return data !== null && typeof data === "object" && !Array.isArray(data) + ? (data as Record) + : {}; +} + +/** Narrow unknown payload data to a string without throwing. */ +export function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** + * Coerce a client-supplied terminal width/height to a sane integer. + * Returns 0 when the value is unusable, so callers can skip the resize + * rather than pass NaN or a negative into ssh2's setWindow. + */ +export function toTerminalDimension(value: unknown): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return 0; + const rounded = Math.floor(n); + if (rounded < 1) return 0; + return Math.min(rounded, 10000); +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 1d1d62a0..65f9ade1 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -40,7 +40,10 @@ export interface ElectronAPI { getServerConfig: () => Promise; saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>; - testServerConnection: (serverUrl: string) => Promise; + testServerConnection: ( + serverUrl: string, + allowInvalidCertificate?: boolean, + ) => Promise; getC2STunnelConfig: () => Promise; saveC2STunnelConfig: ( config: unknown[], @@ -171,6 +174,7 @@ export interface ElectronAPI { startLocalTerminal(dimensions: { cols: number; rows: number; + shell?: "default" | "wsl"; }): Promise<{ sessionId: string; shell: string }>; readyLocalTerminal(sessionId: string): Promise; writeLocalTerminal(sessionId: string, data: string): Promise; diff --git a/src/types/index.ts b/src/types/index.ts index 44da38fb..dfb8a2c2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -715,6 +715,7 @@ export interface TerminalConfig { linkClickBehavior?: "confirm" | "direct"; useSSHTitle?: boolean; agentSocketPath?: string; + agentIdentity?: string; customThemeColors?: { background: string; foreground: string; diff --git a/src/ui/api/credentials-api.ts b/src/ui/api/credentials-api.ts index 1e8336f4..b0ce7517 100644 --- a/src/ui/api/credentials-api.ts +++ b/src/ui/api/credentials-api.ts @@ -55,6 +55,21 @@ export async function updateCredential( } } +export async function duplicateCredential( + credentialId: number, + data: Record, +): Promise> { + try { + const response = await authApi.post( + `/credentials/${credentialId}/duplicate`, + data, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "duplicate credential"); + } +} + export async function deleteCredential( credentialId: number, ): Promise> { diff --git a/src/ui/api/guacamole-api.ts b/src/ui/api/guacamole-api.ts index 419173a9..a37a48a7 100644 --- a/src/ui/api/guacamole-api.ts +++ b/src/ui/api/guacamole-api.ts @@ -6,6 +6,7 @@ import { } from "@/main-axios"; import type { AxiosInstance } from "axios"; import type { GuacamoleConfig } from "@/types/guacamole-config"; +import { resolveRemoteHostId } from "@/lib/remote-server-api"; /** * The embedded desktop backend does not bundle guacd, which is why @@ -185,10 +186,18 @@ export async function getGuacamoleTokenFromHost( password?: string; domain?: string; }, + syncId?: string | null, ): Promise { try { + const remoteHostId = isElectron() + ? await resolveRemoteHostId(syncId) + : null; + if (isElectron() && syncId && remoteHostId === null) { + throw new Error("The synced host does not exist on the remote server"); + } + const targetHostId = remoteHostId ?? hostId; const response = await guacamoleApi().post( - `/guacamole/connect-host/${hostId}`, + `/guacamole/connect-host/${targetHostId}`, { ...(protocol ? { protocol } : {}), ...(promptedCredentials?.username diff --git a/src/ui/api/ssh-file-operations-api.ts b/src/ui/api/ssh-file-operations-api.ts index 3984829b..1b855d41 100644 --- a/src/ui/api/ssh-file-operations-api.ts +++ b/src/ui/api/ssh-file-operations-api.ts @@ -549,14 +549,26 @@ export async function downloadSSHFile( } } +export interface DownloadProgressEvent { + loaded: number; + total?: number; +} + export async function downloadSSHFileStream( sessionId: string, filePath: string, + onProgress?: (event: DownloadProgressEvent) => void, ): Promise { const response = await getFileManagerApiForSession(sessionId).post( "/ssh/downloadFileStream", { sessionId, path: filePath }, - { responseType: "blob", timeout: 0 }, + { + responseType: "blob", + timeout: 0, + onDownloadProgress: onProgress + ? (event) => onProgress({ loaded: event.loaded, total: event.total }) + : undefined, + }, ); const blob = response.data as Blob; const fileName = filePath.split("/").pop() || "download"; diff --git a/src/ui/api/user-management-api.ts b/src/ui/api/user-management-api.ts index 25e65683..bbf3a16b 100644 --- a/src/ui/api/user-management-api.ts +++ b/src/ui/api/user-management-api.ts @@ -257,6 +257,7 @@ export async function updateOidcAutoProvision( export async function getOidcSilentLoginDefault(): Promise<{ enabled: boolean; + locked?: boolean; }> { try { const response = await authApi.get("/users/oidc-silent-login-default"); diff --git a/src/ui/api/webauthn-api.ts b/src/ui/api/webauthn-api.ts index 5c5d0ae9..19b63877 100644 --- a/src/ui/api/webauthn-api.ts +++ b/src/ui/api/webauthn-api.ts @@ -1,8 +1,14 @@ import type { + AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, } from "@simplewebauthn/browser"; -import { startRegistration } from "@simplewebauthn/browser"; +import { + browserSupportsWebAuthn, + startAuthentication, + startRegistration, +} from "@simplewebauthn/browser"; import { authApi, handleApiError } from "@/main-axios"; export type WebAuthnUserVerification = "discouraged" | "preferred" | "required"; @@ -23,6 +29,53 @@ type RegistrationOptionsResponse = { challengeId: string; }; +type AuthenticationOptionsResponse = { + options: PublicKeyCredentialRequestOptionsJSON; + challengeId: string; +}; + +export type PasskeyLoginResult = { + success: boolean; + requires_totp?: boolean; + temp_token?: string; + is_admin?: boolean; + username?: string; + userId?: string; + is_oidc?: boolean; + totp_enabled?: boolean; + token?: string; +}; + +export function isPasskeySupported(): boolean { + return browserSupportsWebAuthn(); +} + +export async function loginWithPasskey( + username?: string, + rememberMe = false, +): Promise { + try { + const optionsResponse = await authApi.post( + "/users/webauthn/authenticate/options", + username ? { username } : {}, + ); + const credential = await startAuthentication({ + optionsJSON: optionsResponse.data.options, + }); + const verifyResponse = await authApi.post( + "/users/webauthn/authenticate/verify", + { + challengeId: optionsResponse.data.challengeId, + response: credential as AuthenticationResponseJSON, + rememberMe, + }, + ); + return verifyResponse.data; + } catch (error) { + throw handleApiError(error, "sign in with passkey"); + } +} + export async function listWebAuthnCredentials(): Promise<{ credentials: WebAuthnCredentialSummary[]; }> { diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 504bdc4f..0b267623 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -14,6 +14,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, + Fingerprint, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { @@ -36,6 +37,7 @@ import { requestTrustedProxyLogin, } from "@/main-axios"; import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api"; +import { isPasskeySupported, loginWithPasskey } from "@/api/webauthn-api"; import type { SSOProviderPublic } from "@/types/index"; import { Checkbox } from "@/components/checkbox"; import { @@ -217,6 +219,13 @@ export function Auth({ onLogin }: AuthProps) { const [providerLoading, setProviderLoading] = useState< Record >({}); + const [passkeySupported] = useState(() => { + try { + return isPasskeySupported(); + } catch { + return false; + } + }); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -635,6 +644,72 @@ export function Auth({ onLogin }: AuthProps) { } } + async function handlePasskeyLogin() { + setLoading(true); + try { + const res = await loginWithPasskey( + username.trim() || undefined, + rememberMe, + ); + if (res.requires_totp) { + setTotpTempToken(res.temp_token ?? ""); + setView("totp"); + return; + } + if (!res?.success) throw new Error(t("auth.passkeyLoginFailed")); + if (isInMobileWebView()) { + const token = res?.token ?? ""; + (window as ExtendedWindow).ReactNativeWebView?.postMessage( + JSON.stringify({ type: "AUTH_SUCCESS", token }), + ); + setWebviewAuthSuccess(true); + return; + } + if (isInElectronWebView()) { + // Same as handleLogin: the iframe never sends X-Electron-App, so read + // the JWT back from the cookie that was just set. + const token = res?.token ?? (await getCurrentToken()); + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + source: "passkey_auth_component", + platform: "desktop", + token: token ?? null, + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + return; + } + const meRes = await getUserInfo(); + storeAuth(meRes.username || res.username || ""); + toast.success(t("messages.loginSuccess")); + onLogin( + meRes.username || res.username || "", + meRes.userId || undefined, + !!meRes.is_admin, + ); + } catch (err: unknown) { + const error = err as { + name?: string; + message?: string; + response?: { data?: { error?: string } }; + }; + // Closing or cancelling the browser prompt is not a failure worth a toast. + if (error?.name === "NotAllowedError" || error?.name === "AbortError") { + return; + } + toast.error( + error?.response?.data?.error || + error?.message || + t("auth.passkeyLoginFailed"), + ); + } finally { + setLoading(false); + } + } + async function handleRegister(e: React.FormEvent) { e.preventDefault(); if (!username.trim()) { @@ -1469,6 +1544,20 @@ export function Auth({ onLogin }: AuthProps) { ); })} + {passkeySupported && ( + + )} )} @@ -1536,6 +1625,20 @@ export function Auth({ onLogin }: AuthProps) { )} + {passkeySupported && ( + + )} )} diff --git a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx index fe1c0aa1..22fa3957 100644 --- a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx +++ b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx @@ -154,7 +154,10 @@ export function ProxmoxDiscoverDialog({ // the real IP. Re-sync keeps the manual value (guest.ip || existing.ip). ip: g.ip || "0.0.0.0", port: g.connectionType === "rdp" ? 3389 : 22, - username: defaultUsername ?? "root", + username: + importAuth.authType === "credential" + ? "" + : (defaultUsername ?? "root"), folder: importFolder, // Inherit the jump-host chain from the scanned Proxmox host so the // imported guests are reachable the same way; user can override. diff --git a/src/ui/components/proxmox/proxmox-import-auth.ts b/src/ui/components/proxmox/proxmox-import-auth.ts index e96dbf21..4aaed4ec 100644 --- a/src/ui/components/proxmox/proxmox-import-auth.ts +++ b/src/ui/components/proxmox/proxmox-import-auth.ts @@ -30,7 +30,7 @@ export function resolveProxmoxImportAuth( return { authType: "credential", credentialId, - overrideCredentialUsername: true, + overrideCredentialUsername: false, }; } diff --git a/src/ui/features/ai/AiProviderSettings.tsx b/src/ui/features/ai/AiProviderSettings.tsx index a6e62a42..040a5b5d 100644 --- a/src/ui/features/ai/AiProviderSettings.tsx +++ b/src/ui/features/ai/AiProviderSettings.tsx @@ -2,7 +2,7 @@ import { getErrorMessage } from "../../lib/error-message.js"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Loader2, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Label } from "@/components/label"; @@ -16,7 +16,9 @@ import { import { createAiProvider, deleteAiProvider, + getAiProviderModels, probeAiModels, + updateAiProvider, type AiProvider, type AiProviderType, } from "@/api/ai-api"; @@ -68,6 +70,159 @@ interface AiProviderSettingsProps { onAdded?: () => void; } +function AiProviderEditForm({ + provider, + onSaved, + onCancel, +}: { + provider: AiProvider; + onSaved: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const [label, setLabel] = useState(provider.label); + const [defaultModel, setDefaultModel] = useState(provider.defaultModel ?? ""); + const [models, setModels] = useState([]); + const [customModel, setCustomModel] = useState(false); + const [detecting, setDetecting] = useState(false); + const [saving, setSaving] = useState(false); + + const detectModels = useCallback(async () => { + setDetecting(true); + try { + const detected = await getAiProviderModels(provider.id); + setModels(detected); + setCustomModel(!!defaultModel && !detected.includes(defaultModel)); + } catch { + setModels([]); + setCustomModel(true); + } finally { + setDetecting(false); + } + }, [provider.id, defaultModel]); + + useEffect(() => { + void detectModels(); + // The initial model value belongs to this provider. Subsequent edits must + // not trigger a provider model-list request on every keystroke. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider.id]); + + async function handleSave() { + if (!label.trim()) { + toast.error(t("ai.labelRequired")); + return; + } + + setSaving(true); + try { + await updateAiProvider(provider.id, { + label: label.trim(), + defaultModel: defaultModel.trim() || null, + }); + toast.success(t("ai.providerUpdated")); + onSaved(); + } catch (error) { + toast.error(getErrorMessage(error, t("ai.providerSaveFailed"))); + } finally { + setSaving(false); + } + } + + return ( +
+
+ + setLabel(event.target.value)} + autoFocus + /> +
+ +
+
+ + +
+ + {models.length > 0 && !customModel ? ( + + ) : ( + setDefaultModel(event.target.value)} + placeholder={t("ai.defaultModelPlaceholder")} + /> + )} +
+ +
+ + +
+
+ ); +} + export function AiProviderSettings({ providers, onChanged, @@ -75,6 +230,7 @@ export function AiProviderSettings({ }: AiProviderSettingsProps) { const { t } = useTranslation(); const [adding, setAdding] = useState(false); + const [editingId, setEditingId] = useState(null); const [saving, setSaving] = useState(false); const [providerType, setProviderType] = useState("ollama"); const [label, setLabel] = useState(""); @@ -173,32 +329,67 @@ export function AiProviderSettings({ return (
- {providers.map((provider) => ( -
-
-
{provider.label}
-
- {provider.providerType} - {provider.baseUrl ? ` · ${provider.baseUrl}` : ""} - {provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""} + {providers.map((provider) => + editingId === provider.id ? ( + { + setEditingId(null); + onChanged(provider.id); + }} + onCancel={() => setEditingId(null)} + /> + ) : ( +
+
+
+ {provider.label} +
+
+ {provider.providerType} + {provider.defaultModel ? ` · ${provider.defaultModel}` : ""} + {provider.baseUrl ? ` · ${provider.baseUrl}` : ""} + {provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""} +
+
+
+ +
- -
- ))} + ), + )} {!adding && ( - diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index ae171f77..22a900a9 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -19,6 +19,7 @@ import { useWindowManager, } from "./components/WindowManager.tsx"; import { FileWindow } from "./components/FileWindow.tsx"; +import { DownloadProgressToast } from "./components/DownloadProgressToast.tsx"; import { DiffWindow } from "./components/DiffWindow.tsx"; import { useDragToDesktop } from "@/features/file-manager/hooks/useDragToDesktop"; import { useDragToSystemDesktop } from "@/features/file-manager/hooks/useDragToSystemDesktop"; @@ -976,19 +977,14 @@ function FileManagerContent({ return () => document.removeEventListener("keydown", handleKeyDown); }, [currentPath]); - async function handleItemsDropped(items: DataTransferItemList) { + async function handleItemsDropped(entries: FileSystemEntry[]) { if (!sshSessionId) { toast.error(t("fileManager.noSSHConnection")); return; } - const entries: FileSystemEntry[] = []; - for (let i = 0; i < items.length; i++) { - const entry = items[i].webkitGetAsEntry?.(); - if (entry) entries.push(entry); - } - const files: { file: File; relativePath: string }[] = []; + const emptyDirs: string[] = []; async function readEntry( entry: FileSystemEntry, @@ -999,51 +995,77 @@ function FileManagerContent({ (entry as FileSystemFileEntry).file(resolve, reject), ); files.push({ file, relativePath: path }); - } else if (entry.isDirectory) { - const reader = (entry as FileSystemDirectoryEntry).createReader(); - let batch: FileSystemEntry[]; - do { - batch = await new Promise((resolve, reject) => - reader.readEntries(resolve, reject), - ); - for (const child of batch) { - await readEntry(child, `${path}/${child.name}`); - } - } while (batch.length > 0); + return; + } + + if (!entry.isDirectory) return; + + // readEntries only hands back a page at a time and signals the end with an + // empty batch, so drain it fully before walking into the children. + const reader = (entry as FileSystemDirectoryEntry).createReader(); + const children: FileSystemEntry[] = []; + for (;;) { + const batch = await new Promise((resolve, reject) => + reader.readEntries(resolve, reject), + ); + if (batch.length === 0) break; + children.push(...batch); + } + + if (children.length === 0) { + emptyDirs.push(path); + return; + } + + for (const child of children) { + await readEntry(child, `${path}/${child.name}`); } } - for (const entry of entries) { - await readEntry(entry, entry.name); + try { + for (const entry of entries) { + await readEntry(entry, entry.name); + } + } catch (error) { + toast.error(t("fileManager.failedToUploadFile")); + console.error("Failed to read dropped folder:", error); + return; } - if (files.length === 0) return; + if (files.length === 0 && emptyDirs.length === 0) return; const progressToast = toast.loading( - `Uploading ${files.length} file(s)...`, + t("fileManager.uploadingFolderFiles", { count: files.length }), { duration: Infinity }, ); + const failed: string[] = []; + try { await ensureSSHConnection(); + const base = currentPath.endsWith("/") ? currentPath : currentPath + "/"; + const dirs = new Set(); - for (const { relativePath } of files) { + for (const relativePath of [ + ...files.map((f) => f.relativePath), + ...emptyDirs.map((d) => `${d}/`), + ]) { const parts = relativePath.split("/"); for (let i = 1; i < parts.length; i++) { dirs.add(parts.slice(0, i).join("/")); } } - const sortedDirs = Array.from(dirs).sort(); + // Shallowest first so each parent exists before its children. + const sortedDirs = Array.from(dirs).sort( + (a, b) => + a.split("/").length - b.split("/").length || a.localeCompare(b), + ); for (const dir of sortedDirs) { - const parentPath = currentPath.endsWith("/") - ? currentPath + dir.split("/").slice(0, -1).join("/") - : currentPath + "/" + dir.split("/").slice(0, -1).join("/"); + const parentDir = dir.split("/").slice(0, -1).join("/"); + const targetPath = parentDir ? `${base}${parentDir}/` : base; const folderName = dir.split("/").pop()!; - const targetPath = parentPath.endsWith("/") - ? parentPath - : parentPath + "/"; try { await createSSHFolder( sshSessionId, @@ -1060,23 +1082,37 @@ function FileManagerContent({ const dirPart = relativePath.includes("/") ? relativePath.substring(0, relativePath.lastIndexOf("/")) : ""; - const uploadPath = dirPart - ? (currentPath.endsWith("/") ? currentPath : currentPath + "/") + - dirPart + - "/" - : currentPath; + const uploadPath = dirPart ? `${base}${dirPart}/` : currentPath; - await uploadSSHFile( - sshSessionId, - uploadPath, - file.name, - file, - currentHost?.id, - ); + try { + await uploadSSHFile( + sshSessionId, + uploadPath, + file.name, + file, + currentHost?.id, + ); + } catch (error) { + failed.push(relativePath); + console.error(`Failed to upload ${relativePath}:`, error); + } } toast.dismiss(progressToast); - toast.success(`Uploaded ${files.length} file(s) successfully`); + if (failed.length === 0) { + toast.success( + t("fileManager.uploadedFolderFiles", { count: files.length }), + ); + } else if (failed.length === files.length) { + toast.error(t("fileManager.failedToUploadFile")); + } else { + toast.warning( + t("fileManager.uploadedFolderPartial", { + uploaded: files.length - failed.length, + failed: failed.length, + }), + ); + } handleRefreshDirectory(); } catch (error) { toast.dismiss(progressToast); @@ -1166,14 +1202,51 @@ function FileManagerContent({ async function handleDownloadFile(file: FileItem) { if (!sshSessionId) return; + const toastId = `download-${file.path}-${Date.now()}`; + let lastLoaded = 0; + let lastTime = Date.now(); + let mbPerSec: number | undefined; + try { await ensureSSHConnection(); const { downloadSSHFileStream } = await import("@/main-axios.ts"); - await downloadSSHFileStream(sshSessionId, file.path); + + toast.loading(, { + id: toastId, + duration: Infinity, + }); + + await downloadSSHFileStream( + sshSessionId, + file.path, + ({ loaded, total }) => { + const now = Date.now(); + const deltaMs = now - lastTime; + if (deltaMs > 200) { + const deltaBytes = loaded - lastLoaded; + if (deltaBytes >= 0) { + mbPerSec = (deltaBytes / deltaMs / 1024 / 1024) * 1000; + } + lastLoaded = loaded; + lastTime = now; + } + + toast.loading( + , + { id: toastId, duration: Infinity }, + ); + }, + ); toast.success( t("fileManager.fileDownloadedSuccessfully", { name: file.name }), + { id: toastId }, ); } catch (error: unknown) { const err = error instanceof Error ? error : null; @@ -1187,9 +1260,10 @@ function FileManagerContent({ ip: currentHost?.ip, port: currentHost?.port, }), + { id: toastId }, ); } else { - toast.error(t("fileManager.failedToDownloadFile")); + toast.error(t("fileManager.failedToDownloadFile"), { id: toastId }); } console.error("Download failed:", error); } diff --git a/src/ui/features/file-manager/FileManagerToolbar.tsx b/src/ui/features/file-manager/FileManagerToolbar.tsx index c98800b5..85df3c73 100644 --- a/src/ui/features/file-manager/FileManagerToolbar.tsx +++ b/src/ui/features/file-manager/FileManagerToolbar.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import { ArrowUp, ChevronLeft, @@ -76,14 +76,20 @@ function Breadcrumb({ {part === "" && i === 0 ? ( ) : part !== "" ? (
-
- -
+
{selectedFiles.length > 0 && ( @@ -340,9 +426,12 @@ export function FileManagerToolbar({
-
- -
+
); diff --git a/src/ui/features/file-manager/components/DownloadProgressToast.tsx b/src/ui/features/file-manager/components/DownloadProgressToast.tsx new file mode 100644 index 00000000..3888db20 --- /dev/null +++ b/src/ui/features/file-manager/components/DownloadProgressToast.tsx @@ -0,0 +1,87 @@ +import { formatTransferMbPerSec } from "@/main-axios.ts"; +import { useTranslation } from "react-i18next"; + +interface DownloadProgressToastProps { + fileName: string; + loaded: number; + total?: number; + mbPerSec?: number; +} + +function formatBytes(bytes: number): string { + if (bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + const formattedSize = + size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString(); + return `${formattedSize} ${units[unitIndex]}`; +} + +function IndeterminateProgressBar() { + return ( +
+
+
+ ); +} + +function DeterminateProgressBar({ value }: { value: number }) { + const clamped = Math.min(100, Math.max(0, value)); + return ( +
+
+
+ ); +} + +export function DownloadProgressToast({ + fileName, + loaded, + total, + mbPerSec, +}: DownloadProgressToastProps) { + const { t } = useTranslation(); + const percent = + total !== undefined && total > 0 + ? Math.min(100, Math.round((loaded / total) * 100)) + : undefined; + + const speed = formatTransferMbPerSec(mbPerSec); + + return ( +
+

+ {t("fileManager.downloadingFile", { name: fileName })} +

+ {percent === undefined ? ( + + ) : ( + + )} +
+ + {total !== undefined + ? t("fileManager.downloadProgressBytes", { + transferred: formatBytes(loaded), + total: formatBytes(total), + }) + : formatBytes(loaded)} + + + {speed || "0 MB/s"} + +
+
+ ); +} diff --git a/src/ui/features/file-manager/hooks/useDragAndDrop.ts b/src/ui/features/file-manager/hooks/useDragAndDrop.ts index 79fc8551..33b92eff 100644 --- a/src/ui/features/file-manager/hooks/useDragAndDrop.ts +++ b/src/ui/features/file-manager/hooks/useDragAndDrop.ts @@ -8,7 +8,7 @@ interface DragAndDropState { interface UseDragAndDropProps { onFilesDropped: (files: FileList) => void; - onItemsDropped?: (items: DataTransferItemList) => void; + onItemsDropped?: (entries: FileSystemEntry[]) => void; onError?: (error: string) => void; maxFileSize?: number; allowedTypes?: string[]; @@ -119,24 +119,29 @@ export function useDragAndDrop({ e.preventDefault(); e.stopPropagation(); + // Read the entries before touching state. Updating state flushes a render + // and the browser clears dataTransfer once the drop handler unwinds, so + // anything read later comes back empty (Firefox is strictest here). + const entries: FileSystemEntry[] = []; + if (onItemsDropped && e.dataTransfer.items?.length > 0) { + for (const item of Array.from(e.dataTransfer.items)) { + const entry = item.webkitGetAsEntry?.(); + if (entry) entries.push(entry); + } + } + const files = e.dataTransfer.files; + setState({ isDragging: false, dragCounter: 0, draggedFiles: [], }); - if (onItemsDropped && e.dataTransfer.items?.length > 0) { - const hasDirectory = Array.from(e.dataTransfer.items).some( - (item) => item.webkitGetAsEntry?.()?.isDirectory, - ); - if (hasDirectory) { - onItemsDropped(e.dataTransfer.items); - return; - } + if (onItemsDropped && entries.some((entry) => entry.isDirectory)) { + onItemsDropped(entries); + return; } - const files = e.dataTransfer.files; - if (files.length === 0) { return; } diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index 2cbcae46..06c4a242 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -7,6 +7,7 @@ import React, { useImperativeHandle, } from "react"; import type Guacamole from "guacamole-common-js"; +import { toast } from "sonner"; import { GuacamoleDisplay, type GuacamoleDisplayHandle, @@ -133,7 +134,7 @@ interface GuacamoleAppInnerProps { hostId: number; hostConfig: Pick< SSHHost, - "connectionType" | "domain" | "guacamoleConfig" | "rdpAuthType" + "connectionType" | "domain" | "guacamoleConfig" | "rdpAuthType" | "syncId" >; hostName: string; tabId?: string; @@ -180,6 +181,16 @@ const GuacamoleAppInner = React.forwardRef< setFileBrowserOpen(true); }, []); + const handleDropUnavailable = useCallback(() => { + toast.error( + t( + allowUpload + ? "guacamole.files.driveUnavailable" + : "guacamole.files.uploadDisabled", + ), + ); + }, [allowUpload, t]); + const resolvedProtocolForConnect = (protocol ?? hostConfig.connectionType ?? "rdp") as "rdp" | "vnc" | "telnet"; @@ -245,6 +256,7 @@ const GuacamoleAppInner = React.forwardRef< hostId, protocol, promptedCredentials ?? undefined, + hostConfig.syncId, ); if (result) { setToken(result.token); @@ -257,6 +269,7 @@ const GuacamoleAppInner = React.forwardRef< protocol, promptedCredentials, resolvedProtocolForConnect, + hostConfig.syncId, addLog, t, ]); @@ -461,6 +474,7 @@ const GuacamoleAppInner = React.forwardRef< } onFilesystem={setFilesystem} onDropFiles={handleDropFiles} + onDropUnavailable={handleDropUnavailable} /> {filesystem && fileBrowserOpen && ( void; onFilesystem?: (filesystem: Guacamole.Object | null) => void; onDropFiles?: (files: File[]) => void; + onDropUnavailable?: () => void; onStageChange?: (stage: ConnectionStage) => void; } @@ -85,6 +90,7 @@ export const GuacamoleDisplay = forwardRef< onError, onFilesystem, onDropFiles, + onDropUnavailable, onStageChange, }, ref, @@ -776,8 +782,9 @@ export const GuacamoleDisplay = forwardRef< const handleDragEnter = useCallback( (event: React.DragEvent) => { - if (!canDropFiles || !event.dataTransfer.types.includes("Files")) return; + if (!hasDraggedFiles(event.dataTransfer.types)) return; event.preventDefault(); + if (!canDropFiles) return; dragDepthRef.current += 1; setIsDraggingFiles(true); }, @@ -791,15 +798,21 @@ export const GuacamoleDisplay = forwardRef< const handleDrop = useCallback( (event: React.DragEvent) => { - if (!canDropFiles) return; + if (!hasDraggedFiles(event.dataTransfer.types)) return; event.preventDefault(); dragDepthRef.current = 0; setIsDraggingFiles(false); const files = Array.from(event.dataTransfer.files); - if (files.length > 0) onDropFiles?.(files); + const disposition = getFileDropDisposition( + event.dataTransfer.types, + files.length, + canDropFiles, + ); + if (disposition === "upload") onDropFiles?.(files); + if (disposition === "reject") onDropUnavailable?.(); }, - [canDropFiles, onDropFiles], + [canDropFiles, onDropFiles, onDropUnavailable], ); return ( @@ -808,7 +821,9 @@ export const GuacamoleDisplay = forwardRef< className="absolute inset-0 overflow-hidden" style={{ backgroundColor: "var(--bg-base)" }} onDragEnter={handleDragEnter} - onDragOver={canDropFiles ? (e) => e.preventDefault() : undefined} + onDragOver={(event) => { + if (hasDraggedFiles(event.dataTransfer.types)) event.preventDefault(); + }} onDragLeave={canDropFiles ? handleDragLeave : undefined} onDrop={handleDrop} > diff --git a/src/ui/features/guacamole/guacamole-file-drop.ts b/src/ui/features/guacamole/guacamole-file-drop.ts new file mode 100644 index 00000000..07b0c8ee --- /dev/null +++ b/src/ui/features/guacamole/guacamole-file-drop.ts @@ -0,0 +1,14 @@ +export type FileDropDisposition = "ignore" | "reject" | "upload"; + +export function hasDraggedFiles(types: readonly string[]): boolean { + return types.includes("Files"); +} + +export function getFileDropDisposition( + types: readonly string[], + fileCount: number, + canUpload: boolean, +): FileDropDisposition { + if (!hasDraggedFiles(types) || fileCount === 0) return "ignore"; + return canUpload ? "upload" : "reject"; +} diff --git a/src/ui/features/local-terminal/LocalTerminal.tsx b/src/ui/features/local-terminal/LocalTerminal.tsx index f5a9dbe3..c9424393 100644 --- a/src/ui/features/local-terminal/LocalTerminal.tsx +++ b/src/ui/features/local-terminal/LocalTerminal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { FitAddon } from "@xterm/addon-fit"; import { useXTerm } from "react-xtermjs"; import { useTheme } from "@/components/theme-provider"; @@ -17,6 +17,14 @@ export function LocalTerminal({ const { instance: terminal, ref: xtermRef } = useXTerm(); const fitAddonRef = useRef(null); const sessionIdRef = useRef(null); + const [isWindows, setIsWindows] = useState(false); + const [shell, setShell] = useState<"default" | "wsl">("default"); + + useEffect(() => { + window.electronAPI?.getPlatform().then((platform) => { + setIsWindows(platform === "win32"); + }); + }, []); const fit = useCallback(() => { const fitAddon = fitAddonRef.current; @@ -60,7 +68,7 @@ export function LocalTerminal({ }); window.electronAPI - .startLocalTerminal({ cols: terminal.cols, rows: terminal.rows }) + .startLocalTerminal({ cols: terminal.cols, rows: terminal.rows, shell }) .then(({ sessionId }) => { if (disposed) { window.electronAPI.closeLocalTerminal(sessionId); @@ -100,11 +108,30 @@ export function LocalTerminal({ fitAddonRef.current = null; fitAddon.dispose(); }; - }, [fit, instanceId, terminal, xtermRef]); + }, [fit, instanceId, shell, terminal, xtermRef]); useEffect(() => { if (isVisible) requestAnimationFrame(fit); }, [fit, isVisible]); - return
; + return ( +
+ {isWindows && ( +
+ +
+ )} +
+
+ ); } diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 4d4abd6d..c23aa59b 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -80,7 +80,7 @@ import { getNextTerminalFontSize, getTerminalFontZoomDirection, } from "./terminal-font-zoom.ts"; -import { isTabKeyEvent } from "./terminal-key-event.ts"; +import { isPhysicalShortcutKey, isTabKeyEvent } from "./terminal-key-event.ts"; import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts"; import { loadTouchInputSettings } from "./touch-input-settings-store.ts"; import { quoteTerminalImagePath } from "./terminal-image-path.ts"; @@ -2869,7 +2869,7 @@ const TerminalInner = forwardRef( !e.shiftKey && !e.altKey && !e.metaKey && - e.key.toLowerCase() === "c" && + isPhysicalShortcutKey(e, "KeyC", "c") && terminal.hasSelection() ) { const selection = terminal.getSelection(); @@ -2883,13 +2883,16 @@ const TerminalInner = forwardRef( } if ( - ((e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey) || - (e.ctrlKey && - !e.shiftKey && - !e.altKey && - !e.metaKey && - e.key === "Insert")) && - (e.key.toLowerCase() === "c" || e.key === "Insert") + (e.metaKey && + !e.shiftKey && + !e.ctrlKey && + !e.altKey && + isPhysicalShortcutKey(e, "KeyC", "c")) || + (e.ctrlKey && + !e.shiftKey && + !e.altKey && + !e.metaKey && + e.key === "Insert") ) { const selection = terminal.getSelection(); if (selection) { @@ -2905,7 +2908,7 @@ const TerminalInner = forwardRef( e.shiftKey && !e.altKey && !e.metaKey && - e.key.toLowerCase() === "c" + isPhysicalShortcutKey(e, "KeyC", "c") ) { const selection = terminal.getSelection(); if (selection) { @@ -2922,7 +2925,7 @@ const TerminalInner = forwardRef( e.shiftKey && !e.altKey && !e.metaKey && - e.key.toLowerCase() === "v" + isPhysicalShortcutKey(e, "KeyV", "v") ) { e.preventDefault(); e.stopPropagation(); @@ -2937,7 +2940,7 @@ const TerminalInner = forwardRef( !e.shiftKey && !e.altKey && !e.metaKey && - e.key.toLowerCase() === "v" + isPhysicalShortcutKey(e, "KeyV", "v") ) { // Let the browser handle Ctrl+V natively, the paste event // listener will intercept the result without triggering the diff --git a/src/ui/features/terminal/terminal-key-event.ts b/src/ui/features/terminal/terminal-key-event.ts index 4009ffe9..4aa0acb5 100644 --- a/src/ui/features/terminal/terminal-key-event.ts +++ b/src/ui/features/terminal/terminal-key-event.ts @@ -1,3 +1,13 @@ export function isTabKeyEvent(event: KeyboardEvent): boolean { return event.key === "Tab" || event.code === "Tab" || event.keyCode === 9; } + +export function isPhysicalShortcutKey( + event: Pick, + code: string, + fallbackKey: string, +): boolean { + return event.code + ? event.code === code + : event.key.toLowerCase() === fallbackKey; +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 35d2d6b3..62ba72d5 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH Key", "filterTagsGroup": "Tags", "copyDeployCommand": "Copy deploy command", + "cloneCredentialAction": "Clone credential", + "clonedCredential": "Cloned {{name}}", + "clonedCredentialName": "{{name}} (copy)", "deleteCredentialAction": "Delete credential", "editCredentialAction": "Edit credential", + "failedToCloneCredential": "Failed to clone credential", "failedToDeleteCredential": "Failed to delete credential", "idBadge": "ID", "keyBadge": "KEY", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Agent Socket Path", "agentSocketPathPlaceholder": "Leave empty to use SSH_AUTH_SOCK", "agentSocketPathHint": "Leave empty to auto-detect from the SSH_AUTH_SOCK environment variable, or enter a custom socket path (e.g. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Agent Identity (Public Key)", + "agentIdentityPlaceholder": "Leave empty to try all keys from the agent", + "agentIdentityHint": "Paste a public key (e.g. ssh-ed25519 AAAA...) to only offer that key from the agent. Recommended when the agent holds many keys, to avoid exceeding the server's MaxAuthTries.", "shareSshAuthLabel": "Share SSH Authentication", "shareSshAuthDesc": "Give recipients encrypted copies of this host's SSH authentication. A recipient's personal credential still takes precedence.", "tailscaleDeviceSelect": "Select Tailscale device", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "No credentials found", "addCredentialBtn": "Add Credential", "updateCredentialBtn": "Update Credential", + "editCredentialAction": "Edit credential", + "backToHost": "Back to Host", + "saveCredentialAsNewBtn": "Save as New", + "saveCredentialAsNewDesc": "Creates a new credential with these changes and assigns it to this host. The original credential is left unchanged.", + "credentialSavedAsNew": "Saved as a new credential", + "failedToSaveCredentialAsNew": "Failed to save as a new credential", "features": "Features", "noFolder": "(No folder)", "deleteSelected": "Delete", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Compact", "displayShowTags": "Show tags", "displayTrayOnClick": "Open actions on click (instead of hover)", - "actionsVisibility": "Host actions", - "actionsVisibilityDesc": "When the connection and management buttons on each host show up", + "actionsVisibility": "Host action display", + "actionsVisibilityDesc": "Choose when host actions expand. Select click to restore the former click-to-expand behavior.", "actionsAlways": "Always visible", "actionsOnly": "Connect only, rest on click", "actionsHover": "Show on hover", - "actionsClick": "Show on click", + "actionsClick": "Click to expand/collapse", "openOnDoubleClick": "Open on double click", "openOnDoubleClickDesc": "Require a double click to open a host session instead of a single click", "deletedHost": "Deleted {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Uploaded {{name}}", "uploadFailed": "Failed to upload {{name}}", "uploadDisabled": "Uploads are disabled for this connection", + "driveUnavailable": "Enable RDP drive redirection before uploading files", "dropToUpload": "Drop files to upload" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Folder Name", "fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully", "failedToUploadFile": "Failed to upload file", + "uploadingFolderFiles": "Uploading {{count}} file(s)...", + "uploadedFolderFiles": "Uploaded {{count}} file(s) successfully", + "uploadedFolderPartial": "Uploaded {{uploaded}} file(s), {{failed}} failed", "fileDownloadedSuccessfully": "File \"{{name}}\" downloaded successfully", "failedToDownloadFile": "Failed to download file", "fileCreatedSuccessfully": "File \"{{name}}\" created successfully", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} items", "downloadFileSuccess": "File {{name}} downloaded successfully", "downloadFileFailed": "Download failed", + "downloadingFile": "Downloading {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Move to {{name}}", "diffCompareWith": "Diff compare with {{name}}", "dragOutsideToDownload": "Drag outside window to download ({{count}} files)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Auto-create accounts for OIDC/SSO users on first login (independent of the registration toggle)", "oidcSilentLoginDefault": "Silent OIDC Login by Default", "oidcSilentLoginDefaultDesc": "Automatically redirect to OIDC login on every visit, skipping the login form entirely", + "oidcSilentLoginDefaultLockedDesc": "This setting is locked by the OIDC_SILENT_LOGIN_DEFAULT environment variable and cannot be changed here.", "allowPasswordReset": "Allow Password Reset", "allowPasswordResetDesc": "Reset code via Docker logs", "commandHistoryEnabled": "Command History", @@ -3317,11 +3337,11 @@ "createRole": "Create", "creating": "Creating...", "exportDatabase": "Export Database", - "exportDatabaseDesc": "Download a backup of all hosts, credentials, and settings", + "exportDatabaseDesc": "Download a portable SQLite backup of all hosts, credentials, and settings", "export": "Export", "exporting": "Exporting...", "importDatabase": "Import Database", - "importDatabaseDesc": "Restore from a .sqlite backup file", + "importDatabaseDesc": "Restore from a portable .sqlite backup on any database backend", "importDatabaseSelected": "Selected: {{name}}", "selectFile": "Select File", "changeFile": "Change", @@ -4516,6 +4536,7 @@ "selectProvider": "Select a provider", "providerSettings": "Provider settings", "addProvider": "Add provider", + "editProvider": "Edit provider", "removeProvider": "Remove provider", "providerType": "Provider", "providerLabel": "Name", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5", "labelRequired": "Give this provider a name", "providerSaveFailed": "Could not save the provider", + "providerUpdated": "Provider updated", "providerDeleteFailed": "Could not remove the provider", "privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/af_ZA.json b/src/ui/locales/translated/af_ZA.json index 3ccb66e2..c92345d8 100644 --- a/src/ui/locales/translated/af_ZA.json +++ b/src/ui/locales/translated/af_ZA.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-sleutel", "filterTagsGroup": "Etikette", "copyDeployCommand": "Kopieer ontplooi-opdrag", + "cloneCredentialAction": "Kloonbewys", + "clonedCredential": "Gekloon {{name}}", + "clonedCredentialName": "{{name}} (kopieer)", "deleteCredentialAction": "Vee geloofsbriewe uit", "editCredentialAction": "Wysig geloofsbriewe", + "failedToCloneCredential": "Kon nie geloofsbriewe kloon nie", "failedToDeleteCredential": "Kon nie geloofsbriewe verwyder nie", "idBadge": "ID", "keyBadge": "SLEUTEL", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Agent-sokpad", "agentSocketPathPlaceholder": "Los leeg om SSH_AUTH_SOCK te gebruik", "agentSocketPathHint": "Los leeg om outomaties op te spoor vanaf die SSH_AUTH_SOCK omgewing veranderlike, of voer 'n persoonlike sokpad in (bv. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Agentidentiteit (Publieke Sleutel)", + "agentIdentityPlaceholder": "Los leeg om alle sleutels van die agent te probeer", + "agentIdentityHint": "Plak 'n publieke sleutel (bv. ssh-ed25519 AAAA...) om slegs daardie sleutel van die agent aan te bied. Aanbeveel wanneer die agent baie sleutels besit, om te verhoed dat die bediener se MaxAuthTries oorskry word.", "shareSshAuthLabel": "Deel SSH-verifikasie", "shareSshAuthDesc": "Gee ontvangers geïnkripteerde kopieë van hierdie gasheer se SSH-verifikasie. 'n Ontvanger se persoonlike geloofsbriewe geniet steeds voorrang.", "tailscaleDeviceSelect": "Kies Tailscale-toestel", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Geen geloofsbriewe gevind nie", "addCredentialBtn": "Voeg geloofsbriewe by", "updateCredentialBtn": "Opdatering van geloofsbriewe", + "editCredentialAction": "Wysig geloofsbriewe", + "backToHost": "Terug na gasheer", + "saveCredentialAsNewBtn": "Stoor as Nuut", + "saveCredentialAsNewDesc": "Skep 'n nuwe geloofsbrief met hierdie veranderinge en ken dit aan hierdie gasheer toe. Die oorspronklike geloofsbrief word onveranderd gelaat.", + "credentialSavedAsNew": "Gestoor as 'n nuwe geloofsbrief", + "failedToSaveCredentialAsNew": "Kon nie as 'n nuwe geloofsbrief stoor nie", "features": "Kenmerke", "noFolder": "(Geen vouer nie)", "deleteSelected": "Vee uit", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompak", "displayShowTags": "Wys etikette", "displayTrayOnClick": "Maak aksies oop met klik (in plaas van om oor te beweeg)", - "actionsVisibility": "Gasheeraksies", - "actionsVisibilityDesc": "Wanneer die verbindings- en bestuursknoppies op elke gasheer verskyn", + "actionsVisibility": "Gasheeraksie-vertoning", + "actionsVisibilityDesc": "Kies wanneer gasheeraksies uitbrei. Kies klik om die vorige klik-om-uit te brei-gedrag te herstel.", "actionsAlways": "Altyd sigbaar", "actionsOnly": "Verbind slegs, rus op klik", "actionsHover": "Wys wanneer jy beweeg", - "actionsClick": "Wys met klik", + "actionsClick": "Klik om uit te brei/in te vou", "openOnDoubleClick": "Maak oop met dubbelklik", "openOnDoubleClickDesc": "Vereis 'n dubbelklik om 'n gasheersessie oop te maak in plaas van 'n enkele klik", "deletedHost": "Verwyder {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Opgelaai {{name}}", "uploadFailed": "Kon nie {{name}} oplaai nie", "uploadDisabled": "Oplaaie is gedeaktiveer vir hierdie verbinding", + "driveUnavailable": "Aktiveer RDP-skyfherleiding voordat lêers opgelaai word", "dropToUpload": "Laat lêers los om op te laai" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Vouernaam", "fileUploadedSuccessfully": "Lêer \"{{name}}\" is suksesvol opgelaai", "failedToUploadFile": "Kon nie lêer oplaai nie", + "uploadingFolderFiles": "Laai tans {{count}} lêer(s) op...", + "uploadedFolderFiles": "{{count}} lêer(s) suksesvol opgelaai", + "uploadedFolderPartial": "Opgelaaide {{uploaded}} lêer(s), {{failed}} het misluk", "fileDownloadedSuccessfully": "Lêer \"{{name}}\" is suksesvol afgelaai", "failedToDownloadFile": "Kon nie lêer aflaai nie", "fileCreatedSuccessfully": "Lêer \"{{name}}\" suksesvol geskep", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} items", "downloadFileSuccess": "Lêer {{name}} suksesvol afgelaai", "downloadFileFailed": "Aflaai het misluk", + "downloadingFile": "Laai tans af {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Skuif na {{name}}", "diffCompareWith": "Verskil vergelyking met {{name}}", "dragOutsideToDownload": "Sleep buite die venster om af te laai ({{count}} lêers)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Skep outomaties rekeninge vir OIDC/SSO-gebruikers met die eerste aanmelding (onafhanklik van die registrasie-skakelaar)", "oidcSilentLoginDefault": "Stille OIDC-aanmelding by verstek", "oidcSilentLoginDefaultDesc": "Herlei outomaties na OIDC-aanmelding met elke besoek, en slaan die aanmeldvorm heeltemal oor", + "oidcSilentLoginDefaultLockedDesc": "Hierdie instelling word gesluit deur die OIDC_SILENT_LOGIN_DEFAULT omgewingveranderlike en kan nie hier verander word nie.", "allowPasswordReset": "Laat wagwoordherstel toe", "allowPasswordResetDesc": "Herstel kode via Docker-logboeke", "commandHistoryEnabled": "Opdraggeskiedenis", @@ -3317,11 +3337,11 @@ "createRole": "Skep", "creating": "Skep...", "exportDatabase": "Uitvoer van databasis", - "exportDatabaseDesc": "Laai 'n rugsteun van alle gashere, geloofsbriewe en instellings af", + "exportDatabaseDesc": "Laai 'n draagbare SQLite-rugsteun van alle gashere, geloofsbriewe en instellings af", "export": "Uitvoer", "exporting": "Uitvoer tans...", "importDatabase": "Voer databasis in", - "importDatabaseDesc": "Herstel vanaf 'n .sqlite rugsteunlêer", + "importDatabaseDesc": "Herstel vanaf 'n draagbare .sqlite-rugsteun op enige databasis-agtergrond", "importDatabaseSelected": "Gekies: {{name}}", "selectFile": "Kies Lêer", "changeFile": "Verandering", @@ -4516,6 +4536,7 @@ "selectProvider": "Kies 'n verskaffer", "providerSettings": "Verskafferinstellings", "addProvider": "Voeg verskaffer by", + "editProvider": "Wysig verskaffer", "removeProvider": "Verwyder verskaffer", "providerType": "Verskaffer", "providerLabel": "Naam", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Byvoorbeeld: llama3.1 of claude-opus-5", "labelRequired": "Gee hierdie verskaffer 'n naam", "providerSaveFailed": "Kon nie die verskaffer stoor nie", + "providerUpdated": "Verskaffer opgedateer", "providerDeleteFailed": "Kon nie die verskaffer verwyder nie", "privateEndpointHint": "Adresse op jou private netwerk benodig eers 'n administrateur om die gasheer toe te laat.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/ar_SA.json b/src/ui/locales/translated/ar_SA.json index a3dd2dc5..5c84f900 100644 --- a/src/ui/locales/translated/ar_SA.json +++ b/src/ui/locales/translated/ar_SA.json @@ -111,8 +111,12 @@ "filterTypeKey": "مفتاح SSH", "filterTagsGroup": "وسوم", "copyDeployCommand": "نسخ أمر النشر", + "cloneCredentialAction": "بيانات اعتماد الاستنساخ", + "clonedCredential": "مستنسخ {{name}}", + "clonedCredentialName": "{{name}} (نسخة)", "deleteCredentialAction": "حذف بيانات الاعتماد", "editCredentialAction": "تعديل بيانات الاعتماد", + "failedToCloneCredential": "فشل استنساخ بيانات الاعتماد", "failedToDeleteCredential": "فشل حذف بيانات الاعتماد", "idBadge": "بطاقة تعريف", "keyBadge": "مفتاح", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "مسار مقبس الوكيل", "agentSocketPathPlaceholder": "اتركه فارغًا لاستخدام SSH_AUTH_SOCK", "agentSocketPathHint": "اتركه فارغًا للاكتشاف التلقائي من متغير البيئة SSH_AUTH_SOCK، أو أدخل مسار مقبس مخصص (مثال: /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "هوية الوكيل (المفتاح العام)", + "agentIdentityPlaceholder": "اترك هذا الحقل فارغًا لتجربة جميع المفاتيح من الوكيل.", + "agentIdentityHint": "ألصق مفتاحًا عامًا (مثل ssh-ed25519 AAAA...) لعرض هذا المفتاح فقط من جانب الوكيل. يُنصح بذلك عندما يمتلك الوكيل العديد من المفاتيح، لتجنب تجاوز الحد الأقصى لمحاولات المصادقة (MaxAuthTries) للخادم.", "shareSshAuthLabel": "مشاركة مصادقة SSH", "shareSshAuthDesc": "قم بتزويد المستلمين بنسخ مشفرة من بيانات مصادقة SSH الخاصة بهذا المضيف. مع ذلك، تظل بيانات اعتماد المستلم الشخصية هي المرجع الأساسي.", "tailscaleDeviceSelect": "اختيار جهاز Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "لم يتم العثور على بيانات اعتماد", "addCredentialBtn": "إضافة بيانات اعتماد", "updateCredentialBtn": "تحديث بيانات اعتماد", + "editCredentialAction": "تعديل بيانات الاعتماد", + "backToHost": "العودة إلى المضيف", + "saveCredentialAsNewBtn": "حفظ كجديد", + "saveCredentialAsNewDesc": "يُنشئ بيانات اعتماد جديدة تتضمن هذه التغييرات ويُخصصها لهذا المضيف. وتبقى بيانات الاعتماد الأصلية دون تغيير.", + "credentialSavedAsNew": "تم حفظها كبيانات اعتماد جديدة", + "failedToSaveCredentialAsNew": "فشل الحفظ كبيانات اعتماد جديدة", "features": "الميزات", "noFolder": "(بلا مجلد)", "deleteSelected": "حذف", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "صغير الحجم", "displayShowTags": "عرض العلامات", "displayTrayOnClick": "فتح الإجراءات عند النقر (بدلاً من التمرير)", - "actionsVisibility": "إجراءات المضيف", - "actionsVisibilityDesc": "عندما تظهر أزرار الاتصال والإدارة على كل مضيف", + "actionsVisibility": "عرض إجراءات المضيف", + "actionsVisibilityDesc": "اختر متى تتوسع إجراءات المضيف. حدد \"انقر\" لاستعادة سلوك النقر للتوسيع السابق.", "actionsAlways": "مرئي دائمًا", "actionsOnly": "الاتصال فقط، والباقي عند النقر", "actionsHover": "عرض عند تمرير المؤشر", - "actionsClick": "يظهر عند النقر", + "actionsClick": "انقر للتوسيع/الطي", "openOnDoubleClick": "افتح بالنقر المزدوج", "openOnDoubleClickDesc": "يتطلب فتح جلسة المضيف نقرتين بدلاً من نقرة واحدة", "deletedHost": "تم حذف {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "تم التحميل {{name}}", "uploadFailed": "فشل تحميل {{name}}", "uploadDisabled": "تم تعطيل التحميلات لهذا الاتصال", + "driveUnavailable": "قم بتمكين إعادة توجيه محرك الأقراص RDP قبل تحميل الملفات", "dropToUpload": "قم بإسقاط الملفات لتحميلها" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "اسم المجلد", "fileUploadedSuccessfully": "تم رفع الملف \"{{name}}\" بنجاح", "failedToUploadFile": "فشل رفع الملف", + "uploadingFolderFiles": "جارٍ تحميل {{count}} ملف(ملفات)...", + "uploadedFolderFiles": "تم تحميل {{count}} ملف بنجاح", + "uploadedFolderPartial": "تم تحميل {{uploaded}} ملفًا، {{failed}} فشل", "fileDownloadedSuccessfully": "تم تنزيل الملف \"{{name}}\" بنجاح", "failedToDownloadFile": "فشل تنزيل الملف", "fileCreatedSuccessfully": "تم إنشاء الملف \"{{name}}\" بنجاح", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} عناصر", "downloadFileSuccess": "تم تنزيل الملف {{name}} بنجاح", "downloadFileFailed": "فشل التنزيل", + "downloadingFile": "جارٍ التنزيل {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "نقل إلى {{name}}", "diffCompareWith": "مقارنة الاختلافات مع {{name}}", "dragOutsideToDownload": "اسحب خارج النافذة للتنزيل ({{count}} ملفات)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "إنشاء الحسابات تلقائياً لمستخدمي OIDC/SSO عند تسجيل الدخول الأول (بغض النظر عن مفتاح التسجيل)", "oidcSilentLoginDefault": "تسجيل الدخول الصامت لـ OIDC بشكل افتراضي", "oidcSilentLoginDefaultDesc": "إعادة التوجيه تلقائياً إلى تسجيل الدخول OIDC في كل زيارة، وتخطي نموذج تسجيل الدخول بالكامل", + "oidcSilentLoginDefaultLockedDesc": "هذا الإعداد مقفل بواسطة متغير البيئة OIDC_SILENT_LOGIN_DEFAULT ولا يمكن تغييره هنا.", "allowPasswordReset": "السماح بإعادة تعيين كلمة المرور", "allowPasswordResetDesc": "رمز إعادة التعيين عبر سجلات Docker", "commandHistoryEnabled": "سجل الأوامر", @@ -3317,11 +3337,11 @@ "createRole": "إنشاء", "creating": "جارٍ الإنشاء...", "exportDatabase": "تصدير قاعدة البيانات", - "exportDatabaseDesc": "تنزيل نسخة احتياطية لجميع المضيفين وبيانات الاعتماد والإعدادات", + "exportDatabaseDesc": "قم بتنزيل نسخة احتياطية محمولة من SQLite لجميع المضيفين وبيانات الاعتماد والإعدادات", "export": "تصدير", "exporting": "جارٍ التصدير...", "importDatabase": "استيراد قاعدة البيانات", - "importDatabaseDesc": "استعادة من ملف نسخ احتياطي بصيغة .sqlite", + "importDatabaseDesc": "استعادة البيانات من نسخة احتياطية محمولة بصيغة .sqlite على أي نظام قاعدة بيانات.", "importDatabaseSelected": "المحدد: {{name}}", "selectFile": "اختيار ملف", "changeFile": "تغيير", @@ -4516,6 +4536,7 @@ "selectProvider": "اختر مزود الخدمة", "providerSettings": "إعدادات المزوّد", "addProvider": "إضافة موفر", + "editProvider": "موفر التحرير", "removeProvider": "إزالة الموفر", "providerType": "مزود", "providerLabel": "اسم", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "على سبيل المثال: llama3.1 أو claude-opus-5", "labelRequired": "أعطِ هذا المزوّد اسمًا", "providerSaveFailed": "تعذر حفظ بيانات المزوّد", + "providerUpdated": "تم تحديث المزوّد", "providerDeleteFailed": "تعذر إزالة الموفر", "privateEndpointHint": "تحتاج العناوين الموجودة على شبكتك الخاصة إلى موافقة المسؤول أولاً للسماح للمضيف بالوصول إليها.", "providerOllama": "أولاما", diff --git a/src/ui/locales/translated/bg_BG.json b/src/ui/locales/translated/bg_BG.json index 30ef8674..99d3c797 100644 --- a/src/ui/locales/translated/bg_BG.json +++ b/src/ui/locales/translated/bg_BG.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH ключ", "filterTagsGroup": "Етикети", "copyDeployCommand": "Копиране на командата за разгръщане", + "cloneCredentialAction": "Клониране на идентификационни данни", + "clonedCredential": "Клонирано {{name}}", + "clonedCredentialName": "{{name}} (копие)", "deleteCredentialAction": "Изтриване на идентификационни данни", "editCredentialAction": "Редактиране на идентификационни данни", + "failedToCloneCredential": "Клонирането на идентификационните данни не бе успешно", "failedToDeleteCredential": "Неуспешно изтриване на идентификационните данни", "idBadge": "Идентификационен номер", "keyBadge": "КЛЮЧ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Път на сокета на агента", "agentSocketPathPlaceholder": "Оставете празно, за да използвате SSH_AUTH_SOCK", "agentSocketPathHint": "Оставете празно, за да се открие автоматично от променливата на средата SSH_AUTH_SOCK, или въведете персонализиран път до сокета (напр. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Идентичност на агента (публичен ключ)", + "agentIdentityPlaceholder": "Оставете празно, за да изпробвате всички ключове от агента", + "agentIdentityHint": "Поставете публичен ключ (напр. ssh-ed25519 AAAA...), за да предложите само този ключ от агента. Препоръчва се, когато агентът притежава много ключове, за да се избегне превишаване на MaxAuthTries на сървъра.", "shareSshAuthLabel": "Споделяне на SSH удостоверяване", "shareSshAuthDesc": "Дайте на получателите криптирани копия на SSH удостоверяването на този хост. Личните идентификационни данни на получателя все още имат приоритет.", "tailscaleDeviceSelect": "Изберете устройство за Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Не са намерени идентификационни данни", "addCredentialBtn": "Добавяне на идентификационни данни", "updateCredentialBtn": "Актуализиране на идентификационните данни", + "editCredentialAction": "Редактиране на идентификационни данни", + "backToHost": "Обратно към хоста", + "saveCredentialAsNewBtn": "Запазване като ново", + "saveCredentialAsNewDesc": "Създава нови идентификационни данни с тези промени и ги присвоява на този хост. Оригиналните идентификационни данни се оставят непроменени.", + "credentialSavedAsNew": "Запазено като нови идентификационни данни", + "failedToSaveCredentialAsNew": "Запазването като нови идентификационни данни не бе успешно", "features": "Характеристики", "noFolder": "(Няма папка)", "deleteSelected": "Изтриване", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Компактен", "displayShowTags": "Показване на етикети", "displayTrayOnClick": "Отваряне на действия при щракване (вместо при задържане на курсора на мишката)", - "actionsVisibility": "Действия на хоста", - "actionsVisibilityDesc": "Когато се появят бутоните за връзка и управление на всеки хост", + "actionsVisibility": "Показване на действието на хоста", + "actionsVisibilityDesc": "Изберете кога действията на хоста да се разгъват. Изберете „щракване“, за да възстановите предишното поведение „щракване за разгъване“.", "actionsAlways": "Винаги видим", "actionsOnly": "Само свързване, почивка при щракване", "actionsHover": "Показване при задържане на курсора на мишката", - "actionsClick": "Показване при кликване", + "actionsClick": "Кликнете, за да разгънете/свиете", "openOnDoubleClick": "Отваряне с двойно щракване", "openOnDoubleClickDesc": "Изисква се двойно щракване за отваряне на хост сесия вместо едно щракване", "deletedHost": "Изтрито {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Качено {{name}}", "uploadFailed": "Качването не бе успешно {{name}}", "uploadDisabled": "Качванията са деактивирани за тази връзка", + "driveUnavailable": "Активирайте пренасочването на RDP устройството, преди да качвате файлове", "dropToUpload": "Пуснете файловете, за да ги качите" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Име на папката", "fileUploadedSuccessfully": "Файлът „{{name}}“ е качен успешно", "failedToUploadFile": "Качването на файла не бе успешно", + "uploadingFolderFiles": "Качване на {{count}} файл(а)...", + "uploadedFolderFiles": "Качени {{count}} файла(а) успешно", + "uploadedFolderPartial": "Качени {{uploaded}} файла(а), {{failed}} неуспешно", "fileDownloadedSuccessfully": "Файлът „{{name}}“ е изтеглен успешно", "failedToDownloadFile": "Изтеглянето на файла не бе успешно", "fileCreatedSuccessfully": "Файлът „{{name}}“ е създаден успешно", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} артикули", "downloadFileSuccess": "Файлът {{name}} е изтеглен успешно", "downloadFileFailed": "Изтеглянето не бе успешно", + "downloadingFile": "Изтегляне {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Премести се в {{name}}", "diffCompareWith": "Разлика в сравнение с {{name}}", "dragOutsideToDownload": "Плъзнете извън прозореца, за да изтеглите ({{count}} файлове)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Автоматично създаване на акаунти за потребители на OIDC/SSO при първо влизане (независимо от превключвателя за регистрация)", "oidcSilentLoginDefault": "Тихо влизане в OIDC по подразбиране", "oidcSilentLoginDefaultDesc": "Автоматично пренасочване към вход в OIDC при всяко посещение, като се пропуска изцяло формуляра за вход", + "oidcSilentLoginDefaultLockedDesc": "Тази настройка е заключена от променливата на средата OIDC_SILENT_LOGIN_DEFAULT и не може да бъде променена тук.", "allowPasswordReset": "Разрешаване на нулиране на паролата", "allowPasswordResetDesc": "Нулиране на кода чрез Docker логове", "commandHistoryEnabled": "История на командите", @@ -3317,11 +3337,11 @@ "createRole": "Създаване", "creating": "Създаване...", "exportDatabase": "Експортиране на база данни", - "exportDatabaseDesc": "Изтеглете резервно копие на всички хостове, идентификационни данни и настройки", + "exportDatabaseDesc": "Изтеглете преносимо SQLite резервно копие на всички хостове, идентификационни данни и настройки", "export": "Експорт", "exporting": "Експортиране...", "importDatabase": "Импортиране на база данни", - "importDatabaseDesc": "Възстановяване от .sqlite архивен файл", + "importDatabaseDesc": "Възстановяване от преносимо .sqlite архивиране на произволен backend за база данни", "importDatabaseSelected": "Избрано: {{name}}", "selectFile": "Изберете файл", "changeFile": "Промяна", @@ -4516,6 +4536,7 @@ "selectProvider": "Изберете доставчик", "providerSettings": "Настройки на доставчика", "addProvider": "Добавяне на доставчик", + "editProvider": "Редактиране на доставчика", "removeProvider": "Премахване на доставчика", "providerType": "Доставчик", "providerLabel": "Име", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Например: llama3.1 или claude-opus-5", "labelRequired": "Дайте име на този доставчик", "providerSaveFailed": "Доставчикът не можа да бъде запазен", + "providerUpdated": "Доставчикът е актуализиран", "providerDeleteFailed": "Доставчикът не можа да бъде премахнат", "privateEndpointHint": "Адресите във вашата частна мрежа се нуждаят първо от администратор, който да разреши хоста.", "providerOllama": "Олама", diff --git a/src/ui/locales/translated/bn_BD.json b/src/ui/locales/translated/bn_BD.json index bfd4e958..5bf63ab4 100644 --- a/src/ui/locales/translated/bn_BD.json +++ b/src/ui/locales/translated/bn_BD.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH কী", "filterTagsGroup": "ট্যাগ", "copyDeployCommand": "ডিপ্লয় কমান্ড কপি করুন", + "cloneCredentialAction": "ক্লোন ক্রেডেনশিয়াল", + "clonedCredential": "ক্লোন করা {{name}}", + "clonedCredentialName": "{{name}} (অনুলিপি)", "deleteCredentialAction": "পরিচয়পত্র মুছুন", "editCredentialAction": "পরিচয়পত্র সম্পাদনা করুন", + "failedToCloneCredential": "ক্রেডেনশিয়াল ক্লোন করতে ব্যর্থ হয়েছে", "failedToDeleteCredential": "ক্রেডেনশিয়াল মুছে ফেলতে ব্যর্থ হয়েছে", "idBadge": "আইডি", "keyBadge": "চাবি", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "এজেন্ট সকেট পাথ", "agentSocketPathPlaceholder": "SSH_AUTH_SOCK ব্যবহার করতে খালি রাখুন", "agentSocketPathHint": "SSH_AUTH_SOCK এনভায়রনমেন্ট ভেরিয়েবল থেকে স্বয়ংক্রিয়ভাবে শনাক্ত করার জন্য এটি খালি রাখুন, অথবা একটি কাস্টম সকেট পাথ লিখুন (যেমন /run/user/1000/gnupg/S.gpg-agent.ssh)।", + "agentIdentityLabel": "এজেন্ট পরিচয় (পাবলিক কী)", + "agentIdentityPlaceholder": "এজেন্ট থেকে সমস্ত কী চেষ্টা করার জন্য খালি রাখুন।", + "agentIdentityHint": "এজেন্ট থেকে শুধুমাত্র সেই নির্দিষ্ট পাবলিক কী (যেমন ssh-ed25519 AAAA...) অফার করার জন্য সেটি পেস্ট করুন। এজেন্টের কাছে একাধিক কী থাকলে সার্ভারের MaxAuthTries সীমা অতিক্রম করা এড়ানোর জন্য এটি করার পরামর্শ দেওয়া হয়।", "shareSshAuthLabel": "SSH প্রমাণীকরণ শেয়ার করুন", "shareSshAuthDesc": "প্রাপকদের এই হোস্টের SSH প্রমাণীকরণের এনক্রিপ্টেড কপি দিন। প্রাপকের ব্যক্তিগত পরিচয়পত্রই অগ্রাধিকার পাবে।", "tailscaleDeviceSelect": "টেইলস্কেল ডিভাইস নির্বাচন করুন", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "কোনো পরিচয়পত্র খুঁজে পাওয়া যায়নি", "addCredentialBtn": "পরিচয়পত্র যোগ করুন", "updateCredentialBtn": "পরিচয়পত্র আপডেট করুন", + "editCredentialAction": "পরিচয়পত্র সম্পাদনা করুন", + "backToHost": "হোস্টে ফিরে যান", + "saveCredentialAsNewBtn": "নতুন হিসাবে সংরক্ষণ করুন", + "saveCredentialAsNewDesc": "এই পরিবর্তনগুলো সহ একটি নতুন ক্রেডেনশিয়াল তৈরি করে এবং এটিকে এই হোস্টে বরাদ্দ করে। মূল ক্রেডেনশিয়ালটি অপরিবর্তিত থাকে।", + "credentialSavedAsNew": "নতুন পরিচয়পত্র হিসেবে সংরক্ষিত।", + "failedToSaveCredentialAsNew": "নতুন পরিচয়পত্র হিসেবে সংরক্ষণ করা যায়নি", "features": "বৈশিষ্ট্য", "noFolder": "(কোন ফোল্ডার নেই)", "deleteSelected": "মুছে ফেলুন", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "সংক্ষিপ্ত", "displayShowTags": "ট্যাগ দেখান", "displayTrayOnClick": "ক্লিক করলে অ্যাকশন খুলবে (হোভার করার পরিবর্তে)", - "actionsVisibility": "হোস্টের কার্যকলাপ", - "actionsVisibilityDesc": "যখন প্রতিটি হোস্টে সংযোগ এবং ব্যবস্থাপনা বোতামগুলি প্রদর্শিত হয়", + "actionsVisibility": "হোস্ট অ্যাকশন প্রদর্শন", + "actionsVisibilityDesc": "হোস্ট অ্যাকশন কখন প্রসারিত হবে তা বেছে নিন। পূর্ববর্তী ক্লিক-টু-এক্সপ্যান্ড আচরণ পুনরুদ্ধার করতে ক্লিক নির্বাচন করুন।", "actionsAlways": "সর্বদা দৃশ্যমান", "actionsOnly": "শুধু সংযোগ করুন, বাকিটা ক্লিকে।", "actionsHover": "হোভার করলে দেখান", - "actionsClick": "ক্লিক করলে দেখান", + "actionsClick": "প্রসারিত/সংকুচিত করতে ক্লিক করুন", "openOnDoubleClick": "ডাবল ক্লিকে খুলুন", "openOnDoubleClickDesc": "হোস্ট সেশন খুলতে একক ক্লিকের পরিবর্তে ডাবল ক্লিক প্রয়োজন।", "deletedHost": "মুছে ফেলা হয়েছে {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "আপলোড করা হয়েছে {{name}}", "uploadFailed": "{{name}} আপলোড করতে ব্যর্থ হয়েছে", "uploadDisabled": "এই সংযোগের জন্য আপলোড নিষ্ক্রিয় করা হয়েছে।", + "driveUnavailable": "ফাইল আপলোড করার আগে RDP ড্রাইভ রিডাইরেকশন সক্রিয় করুন।", "dropToUpload": "আপলোড করতে ফাইলগুলো ড্রপ করুন" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "ফোল্ডারের নাম", "fileUploadedSuccessfully": "ফাইল \"{{name}}\" সফলভাবে আপলোড হয়েছে", "failedToUploadFile": "ফাইল আপলোড করতে ব্যর্থ হয়েছে", + "uploadingFolderFiles": "{{count}} টি ফাইল আপলোড করা হচ্ছে...", + "uploadedFolderFiles": "{{count}} টি ফাইল সফলভাবে আপলোড করা হয়েছে", + "uploadedFolderPartial": "{{uploaded}} টি ফাইল আপলোড করা হয়েছে, {{failed}} টি ফাইল আপলোড ব্যর্থ হয়েছে", "fileDownloadedSuccessfully": "ফাইল \"{{name}}\" সফলভাবে ডাউনলোড হয়েছে", "failedToDownloadFile": "ফাইল ডাউনলোড করতে ব্যর্থ হয়েছে", "fileCreatedSuccessfully": "ফাইল \"{{name}}\" সফলভাবে তৈরি হয়েছে", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} আইটেম", "downloadFileSuccess": "ফাইল {{name}} সফলভাবে ডাউনলোড হয়েছে", "downloadFileFailed": "ডাউনলোড ব্যর্থ হয়েছে", + "downloadingFile": "ডাউনলোড হচ্ছে {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "{{name}} এ যান", "diffCompareWith": "{{name}} এর সাথে পার্থক্য তুলনা করুন", "dragOutsideToDownload": "ডাউনলোড করতে উইন্ডোর বাইরে টেনে আনুন ({{count}} ফাইলগুলো)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "প্রথমবার লগইন করার সময় OIDC/SSO ব্যবহারকারীদের জন্য স্বয়ংক্রিয়ভাবে অ্যাকাউন্ট তৈরি করুন (রেজিস্ট্রেশন টগল থেকে স্বাধীনভাবে)", "oidcSilentLoginDefault": "ডিফল্টরূপে নীরব OIDC লগইন", "oidcSilentLoginDefaultDesc": "প্রতিবার পরিদর্শনে লগইন ফর্মটি সম্পূর্ণভাবে এড়িয়ে স্বয়ংক্রিয়ভাবে OIDC লগইনে পুনঃনির্দেশিত করুন।", + "oidcSilentLoginDefaultLockedDesc": "এই সেটিংটি OIDC_SILENT_LOGIN_DEFAULT এনভায়রনমেন্ট ভেরিয়েবল দ্বারা লক করা আছে এবং এখান থেকে এটি পরিবর্তন করা যাবে না।", "allowPasswordReset": "পাসওয়ার্ড রিসেট করার অনুমতি দিন", "allowPasswordResetDesc": "ডকার লগের মাধ্যমে কোড রিসেট করুন", "commandHistoryEnabled": "কমান্ডের ইতিহাস", @@ -3317,11 +3337,11 @@ "createRole": "তৈরি করুন", "creating": "তৈরি করা হচ্ছে...", "exportDatabase": "ডেটাবেস রপ্তানি করুন", - "exportDatabaseDesc": "সমস্ত হোস্ট, ক্রেডেনশিয়াল এবং সেটিংসের একটি ব্যাকআপ ডাউনলোড করুন।", + "exportDatabaseDesc": "সমস্ত হোস্ট, ক্রেডেনশিয়াল এবং সেটিংসের একটি পোর্টেবল SQLite ব্যাকআপ ডাউনলোড করুন।", "export": "রপ্তানি", "exporting": "রপ্তানি করা হচ্ছে...", "importDatabase": "ডেটাবেস আমদানি করুন", - "importDatabaseDesc": "একটি .sqlite ব্যাকআপ ফাইল থেকে পুনরুদ্ধার করুন", + "importDatabaseDesc": "যেকোনো ডাটাবেস ব্যাকএন্ডে একটি পোর্টেবল .sqlite ব্যাকআপ থেকে পুনরুদ্ধার করুন", "importDatabaseSelected": "নির্বাচিত: {{name}}", "selectFile": "ফাইল নির্বাচন করুন", "changeFile": "পরিবর্তন", @@ -4516,6 +4536,7 @@ "selectProvider": "একজন সরবরাহকারী নির্বাচন করুন", "providerSettings": "প্রদানকারীর সেটিংস", "addProvider": "প্রদানকারী যোগ করুন", + "editProvider": "সম্পাদনা প্রদানকারী", "removeProvider": "প্রদানকারীকে অপসারণ করুন", "providerType": "সরবরাহকারী", "providerLabel": "নাম", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "উদাহরণস্বরূপ: llama3.1 অথবা claude-opus-5", "labelRequired": "এই সরবরাহকারীকে একটি নাম দিন", "providerSaveFailed": "প্রদানকারীকে সংরক্ষণ করা যায়নি", + "providerUpdated": "প্রদানকারী আপডেট করেছেন", "providerDeleteFailed": "প্রদানকারীকে সরানো যায়নি", "privateEndpointHint": "আপনার ব্যক্তিগত নেটওয়ার্কের অ্যাড্রেসগুলো ব্যবহার করার জন্য প্রথমে একজন অ্যাডমিনিস্ট্রেটরের কাছ থেকে হোস্টটিকে অনুমতি নিতে হয়।", "providerOllama": "ওল্লামা", diff --git a/src/ui/locales/translated/ca_ES.json b/src/ui/locales/translated/ca_ES.json index 63721437..c6ae0229 100644 --- a/src/ui/locales/translated/ca_ES.json +++ b/src/ui/locales/translated/ca_ES.json @@ -111,8 +111,12 @@ "filterTypeKey": "Clau SSH", "filterTagsGroup": "Etiquetes", "copyDeployCommand": "Copia l'ordre de desplegament", + "cloneCredentialAction": "Credencial clonada", + "clonedCredential": "Clonat {{name}}", + "clonedCredentialName": "{{name}} (còpia)", "deleteCredentialAction": "Suprimeix la credencial", "editCredentialAction": "Edita la credencial", + "failedToCloneCredential": "No s'ha pogut clonar la credencial", "failedToDeleteCredential": "No s'ha pogut suprimir la credencial", "idBadge": "ID", "keyBadge": "CLAU", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Ruta del sòcol de l'agent", "agentSocketPathPlaceholder": "Deixeu-ho en blanc per utilitzar SSH_AUTH_SOCK", "agentSocketPathHint": "Deixeu-ho en blanc per detectar-ho automàticament des de la variable d'entorn SSH_AUTH_SOCK o introduïu una ruta de sòcol personalitzada (per exemple, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Identitat de l'agent (clau pública)", + "agentIdentityPlaceholder": "Deixeu-ho en blanc per provar totes les claus de l'agent", + "agentIdentityHint": "Enganxa una clau pública (per exemple, ssh-ed25519 AAAA...) per oferir només aquesta clau de l'agent. Es recomana quan l'agent té moltes claus, per evitar superar el MaxAuthTries del servidor.", "shareSshAuthLabel": "Comparteix l'autenticació SSH", "shareSshAuthDesc": "Doneu als destinataris còpies xifrades de l'autenticació SSH d'aquest amfitrió. Les credencials personals d'un destinatari encara tenen prioritat.", "tailscaleDeviceSelect": "Selecciona el dispositiu Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "No s'han trobat credencials", "addCredentialBtn": "Afegeix credencials", "updateCredentialBtn": "Actualitza les credencials", + "editCredentialAction": "Edita la credencial", + "backToHost": "Torna a l'amfitrió", + "saveCredentialAsNewBtn": "Desa com a nou", + "saveCredentialAsNewDesc": "Crea una nova credencial amb aquests canvis i l'assigna a aquest amfitrió. La credencial original no es modifica.", + "credentialSavedAsNew": "Desada com a credencial nova", + "failedToSaveCredentialAsNew": "No s'ha pogut desar com a credencial nova", "features": "Característiques", "noFolder": "(Sense carpeta)", "deleteSelected": "Suprimeix", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Compacte", "displayShowTags": "Mostra les etiquetes", "displayTrayOnClick": "Obreix accions en fer clic (en comptes de passar el cursor per sobre)", - "actionsVisibility": "Accions de l'amfitrió", - "actionsVisibilityDesc": "Quan apareixen els botons de connexió i administració de cada host", + "actionsVisibility": "Visualització de l'acció de l'amfitrió", + "actionsVisibilityDesc": "Trieu quan s'expandeixen les accions de l'amfitrió. Seleccioneu \"Clic\" per restaurar el comportament anterior de clic per expandir.", "actionsAlways": "Sempre visible", "actionsOnly": "Només connecta, descansa en fer clic", "actionsHover": "Mostra en passar el cursor per sobre", - "actionsClick": "Mostra en fer clic", + "actionsClick": "Feu clic per expandir/replegar", "openOnDoubleClick": "Obre amb doble clic", "openOnDoubleClickDesc": "Cal fer doble clic per obrir una sessió d'amfitrió en comptes d'un sol clic", "deletedHost": "Suprimit {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Pujat {{name}}", "uploadFailed": "No s'ha pogut carregar {{name}}", "uploadDisabled": "Les càrregues estan desactivades per a aquesta connexió", + "driveUnavailable": "Activa la redirecció de la unitat RDP abans de carregar fitxers", "dropToUpload": "Deixa anar els fitxers per pujar-los" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Nom de la carpeta", "fileUploadedSuccessfully": "El fitxer \"{{name}}\" s'ha carregat correctament", "failedToUploadFile": "No s'ha pogut carregar el fitxer", + "uploadingFolderFiles": "S'estan pujant {{count}} fitxer(s)...", + "uploadedFolderFiles": "S'han carregat correctament els fitxers {{count}}", + "uploadedFolderPartial": "No s'ha pogut pujar el(s) fitxer(s) {{uploaded}} , {{failed}}", "fileDownloadedSuccessfully": "Fitxer \"{{name}}\" descarregat correctament", "failedToDownloadFile": "No s'ha pogut descarregar el fitxer", "fileCreatedSuccessfully": "Fitxer \"{{name}}\" creat correctament", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} elements", "downloadFileSuccess": "Fitxer {{name}} descarregat correctament", "downloadFileFailed": "La descàrrega ha fallat", + "downloadingFile": "Descarregant {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Mou a {{name}}", "diffCompareWith": "Comparació de diferències amb {{name}}", "dragOutsideToDownload": "Arrossega fora de la finestra per descarregar (fitxers{{count}})", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Crea automàticament comptes per a usuaris OIDC/SSO en el primer inici de sessió (independentment de l'interruptor de registre)", "oidcSilentLoginDefault": "Inici de sessió silenciós a l'OIDC per defecte", "oidcSilentLoginDefaultDesc": "Redirecciona automàticament a l'inici de sessió d'OIDC en cada visita, ometent completament el formulari d'inici de sessió", + "oidcSilentLoginDefaultLockedDesc": "Aquesta configuració està bloquejada per la variable d'entorn OIDC_SILENT_LOGIN_DEFAULT i no es pot canviar aquí.", "allowPasswordReset": "Permet el restabliment de la contrasenya", "allowPasswordResetDesc": "Restableix el codi a través dels registres de Docker", "commandHistoryEnabled": "Historial de comandaments", @@ -3317,11 +3337,11 @@ "createRole": "Crea", "creating": "Creant...", "exportDatabase": "Exporta la base de dades", - "exportDatabaseDesc": "Baixeu una còpia de seguretat de tots els hosts, credencials i configuracions", + "exportDatabaseDesc": "Baixeu una còpia de seguretat SQLite portàtil de tots els hosts, credencials i configuracions", "export": "Exporta", "exporting": "Exportant...", "importDatabase": "Importa la base de dades", - "importDatabaseDesc": "Restaurar des d'un fitxer de còpia de seguretat .sqlite", + "importDatabaseDesc": "Restaura des d'una còpia de seguretat .sqlite portàtil en qualsevol backend de base de dades", "importDatabaseSelected": "Seleccionat: {{name}}", "selectFile": "Selecciona un fitxer", "changeFile": "Canvi", @@ -4516,6 +4536,7 @@ "selectProvider": "Seleccioneu un proveïdor", "providerSettings": "Configuració del proveïdor", "addProvider": "Afegeix un proveïdor", + "editProvider": "Edita el proveïdor", "removeProvider": "Elimina el proveïdor", "providerType": "Proveïdor", "providerLabel": "Nom", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Per exemple: llama3.1 o claude-opus-5", "labelRequired": "Doneu un nom a aquest proveïdor", "providerSaveFailed": "No s'ha pogut desar el proveïdor", + "providerUpdated": "Proveïdor actualitzat", "providerDeleteFailed": "No s'ha pogut eliminar el proveïdor", "privateEndpointHint": "Les adreces de la vostra xarxa privada necessiten primer que un administrador permeti l'accés a l'amfitrió.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/cs_CZ.json b/src/ui/locales/translated/cs_CZ.json index b615a662..564a7712 100644 --- a/src/ui/locales/translated/cs_CZ.json +++ b/src/ui/locales/translated/cs_CZ.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH klíč", "filterTagsGroup": "Štítky", "copyDeployCommand": "Kopírovat příkaz nasazení", + "cloneCredentialAction": "Klonovat přihlašovací údaje", + "clonedCredential": "Klonováno {{name}}", + "clonedCredentialName": "{{name}} (kopie)", "deleteCredentialAction": "Smazat přihlašovací údaje", "editCredentialAction": "Upravit přihlašovací údaje", + "failedToCloneCredential": "Nepodařilo se klonovat přihlašovací údaje", "failedToDeleteCredential": "Nepodařilo se smazat přihlašovací údaje", "idBadge": "Průkaz totožnosti", "keyBadge": "KLÍČ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Cesta soketu agenta", "agentSocketPathPlaceholder": "Pro použití SSH_AUTH_SOCK ponechte prázdné.", "agentSocketPathHint": "Nechte prázdné pro automatickou detekci z proměnné prostředí SSH_AUTH_SOCK nebo zadejte vlastní cestu k socketu (např. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Identita agenta (veřejný klíč)", + "agentIdentityPlaceholder": "Nechte prázdné, chcete-li vyzkoušet všechny klíče od agenta.", + "agentIdentityHint": "Vložte veřejný klíč (např. ssh-ed25519 AAAA...), aby se od agenta nabídl pouze tento klíč. Doporučuje se, když agent drží mnoho klíčů, aby se zabránilo překročení maximálního počtu pokusů o ověření serveru.", "shareSshAuthLabel": "Sdílení ověřování SSH", "shareSshAuthDesc": "Poskytněte příjemcům šifrované kopie SSH ověření tohoto hostitele. Osobní přihlašovací údaje příjemce mají stále přednost.", "tailscaleDeviceSelect": "Vyberte zařízení Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Nenalezeny žádné přihlašovací údaje", "addCredentialBtn": "Přidat přihlašovací údaje", "updateCredentialBtn": "Aktualizovat přihlašovací údaje", + "editCredentialAction": "Upravit přihlašovací údaje", + "backToHost": "Zpět k hostiteli", + "saveCredentialAsNewBtn": "Uložit jako nové", + "saveCredentialAsNewDesc": "Vytvoří nové přihlašovací údaje s těmito změnami a přiřadí je tomuto hostiteli. Původní přihlašovací údaje zůstanou nezměněny.", + "credentialSavedAsNew": "Uloženo jako nové pověření", + "failedToSaveCredentialAsNew": "Uložení jako nových přihlašovacích údajů se nezdařilo", "features": "Funkce", "noFolder": "(Žádná složka)", "deleteSelected": "Vymazat", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompaktní", "displayShowTags": "Zobrazit štítky", "displayTrayOnClick": "Otevírat akce po kliknutí (místo najetí myší)", - "actionsVisibility": "Akce hostitele", - "actionsVisibilityDesc": "Když se na každém hostiteli zobrazí tlačítka pro připojení a správu", + "actionsVisibility": "Zobrazení akce hostitele", + "actionsVisibilityDesc": "Vyberte, kdy se akce hostitele rozbalí. Vyberte možnost Kliknout, chcete-li obnovit původní chování rozbalení kliknutím.", "actionsAlways": "Vždy viditelné", "actionsOnly": "Pouze připojení, odpočinek po kliknutí", "actionsHover": "Zobrazit při najetí myší", - "actionsClick": "Zobrazit po kliknutí", + "actionsClick": "Kliknutím rozbalíte/sbalíte", "openOnDoubleClick": "Otevřít dvojitým kliknutím", "openOnDoubleClickDesc": "Vyžadovat dvojité kliknutí pro otevření hostitelské relace místo jednoho kliknutí", "deletedHost": "Smazáno {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Nahráno {{name}}", "uploadFailed": "Nahrávání se nezdařilo {{name}}", "uploadDisabled": "Nahrávání je pro toto připojení zakázáno.", + "driveUnavailable": "Před nahráváním souborů povolte přesměrování disku RDP", "dropToUpload": "Přetáhněte soubory k nahrání" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Název složky", "fileUploadedSuccessfully": "Soubor „{{name}}“ byl úspěšně nahrán", "failedToUploadFile": "Nahrávání souboru se nezdařilo", + "uploadingFolderFiles": "Nahrávání {{count}} souborů...", + "uploadedFolderFiles": "Úspěšně nahráno {{count}} souborů", + "uploadedFolderPartial": "Nahrání souboru (souborů) {{uploaded}} se nezdařilo. {{failed}}", "fileDownloadedSuccessfully": "Soubor „{{name}}“ byl úspěšně stažen", "failedToDownloadFile": "Stažení souboru se nezdařilo", "fileCreatedSuccessfully": "Soubor „{{name}}“ byl úspěšně vytvořen", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} položek", "downloadFileSuccess": "Soubor {{name}} byl úspěšně stažen", "downloadFileFailed": "Stahování se nezdařilo", + "downloadingFile": "Stahování {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Přesunout na {{name}}", "diffCompareWith": "Rozdíl ve srovnání s {{name}}", "dragOutsideToDownload": "Přetáhněte mimo okno pro stažení ({{count}} souborů)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Automatické vytváření účtů pro uživatele OIDC/SSO při prvním přihlášení (nezávisle na přepínači registrace)", "oidcSilentLoginDefault": "Tiché přihlášení OIDC ve výchozím nastavení", "oidcSilentLoginDefaultDesc": "Automatické přesměrování na přihlášení k OIDC při každé návštěvě, úplné přeskakování přihlašovacího formuláře", + "oidcSilentLoginDefaultLockedDesc": "Toto nastavení je uzamčeno proměnnou prostředí OIDC_SILENT_LOGIN_DEFAULT a nelze jej zde změnit.", "allowPasswordReset": "Povolit resetování hesla", "allowPasswordResetDesc": "Reset kódu pomocí protokolů Dockeru", "commandHistoryEnabled": "Historie příkazů", @@ -3317,11 +3337,11 @@ "createRole": "Vytvořit", "creating": "Vytváření...", "exportDatabase": "Exportovat databázi", - "exportDatabaseDesc": "Stáhněte si zálohu všech hostitelů, přihlašovacích údajů a nastavení", + "exportDatabaseDesc": "Stáhněte si přenosnou zálohu SQLite všech hostitelů, přihlašovacích údajů a nastavení", "export": "Vývozní", "exporting": "Export...", "importDatabase": "Importovat databázi", - "importDatabaseDesc": "Obnovení ze záložního souboru .sqlite", + "importDatabaseDesc": "Obnovení z přenosné zálohy .sqlite na libovolném backendu databáze", "importDatabaseSelected": "Vybráno: {{name}}", "selectFile": "Vybrat soubor", "changeFile": "Přeměna", @@ -4516,6 +4536,7 @@ "selectProvider": "Vyberte poskytovatele", "providerSettings": "Nastavení poskytovatele", "addProvider": "Přidat poskytovatele", + "editProvider": "Upravit poskytovatele", "removeProvider": "Odebrat poskytovatele", "providerType": "Poskytovatel", "providerLabel": "Jméno", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Například: llama3.1 nebo claude-opus-5", "labelRequired": "Zadejte tomuto poskytovateli název", "providerSaveFailed": "Nepodařilo se uložit poskytovatele", + "providerUpdated": "Poskytovatel aktualizován", "providerDeleteFailed": "Nepodařilo se odebrat poskytovatele", "privateEndpointHint": "Adresy ve vaší privátní síti vyžadují, aby je správce nejprve povolil hostiteli.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/da_DK.json b/src/ui/locales/translated/da_DK.json index 5c11dab3..88d5501b 100644 --- a/src/ui/locales/translated/da_DK.json +++ b/src/ui/locales/translated/da_DK.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-nøgle", "filterTagsGroup": "Tags", "copyDeployCommand": "Kopiér deploy-kommandoen", + "cloneCredentialAction": "Klon legitimationsoplysninger", + "clonedCredential": "Klonet {{name}}", + "clonedCredentialName": "{{name}} (kopi)", "deleteCredentialAction": "Slet legitimationsoplysninger", "editCredentialAction": "Rediger legitimationsoplysninger", + "failedToCloneCredential": "Kunne ikke klone legitimationsoplysninger", "failedToDeleteCredential": "Kunne ikke slette legitimationsoplysninger", "idBadge": "ID", "keyBadge": "NØGLE", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Agent Socket-sti", "agentSocketPathPlaceholder": "Lad feltet stå tomt for at bruge SSH_AUTH_SOCK", "agentSocketPathHint": "Lad feltet stå tomt for automatisk at detektere fra miljøvariablen SSH_AUTH_SOCK, eller indtast en brugerdefineret socket-sti (f.eks. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Agentidentitet (offentlig nøgle)", + "agentIdentityPlaceholder": "Lad feltet stå tomt for at prøve alle nøgler fra agenten", + "agentIdentityHint": "Indsæt en offentlig nøgle (f.eks. ssh-ed25519 AAAA...) for kun at tilbyde den nøgle fra agenten. Anbefales, når agenten har mange nøgler, for at undgå at overskride serverens MaxAuthTries.", "shareSshAuthLabel": "Del SSH-godkendelse", "shareSshAuthDesc": "Giv modtagerne krypterede kopier af denne værts SSH-godkendelse. En modtagers personlige legitimationsoplysninger har stadig forrang.", "tailscaleDeviceSelect": "Vælg Tailscale-enhed", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Ingen legitimationsoplysninger fundet", "addCredentialBtn": "Tilføj legitimationsoplysninger", "updateCredentialBtn": "Opdater legitimationsoplysninger", + "editCredentialAction": "Rediger legitimationsoplysninger", + "backToHost": "Tilbage til værten", + "saveCredentialAsNewBtn": "Gem som ny", + "saveCredentialAsNewDesc": "Opretter en ny legitimationsoplysning med disse ændringer og tildeler den til denne vært. Den oprindelige legitimationsoplysning forbliver uændret.", + "credentialSavedAsNew": "Gemt som en ny legitimationsoplysninger", + "failedToSaveCredentialAsNew": "Kunne ikke gemme som en ny legitimationsoplysninger", "features": "Funktioner", "noFolder": "(Ingen mappe)", "deleteSelected": "Slet", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompakt", "displayShowTags": "Vis tags", "displayTrayOnClick": "Åbn handlinger ved klik (i stedet for at holde musen over)", - "actionsVisibility": "Værtshandlinger", - "actionsVisibilityDesc": "Når knapperne for forbindelse og administration på hver vært vises", + "actionsVisibility": "Værtshandlingsvisning", + "actionsVisibilityDesc": "Vælg, hvornår værtshandlinger udvides. Vælg klik for at gendanne den tidligere klik-for-at-udvide-funktion.", "actionsAlways": "Altid synlig", "actionsOnly": "Kun forbindelse, hvile ved klik", "actionsHover": "Vis ved at holde musen over", - "actionsClick": "Vis ved klik", + "actionsClick": "Klik for at udvide/skjule", "openOnDoubleClick": "Åbn ved dobbeltklik", "openOnDoubleClickDesc": "Kræv et dobbeltklik for at åbne en værtsession i stedet for et enkelt klik", "deletedHost": "Slettet {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Uploadet {{name}}", "uploadFailed": "Kunne ikke uploade {{name}}", "uploadDisabled": "Uploads er deaktiveret for denne forbindelse", + "driveUnavailable": "Aktivér omdirigering af RDP-drev før upload af filer", "dropToUpload": "Slip filer for at uploade" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Mappenavn", "fileUploadedSuccessfully": "Filen \"{{name}}\" er blevet uploadet", "failedToUploadFile": "Kunne ikke uploade filen", + "uploadingFolderFiles": "Uploader {{count}} fil(er)...", + "uploadedFolderFiles": "Uploadede {{count}} fil(er)", + "uploadedFolderPartial": "Upload af {{uploaded}} fil(er), {{failed}} mislykkedes", "fileDownloadedSuccessfully": "Filen \"{{name}}\" er blevet downloadet", "failedToDownloadFile": "Kunne ikke downloade filen", "fileCreatedSuccessfully": "Filen \"{{name}}\" er blevet oprettet", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} elementer", "downloadFileSuccess": "Fil {{name}} er blevet downloadet", "downloadFileFailed": "Download mislykkedes", + "downloadingFile": "Downloader {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Flyt til {{name}}", "diffCompareWith": "Differens sammenligning med {{name}}", "dragOutsideToDownload": "Træk uden for vinduet for at downloade ({{count}} filer)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Automatisk oprettelse af konti til OIDC/SSO-brugere ved første login (uafhængigt af registreringsknappen)", "oidcSilentLoginDefault": "Lydløs OIDC-login som standard", "oidcSilentLoginDefaultDesc": "Omdiriger automatisk til OIDC-login ved hvert besøg, og spring loginformularen helt over", + "oidcSilentLoginDefaultLockedDesc": "Denne indstilling er låst af miljøvariablen OIDC_SILENT_LOGIN_DEFAULT og kan ikke ændres her.", "allowPasswordReset": "Tillad nulstilling af adgangskode", "allowPasswordResetDesc": "Nulstil kode via Docker-logfiler", "commandHistoryEnabled": "Kommandohistorik", @@ -3317,11 +3337,11 @@ "createRole": "Skabe", "creating": "Opretter...", "exportDatabase": "Eksportér database", - "exportDatabaseDesc": "Download en sikkerhedskopi af alle værter, legitimationsoplysninger og indstillinger", + "exportDatabaseDesc": "Download en bærbar SQLite-sikkerhedskopi af alle værter, legitimationsoplysninger og indstillinger", "export": "Eksportere", "exporting": "Eksporterer...", "importDatabase": "Importér database", - "importDatabaseDesc": "Gendan fra en .sqlite-backupfil", + "importDatabaseDesc": "Gendan fra en bærbar .sqlite-backup på enhver database-backend", "importDatabaseSelected": "Valgt: {{name}}", "selectFile": "Vælg fil", "changeFile": "Forandring", @@ -4516,6 +4536,7 @@ "selectProvider": "Vælg en udbyder", "providerSettings": "Udbyderindstillinger", "addProvider": "Tilføj udbyder", + "editProvider": "Rediger udbyder", "removeProvider": "Fjern udbyder", "providerType": "Udbyder", "providerLabel": "Navn", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "For eksempel: llama3.1 eller claude-opus-5", "labelRequired": "Giv denne udbyder et navn", "providerSaveFailed": "Udbyderen kunne ikke gemmes", + "providerUpdated": "Udbyder opdateret", "providerDeleteFailed": "Udbyderen kunne ikke fjernes", "privateEndpointHint": "Adresser på dit private netværk kræver først tilladelse fra en administrator til værten.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/de_DE.json b/src/ui/locales/translated/de_DE.json index cd99abfa..2b119087 100644 --- a/src/ui/locales/translated/de_DE.json +++ b/src/ui/locales/translated/de_DE.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-Schlüssel", "filterTagsGroup": "Tags", "copyDeployCommand": "Befehl zum Bereitstellen kopieren", + "cloneCredentialAction": "Anmeldeinformationen klonen", + "clonedCredential": "Geklont {{name}}", + "clonedCredentialName": "{{name}} (Kopie)", "deleteCredentialAction": "Anmeldeinformationen löschen", "editCredentialAction": "Anmeldeinformationen bearbeiten", + "failedToCloneCredential": "Fehler beim Klonen der Anmeldeinformationen", "failedToDeleteCredential": "Anmeldeinformationen konnten nicht gelöscht werden", "idBadge": "AUSWEIS", "keyBadge": "SCHLÜSSEL", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Agent-Socket-Pfad", "agentSocketPathPlaceholder": "Leer lassen, um SSH_AUTH_SOCK zu verwenden", "agentSocketPathHint": "Leer lassen, um automatisch über die Umgebungsvariable SSH_AUTH_SOCK zu erkennen, oder geben Sie einen benutzerdefinierten Socket-Pfad ein (z. B. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Agentenidentität (öffentlicher Schlüssel)", + "agentIdentityPlaceholder": "Lassen Sie dieses Feld leer, um alle Schlüssel des Agenten auszuprobieren.", + "agentIdentityHint": "Fügen Sie einen öffentlichen Schlüssel ein (z. B. ssh-ed25519 AAAA...), damit der Agent nur diesen Schlüssel anbietet. Dies wird empfohlen, wenn der Agent viele Schlüssel verwaltet, um die maximale Anzahl an Authentifizierungsversuchen (MaxAuthTries) des Servers nicht zu überschreiten.", "shareSshAuthLabel": "SSH-Authentifizierung teilen", "shareSshAuthDesc": "Geben Sie den Empfängern verschlüsselte Kopien der SSH-Authentifizierungsinformationen dieses Hosts. Die persönlichen Anmeldeinformationen des Empfängers haben weiterhin Vorrang.", "tailscaleDeviceSelect": "Tailscale-Gerät auswählen", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Keine Anmeldedaten gefunden", "addCredentialBtn": "Anmeldedaten hinzufügen", "updateCredentialBtn": "Anmeldedaten aktualisieren", + "editCredentialAction": "Anmeldeinformationen bearbeiten", + "backToHost": "Zurück zur Startseite", + "saveCredentialAsNewBtn": "Als neu speichern", + "saveCredentialAsNewDesc": "Erstellt mit diesen Änderungen eine neue Anmeldeinformation und weist sie diesem Host zu. Die ursprüngliche Anmeldeinformation bleibt unverändert.", + "credentialSavedAsNew": "Als neue Anmeldeinformation gespeichert", + "failedToSaveCredentialAsNew": "Speichern als neue Anmeldeinformationen fehlgeschlagen", "features": "Funktionen", "noFolder": "(Kein Ordner)", "deleteSelected": "Löschen", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompakt", "displayShowTags": "Tags anzeigen", "displayTrayOnClick": "Aktionen per Klick öffnen (statt per Hover)", - "actionsVisibility": "Aktionen des Hosts", - "actionsVisibilityDesc": "Wenn die Verbindungs- und Verwaltungsschaltflächen auf jedem Host angezeigt werden", + "actionsVisibility": "Anzeige der Host-Aktion", + "actionsVisibilityDesc": "Wählen Sie aus, wann die Host-Aktionen erweitert werden. Wählen Sie „Klicken“, um das vorherige Verhalten (Klicken zum Erweitern) wiederherzustellen.", "actionsAlways": "Immer sichtbar", "actionsOnly": "Nur verbinden, Rest per Klick", "actionsHover": "Beim Überfahren mit der Maus anzeigen", - "actionsClick": "Bei Klick anzeigen", + "actionsClick": "Zum Ein-/Ausklappen klicken", "openOnDoubleClick": "Bei Doppelklick öffnen", "openOnDoubleClickDesc": "Zum Öffnen einer Host-Sitzung ist ein Doppelklick anstelle eines einfachen Klicks erforderlich.", "deletedHost": "{{name}} gelöscht", @@ -1867,6 +1880,7 @@ "uploaded": "Hochgeladen {{name}}", "uploadFailed": "Hochladen fehlgeschlagen {{name}}", "uploadDisabled": "Uploads sind für diese Verbindung deaktiviert.", + "driveUnavailable": "Aktivieren Sie die RDP-Laufwerksumleitung, bevor Sie Dateien hochladen.", "dropToUpload": "Dateien zum Hochladen per Drag & Drop ablegen" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Ordnername", "fileUploadedSuccessfully": "Datei \"{{name}}\" erfolgreich hochgeladen", "failedToUploadFile": "Hochladen der Datei fehlgeschlagen", + "uploadingFolderFiles": "Uploading {{count}} file(s)...", + "uploadedFolderFiles": "{{count}} Datei(en) erfolgreich hochgeladen", + "uploadedFolderPartial": "Hochladen von {{uploaded}} Datei(en), {{failed}} fehlgeschlagen", "fileDownloadedSuccessfully": "Datei \"{{name}}\" erfolgreich heruntergeladen", "failedToDownloadFile": "Herunterladen der Datei fehlgeschlagen", "fileCreatedSuccessfully": "Datei \"{{name}}\" erfolgreich erstellt", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} Elemente", "downloadFileSuccess": "Datei {{name}} erfolgreich heruntergeladen", "downloadFileFailed": "Download fehlgeschlagen", + "downloadingFile": "Herunterladen {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Verschieben nach {{name}}", "diffCompareWith": "Diff-Vergleich mit {{name}}", "dragOutsideToDownload": "Zum Herunterladen aus dem Fenster ziehen ({{count}} Dateien)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Konten für OIDC/SSO-Benutzer bei der ersten Anmeldung automatisch erstellen (unabhängig vom Registrierungs-Schalter)", "oidcSilentLoginDefault": "Stiller OIDC-Login standardmäßig", "oidcSilentLoginDefaultDesc": "Bei jedem Besuch automatisch zum OIDC-Login weiterleiten, das Anmeldeformular vollständig überspringen", + "oidcSilentLoginDefaultLockedDesc": "Diese Einstellung ist durch die Umgebungsvariable OIDC_SILENT_LOGIN_DEFAULT gesperrt und kann hier nicht geändert werden.", "allowPasswordReset": "Passwort-Zurücksetzen erlauben", "allowPasswordResetDesc": "Reset-Code über Docker-Logs", "commandHistoryEnabled": "Befehlsverlauf", @@ -3317,11 +3337,11 @@ "createRole": "Erstellen", "creating": "Wird erstellt...", "exportDatabase": "Datenbank exportieren", - "exportDatabaseDesc": "Sicherung aller Hosts, Anmeldeinformationen und Einstellungen herunterladen", + "exportDatabaseDesc": "Laden Sie eine portable SQLite-Sicherung aller Hosts, Anmeldeinformationen und Einstellungen herunter.", "export": "Exportieren", "exporting": "Exportiere...", "importDatabase": "Datenbank importieren", - "importDatabaseDesc": "Aus einer .sqlite-Sicherungsdatei wiederherstellen", + "importDatabaseDesc": "Wiederherstellung aus einem portablen .sqlite-Backup auf einem beliebigen Datenbank-Backend", "importDatabaseSelected": "Ausgewählt: {{name}}", "selectFile": "Datei auswählen", "changeFile": "Ändern", @@ -4516,6 +4536,7 @@ "selectProvider": "Wählen Sie einen Anbieter aus", "providerSettings": "Anbietereinstellungen", "addProvider": "Anbieter hinzufügen", + "editProvider": "Anbieter bearbeiten", "removeProvider": "Anbieter entfernen", "providerType": "Anbieter", "providerLabel": "Name", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Zum Beispiel: llama3.1 oder claude-opus-5", "labelRequired": "Geben Sie diesem Anbieter einen Namen", "providerSaveFailed": "Der Anbieter konnte nicht gespeichert werden.", + "providerUpdated": "Anbieter aktualisiert", "providerDeleteFailed": "Der Anbieter konnte nicht entfernt werden.", "privateEndpointHint": "Adressen in Ihrem privaten Netzwerk benötigen eine vorherige Genehmigung durch einen Administrator.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/el_GR.json b/src/ui/locales/translated/el_GR.json index 79765a3a..32884bd3 100644 --- a/src/ui/locales/translated/el_GR.json +++ b/src/ui/locales/translated/el_GR.json @@ -111,8 +111,12 @@ "filterTypeKey": "Κλειδί SSH", "filterTagsGroup": "Ετικέτες", "copyDeployCommand": "Αντιγραφή εντολής ανάπτυξης", + "cloneCredentialAction": "Κλωνοποίηση διαπιστευτηρίων", + "clonedCredential": "Κλωνοποιημένο {{name}}", + "clonedCredentialName": "{{name}} (αντίγραφο)", "deleteCredentialAction": "Διαγραφή διαπιστευτηρίων", "editCredentialAction": "Επεξεργασία διαπιστευτηρίων", + "failedToCloneCredential": "Αποτυχία κλωνοποίησης διαπιστευτηρίων", "failedToDeleteCredential": "Η διαγραφή των διαπιστευτηρίων απέτυχε", "idBadge": "ταυτότητα", "keyBadge": "ΚΛΕΙΔΙ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Διαδρομή υποδοχής παράγοντα", "agentSocketPathPlaceholder": "Αφήστε κενό για να χρησιμοποιήσετε το SSH_AUTH_SOCK", "agentSocketPathHint": "Αφήστε το κενό για αυτόματη ανίχνευση από τη μεταβλητή περιβάλλοντος SSH_AUTH_SOCK ή εισαγάγετε μια προσαρμοσμένη διαδρομή υποδοχής (π.χ. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Ταυτότητα Πράκτορα (Δημόσιο Κλειδί)", + "agentIdentityPlaceholder": "Αφήστε το κενό για να δοκιμάσετε όλα τα κλειδιά από τον πράκτορα", + "agentIdentityHint": "Επικολλήστε ένα δημόσιο κλειδί (π.χ. ssh-ed25519 AAAA...) για να προσφέρετε μόνο αυτό το κλειδί από τον παράγοντα. Συνιστάται όταν ο παράγοντας κατέχει πολλά κλειδιά, για να αποφευχθεί η υπέρβαση των MaxAuthTries του διακομιστή.", "shareSshAuthLabel": "Κοινή χρήση ελέγχου ταυτότητας SSH", "shareSshAuthDesc": "Δώστε στους παραλήπτες κρυπτογραφημένα αντίγραφα του ελέγχου ταυτότητας SSH αυτού του κεντρικού υπολογιστή. Τα προσωπικά διαπιστευτήρια ενός παραλήπτη εξακολουθούν να έχουν προτεραιότητα.", "tailscaleDeviceSelect": "Επιλογή συσκευής κλίμακας ουράς", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Δεν βρέθηκαν διαπιστευτήρια", "addCredentialBtn": "Προσθήκη διαπιστευτηρίων", "updateCredentialBtn": "Ενημέρωση διαπιστευτηρίων", + "editCredentialAction": "Επεξεργασία διαπιστευτηρίων", + "backToHost": "Επιστροφή στον κεντρικό υπολογιστή", + "saveCredentialAsNewBtn": "Αποθήκευση ως νέα", + "saveCredentialAsNewDesc": "Δημιουργεί ένα νέο πιστοποιητικό με αυτές τις αλλαγές και το αντιστοιχίζει σε αυτόν τον κεντρικό υπολογιστή. Το αρχικό πιστοποιητικό παραμένει αμετάβλητο.", + "credentialSavedAsNew": "Αποθηκεύτηκε ως νέο πιστοποιητικό", + "failedToSaveCredentialAsNew": "Αποτυχία αποθήκευσης ως νέας πιστοποίησης", "features": "Χαρακτηριστικά", "noFolder": "(Δεν υπάρχει φάκελος)", "deleteSelected": "Διαγράφω", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Συμπαγής", "displayShowTags": "Εμφάνιση ετικετών", "displayTrayOnClick": "Άνοιγμα ενεργειών με κλικ (αντί για τοποθέτηση του δείκτη του ποντικιού)", - "actionsVisibility": "Ενέργειες κεντρικού υπολογιστή", - "actionsVisibilityDesc": "Όταν εμφανίζονται τα κουμπιά σύνδεσης και διαχείρισης σε κάθε κεντρικό υπολογιστή", + "actionsVisibility": "Εμφάνιση ενέργειας οικοδεσπότη", + "actionsVisibilityDesc": "Επιλέξτε πότε θα αναπτύσσονται οι ενέργειες κεντρικού υπολογιστή. Επιλέξτε κλικ για να επαναφέρετε την προηγούμενη συμπεριφορά κλικ-για-ανάπτυξη.", "actionsAlways": "Πάντα ορατό", "actionsOnly": "Μόνο σύνδεση, ξεκούραση με κλικ", "actionsHover": "Εμφάνιση κατά την τοποθέτηση του δείκτη του ποντικιού", - "actionsClick": "Εμφάνιση με κλικ", + "actionsClick": "Κάντε κλικ για ανάπτυξη/σύμπτυξη", "openOnDoubleClick": "Άνοιγμα με διπλό κλικ", "openOnDoubleClickDesc": "Απαιτείται διπλό κλικ για το άνοιγμα μιας συνεδρίας κεντρικού υπολογιστή αντί για ένα μόνο κλικ", "deletedHost": "Διαγράφηκε {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Μεταφορτώθηκε {{name}}", "uploadFailed": "Αποτυχία μεταφόρτωσης {{name}}", "uploadDisabled": "Οι μεταφορτώσεις είναι απενεργοποιημένες για αυτήν τη σύνδεση", + "driveUnavailable": "Ενεργοποίηση ανακατεύθυνσης μονάδας δίσκου RDP πριν από την αποστολή αρχείων", "dropToUpload": "Αποθέστε αρχεία για μεταφόρτωση" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Όνομα φακέλου", "fileUploadedSuccessfully": "Το αρχείο \"{{name}}\" μεταφορτώθηκε με επιτυχία", "failedToUploadFile": "Η μεταφόρτωση του αρχείου απέτυχε", + "uploadingFolderFiles": "Μεταφόρτωση {{count}} αρχείου(ων)...", + "uploadedFolderFiles": "Μεταφορτώθηκαν με επιτυχία τα {{count}} αρχεία", + "uploadedFolderPartial": "Η μεταφόρτωση {{uploaded}} αρχείου(ων), {{failed}} απέτυχε", "fileDownloadedSuccessfully": "Το αρχείο \"{{name}}\" λήφθηκε με επιτυχία.", "failedToDownloadFile": "Αποτυχία λήψης αρχείου", "fileCreatedSuccessfully": "Το αρχείο \"{{name}}\" δημιουργήθηκε με επιτυχία", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} στοιχεία", "downloadFileSuccess": "Το αρχείο {{name}} λήφθηκε με επιτυχία.", "downloadFileFailed": "Η λήψη απέτυχε.", + "downloadingFile": "Λήψη {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Μετακίνηση σε {{name}}", "diffCompareWith": "Διαφορά σε σύγκριση με {{name}}", "dragOutsideToDownload": "Σύρετε έξω από το παράθυρο για λήψη ({{count}} αρχείων)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Αυτόματη δημιουργία λογαριασμών για χρήστες OIDC/SSO κατά την πρώτη σύνδεση (ανεξάρτητα από την εναλλαγή εγγραφής)", "oidcSilentLoginDefault": "Σιωπηλή σύνδεση OIDC από προεπιλογή", "oidcSilentLoginDefaultDesc": "Αυτόματη ανακατεύθυνση στα στοιχεία σύνδεσης OIDC σε κάθε επίσκεψη, παρακάμπτοντας εντελώς τη φόρμα σύνδεσης", + "oidcSilentLoginDefaultLockedDesc": "Αυτή η ρύθμιση είναι κλειδωμένη από τη μεταβλητή περιβάλλοντος OIDC_SILENT_LOGIN_DEFAULT και δεν μπορεί να αλλάξει εδώ.", "allowPasswordReset": "Να επιτρέπεται η επαναφορά κωδικού πρόσβασης", "allowPasswordResetDesc": "Επαναφορά κώδικα μέσω αρχείων καταγραφής Docker", "commandHistoryEnabled": "Ιστορικό εντολών", @@ -3317,11 +3337,11 @@ "createRole": "Δημιουργώ", "creating": "Δημιουργία...", "exportDatabase": "Εξαγωγή βάσης δεδομένων", - "exportDatabaseDesc": "Λήψη αντιγράφου ασφαλείας όλων των κεντρικών υπολογιστών, των διαπιστευτηρίων και των ρυθμίσεων", + "exportDatabaseDesc": "Λήψη ενός φορητού αντιγράφου ασφαλείας SQLite όλων των κεντρικών υπολογιστών, των διαπιστευτηρίων και των ρυθμίσεων", "export": "Εξαγωγή", "exporting": "Εξαγωγή...", "importDatabase": "Εισαγωγή βάσης δεδομένων", - "importDatabaseDesc": "Επαναφορά από αρχείο αντιγράφου ασφαλείας .sqlite", + "importDatabaseDesc": "Επαναφορά από ένα φορητό αντίγραφο ασφαλείας .sqlite σε οποιοδήποτε backend βάσης δεδομένων", "importDatabaseSelected": "Επιλεγμένα: {{name}}", "selectFile": "Επιλογή Αρχείου", "changeFile": "Αλλαγή", @@ -4516,6 +4536,7 @@ "selectProvider": "Επιλέξτε έναν πάροχο", "providerSettings": "Ρυθμίσεις παρόχου", "addProvider": "Προσθήκη παρόχου", + "editProvider": "Επεξεργασία παρόχου", "removeProvider": "Κατάργηση παρόχου", "providerType": "Προμηθευτής", "providerLabel": "Ονομα", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Για παράδειγμα: llama3.1 ή claude-opus-5", "labelRequired": "Δώστε σε αυτόν τον πάροχο ένα όνομα", "providerSaveFailed": "Δεν ήταν δυνατή η αποθήκευση του παρόχου", + "providerUpdated": "Ο πάροχος ενημερώθηκε", "providerDeleteFailed": "Δεν ήταν δυνατή η κατάργηση του παρόχου", "privateEndpointHint": "Οι διευθύνσεις στο ιδιωτικό σας δίκτυο χρειάζονται πρώτα έναν διαχειριστή για να επιτρέψει στον κεντρικό υπολογιστή.", "providerOllama": "Ολάμα", diff --git a/src/ui/locales/translated/es_ES.json b/src/ui/locales/translated/es_ES.json index ad17c40f..8b7f0958 100644 --- a/src/ui/locales/translated/es_ES.json +++ b/src/ui/locales/translated/es_ES.json @@ -111,8 +111,12 @@ "filterTypeKey": "Clave SSH", "filterTagsGroup": "Etiquetas", "copyDeployCommand": "Copiar comando de despliegue", + "cloneCredentialAction": "Credencial clonada", + "clonedCredential": "Clonado {{name}}", + "clonedCredentialName": "{{name}} (copiar)", "deleteCredentialAction": "Eliminar credenciales", "editCredentialAction": "Editar credenciales", + "failedToCloneCredential": "Error al clonar las credenciales", "failedToDeleteCredential": "Error al eliminar las credenciales", "idBadge": "IDENTIFICACIÓN", "keyBadge": "LLAVE", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Ruta del socket del agente", "agentSocketPathPlaceholder": "Dejar vacío para usar SSH_AUTH_SOCK", "agentSocketPathHint": "Dejar vacío para detectar automáticamente desde la variable de entorno SSH_AUTH_SOCK, o introduzca una ruta de socket personalizada (p. ej. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Identidad del agente (clave pública)", + "agentIdentityPlaceholder": "Déjelo en blanco para probar todas las claves del agente.", + "agentIdentityHint": "Pegue una clave pública (por ejemplo, ssh-ed25519 AAAA...) para ofrecer únicamente esa clave desde el agente. Se recomienda cuando el agente maneja muchas claves, para evitar exceder el límite de intentos de autenticación (MaxAuthTries) del servidor.", "shareSshAuthLabel": "Compartir autenticación SSH", "shareSshAuthDesc": "Entregue a los destinatarios copias cifradas de la autenticación SSH de este host. La credencial personal del destinatario seguirá teniendo prioridad.", "tailscaleDeviceSelect": "Seleccionar dispositivo Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "No se encontraron credenciales", "addCredentialBtn": "Añadir credencial", "updateCredentialBtn": "Actualizar credencial", + "editCredentialAction": "Editar credenciales", + "backToHost": "Volver al anfitrión", + "saveCredentialAsNewBtn": "Guardar como nuevo", + "saveCredentialAsNewDesc": "Crea una nueva credencial con estos cambios y la asigna a este host. La credencial original permanece sin cambios.", + "credentialSavedAsNew": "Guardado como una nueva credencial", + "failedToSaveCredentialAsNew": "No se pudo guardar como una nueva credencial.", "features": "Características", "noFolder": "(Sin carpeta)", "deleteSelected": "Eliminar", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Compacto", "displayShowTags": "Mostrar etiquetas", "displayTrayOnClick": "Abrir acciones al hacer clic (en lugar de al pasar el ratón por encima)", - "actionsVisibility": "Acciones del anfitrión", - "actionsVisibilityDesc": "Cuando aparecen los botones de conexión y administración en cada host", + "actionsVisibility": "Visualización de acciones del host", + "actionsVisibilityDesc": "Elige cuándo se expanden las acciones del anfitrión. Selecciona \"Clic\" para restaurar el comportamiento anterior de \"clic para expandir\".", "actionsAlways": "Siempre visible", "actionsOnly": "Conéctate solamente, el resto al hacer clic.", "actionsHover": "Mostrar al pasar el cursor", - "actionsClick": "Mostrar al hacer clic", + "actionsClick": "Haz clic para expandir/contraer", "openOnDoubleClick": "Abrir con doble clic", "openOnDoubleClickDesc": "Se requiere un doble clic para abrir una sesión de host en lugar de un solo clic.", "deletedHost": "Eliminado {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Subido {{name}}", "uploadFailed": "Error al cargar {{name}}", "uploadDisabled": "Las cargas están deshabilitadas para esta conexión.", + "driveUnavailable": "Habilite la redirección de unidades RDP antes de cargar archivos.", "dropToUpload": "Arrastra y suelta los archivos para subirlos" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Nombre de la carpeta", "fileUploadedSuccessfully": "Archivo \"{{name}}\" subido correctamente", "failedToUploadFile": "Error al subir el archivo", + "uploadingFolderFiles": "Subiendo {{count}} archivo(s)...", + "uploadedFolderFiles": "Se subieron correctamente los archivos {{count}}", + "uploadedFolderPartial": "Se subieron {{uploaded}} archivos, {{failed}} falló.", "fileDownloadedSuccessfully": "Archivo \"{{name}}\" descargado correctamente", "failedToDownloadFile": "Error al descargar el archivo", "fileCreatedSuccessfully": "Archivo \"{{name}}\" creado correctamente", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} elementos", "downloadFileSuccess": "Archivo {{name}} descargado correctamente", "downloadFileFailed": "Error en la descarga", + "downloadingFile": "Descargando {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Mover a {{name}}", "diffCompareWith": "Comparar diferencias con {{name}}", "dragOutsideToDownload": "Arrastrar fuera de la ventana para descargar ({{count}} archivos)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Crear automáticamente cuentas para usuarios OIDC/SSO en el primer inicio de sesión (independiente de la opción de registro)", "oidcSilentLoginDefault": "Inicio de sesión silencioso OIDC por defecto", "oidcSilentLoginDefaultDesc": "Redirige automáticamente al inicio de sesión OIDC en cada visita, omitiendo por completo el formulario de inicio de sesión", + "oidcSilentLoginDefaultLockedDesc": "Esta configuración está bloqueada por la variable de entorno OIDC_SILENT_LOGIN_DEFAULT y no se puede cambiar aquí.", "allowPasswordReset": "Permitir restablecimiento de contraseña", "allowPasswordResetDesc": "Código de restablecimiento a través de los registros de Docker", "commandHistoryEnabled": "Historial de comandos", @@ -3317,11 +3337,11 @@ "createRole": "Crear", "creating": "Creando...", "exportDatabase": "Exportar base de datos", - "exportDatabaseDesc": "Descargar una copia de seguridad de todos los hosts, credenciales y configuraciones", + "exportDatabaseDesc": "Descarga una copia de seguridad portátil de SQLite de todos los hosts, credenciales y configuraciones.", "export": "Exportar", "exporting": "Exportando...", "importDatabase": "Importar base de datos", - "importDatabaseDesc": "Restaurar desde un archivo de copia de seguridad .sqlite", + "importDatabaseDesc": "Restaurar desde una copia de seguridad .sqlite portátil en cualquier base de datos.", "importDatabaseSelected": "Seleccionado: {{name}}", "selectFile": "Seleccionar archivo", "changeFile": "Cambiar", @@ -4516,6 +4536,7 @@ "selectProvider": "Seleccione un proveedor", "providerSettings": "Configuración del proveedor", "addProvider": "Agregar proveedor", + "editProvider": "Editar proveedor", "removeProvider": "Eliminar proveedor", "providerType": "Proveedor", "providerLabel": "Nombre", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Por ejemplo: llama3.1 o claude-opus-5", "labelRequired": "Dale un nombre a este proveedor.", "providerSaveFailed": "No se pudo guardar el proveedor", + "providerUpdated": "Proveedor actualizado", "providerDeleteFailed": "No se pudo eliminar al proveedor.", "privateEndpointHint": "Las direcciones de su red privada necesitan que un administrador autorice el acceso al host previamente.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/fi_FI.json b/src/ui/locales/translated/fi_FI.json index e49312b6..bebaca72 100644 --- a/src/ui/locales/translated/fi_FI.json +++ b/src/ui/locales/translated/fi_FI.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-avain", "filterTagsGroup": "Tunnisteet", "copyDeployCommand": "Kopioi käyttöönottokomento", + "cloneCredentialAction": "Kloonaa tunnistetiedot", + "clonedCredential": "Kloonattu {{name}}", + "clonedCredentialName": "{{name}} (kopio)", "deleteCredentialAction": "Poista tunnistetiedot", "editCredentialAction": "Muokkaa tunnistetietoja", + "failedToCloneCredential": "Tunnistetietojen kloonaaminen epäonnistui", "failedToDeleteCredential": "Tunnuksen poistaminen epäonnistui", "idBadge": "Henkilöllisyystodistus", "keyBadge": "AVAIN", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Agentin soketin polku", "agentSocketPathPlaceholder": "Jätä tyhjäksi käyttääksesi SSH_AUTH_SOCKia", "agentSocketPathHint": "Jätä tyhjäksi, jos haluat automaattisen tunnistuksen SSH_AUTH_SOCK-ympäristömuuttujasta, tai anna mukautettu soketin polku (esim. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Agentin identiteetti (julkinen avain)", + "agentIdentityPlaceholder": "Jätä tyhjäksi kokeillaksesi kaikkia agentin avaimia", + "agentIdentityHint": "Liitä julkinen avain (esim. ssh-ed25519 AAAA...) tarjotaksesi agentilta vain kyseisen avaimen. Suositellaan, kun agentilla on useita avaimia, jotta palvelimen MaxAuthTries-arvo ei ylity.", "shareSshAuthLabel": "Jaa SSH-todennus", "shareSshAuthDesc": "Anna vastaanottajille salatut kopiot tämän isännän SSH-todennuksesta. Vastaanottajan henkilökohtaiset tunnistetiedot ovat edelleen etusijalla.", "tailscaleDeviceSelect": "Valitse Tailscale-laite", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Tunnuksia ei löytynyt", "addCredentialBtn": "Lisää tunnistetiedot", "updateCredentialBtn": "Päivitä tunnistetiedot", + "editCredentialAction": "Muokkaa tunnistetietoja", + "backToHost": "Takaisin isäntään", + "saveCredentialAsNewBtn": "Tallenna uutena", + "saveCredentialAsNewDesc": "Luo uudet tunnistetiedot näillä muutoksilla ja määrittää ne tälle isännälle. Alkuperäiset tunnistetiedot jätetään muuttumattomiksi.", + "credentialSavedAsNew": "Tallennettu uutena tunnistetietona", + "failedToSaveCredentialAsNew": "Uutena tunnistetietona tallentaminen epäonnistui", "features": "Ominaisuudet", "noFolder": "(Ei kansiota)", "deleteSelected": "Poistaa", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompakti", "displayShowTags": "Näytä tunnisteet", "displayTrayOnClick": "Avaa toiminnot napsauttamalla (hiirellä osoittimen sijaan)", - "actionsVisibility": "Isännän toiminnot", - "actionsVisibilityDesc": "Kun kunkin isännän yhteys- ja hallintapainikkeet tulevat näkyviin", + "actionsVisibility": "Isännän toiminnan näyttö", + "actionsVisibilityDesc": "Valitse, milloin isäntätoiminnot laajenevat. Valitse napsautus palauttaaksesi aiemman napsautuslaajennustoiminnon.", "actionsAlways": "Aina näkyvissä", "actionsOnly": "Vain yhdistä, lopeta napsauttamalla", "actionsHover": "Näytä hiiren osoittimen päällä", - "actionsClick": "Näytä klikattaessa", + "actionsClick": "Napsauta laajentaaksesi/pienentääksesi", "openOnDoubleClick": "Avaa kaksoisnapsauttamalla", "openOnDoubleClickDesc": "Vaadi kaksoisnapsautus isäntäistunnon avaamiseen yhden napsautuksen sijaan", "deletedHost": "Poistettu {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Ladattu {{name}}", "uploadFailed": "Lataus epäonnistui {{name}}", "uploadDisabled": "Lähetykset on poistettu käytöstä tällä yhteydellä", + "driveUnavailable": "Ota RDP-aseman uudelleenohjaus käyttöön ennen tiedostojen lataamista", "dropToUpload": "Pudota tiedostot ladattavaksi" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Kansion nimi", "fileUploadedSuccessfully": "Tiedosto \"{{name}}\" ladattu onnistuneesti", "failedToUploadFile": "Tiedoston lataaminen epäonnistui", + "uploadingFolderFiles": "Ladataan {{count}} tiedostoa/tiedostoja...", + "uploadedFolderFiles": "{{count}} tiedostoa ladattu onnistuneesti", + "uploadedFolderPartial": "{{uploaded}} tiedoston/tiedostojen {{failed}} lataaminen epäonnistui", "fileDownloadedSuccessfully": "Tiedosto \"{{name}}\" ladattu onnistuneesti", "failedToDownloadFile": "Tiedoston lataaminen epäonnistui", "fileCreatedSuccessfully": "Tiedosto \"{{name}}\" luotu onnistuneesti", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} kohdetta", "downloadFileSuccess": "Tiedosto {{name}} ladattu onnistuneesti", "downloadFileFailed": "Lataus epäonnistui", + "downloadingFile": "Ladataan {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Siirrä kohtaan {{name}}", "diffCompareWith": "Vertaa eroa kohtaan {{name}}", "dragOutsideToDownload": "Vedä ikkunan ulkopuolelle ladataksesi ({{count}} tiedostoa)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Luo OIDC/SSO-käyttäjille tilit automaattisesti ensimmäisen kirjautumisen yhteydessä (rekisteröintiasetuksista riippumatta)", "oidcSilentLoginDefault": "Hiljainen OIDC-kirjautuminen oletuksena", "oidcSilentLoginDefaultDesc": "Ohjaa automaattisesti OIDC-kirjautumiseen joka käynnillä, ohittaen kirjautumislomakkeen kokonaan", + "oidcSilentLoginDefaultLockedDesc": "Tämä asetus on lukittu OIDC_SILENT_LOGIN_DEFAULT-ympäristömuuttujalla, eikä sitä voi muuttaa täällä.", "allowPasswordReset": "Salli salasanan palautus", "allowPasswordResetDesc": "Palauta koodi Docker-lokien kautta", "commandHistoryEnabled": "Komentohistoria", @@ -3317,11 +3337,11 @@ "createRole": "Luoda", "creating": "Luodaan...", "exportDatabase": "Vie tietokanta", - "exportDatabaseDesc": "Lataa varmuuskopio kaikista isännistä, tunnistetiedoista ja asetuksista", + "exportDatabaseDesc": "Lataa kannettava SQLite-varmuuskopio kaikista isännistä, tunnistetiedoista ja asetuksista", "export": "Viedä", "exporting": "Vienti...", "importDatabase": "Tuo tietokanta", - "importDatabaseDesc": "Palauttaminen .sqlite-varmuuskopiotiedostosta", + "importDatabaseDesc": "Palauta kannettavasta .sqlite-varmuuskopiosta millä tahansa tietokannan taustajärjestelmällä", "importDatabaseSelected": "Valittu: {{name}}", "selectFile": "Valitse tiedosto", "changeFile": "Muuttaa", @@ -4516,6 +4536,7 @@ "selectProvider": "Valitse palveluntarjoaja", "providerSettings": "Palveluntarjoajan asetukset", "addProvider": "Lisää palveluntarjoaja", + "editProvider": "Muokkaa palveluntarjoajaa", "removeProvider": "Poista palveluntarjoaja", "providerType": "Palveluntarjoaja", "providerLabel": "Nimi", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Esimerkiksi: llama3.1 tai claude-opus-5", "labelRequired": "Anna tälle palveluntarjoajalle nimi", "providerSaveFailed": "Palveluntarjoajaa ei voitu tallentaa", + "providerUpdated": "Palveluntarjoaja päivitetty", "providerDeleteFailed": "Palveluntarjoajaa ei voitu poistaa", "privateEndpointHint": "Yksityisen verkon osoitteiden ylläpitäjän on ensin sallittava isäntä.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/fr_FR.json b/src/ui/locales/translated/fr_FR.json index 5d8c4818..2b9b7e97 100644 --- a/src/ui/locales/translated/fr_FR.json +++ b/src/ui/locales/translated/fr_FR.json @@ -111,8 +111,12 @@ "filterTypeKey": "Clé SSH", "filterTagsGroup": "Étiquettes", "copyDeployCommand": "Copier la commande de déploiement", + "cloneCredentialAction": "Cloner les informations d'identification", + "clonedCredential": "Cloné {{name}}", + "clonedCredentialName": "{{name}} (copie)", "deleteCredentialAction": "Supprimer les identifiants", "editCredentialAction": "Modifier les identifiants", + "failedToCloneCredential": "Échec du clonage des identifiants", "failedToDeleteCredential": "Échec de la suppression des identifiants", "idBadge": "IDENTIFIANT", "keyBadge": "CLÉ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Chemin du socket de l'agent", "agentSocketPathPlaceholder": "Laisser vide pour utiliser SSH_AUTH_SOCK", "agentSocketPathHint": "Laisser vide pour détecter automatiquement à partir de la variable d'environnement SSH_AUTH_SOCK, ou saisir un chemin de socket personnalisé (ex. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Identité de l'agent (clé publique)", + "agentIdentityPlaceholder": "Laisser vide pour essayer toutes les clés de l'agent", + "agentIdentityHint": "Collez une clé publique (par exemple, ssh-ed25519 AAAA...) pour que seule cette clé soit autorisée par l'agent. Cette opération est recommandée lorsque l'agent détient de nombreuses clés, afin d'éviter de dépasser le nombre maximal de tentatives d'authentification (MaxAuthTries) du serveur.", "shareSshAuthLabel": "Authentification SSH partagée", "shareSshAuthDesc": "Fournissez aux destinataires des copies chiffrées de l'authentification SSH de cet hôte. Les informations d'identification personnelles du destinataire restent prioritaires.", "tailscaleDeviceSelect": "Sélectionner un appareil Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Aucun identifiant trouvé", "addCredentialBtn": "Ajouter un identifiant", "updateCredentialBtn": "Mettre à jour l'identifiant", + "editCredentialAction": "Modifier les identifiants", + "backToHost": "Retour à l'hôte", + "saveCredentialAsNewBtn": "Enregistrer comme nouveau", + "saveCredentialAsNewDesc": "Crée une nouvelle authentification avec ces modifications et l'attribue à cet hôte. L'authentification d'origine reste inchangée.", + "credentialSavedAsNew": "Enregistré comme nouvelle authentification", + "failedToSaveCredentialAsNew": "Impossible d'enregistrer en tant que nouvelle information d'identification", "features": "Fonctionnalités", "noFolder": "(Aucun dossier)", "deleteSelected": "Supprimer", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Compact", "displayShowTags": "Afficher les étiquettes", "displayTrayOnClick": "Ouvrir les actions au clic (au lieu du survol)", - "actionsVisibility": "Actions de l'hôte", - "actionsVisibilityDesc": "Lorsque les boutons de connexion et de gestion de chaque hôte s'affichent", + "actionsVisibility": "Affichage des actions de l'hôte", + "actionsVisibilityDesc": "Choisissez le moment où les actions de l'hôte s'affichent. Sélectionnez « cliquer » pour rétablir le comportement précédent (cliquer pour développer).", "actionsAlways": "Toujours visible", "actionsOnly": "Connexion uniquement, le reste se fait en cliquant", "actionsHover": "Afficher au survol", - "actionsClick": "Afficher au clic", + "actionsClick": "Cliquez pour développer/réduire", "openOnDoubleClick": "Ouvrir en double-cliquant", "openOnDoubleClickDesc": "Exiger un double clic pour ouvrir une session hôte au lieu d'un simple clic", "deletedHost": "{{name}} supprimé", @@ -1867,6 +1880,7 @@ "uploaded": "Téléchargé {{name}}", "uploadFailed": "Échec du chargement {{name}}", "uploadDisabled": "Les chargements sont désactivés pour cette connexion.", + "driveUnavailable": "Activez la redirection du lecteur RDP avant de télécharger les fichiers", "dropToUpload": "Déposez les fichiers à télécharger" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Nom du dossier", "fileUploadedSuccessfully": "Fichier \"{{name}}\" téléversé avec succès", "failedToUploadFile": "Échec du téléversement du fichier", + "uploadingFolderFiles": "Téléchargement du ou des fichiers {{count}}...", + "uploadedFolderFiles": "Fichier(s) {{count}} téléchargé(s) avec succès", + "uploadedFolderPartial": "Le(s) fichier(s) {{uploaded}} téléchargé(s), {{failed}} a échoué", "fileDownloadedSuccessfully": "Fichier \"{{name}}\" téléchargé avec succès", "failedToDownloadFile": "Échec du téléchargement du fichier", "fileCreatedSuccessfully": "Fichier \"{{name}}\" créé avec succès", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} éléments", "downloadFileSuccess": "Fichier {{name}} téléchargé avec succès", "downloadFileFailed": "Échec du téléchargement", + "downloadingFile": "Téléchargement de {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Déplacer vers {{name}}", "diffCompareWith": "Diff avec {{name}}", "dragOutsideToDownload": "Faites glisser hors de la fenêtre pour télécharger ({{count}} fichiers)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Créer automatiquement des comptes pour les utilisateurs OIDC/SSO lors de la première connexion (indépendant de l'option d'enregistrement)", "oidcSilentLoginDefault": "Connexion silencieuse OIDC par défaut", "oidcSilentLoginDefaultDesc": "Rediriger automatiquement vers la connexion OIDC à chaque visite, sans afficher le formulaire de connexion", + "oidcSilentLoginDefaultLockedDesc": "Ce paramètre est verrouillé par la variable d'environnement OIDC_SILENT_LOGIN_DEFAULT et ne peut pas être modifié ici.", "allowPasswordReset": "Autoriser la réinitialisation du mot de passe", "allowPasswordResetDesc": "Code de réinitialisation via les journaux Docker", "commandHistoryEnabled": "Historique des commandes", @@ -3317,11 +3337,11 @@ "createRole": "Créer", "creating": "Création...", "exportDatabase": "Exporter la base de données", - "exportDatabaseDesc": "Télécharger une sauvegarde de tous les hôtes, identifiants et paramètres", + "exportDatabaseDesc": "Téléchargez une sauvegarde SQLite portable de tous les hôtes, identifiants et paramètres", "export": "Exporter", "exporting": "Exportation en cours...", "importDatabase": "Importer la base de données", - "importDatabaseDesc": "Restaurer à partir d'un fichier de sauvegarde .sqlite", + "importDatabaseDesc": "Restauration à partir d'une sauvegarde .sqlite portable sur n'importe quel système de gestion de base de données", "importDatabaseSelected": "Sélectionné : {{name}}", "selectFile": "Sélectionner un fichier", "changeFile": "Modifier", @@ -4516,6 +4536,7 @@ "selectProvider": "Sélectionnez un fournisseur", "providerSettings": "Paramètres du fournisseur", "addProvider": "Ajouter un fournisseur", + "editProvider": "Modifier le fournisseur", "removeProvider": "Supprimer le fournisseur", "providerType": "Fournisseur", "providerLabel": "Nom", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Par exemple : llama3.1 ou claude-opus-5", "labelRequired": "Donnez un nom à ce fournisseur", "providerSaveFailed": "Impossible de sauver le fournisseur", + "providerUpdated": "Mise à jour du fournisseur", "providerDeleteFailed": "Impossible de supprimer le fournisseur", "privateEndpointHint": "Les adresses de votre réseau privé nécessitent l'autorisation préalable d'un administrateur pour accéder à l'hôte.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/he_IL.json b/src/ui/locales/translated/he_IL.json index 16e78481..fd8861d6 100644 --- a/src/ui/locales/translated/he_IL.json +++ b/src/ui/locales/translated/he_IL.json @@ -111,8 +111,12 @@ "filterTypeKey": "מפתח SSH", "filterTagsGroup": "תגיות", "copyDeployCommand": "העתקת פקודת פריסה", + "cloneCredentialAction": "אישור שכפול", + "clonedCredential": "משוכפל {{name}}", + "clonedCredentialName": "{{name}} (העתק)", "deleteCredentialAction": "מחיקת אישורים", "editCredentialAction": "עריכת אישורים", + "failedToCloneCredential": "שכפול האישורים נכשל", "failedToDeleteCredential": "מחיקת האישורים נכשלה", "idBadge": "תְעוּדַת זֶהוּת", "keyBadge": "מַפְתֵחַ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "נתיב שקע הסוכן", "agentSocketPathPlaceholder": "השאר ריק כדי להשתמש ב-SSH_AUTH_SOCK", "agentSocketPathHint": "השאר ריק כדי לזהות אוטומטית ממשתנה הסביבה SSH_AUTH_SOCK, או הזן נתיב socket מותאם אישית (לדוגמה /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "זהות סוכן (מפתח ציבורי)", + "agentIdentityPlaceholder": "השאר ריק כדי לנסות את כל המפתחות מהסוכן", + "agentIdentityHint": "הדבק מפתח ציבורי (למשל ssh-ed25519 AAAA...) כדי להציע רק את המפתח הזה מהסוכן. מומלץ כאשר הסוכן מחזיק במספר מפתחות, כדי להימנע מחריגה מ-MaxAuthTries של השרת.", "shareSshAuthLabel": "שתף אימות SSH", "shareSshAuthDesc": "תן לנמענים עותקים מוצפנים של אימות SSH של מארח זה. אישורים אישיים של הנמען עדיין מקבלים עדיפות.", "tailscaleDeviceSelect": "בחר מכשיר Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "לא נמצאו אישורים", "addCredentialBtn": "הוסף אישור", "updateCredentialBtn": "עדכון אישורים", + "editCredentialAction": "עריכת אישורים", + "backToHost": "חזרה למארח", + "saveCredentialAsNewBtn": "שמור כחדש", + "saveCredentialAsNewDesc": "יוצר אישור חדש עם שינויים אלה ומקצה אותו למארח זה. האישור המקורי נשאר ללא שינוי.", + "credentialSavedAsNew": "נשמר כאישור חדש", + "failedToSaveCredentialAsNew": "נכשלה השמירה כאישור חדש", "features": "תכונות", "noFolder": "(אין תיקייה)", "deleteSelected": "לִמְחוֹק", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "קוֹמפָּקטִי", "displayShowTags": "הצג תגיות", "displayTrayOnClick": "פעולות פתיחה בלחיצה (במקום ריחוף)", - "actionsVisibility": "פעולות מארח", - "actionsVisibilityDesc": "כאשר כפתורי החיבור והניהול בכל מארח מופיעים", + "actionsVisibility": "תצוגת פעולה מארח", + "actionsVisibilityDesc": "בחר מתי פעולות מארח מתרחבות. בחר לחץ כדי לשחזר את התנהגות הלחץ להרחבה הקודמת.", "actionsAlways": "תמיד גלוי", "actionsOnly": "חיבור בלבד, מנוחה בלחיצה", "actionsHover": "הצג בעת ריחוף", - "actionsClick": "הצג בלחיצה", + "actionsClick": "לחץ כדי להרחיב/לכווץ", "openOnDoubleClick": "פתיחה בלחיצה כפולה", "openOnDoubleClickDesc": "נדרש לחיצה כפולה כדי לפתוח סשן מארח במקום לחיצה בודדת", "deletedHost": "נמחק {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "הועלה {{name}}", "uploadFailed": "נכשלה העלאת {{name}}", "uploadDisabled": "העלאות מושבתות עבור חיבור זה", + "driveUnavailable": "הפעל ניתוב מחדש של כונן RDP לפני העלאת קבצים", "dropToUpload": "שחררו קבצים להעלאה" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "שם התיקייה", "fileUploadedSuccessfully": "הקובץ \"{{name}}\" הועלה בהצלחה", "failedToUploadFile": "העלאת הקובץ נכשלה", + "uploadingFolderFiles": "מעלה קובץ/ים {{count}}...", + "uploadedFolderFiles": "הקבצים {{count}} הועלו בהצלחה", + "uploadedFolderPartial": "העלאת קובץ/קבצים {{uploaded}} , {{failed}} נכשלה", "fileDownloadedSuccessfully": "הקובץ \"{{name}}\" הורד בהצלחה", "failedToDownloadFile": "הורדת הקובץ נכשלה", "fileCreatedSuccessfully": "הקובץ \"{{name}}\" נוצר בהצלחה", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} פריטים", "downloadFileSuccess": "הקובץ {{name}} הורד בהצלחה", "downloadFileFailed": "ההורדה נכשלה", + "downloadingFile": "מוריד {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "מעבר אל {{name}}", "diffCompareWith": "השוואת הבדלים עם {{name}}", "dragOutsideToDownload": "גרור את הקבצים אל מחוץ לחלון כדי להוריד אותם ({{count}})", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "יצירה אוטומטית של חשבונות עבור משתמשי OIDC/SSO בכניסה הראשונה (ללא תלות בלחצן הרישום)", "oidcSilentLoginDefault": "כניסה שקטה ל-OIDC כברירת מחדל", "oidcSilentLoginDefaultDesc": "הפניה אוטומטית לכניסה ל-OIDC בכל ביקור, דילוג מוחלט על טופס הכניסה", + "oidcSilentLoginDefaultLockedDesc": "הגדרה זו נעולה על ידי משתנה הסביבה OIDC_SILENT_LOGIN_DEFAULT ולא ניתן לשנות אותה כאן.", "allowPasswordReset": "אפשר איפוס סיסמה", "allowPasswordResetDesc": "איפוס קוד דרך יומני Docker", "commandHistoryEnabled": "היסטוריית פקודות", @@ -3317,11 +3337,11 @@ "createRole": "לִיצוֹר", "creating": "יוצר...", "exportDatabase": "ייצוא מסד נתונים", - "exportDatabaseDesc": "הורד גיבוי של כל המארחים, האישורים וההגדרות", + "exportDatabaseDesc": "הורד גיבוי SQLite נייד של כל המארחים, האישורים וההגדרות", "export": "יְצוּא", "exporting": "ייצוא...", "importDatabase": "ייבוא מסד נתונים", - "importDatabaseDesc": "שחזור מקובץ גיבוי .sqlite", + "importDatabaseDesc": "שחזור מגיבוי .sqlite נייד על כל backend של מסד נתונים", "importDatabaseSelected": "נבחר: {{name}}", "selectFile": "בחר קובץ", "changeFile": "לְשַׁנוֹת", @@ -4516,6 +4536,7 @@ "selectProvider": "בחר ספק", "providerSettings": "הגדרות ספק", "addProvider": "הוסף ספק", + "editProvider": "עריכת ספק", "removeProvider": "הסר ספק", "providerType": "ספק", "providerLabel": "שֵׁם", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "לדוגמה: llama3.1 או claude-opus-5", "labelRequired": "תן שם לספק הזה", "providerSaveFailed": "לא ניתן היה לשמור את הספק", + "providerUpdated": "הספק עודכן", "providerDeleteFailed": "לא ניתן היה להסיר את הספק", "privateEndpointHint": "כתובות ברשת הפרטית שלך דורשות תחילה אישור ממנהל מערכת למארח.", "providerOllama": "אולמה", diff --git a/src/ui/locales/translated/hi_IN.json b/src/ui/locales/translated/hi_IN.json index 6d0fd6fb..522030ba 100644 --- a/src/ui/locales/translated/hi_IN.json +++ b/src/ui/locales/translated/hi_IN.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH कुंजी", "filterTagsGroup": "टैग", "copyDeployCommand": "डिप्लॉय कमांड कॉपी करें", + "cloneCredentialAction": "क्लोन क्रेडेंशियल", + "clonedCredential": "क्लोन किया गया {{name}}", + "clonedCredentialName": "{{name}} (कॉपी)", "deleteCredentialAction": "क्रेडेंशियल हटाएं", "editCredentialAction": "क्रेडेंशियल संपादित करें", + "failedToCloneCredential": "क्रेडेंशियल क्लोन करने में विफल", "failedToDeleteCredential": "क्रेडेंशियल हटाने में विफल", "idBadge": "पहचान", "keyBadge": "चाबी", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "एजेंट सॉकेट पथ", "agentSocketPathPlaceholder": "SSH_AUTH_SOCK का उपयोग करने के लिए खाली छोड़ें", "agentSocketPathHint": "SSH_AUTH_SOCK पर्यावरण चर से स्वतः पता लगाने के लिए खाली छोड़ें, या एक कस्टम सॉकेट पथ दर्ज करें (जैसे /run/user/1000/gnupg/S.gpg-agent.ssh)।", + "agentIdentityLabel": "एजेंट पहचान (सार्वजनिक कुंजी)", + "agentIdentityPlaceholder": "एजेंट से सभी कुंजियों को आज़माने के लिए इसे खाली छोड़ दें", + "agentIdentityHint": "एजेंट से केवल वही कुंजी उपलब्ध कराने के लिए एक सार्वजनिक कुंजी (जैसे ssh-ed25519 AAAA...) पेस्ट करें। यह तब अनुशंसित है जब एजेंट के पास कई कुंजियाँ हों, ताकि सर्वर की अधिकतम प्रमाणीकरण सीमा से अधिक न हो।", "shareSshAuthLabel": "SSH प्रमाणीकरण साझा करें", "shareSshAuthDesc": "प्राप्तकर्ताओं को इस होस्ट के SSH प्रमाणीकरण की एन्क्रिप्टेड प्रतियां प्रदान करें। प्राप्तकर्ता के व्यक्तिगत क्रेडेंशियल को ही प्राथमिकता दी जाएगी।", "tailscaleDeviceSelect": "Tailscale डिवाइस चुनें", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "कोई क्रेडेंशियल नहीं मिला", "addCredentialBtn": "क्रेडेंशियल जोड़ें", "updateCredentialBtn": "क्रेडेंशियल अपडेट करें", + "editCredentialAction": "क्रेडेंशियल संपादित करें", + "backToHost": "होस्ट पर वापस जाएँ", + "saveCredentialAsNewBtn": "नए के रूप में सहेजें", + "saveCredentialAsNewDesc": "इन परिवर्तनों के साथ एक नया क्रेडेंशियल बनाता है और इसे इस होस्ट को असाइन करता है। मूल क्रेडेंशियल अपरिवर्तित रहता है।", + "credentialSavedAsNew": "नए क्रेडेंशियल के रूप में सहेजा गया", + "failedToSaveCredentialAsNew": "नए क्रेडेंशियल के रूप में सहेजने में विफल", "features": "विशेषताएं", "noFolder": "(कोई फ़ोल्डर नहीं)", "deleteSelected": "हटाएं", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "सघन", "displayShowTags": "टैग्स दिखाएं", "displayTrayOnClick": "क्लिक करने पर क्रियाएं खोलें (होवर करने के बजाय)", - "actionsVisibility": "मेज़बान कार्रवाइयाँ", - "actionsVisibilityDesc": "जब प्रत्येक होस्ट पर कनेक्शन और प्रबंधन बटन दिखाई देते हैं", + "actionsVisibility": "होस्ट क्रिया प्रदर्शन", + "actionsVisibilityDesc": "होस्ट क्रियाओं के विस्तार का समय चुनें। पहले की क्लिक-टू-एक्सपैंड प्रक्रिया को बहाल करने के लिए क्लिक का चयन करें।", "actionsAlways": "सदैव दृश्य", "actionsOnly": "केवल कनेक्ट करें, क्लिक पर आराम करें", "actionsHover": "होवर करने पर दिखाएँ", - "actionsClick": "क्लिक करने पर दिखाएँ", + "actionsClick": "विस्तार/संक्षिप्त करने के लिए क्लिक करें", "openOnDoubleClick": "डबल क्लिक करके खोलें", "openOnDoubleClickDesc": "होस्ट सेशन खोलने के लिए सिंगल क्लिक के बजाय डबल क्लिक की आवश्यकता होगी।", "deletedHost": "{{name}} हटा दिया गया", @@ -1867,6 +1880,7 @@ "uploaded": "अपलोड किया गया {{name}}", "uploadFailed": "अपलोड करने में विफल {{name}}", "uploadDisabled": "इस कनेक्शन के लिए अपलोड अक्षम हैं।", + "driveUnavailable": "फ़ाइलें अपलोड करने से पहले RDP ड्राइव रीडायरेक्शन को सक्षम करें", "dropToUpload": "अपलोड करने के लिए फ़ाइलें ड्रॉप करें" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "फ़ोल्डर का नाम", "fileUploadedSuccessfully": "फ़ाइल \"{{name}}\" सफलतापूर्वक अपलोड हो गई", "failedToUploadFile": "फ़ाइल अपलोड करने में विफल", + "uploadingFolderFiles": "{{count}} फ़ाइलें अपलोड की जा रही हैं...", + "uploadedFolderFiles": "{{count}} फ़ाइलें सफलतापूर्वक अपलोड हो गईं", + "uploadedFolderPartial": "{{uploaded}} फ़ाइलें अपलोड की गईं, {{failed}} विफल रहीं", "fileDownloadedSuccessfully": "फ़ाइल \"{{name}}\" सफलतापूर्वक डाउनलोड हो गई", "failedToDownloadFile": "फ़ाइल डाउनलोड करने में विफल", "fileCreatedSuccessfully": "फ़ाइल \"{{name}}\" सफलतापूर्वक बनाई गई", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} आइटम", "downloadFileSuccess": "फ़ाइल {{name}} सफलतापूर्वक डाउनलोड हुई", "downloadFileFailed": "डाउनलोड विफल", + "downloadingFile": "डाउनलोड हो रहा है {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "{{name}} में ले जाएँ", "diffCompareWith": "{{name}} के साथ डिफ तुलना करें", "dragOutsideToDownload": "डाउनलोड करने के लिए विंडो से बाहर खींचें ({{count}} फ़ाइलें)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "OIDC/SSO उपयोगकर्ताओं के लिए पहली लॉगिन पर स्वचालित रूप से खाते बनाएं (पंजीकरण टॉगल से स्वतंत्र)", "oidcSilentLoginDefault": "डिफ़ॉल्ट रूप से साइलेंट OIDC लॉगिन", "oidcSilentLoginDefaultDesc": "हर विज़िट पर स्वचालित रूप से OIDC लॉगिन पर रीडायरेक्ट करें, लॉगिन फ़ॉर्म को पूरी तरह छोड़ दें", + "oidcSilentLoginDefaultLockedDesc": "यह सेटिंग OIDC_SILENT_LOGIN_DEFAULT पर्यावरण चर द्वारा लॉक की गई है और इसे यहां बदला नहीं जा सकता है।", "allowPasswordReset": "पासवर्ड रीसेट की अनुमति दें", "allowPasswordResetDesc": "Docker लॉग के माध्यम से रीसेट कोड", "commandHistoryEnabled": "कमांड इतिहास", @@ -3317,11 +3337,11 @@ "createRole": "बनाएं", "creating": "बन रहा है...", "exportDatabase": "डेटाबेस निर्यात करें", - "exportDatabaseDesc": "सभी होस्टों, क्रेडेंशियलों और सेटिंग्स का बैकअप डाउनलोड करें", + "exportDatabaseDesc": "सभी होस्ट, क्रेडेंशियल और सेटिंग्स का पोर्टेबल SQLite बैकअप डाउनलोड करें", "export": "निर्यात करें", "exporting": "निर्यात हो रहा है...", "importDatabase": "डेटाबेस आयात करें", - "importDatabaseDesc": ".sqlite बैकअप फ़ाइल से पुनर्स्थापित करें", + "importDatabaseDesc": "किसी भी डेटाबेस बैकएंड पर पोर्टेबल .sqlite बैकअप से पुनर्स्थापित करें", "importDatabaseSelected": "चयनित: {{name}}", "selectFile": "फ़ाइल चुनें", "changeFile": "बदलें", @@ -4516,6 +4536,7 @@ "selectProvider": "एक प्रदाता का चयन करें", "providerSettings": "प्रदाता सेटिंग्स", "addProvider": "प्रदाता जोड़ें", + "editProvider": "प्रदाता संपादित करें", "removeProvider": "प्रदाता को हटाएँ", "providerType": "प्रदाता", "providerLabel": "नाम", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "उदाहरण के लिए: llama3.1 या claude-opus-5", "labelRequired": "इस प्रदाता को एक नाम दें", "providerSaveFailed": "प्रदाता को बचाया नहीं जा सका", + "providerUpdated": "प्रदाता ने अपडेट किया", "providerDeleteFailed": "प्रदाता को हटाया नहीं जा सका", "privateEndpointHint": "आपके निजी नेटवर्क पर मौजूद पतों के लिए पहले एक प्रशासक द्वारा होस्ट को अनुमति देना आवश्यक है।", "providerOllama": "ओलामा", diff --git a/src/ui/locales/translated/hu_HU.json b/src/ui/locales/translated/hu_HU.json index f9f94b0e..f3fbf6f4 100644 --- a/src/ui/locales/translated/hu_HU.json +++ b/src/ui/locales/translated/hu_HU.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-kulcs", "filterTagsGroup": "Címkék", "copyDeployCommand": "Telepítési parancs másolása", + "cloneCredentialAction": "Klónozott hitelesítő adatok", + "clonedCredential": "Klónozott {{name}}", + "clonedCredentialName": "{{name}} (másolat)", "deleteCredentialAction": "Hitelesítő adatok törlése", "editCredentialAction": "Hitelesítő adatok szerkesztése", + "failedToCloneCredential": "Nem sikerült klónozni a hitelesítő adatokat", "failedToDeleteCredential": "Nem sikerült törölni a hitelesítő adatokat", "idBadge": "azonosító", "keyBadge": "KULCSFONTOSSÁGÚ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Ügynök socket útvonala", "agentSocketPathPlaceholder": "Hagyja üresen az SSH_AUTH_SOCK használatához", "agentSocketPathHint": "Hagyja üresen az SSH_AUTH_SOCK környezeti változóból történő automatikus felismeréshez, vagy adjon meg egyéni socket elérési utat (pl. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Ügynök azonosítója (nyilvános kulcs)", + "agentIdentityPlaceholder": "Hagyja üresen, ha az ügynöktől származó összes kulcsot ki szeretné próbálni", + "agentIdentityHint": "Illesszen be egy nyilvános kulcsot (pl. ssh-ed25519 AAAA...), hogy az ügynök csak ezt a kulcsot kínálja fel. Ajánlott, ha az ügynök sok kulccsal rendelkezik, hogy elkerülje a szerver MaxAuthTries értékének túllépését.", "shareSshAuthLabel": "SSH-hitelesítés megosztása", "shareSshAuthDesc": "Küldje el a címzetteknek a gazdagép SSH-hitelesítésének titkosított másolatait. A címzett személyes hitelesítő adatai továbbra is elsőbbséget élveznek.", "tailscaleDeviceSelect": "Tailscale eszköz kiválasztása", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Nem találhatók hitelesítő adatok", "addCredentialBtn": "Hitelesítő adat hozzáadása", "updateCredentialBtn": "Hitelesítő adatok frissítése", + "editCredentialAction": "Hitelesítő adatok szerkesztése", + "backToHost": "Vissza a Hosthoz", + "saveCredentialAsNewBtn": "Mentés újként", + "saveCredentialAsNewDesc": "Létrehoz egy új hitelesítő adatot ezekkel a módosításokkal, és hozzárendeli azt ehhez a gazdagéphez. Az eredeti hitelesítő adat változatlan marad.", + "credentialSavedAsNew": "Új hitelesítő adatként mentve", + "failedToSaveCredentialAsNew": "Nem sikerült menteni új hitelesítő adatként", "features": "Jellemzők", "noFolder": "(Nincs mappa)", "deleteSelected": "Töröl", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompakt", "displayShowTags": "Címkék megjelenítése", "displayTrayOnClick": "Műveletek megnyitása kattintással (az egérrel való rámutatás helyett)", - "actionsVisibility": "Gazdagép-műveletek", - "actionsVisibilityDesc": "Amikor megjelennek a csatlakozási és kezelési gombok az egyes gazdagépeken", + "actionsVisibility": "Gazdagép művelet megjelenítése", + "actionsVisibilityDesc": "Válassza ki, hogy mikor bontódnak ki a gazdaműveletek. Válassza a „kattintás” lehetőséget a korábbi „kattintásos kibontás” viselkedés visszaállításához.", "actionsAlways": "Mindig látható", "actionsOnly": "Csak csatlakozás, kattintásra maradjon", "actionsHover": "Mutatás az egérrel való lebegtetéskor", - "actionsClick": "Megjelenítés kattintásra", + "actionsClick": "Kattintson a kibontáshoz/összecsukáshoz", "openOnDoubleClick": "Megnyitás dupla kattintással", "openOnDoubleClickDesc": "Dupla kattintás szükséges a gazdamunkamenet megnyitásához egyetlen kattintás helyett", "deletedHost": "Törölt {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Feltöltve {{name}}", "uploadFailed": "Nem sikerült feltölteni {{name}}", "uploadDisabled": "A feltöltések le vannak tiltva ennél a kapcsolatnál.", + "driveUnavailable": "RDP meghajtó átirányításának engedélyezése fájlok feltöltése előtt", "dropToUpload": "Fájlok húzása a feltöltéshez" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Mappa neve", "fileUploadedSuccessfully": "A(z) \"{{name}}\" fájl feltöltése sikeresen megtörtént.", "failedToUploadFile": "Nem sikerült feltölteni a fájlt", + "uploadingFolderFiles": "{{count}} fájl(ok) feltöltése...", + "uploadedFolderFiles": "{{count}} fájl(ok) feltöltve sikeresen", + "uploadedFolderPartial": "{{uploaded}} fájl(ok) feltöltése, {{failed}} sikertelen", "fileDownloadedSuccessfully": "A(z) \"{{name}}\" fájl sikeresen letöltve", "failedToDownloadFile": "Nem sikerült letölteni a fájlt", "fileCreatedSuccessfully": "A(z) \"{{name}}\" fájl sikeresen létrehozva", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} elem", "downloadFileSuccess": "A {{name}} fájl sikeresen letöltve", "downloadFileFailed": "Letöltés sikertelen", + "downloadingFile": "{{name}}… letöltése", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Áthelyezés ide: {{name}}", "diffCompareWith": "Különbség összehasonlítása {{name}}-val", "dragOutsideToDownload": "Húzd az ablakon kívülre a letöltéshez ({{count}} fájl)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Automatikus fiókok létrehozása OIDC/SSO felhasználók számára az első bejelentkezéskor (a regisztrációs kapcsolótól függetlenül)", "oidcSilentLoginDefault": "Csendes OIDC bejelentkezés alapértelmezés szerint", "oidcSilentLoginDefaultDesc": "Automatikus átirányítás az OIDC bejelentkezésre minden látogatáskor, a bejelentkezési űrlap teljes kihagyásával", + "oidcSilentLoginDefaultLockedDesc": "Ezt a beállítást az OIDC_SILENT_LOGIN_DEFAULT környezeti változó zárolja, és itt nem módosítható.", "allowPasswordReset": "Jelszó-visszaállítás engedélyezése", "allowPasswordResetDesc": "Kód visszaállítása Docker naplókon keresztül", "commandHistoryEnabled": "Parancsnoki előzmények", @@ -3317,11 +3337,11 @@ "createRole": "Teremt", "creating": "Létrehozás...", "exportDatabase": "Adatbázis exportálása", - "exportDatabaseDesc": "Töltsön le egy biztonsági másolatot az összes gazdagépről, hitelesítő adatokról és beállításokról", + "exportDatabaseDesc": "Töltsön le egy hordozható SQLite biztonsági mentést az összes gazdagépről, hitelesítő adatokról és beállításokról", "export": "Export", "exporting": "Exportálás...", "importDatabase": "Adatbázis importálása", - "importDatabaseDesc": "Visszaállítás .sqlite biztonsági mentésfájlból", + "importDatabaseDesc": "Visszaállítás hordozható .sqlite biztonsági mentésből bármely adatbázis-háttérrendszeren", "importDatabaseSelected": "Kiválasztva: {{name}}", "selectFile": "Fájl kiválasztása", "changeFile": "Változás", @@ -4516,6 +4536,7 @@ "selectProvider": "Válasszon szolgáltatót", "providerSettings": "Szolgáltatói beállítások", "addProvider": "Szolgáltató hozzáadása", + "editProvider": "Szolgáltató szerkesztése", "removeProvider": "Szolgáltató eltávolítása", "providerType": "Szolgáltató", "providerLabel": "Név", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Például: llama3.1 vagy claude-opus-5", "labelRequired": "Adjon nevet ennek a szolgáltatónak", "providerSaveFailed": "Nem sikerült menteni a szolgáltatót", + "providerUpdated": "Szolgáltató frissítve", "providerDeleteFailed": "Nem sikerült eltávolítani a szolgáltatót", "privateEndpointHint": "A privát hálózaton lévő címekhez először egy rendszergazdának kell engedélyeznie a gazdagépet.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/id_ID.json b/src/ui/locales/translated/id_ID.json index c2031ac0..9024e2fb 100644 --- a/src/ui/locales/translated/id_ID.json +++ b/src/ui/locales/translated/id_ID.json @@ -111,8 +111,12 @@ "filterTypeKey": "Kunci SSH", "filterTagsGroup": "Tag", "copyDeployCommand": "Salin perintah penyebaran", + "cloneCredentialAction": "Kloning kredensial", + "clonedCredential": "Dikloning {{name}}", + "clonedCredentialName": "{{name}} (salinan)", "deleteCredentialAction": "Hapus kredensial", "editCredentialAction": "Edit kredensial", + "failedToCloneCredential": "Gagal mengkloning kredensial", "failedToDeleteCredential": "Gagal menghapus kredensial", "idBadge": "PENGENAL", "keyBadge": "KUNCI", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Jalur Soket Agen", "agentSocketPathPlaceholder": "Biarkan kosong untuk menggunakan SSH_AUTH_SOCK", "agentSocketPathHint": "Biarkan kosong untuk mendeteksi secara otomatis dari variabel lingkungan SSH_AUTH_SOCK, atau masukkan jalur soket khusus (misalnya /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Identitas Agen (Kunci Publik)", + "agentIdentityPlaceholder": "Biarkan kosong untuk mencoba semua tombol dari agen.", + "agentIdentityHint": "Tempelkan kunci publik (misalnya ssh-ed25519 AAAA...) untuk hanya menawarkan kunci tersebut dari agen. Disarankan jika agen memegang banyak kunci, untuk menghindari melebihi MaxAuthTries server.", "shareSshAuthLabel": "Bagikan Otentikasi SSH", "shareSshAuthDesc": "Berikan kepada penerima salinan terenkripsi dari otentikasi SSH host ini. Kredensial pribadi penerima tetap diutamakan.", "tailscaleDeviceSelect": "Pilih perangkat Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Tidak ditemukan kredensial.", "addCredentialBtn": "Tambahkan Kredensial", "updateCredentialBtn": "Perbarui Kredensial", + "editCredentialAction": "Edit kredensial", + "backToHost": "Kembali ke Host", + "saveCredentialAsNewBtn": "Simpan sebagai Baru", + "saveCredentialAsNewDesc": "Membuat kredensial baru dengan perubahan ini dan menetapkannya ke host ini. Kredensial asli dibiarkan tidak berubah.", + "credentialSavedAsNew": "Disimpan sebagai kredensial baru", + "failedToSaveCredentialAsNew": "Gagal menyimpan sebagai kredensial baru.", "features": "Fitur", "noFolder": "(Tidak ada folder)", "deleteSelected": "Menghapus", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompak", "displayShowTags": "Tampilkan tag", "displayTrayOnClick": "Aksi terbuka saat diklik (bukan saat diarahkan kursor)", - "actionsVisibility": "Tindakan tuan rumah", - "actionsVisibilityDesc": "Saat tombol koneksi dan manajemen pada setiap host muncul", + "actionsVisibility": "Tampilan tindakan host", + "actionsVisibilityDesc": "Pilih kapan tindakan host diperluas. Pilih klik untuk mengembalikan perilaku klik-untuk-memperluas sebelumnya.", "actionsAlways": "Selalu terlihat", "actionsOnly": "Hanya perlu terhubung, sisanya dengan sekali klik.", "actionsHover": "Tampilkan saat kursor diarahkan ke atasnya.", - "actionsClick": "Tampilkan saat diklik", + "actionsClick": "Klik untuk memperbesar/memperkecil", "openOnDoubleClick": "Buka dengan klik dua kali", "openOnDoubleClickDesc": "Membutuhkan klik ganda untuk membuka sesi host, bukan klik tunggal.", "deletedHost": "Dihapus {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Diunggah {{name}}", "uploadFailed": "Gagal mengunggah {{name}}", "uploadDisabled": "Unggahan dinonaktifkan untuk koneksi ini.", + "driveUnavailable": "Aktifkan pengalihan drive RDP sebelum mengunggah file.", "dropToUpload": "Seret file untuk diunggah" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Nama Folder", "fileUploadedSuccessfully": "Berkas \"{{name}}\" berhasil diunggah", "failedToUploadFile": "Gagal mengunggah file", + "uploadingFolderFiles": "Mengunggah {{count}} berkas...", + "uploadedFolderFiles": "Berhasil mengunggah {{count}} berkas", + "uploadedFolderPartial": "Pengunggahan {{uploaded}} berkas, {{failed}} gagal", "fileDownloadedSuccessfully": "File \"{{name}}\" berhasil diunduh", "failedToDownloadFile": "Gagal mengunduh file", "fileCreatedSuccessfully": "File \"{{name}}\" berhasil dibuat", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} item", "downloadFileSuccess": "File {{name}} berhasil diunduh", "downloadFileFailed": "Pengunduhan gagal", + "downloadingFile": "Mengunduh {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Pindah ke {{name}}", "diffCompareWith": "Bandingkan perbedaan dengan {{name}}", "dragOutsideToDownload": "Seret ke luar jendela untuk mengunduh ({{count}} file)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Buat akun secara otomatis untuk pengguna OIDC/SSO pada saat login pertama (terlepas dari tombol pendaftaran)", "oidcSilentLoginDefault": "Login OIDC Senyap Secara Default", "oidcSilentLoginDefaultDesc": "Secara otomatis mengarahkan ke halaman login OIDC pada setiap kunjungan, melewati formulir login sepenuhnya.", + "oidcSilentLoginDefaultLockedDesc": "Pengaturan ini dikunci oleh variabel lingkungan OIDC_SILENT_LOGIN_DEFAULT dan tidak dapat diubah di sini.", "allowPasswordReset": "Izinkan Reset Kata Sandi", "allowPasswordResetDesc": "Reset kode melalui log Docker", "commandHistoryEnabled": "Riwayat Perintah", @@ -3317,11 +3337,11 @@ "createRole": "Membuat", "creating": "Membuat...", "exportDatabase": "Ekspor Basis Data", - "exportDatabaseDesc": "Unduh cadangan semua host, kredensial, dan pengaturan.", + "exportDatabaseDesc": "Unduh cadangan SQLite portabel dari semua host, kredensial, dan pengaturan.", "export": "Ekspor", "exporting": "Mengekspor...", "importDatabase": "Impor Basis Data", - "importDatabaseDesc": "Pulihkan dari file cadangan .sqlite", + "importDatabaseDesc": "Pulihkan dari cadangan .sqlite portabel pada backend basis data apa pun.", "importDatabaseSelected": "Dipilih: {{name}}", "selectFile": "Pilih File", "changeFile": "Mengubah", @@ -4516,6 +4536,7 @@ "selectProvider": "Pilih penyedia", "providerSettings": "Pengaturan penyedia", "addProvider": "Tambahkan penyedia", + "editProvider": "Penyedia pengeditan", "removeProvider": "Hapus penyedia", "providerType": "Penyedia", "providerLabel": "Nama", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Misalnya: llama3.1 atau claude-opus-5", "labelRequired": "Beri nama penyedia ini", "providerSaveFailed": "Penyedia layanan tidak dapat disimpan.", + "providerUpdated": "Penyedia diperbarui", "providerDeleteFailed": "Penyedia layanan tidak dapat dihapus.", "privateEndpointHint": "Alamat-alamat di jaringan pribadi Anda memerlukan izin administrator terlebih dahulu untuk host tersebut.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/it_IT.json b/src/ui/locales/translated/it_IT.json index 2e2690e7..4a66c006 100644 --- a/src/ui/locales/translated/it_IT.json +++ b/src/ui/locales/translated/it_IT.json @@ -111,8 +111,12 @@ "filterTypeKey": "Chiave SSH", "filterTagsGroup": "Tag", "copyDeployCommand": "Copia il comando di distribuzione", + "cloneCredentialAction": "Credenziali di clonazione", + "clonedCredential": "Clonato {{name}}", + "clonedCredentialName": "{{name}} (copia)", "deleteCredentialAction": "Elimina le credenziali", "editCredentialAction": "Modifica credenziali", + "failedToCloneCredential": "Impossibile clonare le credenziali", "failedToDeleteCredential": "Impossibile eliminare le credenziali", "idBadge": "ID", "keyBadge": "CHIAVE", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Percorso socket agente", "agentSocketPathPlaceholder": "Lascia vuoto per usare SSH_AUTH_SOCK", "agentSocketPathHint": "Lascia vuoto per il rilevamento automatico dalla variabile d'ambiente SSH_AUTH_SOCK o inserisci un percorso socket personalizzato (es. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Identità dell'agente (chiave pubblica)", + "agentIdentityPlaceholder": "Lascia vuoto per provare tutte le chiavi dell'agente", + "agentIdentityHint": "Incolla una chiave pubblica (ad esempio ssh-ed25519 AAAA...) per offrire solo quella chiave dall'agente. Consigliato quando l'agente possiede molte chiavi, per evitare di superare il limite di MaxAuthTries del server.", "shareSshAuthLabel": "Autenticazione SSH condivisa", "shareSshAuthDesc": "Fornite ai destinatari copie crittografate delle credenziali di autenticazione SSH di questo host. Le credenziali personali del destinatario hanno comunque la precedenza.", "tailscaleDeviceSelect": "Seleziona dispositivo Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Nessuna credenziale trovata", "addCredentialBtn": "Aggiungi credenziale", "updateCredentialBtn": "Aggiorna credenziale", + "editCredentialAction": "Modifica credenziali", + "backToHost": "Torna all'host", + "saveCredentialAsNewBtn": "Salva come nuovo", + "saveCredentialAsNewDesc": "Crea una nuova credenziale con queste modifiche e la assegna a questo host. La credenziale originale rimane invariata.", + "credentialSavedAsNew": "Salvato come nuova credenziale", + "failedToSaveCredentialAsNew": "Impossibile salvare le credenziali come nuove.", "features": "Funzionalità", "noFolder": "(Nessuna cartella)", "deleteSelected": "Elimina", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Compatto", "displayShowTags": "Mostra tag", "displayTrayOnClick": "Apri le azioni al clic (anziché al passaggio del mouse)", - "actionsVisibility": "Azioni dell'host", - "actionsVisibilityDesc": "Quando vengono visualizzati i pulsanti di connessione e gestione su ciascun host", + "actionsVisibility": "Visualizzazione delle azioni host", + "actionsVisibilityDesc": "Scegli quando le azioni dell'host si espandono. Seleziona \"clicca\" per ripristinare il precedente comportamento di espansione tramite clic.", "actionsAlways": "Sempre visibile", "actionsOnly": "Connetti solo, il resto al clic", "actionsHover": "Mostra al passaggio del mouse", - "actionsClick": "Mostra al clic", + "actionsClick": "Fai clic per espandere/comprimere", "openOnDoubleClick": "Apri con un doppio clic", "openOnDoubleClickDesc": "Richiedere un doppio clic per aprire una sessione host anziché un singolo clic", "deletedHost": "{{name}} eliminato", @@ -1867,6 +1880,7 @@ "uploaded": "Caricato {{name}}", "uploadFailed": "Impossibile caricare {{name}}", "uploadDisabled": "Il caricamento è disabilitato per questa connessione.", + "driveUnavailable": "Abilita il reindirizzamento delle unità RDP prima di caricare i file", "dropToUpload": "Trascina i file da caricare" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Nome cartella", "fileUploadedSuccessfully": "File \"{{name}}\" caricato con successo", "failedToUploadFile": "Caricamento file non riuscito", + "uploadingFolderFiles": "Caricamento del/dei file {{count}} in corso...", + "uploadedFolderFiles": "File caricati con successo {{count}}", + "uploadedFolderPartial": "Uploaded {{uploaded}} file(s), {{failed}} failed", "fileDownloadedSuccessfully": "File \"{{name}}\" scaricato con successo", "failedToDownloadFile": "Scaricamento file non riuscito", "fileCreatedSuccessfully": "File \"{{name}}\" creato con successo", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} elementi", "downloadFileSuccess": "File {{name}} scaricato con successo", "downloadFileFailed": "Download non riuscito", + "downloadingFile": "Download in corso {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Sposta in {{name}}", "diffCompareWith": "Diff con {{name}}", "dragOutsideToDownload": "Trascina fuori dalla finestra per scaricare ({{count}} file)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Crea automaticamente account per utenti OIDC/SSO al primo accesso (indipendentemente dall'opzione di registrazione)", "oidcSilentLoginDefault": "Accesso OIDC silenzioso predefinito", "oidcSilentLoginDefaultDesc": "Reindirizza automaticamente all'accesso OIDC ad ogni visita, saltando completamente il modulo di accesso", + "oidcSilentLoginDefaultLockedDesc": "Questa impostazione è bloccata dalla variabile d'ambiente OIDC_SILENT_LOGIN_DEFAULT e non può essere modificata qui.", "allowPasswordReset": "Consenti reset password", "allowPasswordResetDesc": "Codice di reset tramite log Docker", "commandHistoryEnabled": "Cronologia comandi", @@ -3317,11 +3337,11 @@ "createRole": "Crea", "creating": "Creazione...", "exportDatabase": "Esporta database", - "exportDatabaseDesc": "Scarica un backup di tutti gli host, credenziali e impostazioni", + "exportDatabaseDesc": "Scarica un backup SQLite portatile di tutti gli host, le credenziali e le impostazioni", "export": "Esporta", "exporting": "Esportazione...", "importDatabase": "Importa database", - "importDatabaseDesc": "Ripristina da un file di backup .sqlite", + "importDatabaseDesc": "Ripristina da un backup .sqlite portatile su qualsiasi database backend", "importDatabaseSelected": "Selezionato: {{name}}", "selectFile": "Seleziona file", "changeFile": "Cambia", @@ -4516,6 +4536,7 @@ "selectProvider": "Seleziona un fornitore", "providerSettings": "Impostazioni del provider", "addProvider": "Aggiungi fornitore", + "editProvider": "Fornitore di modifica", "removeProvider": "Rimuovi il fornitore", "providerType": "Fornitore", "providerLabel": "Nome", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Ad esempio: llama3.1 o claude-opus-5", "labelRequired": "Assegna un nome a questo fornitore", "providerSaveFailed": "Impossibile salvare il provider", + "providerUpdated": "Fornitore aggiornato", "providerDeleteFailed": "Impossibile rimuovere il provider", "privateEndpointHint": "Gli indirizzi sulla tua rete privata richiedono prima l'autorizzazione di un amministratore per l'host.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/ja_JP.json b/src/ui/locales/translated/ja_JP.json index 87ba8765..0900b388 100644 --- a/src/ui/locales/translated/ja_JP.json +++ b/src/ui/locales/translated/ja_JP.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH 鍵", "filterTagsGroup": "タグ", "copyDeployCommand": "デプロイコマンドをコピーする", + "cloneCredentialAction": "クローン認証情報", + "clonedCredential": "クローン {{name}}", + "clonedCredentialName": "{{name}} (コピー)", "deleteCredentialAction": "認証情報を削除します", "editCredentialAction": "認証情報を編集する", + "failedToCloneCredential": "認証情報の複製に失敗しました", "failedToDeleteCredential": "認証情報の削除に失敗しました", "idBadge": "ID", "keyBadge": "鍵", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "エージェントソケットパス", "agentSocketPathPlaceholder": "SSH_AUTH_SOCKを使用するには空のままにしてください", "agentSocketPathHint": "SSH_AUTH_SOCK環境変数から自動検出するには空のままにするか、カスタムソケットパスを入力してください(例: /run/user/1000/gnupg/S.gpg-agent.ssh)。", + "agentIdentityLabel": "エージェントID(公開鍵)", + "agentIdentityPlaceholder": "エージェントからのすべてのキーを試す場合は空欄のままにしてください。", + "agentIdentityHint": "エージェントから提供される鍵のみを指定するには、公開鍵(例:ssh-ed25519 AAAA...)を貼り付けてください。エージェントが多数の鍵を保持している場合に、サーバーの MaxAuthTries を超えないようにするために推奨されます。", "shareSshAuthLabel": "SSH認証を共有する", "shareSshAuthDesc": "受信者には、このホストのSSH認証情報を暗号化して送信します。受信者の個人認証情報が優先されます。", "tailscaleDeviceSelect": "Tailscaleデバイスを選択", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "認証情報が見つかりません", "addCredentialBtn": "認証情報を追加", "updateCredentialBtn": "認証情報を更新", + "editCredentialAction": "認証情報を編集する", + "backToHost": "ホストに戻る", + "saveCredentialAsNewBtn": "新規として保存", + "saveCredentialAsNewDesc": "これらの変更を加えた新しい認証情報を作成し、このホストに割り当てます。元の認証情報は変更されません。", + "credentialSavedAsNew": "新しい認証情報として保存されました", + "failedToSaveCredentialAsNew": "新しい認証情報として保存できませんでした", "features": "機能", "noFolder": "(フォルダなし)", "deleteSelected": "削除", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "コンパクト", "displayShowTags": "タグを表示", "displayTrayOnClick": "クリック時にアクションを開く(ホバー時ではなく)", - "actionsVisibility": "ホストアクション", - "actionsVisibilityDesc": "各ホスト上の接続ボタンと管理ボタンが表示されたら", + "actionsVisibility": "ホストアクションの表示", + "actionsVisibilityDesc": "ホストアクションを展開するタイミングを選択します。クリックを選択すると、以前のクリックして展開する動作に戻ります。", "actionsAlways": "常に見える", "actionsOnly": "接続のみ、クリックで終了", "actionsHover": "マウスオーバー時に表示", - "actionsClick": "クリックで表示", + "actionsClick": "クリックして展開/折りたたみ", "openOnDoubleClick": "ダブルクリックで開く", "openOnDoubleClickDesc": "ホストセッションを開く際に、シングルクリックではなくダブルクリックを要求する。", "deletedHost": "{{name}} を削除しました", @@ -1867,6 +1880,7 @@ "uploaded": "アップロード済み {{name}}", "uploadFailed": "アップロードに失敗しました {{name}}", "uploadDisabled": "この接続ではアップロードが無効になっています。", + "driveUnavailable": "ファイルをアップロードする前に、RDPドライブリダイレクトを有効にしてください。", "dropToUpload": "アップロードするファイルをドロップしてください" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "フォルダ名", "fileUploadedSuccessfully": "ファイル \"{{name}}\" のアップロードが完了しました", "failedToUploadFile": "ファイルのアップロードに失敗しました", + "uploadingFolderFiles": "{{count}} 個のファイルをアップロードしています...", + "uploadedFolderFiles": "{{count}} 個のファイルが正常にアップロードされました", + "uploadedFolderPartial": "Uploaded {{uploaded}} file(s), {{failed}} failed", "fileDownloadedSuccessfully": "ファイル \"{{name}}\" のダウンロードが完了しました", "failedToDownloadFile": "ファイルのダウンロードに失敗しました", "fileCreatedSuccessfully": "ファイル \"{{name}}\" が正常に作成されました", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{count}} アイテムを{{operation}}しました", "downloadFileSuccess": "ファイル {{name}} のダウンロードが完了しました", "downloadFileFailed": "ダウンロードに失敗しました", + "downloadingFile": "ダウンロード中 {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "{{name}} に移動", "diffCompareWith": "{{name}} と差分比較", "dragOutsideToDownload": "ウィンドウ外にドラッグしてダウンロード ({{count}} ファイル)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "初回ログイン時にOIDC/SSOユーザーのアカウントを自動作成します(登録のトグルとは独立)", "oidcSilentLoginDefault": "デフォルトでサイレントOIDCログイン", "oidcSilentLoginDefaultDesc": "アクセス時に自動でOIDCログインにリダイレクトし、ログインフォームを完全にスキップします", + "oidcSilentLoginDefaultLockedDesc": "この設定は環境変数 OIDC_SILENT_LOGIN_DEFAULT によってロックされているため、ここで変更することはできません。", "allowPasswordReset": "パスワードリセットを許可", "allowPasswordResetDesc": "リセットコードをDockerログから取得", "commandHistoryEnabled": "コマンド履歴", @@ -3317,11 +3337,11 @@ "createRole": "作成", "creating": "作成中...", "exportDatabase": "データベースをエクスポート", - "exportDatabaseDesc": "すべてのホスト、認証情報、設定のバックアップをダウンロードします。", + "exportDatabaseDesc": "すべてのホスト、認証情報、および設定のポータブルな SQLite バックアップをダウンロードします", "export": "エクスポート", "exporting": "エクスポート中...", "importDatabase": "データベースをインポート", - "importDatabaseDesc": ".sqlite バックアップファイルから復元します。", + "importDatabaseDesc": "任意のデータベースバックエンド上のポータブルな.sqliteバックアップから復元します。", "importDatabaseSelected": "選択済み: {{name}}", "selectFile": "ファイルを選択", "changeFile": "変更", @@ -4516,6 +4536,7 @@ "selectProvider": "プロバイダーを選択してください", "providerSettings": "プロバイダー設定", "addProvider": "プロバイダーを追加", + "editProvider": "プロバイダーを編集", "removeProvider": "プロバイダーを削除", "providerType": "プロバイダー", "providerLabel": "名前", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "例えば、llama3.1 や claude-opus-5 など。", "labelRequired": "このプロバイダーに名前を付けてください", "providerSaveFailed": "プロバイダーを保存できませんでした", + "providerUpdated": "プロバイダーが更新されました", "providerDeleteFailed": "プロバイダーを削除できませんでした", "privateEndpointHint": "プライベートネットワーク上のアドレスを使用するには、まず管理者がホストを許可する必要があります。", "providerOllama": "オラマ", diff --git a/src/ui/locales/translated/ko_KR.json b/src/ui/locales/translated/ko_KR.json index 0e0e4dc1..a8c1b314 100644 --- a/src/ui/locales/translated/ko_KR.json +++ b/src/ui/locales/translated/ko_KR.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH 키", "filterTagsGroup": "태그", "copyDeployCommand": "복사 배포 명령", + "cloneCredentialAction": "자격 증명 복제", + "clonedCredential": "복제됨 {{name}}", + "clonedCredentialName": "{{name}} (복사)", "deleteCredentialAction": "자격 증명 삭제", "editCredentialAction": "자격 증명 수정", + "failedToCloneCredential": "자격 증명 복제에 실패했습니다.", "failedToDeleteCredential": "자격 증명 삭제에 실패했습니다.", "idBadge": "ID", "keyBadge": "열쇠", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "에이전트 소켓 경로", "agentSocketPathPlaceholder": "SSH_AUTH_SOCK을 사용하려면 비워 두세요.", "agentSocketPathHint": "비워 두면 SSH_AUTH_SOCK 환경 변수에서 자동 감지합니다. 또는 사용자 지정 소켓 경로를 입력하세요(예: /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "에이전트 ID(공개 키)", + "agentIdentityPlaceholder": "에이전트에서 제공하는 모든 키를 시도하려면 비워 두십시오.", + "agentIdentityHint": "에이전트에서 해당 키만 제공하려면 공개 키(예: ssh-ed25519 AAAA...)를 붙여넣으세요. 에이전트에 키가 많은 경우 서버의 MaxAuthTries 제한을 초과하지 않도록 하는 데 권장됩니다.", "shareSshAuthLabel": "SSH 인증 공유", "shareSshAuthDesc": "수신자에게 이 호스트의 SSH 인증 정보를 암호화한 사본을 제공하십시오. 수신자의 개인 자격 증명이 여전히 우선합니다.", "tailscaleDeviceSelect": "Tailscale 기기 선택", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "자격증명을 찾을 수 없습니다", "addCredentialBtn": "자격증명 추가", "updateCredentialBtn": "자격증명 업데이트", + "editCredentialAction": "자격 증명 수정", + "backToHost": "호스트 페이지로 돌아가기", + "saveCredentialAsNewBtn": "새 상품으로 저장", + "saveCredentialAsNewDesc": "이러한 변경 사항을 반영하여 새 자격 증명을 생성하고 해당 호스트에 할당합니다. 기존 자격 증명은 변경되지 않습니다.", + "credentialSavedAsNew": "새 자격 증명으로 저장되었습니다", + "failedToSaveCredentialAsNew": "새 자격 증명으로 저장하는 데 실패했습니다.", "features": "기능", "noFolder": "(폴더 없음)", "deleteSelected": "삭제", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "콤팩트", "displayShowTags": "태그 표시", "displayTrayOnClick": "마우스 오버 대신 클릭 시 실행 항목을 엽니다.", - "actionsVisibility": "호스트 작업", - "actionsVisibilityDesc": "각 호스트에 연결 및 관리 버튼이 표시될 때", + "actionsVisibility": "호스트 액션 표시", + "actionsVisibilityDesc": "호스트 작업이 확장되는 시점을 선택하세요. '클릭'을 선택하면 이전의 클릭 확장 동작으로 복원됩니다.", "actionsAlways": "항상 보이는", "actionsOnly": "연결만 하시고, 나머지는 클릭 한 번으로!", "actionsHover": "마우스 오버 시 표시", - "actionsClick": "클릭하면 표시됩니다", + "actionsClick": "클릭하여 펼치기/접기", "openOnDoubleClick": "두 번 클릭하면 열립니다", "openOnDoubleClickDesc": "호스트 세션을 열 때 한 번 클릭하는 대신 두 번 클릭하도록 변경합니다.", "deletedHost": "{{name}} 삭제됨", @@ -1867,6 +1880,7 @@ "uploaded": "업로드됨 {{name}}", "uploadFailed": "{{name}} 업로드에 실패했습니다.", "uploadDisabled": "이 연결에서는 업로드가 비활성화되어 있습니다.", + "driveUnavailable": "파일을 업로드하기 전에 RDP 드라이브 리디렉션을 활성화하십시오.", "dropToUpload": "업로드할 파일을 드롭하세요" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "폴더 이름", "fileUploadedSuccessfully": "파일 \"{{name}}\" 업로드 완료", "failedToUploadFile": "파일 업로드 실패", + "uploadingFolderFiles": "{{count}} 파일을 업로드하는 중...", + "uploadedFolderFiles": "{{count}} 파일을 성공적으로 업로드했습니다.", + "uploadedFolderPartial": "업로드된 파일 {{uploaded}} 개 중 {{failed}} 개에 실패했습니다.", "fileDownloadedSuccessfully": "파일 \"{{name}}\" 다운로드 완료", "failedToDownloadFile": "파일 다운로드 실패", "fileCreatedSuccessfully": "파일 \"{{name}}\" 생성 완료", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{count}}개 항목 {{operation}}", "downloadFileSuccess": "파일 {{name}} 다운로드 성공", "downloadFileFailed": "다운로드 실패", + "downloadingFile": "{{name}}… 다운로드 중", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "{{name}}로 이동", "diffCompareWith": "{{name}}와/과 Diff 비교", "dragOutsideToDownload": "창 밖으로 드래그하여 다운로드 ({{count}}개 파일)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "최초 로그인 시 OIDC/SSO 사용자 계정 자동 생성 (등록 토글과 무관)", "oidcSilentLoginDefault": "기본 자동 OIDC 로그인", "oidcSilentLoginDefaultDesc": "방문 시마다 OIDC 로그인으로 자동 리디렉션, 로그인 양식 완전히 건너뜀", + "oidcSilentLoginDefaultLockedDesc": "이 설정은 OIDC_SILENT_LOGIN_DEFAULT 환경 변수에 의해 잠겨 있으므로 여기에서 변경할 수 없습니다.", "allowPasswordReset": "비밀번호 재설정 허용", "allowPasswordResetDesc": "Docker 로그를 통해 재설정 코드 확인", "commandHistoryEnabled": "명령어 히스토리", @@ -3317,11 +3337,11 @@ "createRole": "생성", "creating": "생성 중...", "exportDatabase": "데이터베이스 내보내기", - "exportDatabaseDesc": "모든 호스트, 자격 증명 및 설정의 백업을 다운로드합니다.", + "exportDatabaseDesc": "모든 호스트, 자격 증명 및 설정이 포함된 휴대용 SQLite 백업 파일을 다운로드하세요.", "export": "내보내기", "exporting": "내보내는 중...", "importDatabase": "데이터베이스 가져오기", - "importDatabaseDesc": ".sqlite 백업 파일에서 복원합니다.", + "importDatabaseDesc": "휴대용 .sqlite 백업에서 모든 데이터베이스 백엔드로 복원할 수 있습니다.", "importDatabaseSelected": "선택됨: {{name}}", "selectFile": "파일 선택", "changeFile": "변경", @@ -4516,6 +4536,7 @@ "selectProvider": "제공업체를 선택하세요", "providerSettings": "공급자 설정", "addProvider": "공급자 추가", + "editProvider": "제공자 수정", "removeProvider": "제공업체를 제거하세요", "providerType": "공급자", "providerLabel": "이름", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "예를 들어: llama3.1 또는 claude-opus-5", "labelRequired": "이 제공업체에 이름을 지정하세요", "providerSaveFailed": "공급자를 저장할 수 없습니다.", + "providerUpdated": "제공업체 업데이트됨", "providerDeleteFailed": "공급자를 제거할 수 없습니다.", "privateEndpointHint": "개인 네트워크의 주소에 접근하려면 관리자가 먼저 호스트를 허용해야 합니다.", "providerOllama": "올라마", diff --git a/src/ui/locales/translated/nl_NL.json b/src/ui/locales/translated/nl_NL.json index 8b924442..0a005735 100644 --- a/src/ui/locales/translated/nl_NL.json +++ b/src/ui/locales/translated/nl_NL.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-sleutel", "filterTagsGroup": "Tags", "copyDeployCommand": "Kopieer de implementatieopdracht", + "cloneCredentialAction": "Kopieer inloggegevens", + "clonedCredential": "Gekloond {{name}}", + "clonedCredentialName": "{{name}} (kopiëren)", "deleteCredentialAction": "Verwijder inloggegevens", "editCredentialAction": "Inloggegevens bewerken", + "failedToCloneCredential": "Het klonen van de inloggegevens is mislukt.", "failedToDeleteCredential": "Het verwijderen van de inloggegevens is mislukt.", "idBadge": "ID", "keyBadge": "SLEUTEL", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Agent Socket Path", "agentSocketPathPlaceholder": "Laat dit veld leeg om SSH_AUTH_SOCK te gebruiken.", "agentSocketPathHint": "Laat dit veld leeg om automatisch te detecteren via de omgevingsvariabele SSH_AUTH_SOCK, of voer een aangepast socketpad in (bijv. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Agentidentiteit (openbare sleutel)", + "agentIdentityPlaceholder": "Laat dit veld leeg om alle sleutels van de agent te proberen.", + "agentIdentityHint": "Plak hier een openbare sleutel (bijv. ssh-ed25519 AAAA...) zodat alleen die sleutel door de agent wordt aangeboden. Dit wordt aanbevolen wanneer de agent veel sleutels beheert, om te voorkomen dat het maximale aantal autorisaties (MaxAuthTries) van de server wordt overschreden.", "shareSshAuthLabel": "Deel SSH-authenticatie", "shareSshAuthDesc": "Geef ontvangers versleutelde kopieën van de SSH-authenticatiegegevens van deze host. De persoonlijke inloggegevens van de ontvanger hebben echter nog steeds voorrang.", "tailscaleDeviceSelect": "Selecteer het Tailscale-apparaat", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Geen inloggegevens gevonden", "addCredentialBtn": "Voeg inloggegevens toe", "updateCredentialBtn": "Inloggegevens bijwerken", + "editCredentialAction": "Inloggegevens bewerken", + "backToHost": "Terug naar de host", + "saveCredentialAsNewBtn": "Opslaan als nieuw", + "saveCredentialAsNewDesc": "Hiermee wordt een nieuwe referentie aangemaakt met deze wijzigingen en toegewezen aan deze host. De oorspronkelijke referentie blijft ongewijzigd.", + "credentialSavedAsNew": "Opgeslagen als een nieuwe referentie", + "failedToSaveCredentialAsNew": "Opslaan als nieuwe inloggegevens is mislukt.", "features": "Functies", "noFolder": "(Geen map)", "deleteSelected": "Verwijderen", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Compact", "displayShowTags": "Toon tags", "displayTrayOnClick": "Acties openen bij klikken (in plaats van bij hoveren)", - "actionsVisibility": "Hostacties", - "actionsVisibilityDesc": "Wanneer de verbindings- en beheerknoppen op elke host verschijnen", + "actionsVisibility": "Weergave van hostacties", + "actionsVisibilityDesc": "Kies wanneer hostacties worden uitgevouwen. Selecteer 'klikken' om het eerdere 'klikken om uit te vouwen'-gedrag te herstellen.", "actionsAlways": "Altijd zichtbaar", "actionsOnly": "Alleen verbinden, de rest is klikken.", "actionsHover": "Weergeven bij hoveren", - "actionsClick": "Weergeven bij klikken", + "actionsClick": "Klik om uit te vouwen/in te klappen", "openOnDoubleClick": "Openen bij dubbelklikken", "openOnDoubleClickDesc": "Vereis een dubbelklik in plaats van een enkele klik om een hostsessie te openen.", "deletedHost": "Verwijderd {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Geüpload {{name}}", "uploadFailed": "Uploaden mislukt {{name}}", "uploadDisabled": "Uploaden is uitgeschakeld voor deze verbinding.", + "driveUnavailable": "Schakel RDP-schijfomleiding in voordat u bestanden uploadt.", "dropToUpload": "Sleep de bestanden hierheen om ze te uploaden." }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Mapnaam", "fileUploadedSuccessfully": "Bestand \"{{name}}\" succesvol geüpload", "failedToUploadFile": "Het uploaden van het bestand is mislukt.", + "uploadingFolderFiles": "Bestand(en) {{count}} uploaden...", + "uploadedFolderFiles": "Bestand(en) {{count}} succesvol geüpload", + "uploadedFolderPartial": "Geüploade {{uploaded}} bestand(en), {{failed}} mislukt", "fileDownloadedSuccessfully": "Bestand \"{{name}}\" succesvol gedownload", "failedToDownloadFile": "Het downloaden van het bestand is mislukt.", "fileCreatedSuccessfully": "Bestand \"{{name}}\" is succesvol aangemaakt.", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} items", "downloadFileSuccess": "Bestand {{name}} succesvol gedownload", "downloadFileFailed": "Download mislukt", + "downloadingFile": "Downloaden {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Ga naar {{name}}", "diffCompareWith": "Vergelijk het verschil met {{name}}", "dragOutsideToDownload": "Sleep buiten het venster om bestanden te downloaden ({{count}})", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Automatisch accounts aanmaken voor OIDC/SSO-gebruikers bij de eerste aanmelding (onafhankelijk van de registratie-optie).", "oidcSilentLoginDefault": "Standaard stille OIDC-aanmelding", "oidcSilentLoginDefaultDesc": "Automatisch doorverwijzen naar de OIDC-inlogpagina bij elk bezoek, waarbij het inlogformulier volledig wordt overgeslagen.", + "oidcSilentLoginDefaultLockedDesc": "Deze instelling is vergrendeld door de omgevingsvariabele OIDC_SILENT_LOGIN_DEFAULT en kan hier niet worden gewijzigd.", "allowPasswordReset": "Wachtwoordherstel toestaan", "allowPasswordResetDesc": "Resetcode via Docker-logs", "commandHistoryEnabled": "Commandogeschiedenis", @@ -3317,11 +3337,11 @@ "createRole": "Creëren", "creating": "Aan het creëren...", "exportDatabase": "Exporteer database", - "exportDatabaseDesc": "Download een back-up van alle hosts, inloggegevens en instellingen.", + "exportDatabaseDesc": "Download een draagbare SQLite-back-up van alle hosts, inloggegevens en instellingen.", "export": "Exporteren", "exporting": "Exporteren...", "importDatabase": "Importeer database", - "importDatabaseDesc": "Herstellen vanuit een .sqlite-back-upbestand", + "importDatabaseDesc": "Herstel vanuit een draagbare .sqlite-back-up op elke databasebackend.", "importDatabaseSelected": "Geselecteerd: {{name}}", "selectFile": "Selecteer bestand", "changeFile": "Wijziging", @@ -4516,6 +4536,7 @@ "selectProvider": "Selecteer een aanbieder", "providerSettings": "Aanbiederinstellingen", "addProvider": "Aanbieder toevoegen", + "editProvider": "Bewerk provider", "removeProvider": "Verwijder provider", "providerType": "Aanbieder", "providerLabel": "Naam", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Bijvoorbeeld: llama3.1 of claude-opus-5", "labelRequired": "Geef deze aanbieder een naam", "providerSaveFailed": "De provider kon niet worden opgeslagen.", + "providerUpdated": "Aanbieder bijgewerkt", "providerDeleteFailed": "De provider kon niet worden verwijderd.", "privateEndpointHint": "IP-adressen op uw privénetwerk moeten eerst door een beheerder worden toegestaan.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/no_NO.json b/src/ui/locales/translated/no_NO.json index e4a4eed3..678bbd0e 100644 --- a/src/ui/locales/translated/no_NO.json +++ b/src/ui/locales/translated/no_NO.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-nøkkel", "filterTagsGroup": "Tagger", "copyDeployCommand": "Kopier distribuer-kommandoen", + "cloneCredentialAction": "Klon legitimasjon", + "clonedCredential": "Klonet {{name}}", + "clonedCredentialName": "{{name}} (kopi)", "deleteCredentialAction": "Slett legitimasjon", "editCredentialAction": "Rediger legitimasjon", + "failedToCloneCredential": "Kunne ikke klone legitimasjon", "failedToDeleteCredential": "Kunne ikke slette legitimasjonen", "idBadge": "ID", "keyBadge": "NØKKEL", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Agent Socket-bane", "agentSocketPathPlaceholder": "La stå tomt for å bruke SSH_AUTH_SOCK", "agentSocketPathHint": "La feltet stå tomt for automatisk deteksjon fra miljøvariabelen SSH_AUTH_SOCK, eller skriv inn en egendefinert socket-sti (f.eks. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Agentidentitet (offentlig nøkkel)", + "agentIdentityPlaceholder": "La feltet stå tomt for å prøve alle nøkler fra agenten", + "agentIdentityHint": "Lim inn en offentlig nøkkel (f.eks. ssh-ed25519 AAAA...) for å kun tilby den nøkkelen fra agenten. Anbefales når agenten har mange nøkler, for å unngå å overskride serverens MaxAuthTries.", "shareSshAuthLabel": "Del SSH-autentisering", "shareSshAuthDesc": "Gi mottakerne krypterte kopier av denne vertens SSH-autentisering. Mottakerens personlige legitimasjon har fortsatt forrang.", "tailscaleDeviceSelect": "Velg Tailscale-enhet", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Ingen legitimasjon funnet", "addCredentialBtn": "Legg til legitimasjon", "updateCredentialBtn": "Oppdater legitimasjon", + "editCredentialAction": "Rediger legitimasjon", + "backToHost": "Tilbake til verten", + "saveCredentialAsNewBtn": "Lagre som ny", + "saveCredentialAsNewDesc": "Oppretter en ny legitimasjon med disse endringene og tilordner den til denne verten. Den opprinnelige legitimasjonen forblir uendret.", + "credentialSavedAsNew": "Lagret som ny legitimasjon", + "failedToSaveCredentialAsNew": "Kunne ikke lagre som ny legitimasjon", "features": "Funksjoner", "noFolder": "(Ingen mappe)", "deleteSelected": "Slett", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompakt", "displayShowTags": "Vis tagger", "displayTrayOnClick": "Åpne handlinger ved klikk (i stedet for å holde musepekeren over)", - "actionsVisibility": "Vertshandlinger", - "actionsVisibilityDesc": "Når tilkoblings- og administrasjonsknappene på hver vert vises", + "actionsVisibility": "Vertshandlingsvisning", + "actionsVisibilityDesc": "Velg når vertshandlinger skal utvides. Velg klikk for å gjenopprette den tidligere klikk-for-å-utvide-virkemåten.", "actionsAlways": "Alltid synlig", "actionsOnly": "Bare tilkobling, hvile på klikk", "actionsHover": "Vis ved å holde musepekeren over", - "actionsClick": "Vis ved klikk", + "actionsClick": "Klikk for å utvide/skjule", "openOnDoubleClick": "Åpne ved dobbeltklikk", "openOnDoubleClickDesc": "Krev et dobbeltklikk for å åpne en vertsøkt i stedet for et enkeltklikk", "deletedHost": "Slettet {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Lastet opp {{name}}", "uploadFailed": "Kunne ikke laste opp {{name}}", "uploadDisabled": "Opplastinger er deaktivert for denne tilkoblingen", + "driveUnavailable": "Aktiver omdirigering av RDP-stasjoner før du laster opp filer", "dropToUpload": "Slipp filer for å laste opp" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Mappenavn", "fileUploadedSuccessfully": "Filen «{{name}}» ble lastet opp", "failedToUploadFile": "Kunne ikke laste opp filen", + "uploadingFolderFiles": "Laster opp {{count}} fil(er)...", + "uploadedFolderFiles": "Lastet opp {{count}} fil(er)", + "uploadedFolderPartial": "Opplasting av {{uploaded}} fil(er), {{failed}} mislyktes", "fileDownloadedSuccessfully": "Filen «{{name}}» er lastet ned", "failedToDownloadFile": "Kunne ikke laste ned filen", "fileCreatedSuccessfully": "Filen «{{name}}» er opprettet", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} elementer", "downloadFileSuccess": "Fil {{name}} er lastet ned", "downloadFileFailed": "Nedlastingen mislyktes", + "downloadingFile": "Laster ned {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Flytt til {{name}}", "diffCompareWith": "Differansesammenligning med {{name}}", "dragOutsideToDownload": "Dra utenfor vinduet for å laste ned ({{count}} filer)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Opprett kontoer automatisk for OIDC/SSO-brukere ved første pålogging (uavhengig av registreringsbryteren)", "oidcSilentLoginDefault": "Stille OIDC-pålogging som standard", "oidcSilentLoginDefaultDesc": "Omdiriger automatisk til OIDC-innlogging ved hvert besøk, og hopper over innloggingsskjemaet helt", + "oidcSilentLoginDefaultLockedDesc": "Denne innstillingen er låst av miljøvariabelen OIDC_SILENT_LOGIN_DEFAULT og kan ikke endres her.", "allowPasswordReset": "Tillat tilbakestilling av passord", "allowPasswordResetDesc": "Tilbakestill kode via Docker-logger", "commandHistoryEnabled": "Kommandohistorikk", @@ -3317,11 +3337,11 @@ "createRole": "Skape", "creating": "Oppretter...", "exportDatabase": "Eksporter database", - "exportDatabaseDesc": "Last ned en sikkerhetskopi av alle verter, påloggingsinformasjon og innstillinger", + "exportDatabaseDesc": "Last ned en bærbar SQLite-sikkerhetskopi av alle verter, påloggingsinformasjon og innstillinger", "export": "Eksport", "exporting": "Eksporterer...", "importDatabase": "Importer database", - "importDatabaseDesc": "Gjenopprett fra en .sqlite-sikkerhetskopifil", + "importDatabaseDesc": "Gjenopprett fra en bærbar .sqlite-sikkerhetskopi på en hvilken som helst database-backend", "importDatabaseSelected": "Valgt: {{name}}", "selectFile": "Velg fil", "changeFile": "Endre", @@ -4516,6 +4536,7 @@ "selectProvider": "Velg en leverandør", "providerSettings": "Leverandørinnstillinger", "addProvider": "Legg til leverandør", + "editProvider": "Rediger leverandør", "removeProvider": "Fjern leverandør", "providerType": "Leverandør", "providerLabel": "Navn", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "For eksempel: llama3.1 eller claude-opus-5", "labelRequired": "Gi denne leverandøren et navn", "providerSaveFailed": "Kunne ikke lagre leverandøren", + "providerUpdated": "Leverandøren er oppdatert", "providerDeleteFailed": "Kunne ikke fjerne leverandøren", "privateEndpointHint": "Adresser på ditt private nettverk krever at en administrator tillater verten først.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/pl_PL.json b/src/ui/locales/translated/pl_PL.json index 610516e2..a63787f3 100644 --- a/src/ui/locales/translated/pl_PL.json +++ b/src/ui/locales/translated/pl_PL.json @@ -111,8 +111,12 @@ "filterTypeKey": "Klucz SSH", "filterTagsGroup": "Tagi", "copyDeployCommand": "Kopiuj polecenie wdrożenia", + "cloneCredentialAction": "Klonuj dane uwierzytelniające", + "clonedCredential": "Sklonowano {{name}}", + "clonedCredentialName": "{{name}} (kopiuj)", "deleteCredentialAction": "Usuń poświadczenia", "editCredentialAction": "Edytuj poświadczenia", + "failedToCloneCredential": "Nie udało się sklonować danych uwierzytelniających", "failedToDeleteCredential": "Nie udało się usunąć poświadczeń", "idBadge": "ID", "keyBadge": "KLAWISZ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Ścieżka gniazda agenta", "agentSocketPathPlaceholder": "Pozostaw puste, aby użyć SSH_AUTH_SOCK", "agentSocketPathHint": "Pozostaw puste, aby automatycznie wykryć na podstawie zmiennej środowiskowej SSH_AUTH_SOCK, lub wprowadź niestandardową ścieżkę gniazda (np. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Tożsamość agenta (klucz publiczny)", + "agentIdentityPlaceholder": "Pozostaw puste, aby wypróbować wszystkie klucze od agenta", + "agentIdentityHint": "Wklej klucz publiczny (np. ssh-ed25519 AAAA...), aby udostępnić tylko ten klucz agentowi. Zalecane, gdy agent posiada wiele kluczy, aby uniknąć przekroczenia limitu MaxAuthTries serwera.", "shareSshAuthLabel": "Udostępnij uwierzytelnianie SSH", "shareSshAuthDesc": "Przekaż odbiorcom zaszyfrowane kopie uwierzytelnienia SSH tego hosta. Dane osobowe odbiorcy nadal mają pierwszeństwo.", "tailscaleDeviceSelect": "Wybierz urządzenie Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Nie znaleziono danych uwierzytelniających", "addCredentialBtn": "Dodaj poświadczenia", "updateCredentialBtn": "Aktualizacja poświadczeń", + "editCredentialAction": "Edytuj poświadczenia", + "backToHost": "Powrót do hosta", + "saveCredentialAsNewBtn": "Zapisz jako nowy", + "saveCredentialAsNewDesc": "Tworzy nowe dane uwierzytelniające z tymi zmianami i przypisuje je do tego hosta. Oryginalne dane uwierzytelniające pozostają niezmienione.", + "credentialSavedAsNew": "Zapisano jako nowe dane uwierzytelniające", + "failedToSaveCredentialAsNew": "Nie udało się zapisać jako nowych danych uwierzytelniających", "features": "Cechy", "noFolder": "(Brak folderu)", "deleteSelected": "Usuwać", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompaktowy", "displayShowTags": "Pokaż tagi", "displayTrayOnClick": "Otwórz akcje po kliknięciu (zamiast po najechaniu kursorem)", - "actionsVisibility": "Działania gospodarza", - "actionsVisibilityDesc": "Gdy na każdym hoście pojawią się przyciski połączenia i zarządzania", + "actionsVisibility": "Wyświetlanie akcji hosta", + "actionsVisibilityDesc": "Wybierz, kiedy akcje hosta mają się rozwijać. Wybierz „kliknij”, aby przywrócić poprzednie działanie „kliknij, aby rozwinąć”.", "actionsAlways": "Zawsze widoczny", "actionsOnly": "Tylko połączenie, reszta po kliknięciu", "actionsHover": "Pokaż po najechaniu kursorem", - "actionsClick": "Pokaż po kliknięciu", + "actionsClick": "Kliknij, aby rozwinąć/zwinąć", "openOnDoubleClick": "Otwórz po dwukrotnym kliknięciu", "openOnDoubleClickDesc": "Wymagaj dwukrotnego kliknięcia, aby otworzyć sesję hosta zamiast pojedynczego kliknięcia", "deletedHost": "Usunięto {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Przesłano {{name}}", "uploadFailed": "Nie udało się przesłać {{name}}", "uploadDisabled": "Przesyłanie danych jest wyłączone dla tego połączenia", + "driveUnavailable": "Włącz przekierowanie dysku RDP przed przesłaniem plików", "dropToUpload": "Upuść pliki, aby je przesłać" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Nazwa folderu", "fileUploadedSuccessfully": "Plik „{{name}}” został pomyślnie przesłany", "failedToUploadFile": "Nie udało się przesłać pliku", + "uploadingFolderFiles": "Przesyłanie {{count}} plików...", + "uploadedFolderFiles": "Pomyślnie przesłano {{count}} plików", + "uploadedFolderPartial": "Przesłano {{uploaded}} plików, {{failed}} nie powiodło się", "fileDownloadedSuccessfully": "Plik „{{name}}” został pomyślnie pobrany", "failedToDownloadFile": "Nie udało się pobrać pliku", "fileCreatedSuccessfully": "Plik „{{name}}” został pomyślnie utworzony", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} elementów", "downloadFileSuccess": "Plik {{name}} został pomyślnie pobrany", "downloadFileFailed": "Pobieranie nie powiodło się", + "downloadingFile": "Pobieranie {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Przejdź do {{name}}", "diffCompareWith": "Porównaj z {{name}}", "dragOutsideToDownload": "Przeciągnij poza okno, aby pobrać ({{count}} plików)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Automatyczne tworzenie kont dla użytkowników OIDC/SSO przy pierwszym logowaniu (niezależnie od przełącznika rejestracji)", "oidcSilentLoginDefault": "Domyślne ciche logowanie do OIDC", "oidcSilentLoginDefaultDesc": "Automatyczne przekierowywanie do logowania OIDC przy każdej wizycie, całkowicie pomijając formularz logowania", + "oidcSilentLoginDefaultLockedDesc": "To ustawienie jest zablokowane przez zmienną środowiskową OIDC_SILENT_LOGIN_DEFAULT i nie można go tutaj zmienić.", "allowPasswordReset": "Zezwól na resetowanie hasła", "allowPasswordResetDesc": "Zresetuj kod za pomocą dzienników Dockera", "commandHistoryEnabled": "Historia dowodzenia", @@ -3317,11 +3337,11 @@ "createRole": "Tworzyć", "creating": "Tworzenie...", "exportDatabase": "Eksportuj bazę danych", - "exportDatabaseDesc": "Pobierz kopię zapasową wszystkich hostów, poświadczeń i ustawień", + "exportDatabaseDesc": "Pobierz przenośną kopię zapasową SQLite wszystkich hostów, poświadczeń i ustawień", "export": "Eksport", "exporting": "Eksportowanie...", "importDatabase": "Importuj bazę danych", - "importDatabaseDesc": "Przywracanie z pliku kopii zapasowej .sqlite", + "importDatabaseDesc": "Przywracanie z przenośnej kopii zapasowej .sqlite na dowolnym zapleczu bazy danych", "importDatabaseSelected": "Wybrano: {{name}}", "selectFile": "Wybierz plik", "changeFile": "Zmiana", @@ -4516,6 +4536,7 @@ "selectProvider": "Wybierz dostawcę", "providerSettings": "Ustawienia dostawcy", "addProvider": "Dodaj dostawcę", + "editProvider": "Edytuj dostawcę", "removeProvider": "Usuń dostawcę", "providerType": "Dostawca", "providerLabel": "Nazwa", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Na przykład: llama3.1 lub claude-opus-5", "labelRequired": "Podaj nazwę tego dostawcy", "providerSaveFailed": "Nie udało się zapisać dostawcy", + "providerUpdated": "Dostawca zaktualizowany", "providerDeleteFailed": "Nie można usunąć dostawcy", "privateEndpointHint": "Adresy w Twojej sieci prywatnej wymagają uprzedniej zgody administratora na dostęp do hosta.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/pt_BR.json b/src/ui/locales/translated/pt_BR.json index 3e476bf9..25e33949 100644 --- a/src/ui/locales/translated/pt_BR.json +++ b/src/ui/locales/translated/pt_BR.json @@ -111,8 +111,12 @@ "filterTypeKey": "Chave SSH", "filterTagsGroup": "Etiquetas", "copyDeployCommand": "Copiar comando de implantação", + "cloneCredentialAction": "Credencial de clonagem", + "clonedCredential": "Clonado {{name}}", + "clonedCredentialName": "{{name}} (copiar)", "deleteCredentialAction": "Excluir credencial", "editCredentialAction": "Editar credencial", + "failedToCloneCredential": "Falha ao clonar a credencial", "failedToDeleteCredential": "Falha ao excluir credencial", "idBadge": "EU IA", "keyBadge": "CHAVE", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Caminho do soquete do agente", "agentSocketPathPlaceholder": "Deixe em branco para usar SSH_AUTH_SOCK", "agentSocketPathHint": "Deixe em branco para detecção automática a partir da variável de ambiente SSH_AUTH_SOCK ou insira um caminho de socket personalizado (por exemplo, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Identidade do agente (chave pública)", + "agentIdentityPlaceholder": "Deixe em branco para tentar todas as chaves do agente.", + "agentIdentityHint": "Cole uma chave pública (ex.: ssh-ed25519 AAAA...) para que somente essa chave seja oferecida pelo agente. Recomendado quando o agente possui muitas chaves, para evitar exceder o limite de tentativas de autenticação (MaxAuthTries) do servidor.", "shareSshAuthLabel": "Compartilhar autenticação SSH", "shareSshAuthDesc": "Forneça aos destinatários cópias criptografadas da autenticação SSH deste host. As credenciais pessoais do destinatário ainda têm precedência.", "tailscaleDeviceSelect": "Selecione o dispositivo Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Nenhuma credencial encontrada", "addCredentialBtn": "Adicionar credencial", "updateCredentialBtn": "Atualizar credenciais", + "editCredentialAction": "Editar credencial", + "backToHost": "Voltar ao anfitrião", + "saveCredentialAsNewBtn": "Salvar como novo", + "saveCredentialAsNewDesc": "Cria uma nova credencial com essas alterações e a atribui a este host. A credencial original permanece inalterada.", + "credentialSavedAsNew": "Salvo como uma nova credencial", + "failedToSaveCredentialAsNew": "Falha ao salvar como uma nova credencial.", "features": "Características", "noFolder": "(Sem pasta)", "deleteSelected": "Excluir", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Compactar", "displayShowTags": "Mostrar etiquetas", "displayTrayOnClick": "Abrir ações ao clicar (em vez de passar o cursor sobre elas)", - "actionsVisibility": "Ações do anfitrião", - "actionsVisibilityDesc": "Quando os botões de conexão e gerenciamento em cada host aparecerem", + "actionsVisibility": "Exibição de ação do host", + "actionsVisibilityDesc": "Escolha quando as ações do host se expandirão. Selecione \"clicar\" para restaurar o comportamento anterior de \"clicar para expandir\".", "actionsAlways": "Sempre visível", "actionsOnly": "Conecte apenas, descanse com um clique.", "actionsHover": "Mostrar ao passar o cursor", - "actionsClick": "Mostrar ao clicar", + "actionsClick": "Clique para expandir/recolher", "openOnDoubleClick": "Abrir com um clique duplo", "openOnDoubleClickDesc": "Exigir um clique duplo para abrir uma sessão de host em vez de um único clique.", "deletedHost": "Excluído {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Enviado {{name}}", "uploadFailed": "Falha ao carregar {{name}}", "uploadDisabled": "Os uploads estão desativados para esta conexão.", + "driveUnavailable": "Ative o redirecionamento de unidade RDP antes de enviar arquivos.", "dropToUpload": "Arraste e solte os arquivos para fazer o upload." }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Nome da pasta", "fileUploadedSuccessfully": "Arquivo \"{{name}}\" enviado com sucesso", "failedToUploadFile": "Falha ao carregar o arquivo", + "uploadingFolderFiles": "Enviando {{count}} arquivo(s)...", + "uploadedFolderFiles": "Arquivo(s) carregado(s) com sucesso {{count}}", + "uploadedFolderPartial": "Uploaded {{uploaded}} file(s), {{failed}} failed", "fileDownloadedSuccessfully": "Arquivo \"{{name}}\" baixado com sucesso", "failedToDownloadFile": "Falha ao baixar o arquivo", "fileCreatedSuccessfully": "Arquivo \"{{name}}\" criado com sucesso", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} itens", "downloadFileSuccess": "Arquivo {{name}} baixado com sucesso", "downloadFileFailed": "Falha no download", + "downloadingFile": "Baixando {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Mover para {{name}}", "diffCompareWith": "Diff compare com {{name}}", "dragOutsideToDownload": "Arraste para fora da janela para baixar ({{count}} arquivos)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Criar automaticamente contas para usuários OIDC/SSO no primeiro login (independentemente da ativação/desativação do registro).", "oidcSilentLoginDefault": "Login OIDC silencioso por padrão", "oidcSilentLoginDefaultDesc": "Redirecionamento automático para o login OIDC em todas as visitas, ignorando completamente o formulário de login.", + "oidcSilentLoginDefaultLockedDesc": "Essa configuração está bloqueada pela variável de ambiente OIDC_SILENT_LOGIN_DEFAULT e não pode ser alterada aqui.", "allowPasswordReset": "Permitir redefinição de senha", "allowPasswordResetDesc": "Redefinir código através dos logs do Docker", "commandHistoryEnabled": "Histórico de comandos", @@ -3317,11 +3337,11 @@ "createRole": "Criar", "creating": "Criando...", "exportDatabase": "Exportar banco de dados", - "exportDatabaseDesc": "Faça o download de um backup de todos os hosts, credenciais e configurações.", + "exportDatabaseDesc": "Baixe um backup portátil em SQLite de todos os hosts, credenciais e configurações.", "export": "Exportar", "exporting": "Exportador...", "importDatabase": "Importar banco de dados", - "importDatabaseDesc": "Restaurar a partir de um arquivo de backup .sqlite", + "importDatabaseDesc": "Restaure a partir de um backup .sqlite portátil em qualquer banco de dados.", "importDatabaseSelected": "Selecionado: {{name}}", "selectFile": "Selecionar arquivo", "changeFile": "Mudar", @@ -4516,6 +4536,7 @@ "selectProvider": "Selecione um fornecedor", "providerSettings": "Configurações do provedor", "addProvider": "Adicionar provedor", + "editProvider": "Editar provedor", "removeProvider": "Remover provedor", "providerType": "Fornecedor", "providerLabel": "Nome", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Por exemplo: llama3.1 ou claude-opus-5", "labelRequired": "Dê um nome a este fornecedor.", "providerSaveFailed": "Não foi possível salvar o provedor.", + "providerUpdated": "Fornecedor atualizado", "providerDeleteFailed": "Não foi possível remover o provedor.", "privateEndpointHint": "Os endereços na sua rede privada precisam que um administrador autorize o host primeiro.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/pt_PT.json b/src/ui/locales/translated/pt_PT.json index 1c5b01e3..39a6be49 100644 --- a/src/ui/locales/translated/pt_PT.json +++ b/src/ui/locales/translated/pt_PT.json @@ -111,8 +111,12 @@ "filterTypeKey": "Chave SSH", "filterTagsGroup": "Etiquetas", "copyDeployCommand": "Copiar comando de implantação", + "cloneCredentialAction": "Credencial de clonagem", + "clonedCredential": "Clonado {{name}}", + "clonedCredentialName": "{{name}} (copiar)", "deleteCredentialAction": "Apagar credencial", "editCredentialAction": "Editar credencial", + "failedToCloneCredential": "Falha ao clonar a credencial", "failedToDeleteCredential": "Falha ao eliminar credencial", "idBadge": "EU IA", "keyBadge": "CHAVE", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Caminho do socket do agente", "agentSocketPathPlaceholder": "Deixar vazio para usar SSH_AUTH_SOCK", "agentSocketPathHint": "Deixar vazio para detetar automaticamente a partir da variável de ambiente SSH_AUTH_SOCK, ou introduza um caminho de socket personalizado (ex.: /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Identidade do agente (chave pública)", + "agentIdentityPlaceholder": "Deixe em branco para tentar todas as chaves do agente.", + "agentIdentityHint": "Cole uma chave pública (ex.: ssh-ed25519 AAAA...) para que apenas essa chave seja oferecida pelo agente. Recomendado quando o agente tem muitas chaves, para evitar exceder o limite de tentativas de autenticação (MaxAuthTries) do servidor.", "shareSshAuthLabel": "Partilhar autenticação SSH", "shareSshAuthDesc": "Forneça aos destinatários cópias encriptadas da autenticação SSH deste host. As credenciais pessoais do destinatário ainda têm precedência.", "tailscaleDeviceSelect": "Selecionar dispositivo Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Nenhuma credencial encontrada", "addCredentialBtn": "Adicionar Credencial", "updateCredentialBtn": "Atualizar Credencial", + "editCredentialAction": "Editar credencial", + "backToHost": "Voltar ao anfitrião", + "saveCredentialAsNewBtn": "Guardar como novo", + "saveCredentialAsNewDesc": "Cria uma nova credencial com estas alterações e atribui-a a este host. A credencial original permanece inalterada.", + "credentialSavedAsNew": "Guardado como uma nova credencial", + "failedToSaveCredentialAsNew": "Falha ao guardar como uma nova credencial.", "features": "Funcionalidades", "noFolder": "(Nenhuma pasta)", "deleteSelected": "Eliminar", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Compactar", "displayShowTags": "Mostrar etiquetas", "displayTrayOnClick": "Abrir ações ao clicar (em vez de passar o cursor sobre as mesmas)", - "actionsVisibility": "Ações do anfitrião", - "actionsVisibilityDesc": "Quando os botões de ligação e gestão em cada host aparecerem", + "actionsVisibility": "Exibição de ação do host", + "actionsVisibilityDesc": "Escolha quando as ações do host se irão expandir. Selecione \"clicar\" para restaurar o comportamento anterior de \"clicar para expandir\".", "actionsAlways": "Sempre visível", "actionsOnly": "Ligue apenas, descanse com um clique.", "actionsHover": "Mostrar ao passar o cursor", - "actionsClick": "Mostrar ao clicar", + "actionsClick": "Clique para expandir/recolher", "openOnDoubleClick": "Abrir com um duplo clique", "openOnDoubleClickDesc": "Exigir um duplo clique para abrir uma sessão de host em vez de um único clique.", "deletedHost": "{{name}} eliminado", @@ -1867,6 +1880,7 @@ "uploaded": "Enviado {{name}}", "uploadFailed": "Falha ao carregar {{name}}", "uploadDisabled": "Os uploads estão desativados para esta ligação.", + "driveUnavailable": "Ative o redireccionamento da unidade RDP antes de enviar ficheiros.", "dropToUpload": "Arraste e largue os ficheiros para fazer o upload." }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Nome da pasta", "fileUploadedSuccessfully": "Ficheiro \"{{name}}\" enviado com sucesso", "failedToUploadFile": "Falha ao enviar ficheiro", + "uploadingFolderFiles": "A enviar {{count}} ficheiro(s)...", + "uploadedFolderFiles": "Ficheiro(s) carregado(s) com sucesso {{count}}", + "uploadedFolderPartial": "Uploaded {{uploaded}} file(s), {{failed}} failed", "fileDownloadedSuccessfully": "Ficheiro \"{{name}}\" descarregado com sucesso", "failedToDownloadFile": "Falha ao descarregar ficheiro", "fileCreatedSuccessfully": "Ficheiro \"{{name}}\" criado com sucesso", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} itens", "downloadFileSuccess": "Ficheiro {{name}} descarregado com sucesso", "downloadFileFailed": "Falha ao descarregar", + "downloadingFile": "A descarregar {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Mover para {{name}}", "diffCompareWith": "Comparar diferenças com {{name}}", "dragOutsideToDownload": "Arraste para fora da janela para descarregar ({{count}} ficheiros)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Criar automaticamente contas para utilizadores OIDC/SSO no primeiro início de sessão (independente da opção de registo)", "oidcSilentLoginDefault": "Início de Sessão Silencioso OIDC por Defeito", "oidcSilentLoginDefaultDesc": "Redirecionar automaticamente para o início de sessão OIDC em cada visita, ignorando completamente o formulário de início de sessão", + "oidcSilentLoginDefaultLockedDesc": "Esta configuração está bloqueada pela variável de ambiente OIDC_SILENT_LOGIN_DEFAULT e não pode ser alterada aqui.", "allowPasswordReset": "Permitir Reposição de Palavra-passe", "allowPasswordResetDesc": "Código de reposição via registos do Docker", "commandHistoryEnabled": "Histórico de Comandos", @@ -3317,11 +3337,11 @@ "createRole": "Criar", "creating": "A criar...", "exportDatabase": "Exportar Base de Dados", - "exportDatabaseDesc": "Transferir uma cópia de segurança de todos os hosts, credenciais e definições", + "exportDatabaseDesc": "Transfira um backup portátil em SQLite de todos os hosts, credenciais e definições.", "export": "Exportar", "exporting": "A exportar...", "importDatabase": "Importar Base de Dados", - "importDatabaseDesc": "Restaurar a partir de um ficheiro de cópia de segurança .sqlite", + "importDatabaseDesc": "Restaure a partir de um backup . sqlite portátil em qualquer base de dados.", "importDatabaseSelected": "Selecionado: {{name}}", "selectFile": "Selecionar Ficheiro", "changeFile": "Alterar", @@ -4516,6 +4536,7 @@ "selectProvider": "Selecione um fornecedor", "providerSettings": "Configurações do fornecedor", "addProvider": "Adicionar provedor", + "editProvider": "Editar provedor", "removeProvider": "Remover fornecedor", "providerType": "Fornecedor", "providerLabel": "Nome", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Por exemplo: llama3.1 ou claude-opus-5", "labelRequired": "Dê um nome a este fornecedor.", "providerSaveFailed": "Não foi possível guardar o fornecedor.", + "providerUpdated": "Fornecedor atualizado", "providerDeleteFailed": "Não foi possível remover o fornecedor.", "privateEndpointHint": "Os endereços na sua rede privada necessitam que um administrador autorize primeiro o host.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/ro_RO.json b/src/ui/locales/translated/ro_RO.json index 23abee53..6e195e87 100644 --- a/src/ui/locales/translated/ro_RO.json +++ b/src/ui/locales/translated/ro_RO.json @@ -111,8 +111,12 @@ "filterTypeKey": "Cheie SSH", "filterTagsGroup": "Etichete", "copyDeployCommand": "Copiați comanda de implementare", + "cloneCredentialAction": "Acreditare clonată", + "clonedCredential": "Clonat {{name}}", + "clonedCredentialName": "{{name}} (copie)", "deleteCredentialAction": "Ștergeți acreditările", "editCredentialAction": "Editați acreditările", + "failedToCloneCredential": "Nu s-a putut clona acreditările", "failedToDeleteCredential": "Ștergerea acreditării nu a reușit", "idBadge": "ID-ul", "keyBadge": "CHEIE", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Calea socketului agentului", "agentSocketPathPlaceholder": "Lăsați gol pentru a utiliza SSH_AUTH_SOCK", "agentSocketPathHint": "Lăsați câmpul gol pentru detectarea automată din variabila de mediu SSH_AUTH_SOCK sau introduceți o cale de socket personalizată (de exemplu, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Identitatea agentului (cheie publică)", + "agentIdentityPlaceholder": "Lăsați câmpul gol pentru a încerca toate cheile de la agent", + "agentIdentityHint": "Lipiți o cheie publică (de exemplu, ssh-ed25519 AAAA...) pentru a oferi doar acea cheie de la agent. Recomandat atunci când agentul deține mai multe chei, pentru a evita depășirea numărului MaxAuthTries al serverului.", "shareSshAuthLabel": "Partajați autentificarea SSH", "shareSshAuthDesc": "Oferiți destinatarilor copii criptate ale autentificării SSH a acestei gazde. Acreditările personale ale destinatarului au în continuare prioritate.", "tailscaleDeviceSelect": "Selectați dispozitivul Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Nu au fost găsite acreditări", "addCredentialBtn": "Adăugați acreditări", "updateCredentialBtn": "Actualizați acreditările", + "editCredentialAction": "Editați acreditările", + "backToHost": "Înapoi la gazdă", + "saveCredentialAsNewBtn": "Salvare ca nou", + "saveCredentialAsNewDesc": "Creează o nouă acreditare cu aceste modificări și o atribuie acestei gazde. Acreditarea originală rămâne neschimbată.", + "credentialSavedAsNew": "Salvat ca o nouă acreditare", + "failedToSaveCredentialAsNew": "Nu s-a putut salva ca o nouă acreditare", "features": "Caracteristici", "noFolder": "(Fără dosar)", "deleteSelected": "Şterge", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Compact", "displayShowTags": "Afișați etichetele", "displayTrayOnClick": "Deschideți acțiunile la clic (în loc să treceți cu mouse-ul peste ele)", - "actionsVisibility": "Acțiunile gazdei", - "actionsVisibilityDesc": "Când apar butoanele de conectare și administrare de pe fiecare gazdă", + "actionsVisibility": "Afișajul acțiunilor gazdei", + "actionsVisibilityDesc": "Alegeți când se extind acțiunile gazdei. Selectați clic pentru a restabili comportamentul anterior de tip clic pentru extindere.", "actionsAlways": "Întotdeauna vizibil", "actionsOnly": "Doar conectare, odihnă la clic", "actionsHover": "Afișare la trecerea cu mouse-ul", - "actionsClick": "Afișare la clic", + "actionsClick": "Faceți clic pentru a extinde/restrânge", "openOnDoubleClick": "Deschideți cu dublu clic", "openOnDoubleClickDesc": "Necesită un dublu clic pentru a deschide o sesiune gazdă în loc de un singur clic", "deletedHost": "Șters {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Încărcat {{name}}", "uploadFailed": "Nu s-a putut încărca {{name}}", "uploadDisabled": "Încărcările sunt dezactivate pentru această conexiune", + "driveUnavailable": "Activați redirecționarea unității RDP înainte de încărcarea fișierelor", "dropToUpload": "Plasați fișierele pentru încărcare" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Nume folder", "fileUploadedSuccessfully": "Fișierul „{{name}}” a fost încărcat cu succes", "failedToUploadFile": "Nu s-a putut încărca fișierul", + "uploadingFolderFiles": "Se încarcă {{count}} fișier(e)...", + "uploadedFolderFiles": "Fișier(e) {{count}} încărcat(e) cu succes", + "uploadedFolderPartial": "Încărcarea fișierelor {{uploaded}} , {{failed}} a eșuat.", "fileDownloadedSuccessfully": "Fișierul „{{name}}” a fost descărcat cu succes", "failedToDownloadFile": "Nu s-a putut descărca fișierul", "fileCreatedSuccessfully": "Fișierul „{{name}}” a fost creat cu succes", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} articole", "downloadFileSuccess": "Fișierul {{name}} a fost descărcat cu succes", "downloadFileFailed": "Descărcarea a eșuat", + "downloadingFile": "Se descarcă {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Mută la {{name}}", "diffCompareWith": "Comparație diferențială cu {{name}}", "dragOutsideToDownload": "Trageți în afara ferestrei pentru a descărca (fișiere{{count}})", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Creare automată conturi pentru utilizatorii OIDC/SSO la prima autentificare (independent de comutarea înregistrării)", "oidcSilentLoginDefault": "Conectare OIDC silențioasă în mod implicit", "oidcSilentLoginDefaultDesc": "Redirecționare automată către autentificarea OIDC la fiecare vizită, omițând complet formularul de autentificare", + "oidcSilentLoginDefaultLockedDesc": "Această setare este blocată de variabila de mediu OIDC_SILENT_LOGIN_DEFAULT și nu poate fi modificată aici.", "allowPasswordReset": "Permite resetarea parolei", "allowPasswordResetDesc": "Resetează codul prin jurnalele Docker", "commandHistoryEnabled": "Istoricul comenzilor", @@ -3317,11 +3337,11 @@ "createRole": "Crea", "creating": "Se creează...", "exportDatabase": "Exportați baza de date", - "exportDatabaseDesc": "Descărcați o copie de rezervă a tuturor gazdelor, acreditărilor și setărilor", + "exportDatabaseDesc": "Descărcați o copie de rezervă SQLite portabilă a tuturor gazdelor, acreditărilor și setărilor", "export": "Export", "exporting": "Exportator...", "importDatabase": "Importați baza de date", - "importDatabaseDesc": "Restaurare dintr-un fișier de rezervă .sqlite", + "importDatabaseDesc": "Restaurare dintr-o copie de rezervă .sqlite portabilă pe orice backend de bază de date", "importDatabaseSelected": "Selectat: {{name}}", "selectFile": "Selectați fișierul", "changeFile": "Schimba", @@ -4516,6 +4536,7 @@ "selectProvider": "Selectați un furnizor", "providerSettings": "Setări furnizor", "addProvider": "Adăugați furnizor", + "editProvider": "Editați furnizorul", "removeProvider": "Eliminați furnizorul", "providerType": "Furnizor", "providerLabel": "Nume", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "De exemplu: llama3.1 sau claude-opus-5", "labelRequired": "Dați un nume acestui furnizor", "providerSaveFailed": "Nu s-a putut salva furnizorul", + "providerUpdated": "Furnizor actualizat", "providerDeleteFailed": "Nu s-a putut elimina furnizorul", "privateEndpointHint": "Adresele din rețeaua privată necesită mai întâi permisiunea unui administrator pentru gazdă.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/ru_RU.json b/src/ui/locales/translated/ru_RU.json index 04dac203..e0566e3f 100644 --- a/src/ui/locales/translated/ru_RU.json +++ b/src/ui/locales/translated/ru_RU.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-ключ", "filterTagsGroup": "Теги", "copyDeployCommand": "Скопировать команду развертывания", + "cloneCredentialAction": "Клонировать учетные данные", + "clonedCredential": "Клонировано {{name}}", + "clonedCredentialName": "{{name}} (копия)", "deleteCredentialAction": "Удалить учетные данные", "editCredentialAction": "Изменить учетные данные", + "failedToCloneCredential": "Не удалось клонировать учетные данные.", "failedToDeleteCredential": "Не удалось удалить учетные данные.", "idBadge": "ИДЕНТИФИКАТОР", "keyBadge": "КЛЮЧ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Путь к сокету агента", "agentSocketPathPlaceholder": "Оставьте пустым для использования SSH_AUTH_SOCK", "agentSocketPathHint": "Оставьте пустым для автоопределения из переменной окружения SSH_AUTH_SOCK или укажите свой путь к сокету (например, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Идентификатор агента (открытый ключ)", + "agentIdentityPlaceholder": "Оставьте поле пустым, чтобы попробовать все клавиши от агента.", + "agentIdentityHint": "Вставьте открытый ключ (например, ssh-ed25519 AAAA...), чтобы агент предлагал только этот ключ. Рекомендуется, если у агента много ключей, чтобы избежать превышения лимита MaxAuthTries сервера.", "shareSshAuthLabel": "Совместное использование SSH-аутентификации", "shareSshAuthDesc": "Передайте получателям зашифрованные копии данных SSH-аутентификации этого хоста. При этом персональные учётные данные получателя имеют приоритет.", "tailscaleDeviceSelect": "Выберите устройство Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Учётные данные не найдены", "addCredentialBtn": "Добавить учётные данные", "updateCredentialBtn": "Обновить учётные данные", + "editCredentialAction": "Изменить учетные данные", + "backToHost": "Вернуться к хосту", + "saveCredentialAsNewBtn": "Сохранить как новый", + "saveCredentialAsNewDesc": "Создает новые учетные данные с учетом внесенных изменений и назначает их данному хосту. Исходные учетные данные остаются без изменений.", + "credentialSavedAsNew": "Сохранено как новые учетные данные.", + "failedToSaveCredentialAsNew": "Не удалось сохранить новые учетные данные.", "features": "Функции", "noFolder": "(Нет папки)", "deleteSelected": "Удалить", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Компактный", "displayShowTags": "Показать теги", "displayTrayOnClick": "Действия открываются по клику (а не при наведении курсора).", - "actionsVisibility": "Действия хоста", - "actionsVisibilityDesc": "Когда на каждом хосте появятся кнопки подключения и управления", + "actionsVisibility": "Отображение действий хоста", + "actionsVisibilityDesc": "Выберите момент, когда действия хоста разворачиваются. Выберите «кликнуть», чтобы восстановить прежнее поведение «кликнуть для разворачивания».", "actionsAlways": "Всегда видимый", "actionsOnly": "Подключайтесь только, остальное — щелчком мыши.", "actionsHover": "Отображать при наведении курсора", - "actionsClick": "Показать при клике", + "actionsClick": "Нажмите, чтобы развернуть/свернуть", "openOnDoubleClick": "Открывается двойным щелчком", "openOnDoubleClickDesc": "Для открытия сессии хоста теперь требуется двойной щелчок вместо одного.", "deletedHost": "Удалён {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Загружено {{name}}", "uploadFailed": "Не удалось загрузить {{name}}", "uploadDisabled": "Загрузка файлов для этого соединения отключена.", + "driveUnavailable": "Включите перенаправление RDP-дисков перед загрузкой файлов.", "dropToUpload": "Перетащите файлы для загрузки" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Имя папки", "fileUploadedSuccessfully": "Файл «{{name}}» успешно загружен", "failedToUploadFile": "Не удалось загрузить файл", + "uploadingFolderFiles": "Загрузка {{count}} файла(ов)...", + "uploadedFolderFiles": "Файл(ы) {{count}} успешно загружен(ы).", + "uploadedFolderPartial": "Загрузка {{uploaded}} файла(ов), {{failed}} не удалась.", "fileDownloadedSuccessfully": "Файл «{{name}}» успешно скачан", "failedToDownloadFile": "Не удалось скачать файл", "fileCreatedSuccessfully": "Файл «{{name}}» успешно создан", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} элементов", "downloadFileSuccess": "Файл {{name}} успешно загружен", "downloadFileFailed": "Не удалось загрузить", + "downloadingFile": "Загрузка {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Переместить в {{name}}", "diffCompareWith": "Сравнить с {{name}}", "dragOutsideToDownload": "Перетащите за пределы окна для загрузки ({{count}} файлов)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Автоматически создавать учётные записи для пользователей OIDC/SSO при первом входе (независимо от переключателя регистрации)", "oidcSilentLoginDefault": "Автоматический вход OIDC по умолчанию", "oidcSilentLoginDefaultDesc": "Автоматически перенаправлять на страницу входа OIDC при каждом посещении, полностью пропуская форму входа", + "oidcSilentLoginDefaultLockedDesc": "Этот параметр заблокирован переменной среды OIDC_SILENT_LOGIN_DEFAULT и не может быть изменен здесь.", "allowPasswordReset": "Разрешить сброс пароля", "allowPasswordResetDesc": "Код сброса в логах Docker", "commandHistoryEnabled": "История команд", @@ -3317,11 +3337,11 @@ "createRole": "Создать", "creating": "Создание...", "exportDatabase": "Экспорт базы данных", - "exportDatabaseDesc": "Скачать резервную копию всех хостов, учётных данных и настроек", + "exportDatabaseDesc": "Загрузите портативную резервную копию SQLite, содержащую все хосты, учетные данные и настройки.", "export": "Экспортировать", "exporting": "Экспортирование...", "importDatabase": "Импорт базы данных", - "importDatabaseDesc": "Восстановить из файла резервной копии .sqlite", + "importDatabaseDesc": "Восстановление из портативной резервной копии .sqlite на любой базе данных.", "importDatabaseSelected": "Выбрано: {{name}}", "selectFile": "Выбрать файл", "changeFile": "Изменить", @@ -4516,6 +4536,7 @@ "selectProvider": "Выберите поставщика", "providerSettings": "Настройки поставщика", "addProvider": "Добавить поставщика", + "editProvider": "Поставщик редактирования", "removeProvider": "Удалить поставщика", "providerType": "Поставщик", "providerLabel": "Имя", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Например: llama3.1 или claude-opus-5", "labelRequired": "Укажите имя этого поставщика услуг.", "providerSaveFailed": "Не удалось сохранить поставщика услуг.", + "providerUpdated": "Поставщик обновлен", "providerDeleteFailed": "Не удалось удалить поставщика услуг.", "privateEndpointHint": "Для доступа к адресам в вашей частной сети необходимо предварительное разрешение администратора.", "providerOllama": "оллама", diff --git a/src/ui/locales/translated/sr_SP.json b/src/ui/locales/translated/sr_SP.json index 1d41a4a4..d24ba24a 100644 --- a/src/ui/locales/translated/sr_SP.json +++ b/src/ui/locales/translated/sr_SP.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH кључ", "filterTagsGroup": "Ознаке", "copyDeployCommand": "Копирај команду за распоређивање", + "cloneCredentialAction": "Клонирај акредитив", + "clonedCredential": "Клонирано {{name}}", + "clonedCredentialName": "{{name}} (копија)", "deleteCredentialAction": "Обриши акредитив", "editCredentialAction": "Измени акредитив", + "failedToCloneCredential": "Клонирање акредитива није успело", "failedToDeleteCredential": "Брисање акредитива није успело", "idBadge": "ИД", "keyBadge": "КЉУЧ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Путања агентског сокета", "agentSocketPathPlaceholder": "Оставите празно да бисте користили SSH_AUTH_SOCK", "agentSocketPathHint": "Оставите празно да би се аутоматски детектовало из променљиве окружења SSH_AUTH_SOCK или унесите прилагођену путању до сокета (нпр. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Идентитет агента (јавни кључ)", + "agentIdentityPlaceholder": "Оставите празно да бисте испробали све кључеве од агента", + "agentIdentityHint": "Налепите јавни кључ (нпр. ssh-ed25519 AAAA...) да бисте понудили само тај кључ од агента. Препоручује се када агент поседује много кључева, како би се избегло прекорачење максималног броја покушаја овлашћења сервера.", "shareSshAuthLabel": "Дељење SSH аутентификације", "shareSshAuthDesc": "Доставите примаоцима шифроване копије SSH аутентификације овог хоста. Лични акредитив примаоца и даље има предност.", "tailscaleDeviceSelect": "Изаберите уређај Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Нису пронађени акредитиви", "addCredentialBtn": "Додај акредитив", "updateCredentialBtn": "Ажурирај акредитив", + "editCredentialAction": "Измени акредитив", + "backToHost": "Назад на домаћина", + "saveCredentialAsNewBtn": "Сачувај као ново", + "saveCredentialAsNewDesc": "Креира нови акредитив са овим променама и додељује га овом хосту. Оригинални акредитив остаје непромењен.", + "credentialSavedAsNew": "Сачувано као нови акредитив", + "failedToSaveCredentialAsNew": "Чување као новог акредитива није успело", "features": "Карактеристике", "noFolder": "(Нема фасцикле)", "deleteSelected": "Обриши", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Компактно", "displayShowTags": "Прикажи ознаке", "displayTrayOnClick": "Отварање акција кликом (уместо преласка мишем)", - "actionsVisibility": "Акције домаћина", - "actionsVisibilityDesc": "Када се појаве дугмад за повезивање и управљање на сваком хосту", + "actionsVisibility": "Приказ акције домаћина", + "actionsVisibilityDesc": "Изаберите када се радње хоста проширују. Изаберите клик да бисте вратили претходно понашање клика за проширивање.", "actionsAlways": "Увек видљиво", "actionsOnly": "Само повезивање, остатак на клик", "actionsHover": "Прикажи превлачењем миша", - "actionsClick": "Прикажи кликом", + "actionsClick": "Кликните да бисте проширили/склопили", "openOnDoubleClick": "Отвори двоструким кликом", "openOnDoubleClickDesc": "За отварање сесије домаћина потребан је двоструки клик уместо једног клика", "deletedHost": "Обрисано {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Отпремљено {{name}}", "uploadFailed": "Није успело отпремање {{name}}", "uploadDisabled": "Отпремања су онемогућена за ову везу", + "driveUnavailable": "Омогућите преусмеравање RDP диска пре отпремања датотека", "dropToUpload": "Отпустите датотеке да бисте их отпремили" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Назив фасцикле", "fileUploadedSuccessfully": "Датотека „{{name}}“ је успешно отпремљена", "failedToUploadFile": "Отпремање датотеке није успело", + "uploadingFolderFiles": "Отпремање {{count}} датотека...", + "uploadedFolderFiles": "Успешно отпремљено {{count}} датотека", + "uploadedFolderPartial": "Отпремљено {{uploaded}} датотека, {{failed}} није успело", "fileDownloadedSuccessfully": "Датотека „{{name}}“ је успешно преузета", "failedToDownloadFile": "Преузимање датотеке није успело", "fileCreatedSuccessfully": "Датотека „{{name}}“ је успешно креирана", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} ставке", "downloadFileSuccess": "Датотека {{name}} је успешно преузета", "downloadFileFailed": "Преузимање није успело", + "downloadingFile": "Преузимање {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Премести на {{name}}", "diffCompareWith": "Разлика у поређењу са {{name}}", "dragOutsideToDownload": "Превуците спољашњост прозора да бисте преузели ({{count}} датотека)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Аутоматски креирај налоге за OIDC/SSO кориснике при првом пријављивању (независно од прекидача за регистрацију)", "oidcSilentLoginDefault": "Тихо OIDC пријављивање по подразумеваним подешавањима", "oidcSilentLoginDefaultDesc": "Аутоматско преусмеравање на OIDC пријаву при свакој посети, потпуно прескачући образац за пријаву", + "oidcSilentLoginDefaultLockedDesc": "Ово подешавање је закључано променљивом окружења OIDC_SILENT_LOGIN_DEFAULT и не може се овде променити.", "allowPasswordReset": "Дозволи ресетовање лозинке", "allowPasswordResetDesc": "Ресетујте код путем Докер логова", "commandHistoryEnabled": "Историја команди", @@ -3317,11 +3337,11 @@ "createRole": "Креирај", "creating": "Креирање...", "exportDatabase": "Извоз базе података", - "exportDatabaseDesc": "Преузмите резервну копију свих хостова, акредитива и подешавања", + "exportDatabaseDesc": "Преузмите преносиву SQLite резервну копију свих хостова, акредитива и подешавања", "export": "Извоз", "exporting": "Извоз...", "importDatabase": "Увоз базе података", - "importDatabaseDesc": "Враћање из .sqlite датотеке резервне копије", + "importDatabaseDesc": "Враћање из преносиве .sqlite резервне копије на било којој бази података", "importDatabaseSelected": "Изабрано: {{name}}", "selectFile": "Изаберите датотеку", "changeFile": "Промена", @@ -4516,6 +4536,7 @@ "selectProvider": "Изаберите добављача", "providerSettings": "Подешавања добављача", "addProvider": "Додај добављача", + "editProvider": "Измени добављача", "removeProvider": "Уклони добављача", "providerType": "Провајдер", "providerLabel": "Име", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "На пример: llama3.1 или claude-opus-5", "labelRequired": "Дајте име овом добављачу", "providerSaveFailed": "Није могуће сачувати добављача", + "providerUpdated": "Провајдер је ажуриран", "providerDeleteFailed": "Није могуће уклонити добављача", "privateEndpointHint": "Адресе на вашој приватној мрежи прво треба да дозволе администратору хоста.", "providerOllama": "Олама", diff --git a/src/ui/locales/translated/sv_SE.json b/src/ui/locales/translated/sv_SE.json index 89072d4e..504baae0 100644 --- a/src/ui/locales/translated/sv_SE.json +++ b/src/ui/locales/translated/sv_SE.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-nyckel", "filterTagsGroup": "Taggar", "copyDeployCommand": "Kopiera deploy-kommandot", + "cloneCredentialAction": "Klona inloggningsuppgifter", + "clonedCredential": "Klonad {{name}}", + "clonedCredentialName": "{{name}} (kopiera)", "deleteCredentialAction": "Ta bort inloggningsuppgifter", "editCredentialAction": "Redigera inloggningsuppgifter", + "failedToCloneCredential": "Misslyckades med att klona inloggningsuppgifterna", "failedToDeleteCredential": "Misslyckades med att ta bort inloggningsuppgifter", "idBadge": "ID", "keyBadge": "NYCKEL", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Agent Socket-sökväg", "agentSocketPathPlaceholder": "Lämna tomt för att använda SSH_AUTH_SOCK", "agentSocketPathHint": "Lämna tomt för att automatiskt identifiera från miljövariabeln SSH_AUTH_SOCK, eller ange en anpassad socket-sökväg (t.ex. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Agentidentitet (publik nyckel)", + "agentIdentityPlaceholder": "Lämna tomt för att prova alla nycklar från agenten", + "agentIdentityHint": "Klistra in en offentlig nyckel (t.ex. ssh-ed25519 AAAA...) för att endast erbjuda den nyckeln från agenten. Rekommenderas när agenten har många nycklar, för att undvika att överskrida serverns MaxAuthTries.", "shareSshAuthLabel": "Dela SSH-autentisering", "shareSshAuthDesc": "Ge mottagarna krypterade kopior av denna värds SSH-autentisering. Mottagarens personliga inloggningsuppgifter har fortfarande företräde.", "tailscaleDeviceSelect": "Välj Tailscale-enhet", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Inga inloggningsuppgifter hittades", "addCredentialBtn": "Lägg till autentiseringsuppgifter", "updateCredentialBtn": "Uppdatera autentiseringsuppgifter", + "editCredentialAction": "Redigera inloggningsuppgifter", + "backToHost": "Tillbaka till värden", + "saveCredentialAsNewBtn": "Spara som ny", + "saveCredentialAsNewDesc": "Skapar en ny autentiseringsuppgift med dessa ändringar och tilldelar den till denna värd. Den ursprungliga autentiseringsuppgiften lämnas oförändrad.", + "credentialSavedAsNew": "Sparad som en ny autentiseringsuppgift", + "failedToSaveCredentialAsNew": "Misslyckades med att spara som en ny autentiseringsuppgift", "features": "Drag", "noFolder": "(Ingen mapp)", "deleteSelected": "Radera", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompakt", "displayShowTags": "Visa taggar", "displayTrayOnClick": "Öppna åtgärder vid klick (istället för att hålla muspekaren över)", - "actionsVisibility": "Värdåtgärder", - "actionsVisibilityDesc": "När anslutnings- och hanteringsknapparna på varje värd visas", + "actionsVisibility": "Värdåtgärdsvisning", + "actionsVisibilityDesc": "Välj när värdåtgärder expanderas. Välj klicka för att återställa det tidigare klicka-för-att-expandera-beteendet.", "actionsAlways": "Alltid synlig", "actionsOnly": "Endast anslutning, vila på klick", "actionsHover": "Visa vid muspekning", - "actionsClick": "Visa vid klick", + "actionsClick": "Klicka för att expandera/minimera", "openOnDoubleClick": "Öppna vid dubbelklick", "openOnDoubleClickDesc": "Kräv ett dubbelklick för att öppna en värdsession istället för ett enda klick", "deletedHost": "Borttagen {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Uppladdad {{name}}", "uploadFailed": "Misslyckades med att ladda upp {{name}}", "uploadDisabled": "Uppladdningar är inaktiverade för den här anslutningen", + "driveUnavailable": "Aktivera omdirigering av RDP-enheter innan du laddar upp filer", "dropToUpload": "Släpp filer för att ladda upp" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Mappnamn", "fileUploadedSuccessfully": "Filen \"{{name}}\" har laddats upp", "failedToUploadFile": "Misslyckades med att ladda upp filen", + "uploadingFolderFiles": "Laddar upp {{count}} fil(er)...", + "uploadedFolderFiles": "Uppladdade {{count}} fil(er) framgångsrikt", + "uploadedFolderPartial": "Uppladdade {{uploaded}} fil(er), {{failed}} misslyckades", "fileDownloadedSuccessfully": "Filen \"{{name}}\" har laddats ner", "failedToDownloadFile": "Misslyckades med att ladda ner filen", "fileCreatedSuccessfully": "Filen \"{{name}}\" skapades", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} objekt", "downloadFileSuccess": "Filen {{name}} har laddats ner", "downloadFileFailed": "Nedladdningen misslyckades", + "downloadingFile": "Laddar ner {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Flytta till {{name}}", "diffCompareWith": "Differensjämförelse med {{name}}", "dragOutsideToDownload": "Dra utanför fönstret för att ladda ner ({{count}} filer)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Skapa konton automatiskt för OIDC/SSO-användare vid första inloggningen (oberoende av registreringsknappen)", "oidcSilentLoginDefault": "Tyst OIDC-inloggning som standard", "oidcSilentLoginDefaultDesc": "Omdirigera automatiskt till OIDC-inloggning vid varje besök, hoppar över inloggningsformuläret helt", + "oidcSilentLoginDefaultLockedDesc": "Den här inställningen är låst av miljövariabeln OIDC_SILENT_LOGIN_DEFAULT och kan inte ändras här.", "allowPasswordReset": "Tillåt lösenordsåterställning", "allowPasswordResetDesc": "Återställ kod via Docker-loggar", "commandHistoryEnabled": "Kommandohistorik", @@ -3317,11 +3337,11 @@ "createRole": "Skapa", "creating": "Skapande...", "exportDatabase": "Exportera databas", - "exportDatabaseDesc": "Ladda ner en säkerhetskopia av alla värdar, inloggningsuppgifter och inställningar", + "exportDatabaseDesc": "Ladda ner en portabel SQLite-säkerhetskopia av alla värdar, inloggningsuppgifter och inställningar", "export": "Exportera", "exporting": "Exporterar...", "importDatabase": "Importera databas", - "importDatabaseDesc": "Återställ från en .sqlite-säkerhetskopia", + "importDatabaseDesc": "Återställ från en portabel .sqlite-säkerhetskopia på valfri databas-backend", "importDatabaseSelected": "Vald: {{name}}", "selectFile": "Välj fil", "changeFile": "Ändra", @@ -4516,6 +4536,7 @@ "selectProvider": "Välj en leverantör", "providerSettings": "Leverantörsinställningar", "addProvider": "Lägg till leverantör", + "editProvider": "Redigera leverantör", "removeProvider": "Ta bort leverantör", "providerType": "Leverantör", "providerLabel": "Namn", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Till exempel: llama3.1 eller claude-opus-5", "labelRequired": "Ge den här leverantören ett namn", "providerSaveFailed": "Kunde inte spara leverantören", + "providerUpdated": "Leverantören uppdaterad", "providerDeleteFailed": "Kunde inte ta bort leverantören", "privateEndpointHint": "Adresser i ditt privata nätverk behöver en administratör som först tillåter värden.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/th_TH.json b/src/ui/locales/translated/th_TH.json index d4eab5e1..a0836880 100644 --- a/src/ui/locales/translated/th_TH.json +++ b/src/ui/locales/translated/th_TH.json @@ -111,8 +111,12 @@ "filterTypeKey": "คีย์ SSH", "filterTagsGroup": "แท็ก", "copyDeployCommand": "คัดลอกคำสั่งปรับใช้", + "cloneCredentialAction": "คัดลอกข้อมูลประจำตัว", + "clonedCredential": "โคลน {{name}}", + "clonedCredentialName": "{{name}} (คัดลอก)", "deleteCredentialAction": "ลบข้อมูลรับรอง", "editCredentialAction": "แก้ไขข้อมูลประจำตัว", + "failedToCloneCredential": "ไม่สามารถคัดลอกข้อมูลประจำตัวได้", "failedToDeleteCredential": "ไม่สามารถลบข้อมูลประจำตัวได้", "idBadge": "รหัสประจำตัว", "keyBadge": "สำคัญ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "เส้นทางซ็อกเก็ตเอเจนต์", "agentSocketPathPlaceholder": "เว้นว่างไว้เพื่อใช้ SSH_AUTH_SOCK", "agentSocketPathHint": "เว้นว่างไว้เพื่อให้ระบบตรวจจับอัตโนมัติจากตัวแปรสภาพแวดล้อม SSH_AUTH_SOCK หรือป้อนเส้นทางซ็อกเก็ตแบบกำหนดเอง (เช่น /run/user/1000/gnupg/S.gpg-agent.ssh)", + "agentIdentityLabel": "รหัสประจำตัวของเอเจนต์ (คีย์สาธารณะ)", + "agentIdentityPlaceholder": "ปล่อยว่างไว้เพื่อลองใช้คีย์ทั้งหมดจากเอเจนต์", + "agentIdentityHint": "วางคีย์สาธารณะ (เช่น ssh-ed25519 AAAA...) เพื่อให้ตัวแทนเสนอคีย์นั้นเพียงคีย์เดียว แนะนำให้ใช้เมื่อตัวแทนมีคีย์จำนวนมาก เพื่อหลีกเลี่ยงการเกินจำนวน MaxAuthTries ของเซิร์ฟเวอร์", "shareSshAuthLabel": "แชร์การตรวจสอบสิทธิ์ SSH", "shareSshAuthDesc": "ส่งสำเนาการเข้ารหัสของข้อมูลรับรอง SSH ของโฮสต์นี้ให้แก่ผู้รับ อย่างไรก็ตาม ข้อมูลรับรองส่วนบุคคลของผู้รับยังคงมีความสำคัญเหนือกว่า", "tailscaleDeviceSelect": "เลือกอุปกรณ์ Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "ไม่พบข้อมูลประจำตัว", "addCredentialBtn": "เพิ่มข้อมูลรับรอง", "updateCredentialBtn": "อัปเดตข้อมูลรับรอง", + "editCredentialAction": "แก้ไขข้อมูลประจำตัว", + "backToHost": "กลับสู่หน้าหลัก", + "saveCredentialAsNewBtn": "บันทึกเป็นรายการใหม่", + "saveCredentialAsNewDesc": "สร้างข้อมูลรับรองใหม่โดยใช้การเปลี่ยนแปลงเหล่านี้และกำหนดให้กับโฮสต์นี้ ข้อมูลรับรองเดิมยังคงไม่เปลี่ยนแปลง", + "credentialSavedAsNew": "บันทึกเป็นข้อมูลรับรองใหม่แล้ว", + "failedToSaveCredentialAsNew": "ไม่สามารถบันทึกเป็นข้อมูลรับรองใหม่ได้", "features": "คุณสมบัติ", "noFolder": "(ไม่มีโฟลเดอร์)", "deleteSelected": "ลบ", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "กะทัดรัด", "displayShowTags": "แสดงแท็ก", "displayTrayOnClick": "เปิดใช้งานการทำงานเมื่อคลิก (แทนการวางเมาส์เหนือ)", - "actionsVisibility": "การกระทำของโฮสต์", - "actionsVisibilityDesc": "เมื่อปุ่มเชื่อมต่อและการจัดการบนโฮสต์แต่ละเครื่องปรากฏขึ้น", + "actionsVisibility": "การแสดงผลการกระทำของโฮสต์", + "actionsVisibilityDesc": "เลือกเวลาที่การกระทำของโฮสต์จะขยายออก เลือก \"คลิก\" เพื่อคืนค่าพฤติกรรมการคลิกเพื่อขยายแบบเดิม", "actionsAlways": "มองเห็นได้เสมอ", "actionsOnly": "เชื่อมต่อเท่านั้น ที่เหลือก็คลิกได้เลย", "actionsHover": "แสดงเมื่อวางเมาส์เหนือภาพ", - "actionsClick": "แสดงเมื่อคลิก", + "actionsClick": "คลิกเพื่อขยาย/ยุบ", "openOnDoubleClick": "เปิดเมื่อดับเบิ้ลคลิก", "openOnDoubleClickDesc": "ต้องดับเบิ้ลคลิกเพื่อเปิดเซสชันโฮสต์ แทนที่จะคลิกเพียงครั้งเดียว", "deletedHost": "ลบแล้ว {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "อัปโหลดแล้ว {{name}}", "uploadFailed": "การอัปโหลดล้มเหลว {{name}}", "uploadDisabled": "การอัปโหลดถูกปิดใช้งานสำหรับการเชื่อมต่อนี้", + "driveUnavailable": "เปิดใช้งานการส่งต่อไดรฟ์ RDP ก่อนอัปโหลดไฟล์", "dropToUpload": "ลากไฟล์เพื่ออัปโหลด" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "ชื่อโฟลเดอร์", "fileUploadedSuccessfully": "ไฟล์ \"{{name}}\" อัปโหลดสำเร็จแล้ว", "failedToUploadFile": "ไม่สามารถอัปโหลดไฟล์ได้", + "uploadingFolderFiles": "กำลังอัปโหลดไฟล์ {{count}} ไฟล์...", + "uploadedFolderFiles": "อัปโหลดไฟล์ {{count}} ไฟล์สำเร็จแล้ว", + "uploadedFolderPartial": "อัปโหลดไฟล์ {{uploaded}} ไฟล์, {{failed}} ล้มเหลว", "fileDownloadedSuccessfully": "ดาวน์โหลดไฟล์ \"{{name}}\" สำเร็จแล้ว", "failedToDownloadFile": "ไม่สามารถดาวน์โหลดไฟล์ได้", "fileCreatedSuccessfully": "สร้างไฟล์ \"{{name}}\" สำเร็จแล้ว", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} รายการ", "downloadFileSuccess": "ดาวน์โหลดไฟล์ {{name}} สำเร็จแล้ว", "downloadFileFailed": "การดาวน์โหลดล้มเหลว", + "downloadingFile": "กำลังดาวน์โหลด {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "ย้ายไปที่ {{name}}", "diffCompareWith": "เปรียบเทียบความแตกต่างกับ {{name}}", "dragOutsideToDownload": "ลากออกนอกหน้าต่างเพื่อดาวน์โหลดไฟล์ ({{count}} ไฟล์)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "สร้างบัญชีอัตโนมัติสำหรับผู้ใช้ OIDC/SSO เมื่อเข้าสู่ระบบครั้งแรก (โดยไม่ขึ้นอยู่กับการตั้งค่าการลงทะเบียน)", "oidcSilentLoginDefault": "การเข้าสู่ระบบ OIDC แบบเงียบโดยค่าเริ่มต้น", "oidcSilentLoginDefaultDesc": "ระบบจะเปลี่ยนเส้นทางไปยังหน้าเข้าสู่ระบบ OIDC โดยอัตโนมัติทุกครั้งที่เข้าชม โดยไม่ต้องกรอกแบบฟอร์มเข้าสู่ระบบอีกต่อไป", + "oidcSilentLoginDefaultLockedDesc": "การตั้งค่านี้ถูกล็อกโดยตัวแปรสภาพแวดล้อม OIDC_SILENT_LOGIN_DEFAULT และไม่สามารถเปลี่ยนแปลงได้ที่นี่", "allowPasswordReset": "อนุญาตให้รีเซ็ตรหัสผ่าน", "allowPasswordResetDesc": "รีเซ็ตโค้ดผ่านบันทึก Docker", "commandHistoryEnabled": "ประวัติคำสั่ง", @@ -3317,11 +3337,11 @@ "createRole": "สร้าง", "creating": "กำลังสร้าง...", "exportDatabase": "ส่งออกฐานข้อมูล", - "exportDatabaseDesc": "ดาวน์โหลดไฟล์สำรองข้อมูลของโฮสต์ ข้อมูลประจำตัว และการตั้งค่าทั้งหมด", + "exportDatabaseDesc": "ดาวน์โหลดไฟล์สำรองข้อมูล SQLite แบบพกพาที่มีข้อมูลโฮสต์ ข้อมูลประจำตัว และการตั้งค่าทั้งหมด", "export": "ส่งออก", "exporting": "กำลังส่งออก...", "importDatabase": "นำเข้าฐานข้อมูล", - "importDatabaseDesc": "กู้คืนจากไฟล์สำรองข้อมูล .sqlite", + "importDatabaseDesc": "กู้คืนจากไฟล์สำรองข้อมูล .sqlite แบบพกพาบนระบบฐานข้อมูลใดก็ได้", "importDatabaseSelected": "เลือกแล้ว: {{name}}", "selectFile": "เลือกไฟล์", "changeFile": "เปลี่ยน", @@ -4516,6 +4536,7 @@ "selectProvider": "เลือกผู้ให้บริการ", "providerSettings": "การตั้งค่าผู้ให้บริการ", "addProvider": "เพิ่มผู้ให้บริการ", + "editProvider": "แก้ไขผู้ให้บริการ", "removeProvider": "ลบผู้ให้บริการ", "providerType": "ผู้ให้บริการ", "providerLabel": "ชื่อ", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "ตัวอย่างเช่น: llama3.1 หรือ claude-opus-5", "labelRequired": "ตั้งชื่อให้ผู้ให้บริการรายนี้", "providerSaveFailed": "ไม่สามารถบันทึกผู้ให้บริการได้", + "providerUpdated": "ผู้ให้บริการได้รับการอัปเดตแล้ว", "providerDeleteFailed": "ไม่สามารถลบผู้ให้บริการได้", "privateEndpointHint": "การเข้าถึงที่อยู่บนเครือข่ายส่วนตัวของคุณนั้น จำเป็นต้องให้ผู้ดูแลระบบอนุญาตก่อน", "providerOllama": "โอลาม่า", diff --git a/src/ui/locales/translated/tr_TR.json b/src/ui/locales/translated/tr_TR.json index 3b200928..5c8ae81e 100644 --- a/src/ui/locales/translated/tr_TR.json +++ b/src/ui/locales/translated/tr_TR.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH Anahtarı", "filterTagsGroup": "Etiketler", "copyDeployCommand": "Dağıtım komutunu kopyala", + "cloneCredentialAction": "Klon kimlik bilgisi", + "clonedCredential": "Klonlandı {{name}}", + "clonedCredentialName": "{{name}} (kopyala)", "deleteCredentialAction": "Kimlik bilgilerini sil", "editCredentialAction": "Kimlik bilgilerini düzenle", + "failedToCloneCredential": "Kimlik bilgilerini kopyalama başarısız oldu.", "failedToDeleteCredential": "Kimlik bilgilerini silme işlemi başarısız oldu.", "idBadge": "İD", "keyBadge": "ANAHTAR", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Ajan Soket Yolu", "agentSocketPathPlaceholder": "SSH_AUTH_SOCK kullanmak için boş bırakın", "agentSocketPathHint": "SSH_AUTH_SOCK ortam değişkeninden otomatik algılamak için boş bırakın veya özel bir soket yolu girin (örn. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Temsilci Kimliği (Herkese Açık Anahtar)", + "agentIdentityPlaceholder": "Aracının tüm tuşlarını denemek için boş bırakın.", + "agentIdentityHint": "Aracıdan yalnızca o anahtarı sunmak için bir genel anahtar yapıştırın (örneğin ssh-ed25519 AAAA...). Aracının birçok anahtar tuttuğu durumlarda, sunucunun MaxAuthTries sınırını aşmamak için önerilir.", "shareSshAuthLabel": "SSH Kimlik Doğrulamasını Paylaş", "shareSshAuthDesc": "Alıcılara bu sunucunun SSH kimlik doğrulama bilgilerinin şifrelenmiş kopyalarını verin. Alıcının kişisel kimlik bilgileri öncelikli olmaya devam eder.", "tailscaleDeviceSelect": "Tailscale cihazı seçin", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Hiç kimlik bilgisi bulunamadı", "addCredentialBtn": "Kimlik Bilgisi Ekle", "updateCredentialBtn": "Kimlik Bilgisini Güncelle", + "editCredentialAction": "Kimlik bilgilerini düzenle", + "backToHost": "Ana bilgisayara geri dön", + "saveCredentialAsNewBtn": "Yeni olarak kaydet", + "saveCredentialAsNewDesc": "Bu değişikliklerle yeni bir kimlik bilgisi oluşturulur ve bu sunucuya atanır. Orijinal kimlik bilgisi değiştirilmeden kalır.", + "credentialSavedAsNew": "Yeni bir kimlik bilgisi olarak kaydedildi.", + "failedToSaveCredentialAsNew": "Yeni bir kimlik bilgisi olarak kaydedilemedi.", "features": "Özellikler", "noFolder": "(Klasör yok)", "deleteSelected": "Sil", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Kompakt", "displayShowTags": "Etiketleri göster", "displayTrayOnClick": "Fareyle üzerine gelmek yerine tıklama ile eylemleri aç", - "actionsVisibility": "Sunucu işlemleri", - "actionsVisibilityDesc": "Her sunucudaki bağlantı ve yönetim düğmeleri göründüğünde", + "actionsVisibility": "Sunucu eylemi gösterimi", + "actionsVisibilityDesc": "Sunucu eylemlerinin ne zaman genişleyeceğini seçin. Önceki tıklayarak genişletme davranışını geri yüklemek için \"tıkla\"yı seçin.", "actionsAlways": "Her zaman görünür", "actionsOnly": "Sadece bağlantı kurun, gerisi tıklamaya bağlı.", "actionsHover": "Üzerine gelindiğinde göster", - "actionsClick": "Tıklayınca göster", + "actionsClick": "Genişletmek/Daraltmak için tıklayın", "openOnDoubleClick": "Çift tıklayarak açın", "openOnDoubleClickDesc": "Ana bilgisayar oturumunu açmak için tek tıklama yerine çift tıklama gerektir.", "deletedHost": "{{name}} silindi", @@ -1867,6 +1880,7 @@ "uploaded": "Yüklenen {{name}}", "uploadFailed": "{{name}} yüklenemedi.", "uploadDisabled": "Bu bağlantı için yüklemeler devre dışı bırakıldı.", + "driveUnavailable": "Dosyaları yüklemeden önce RDP sürücü yönlendirmesini etkinleştirin.", "dropToUpload": "Yüklemek için dosyaları sürükleyip bırakın." }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Klasör Adı", "fileUploadedSuccessfully": "\"{{name}}\" dosyası başarıyla yüklendi.", "failedToUploadFile": "Dosya yüklenemedi.", + "uploadingFolderFiles": "{{count}} dosya yükleniyor...", + "uploadedFolderFiles": "{{count}} dosya başarıyla yüklendi", + "uploadedFolderPartial": "{{uploaded}} dosya yüklendi, {{failed}} başarısız oldu", "fileDownloadedSuccessfully": "\"{{name}}\" dosyası başarıyla indirildi.", "failedToDownloadFile": "Dosya indirilemedi.", "fileCreatedSuccessfully": "\"{{name}}\" dosyası başarıyla oluşturuldu.", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{count}} öğe {{operation}}", "downloadFileSuccess": "{{name}} dosyası başarıyla indirildi", "downloadFileFailed": "İndirme başarısız oldu", + "downloadingFile": "{{name}}… indiriliyor", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "{{name}} konumuna taşı", "diffCompareWith": "{{name}} ile diff karşılaştır", "dragOutsideToDownload": "İndirmek için pencere dışına sürükleyin ({{count}} dosya)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "OIDC/SSO kullanıcıları için ilk girişte otomatik hesap oluştur (kayıt ayarından bağımsız)", "oidcSilentLoginDefault": "Varsayılan Olarak Sessiz OIDC Girişi", "oidcSilentLoginDefaultDesc": "Her ziyarette otomatik olarak OIDC girişine yönlendir, giriş formunu tamamen atla", + "oidcSilentLoginDefaultLockedDesc": "Bu ayar, OIDC_SILENT_LOGIN_DEFAULT ortam değişkeni tarafından kilitlenmiştir ve burada değiştirilemez.", "allowPasswordReset": "Parola Sıfırlamaya İzin Ver", "allowPasswordResetDesc": "Docker günlükleri aracılığıyla sıfırlama kodu", "commandHistoryEnabled": "Komut Geçmişi", @@ -3317,11 +3337,11 @@ "createRole": "Oluştur", "creating": "Oluşturuluyor...", "exportDatabase": "Veritabanını Dışa Aktar", - "exportDatabaseDesc": "Tüm sunucuların, kimlik bilgilerinin ve ayarların yedeğini indir", + "exportDatabaseDesc": "Tüm sunucuların, kimlik bilgilerinin ve ayarların taşınabilir bir SQLite yedeğini indirin.", "export": "Dışa Aktar", "exporting": "Dışa aktarılıyor...", "importDatabase": "Veritabanını İçe Aktar", - "importDatabaseDesc": "Bir .sqlite yedek dosyasından geri yükle", + "importDatabaseDesc": "Herhangi bir veritabanı arka ucunda taşınabilir bir .sqlite yedeklemesinden geri yükleme", "importDatabaseSelected": "Seçilen: {{name}}", "selectFile": "Dosya Seç", "changeFile": "Değiştir", @@ -4516,6 +4536,7 @@ "selectProvider": "Bir sağlayıcı seçin", "providerSettings": "Sağlayıcı ayarları", "addProvider": "Sağlayıcı ekle", + "editProvider": "Sağlayıcıyı düzenle", "removeProvider": "Sağlayıcıyı kaldır", "providerType": "Sağlayıcı", "providerLabel": "İsim", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Örneğin: llama3.1 veya claude-opus-5", "labelRequired": "Bu sağlayıcıya bir isim verin.", "providerSaveFailed": "Sağlayıcı kaydedilemedi.", + "providerUpdated": "Sağlayıcı güncellendi.", "providerDeleteFailed": "Sağlayıcı kaldırılamadı.", "privateEndpointHint": "Özel ağınızdaki adreslerin kullanılabilmesi için öncelikle yöneticinin sunucuya izin vermesi gerekmektedir.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/uk_UA.json b/src/ui/locales/translated/uk_UA.json index c751066a..2369b1f4 100644 --- a/src/ui/locales/translated/uk_UA.json +++ b/src/ui/locales/translated/uk_UA.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH-ключ", "filterTagsGroup": "Теги", "copyDeployCommand": "Скопіювати команду розгортання", + "cloneCredentialAction": "Клонувати облікові дані", + "clonedCredential": "Клоновано {{name}}", + "clonedCredentialName": "{{name}} (копія)", "deleteCredentialAction": "Видалити облікові дані", "editCredentialAction": "Редагувати облікові дані", + "failedToCloneCredential": "Не вдалося клонувати облікові дані", "failedToDeleteCredential": "Не вдалося видалити облікові дані", "idBadge": "Ідентифікатор", "keyBadge": "КЛЮЧ", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Шлях сокета агента", "agentSocketPathPlaceholder": "Залиште поле порожнім, щоб використовувати SSH_AUTH_SOCK", "agentSocketPathHint": "Залиште поле порожнім для автоматичного визначення зі змінної середовища SSH_AUTH_SOCK або введіть власний шлях до сокета (наприклад, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Ідентифікація агента (відкритий ключ)", + "agentIdentityPlaceholder": "Залиште поле порожнім, щоб спробувати всі ключі від агента", + "agentIdentityHint": "Вставте відкритий ключ (наприклад, ssh-ed25519 AAAA...), щоб пропонувати лише цей ключ від агента. Рекомендовано, коли агент має багато ключів, щоб уникнути перевищення максимальної кількості спроб автентифікації сервера.", "shareSshAuthLabel": "Спільний доступ до SSH-автентифікації", "shareSshAuthDesc": "Надати одержувачам зашифровані копії SSH-автентифікації цього хоста. Особисті облікові дані одержувача все ще мають пріоритет.", "tailscaleDeviceSelect": "Виберіть пристрій Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Облікові дані не знайдено", "addCredentialBtn": "Додати облікові дані", "updateCredentialBtn": "Оновити облікові дані", + "editCredentialAction": "Редагувати облікові дані", + "backToHost": "Назад до хоста", + "saveCredentialAsNewBtn": "Зберегти як нове", + "saveCredentialAsNewDesc": "Створює нові облікові дані з цими змінами та призначає їх цьому хосту. Початкові облікові дані залишаються без змін.", + "credentialSavedAsNew": "Збережено як нові облікові дані", + "failedToSaveCredentialAsNew": "Не вдалося зберегти як нові облікові дані", "features": "Особливості", "noFolder": "(Без папки)", "deleteSelected": "Видалити", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Компактний", "displayShowTags": "Показати теги", "displayTrayOnClick": "Відкривати дії при кліці (замість наведення курсора)", - "actionsVisibility": "Дії хоста", - "actionsVisibilityDesc": "Коли на кожному хості відображаються кнопки підключення та керування", + "actionsVisibility": "Відображення дій хоста", + "actionsVisibilityDesc": "Виберіть, коли розгортатимуться дії хоста. Виберіть «Клацнути», щоб відновити попередню поведінку «розгортання клацанням».", "actionsAlways": "Завжди видно", "actionsOnly": "Тільки підключення, відпочинок після клацання", "actionsHover": "Показувати при наведенні курсора", - "actionsClick": "Показувати після кліку", + "actionsClick": "Натисніть, щоб розгорнути/згорнути", "openOnDoubleClick": "Відкрити подвійним клацанням", "openOnDoubleClickDesc": "Для відкриття сеансу хоста потрібно подвійне клацання замість одного клацання", "deletedHost": "Видалено {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Завантажено {{name}}", "uploadFailed": "Не вдалося завантажити {{name}}", "uploadDisabled": "Завантаження для цього з’єднання вимкнено", + "driveUnavailable": "Увімкніть перенаправлення диска RDP перед завантаженням файлів", "dropToUpload": "Перетягніть файли для завантаження" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Назва папки", "fileUploadedSuccessfully": "Файл «{{name}}» успішно завантажено", "failedToUploadFile": "Не вдалося завантажити файл", + "uploadingFolderFiles": "Завантаження {{count}} файлів...", + "uploadedFolderFiles": "Успішно завантажено {{count}} файл(ів)", + "uploadedFolderPartial": "Завантажено {{uploaded}} файл(ів), {{failed}} не вдалося", "fileDownloadedSuccessfully": "Файл «{{name}}» успішно завантажено", "failedToDownloadFile": "Не вдалося завантажити файл", "fileCreatedSuccessfully": "Файл «{{name}}» успішно створено", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} елементів", "downloadFileSuccess": "Файл {{name}} успішно завантажено", "downloadFileFailed": "Завантаження не вдалося", + "downloadingFile": "Завантаження {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Перейти до {{name}}", "diffCompareWith": "Різниця у порівнянні з {{name}}", "dragOutsideToDownload": "Перетягніть за межі вікна, щоб завантажити ({{count}} файлів)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Автоматичне створення облікових записів для користувачів OIDC/SSO під час першого входу (незалежно від перемикача реєстрації)", "oidcSilentLoginDefault": "Тихий вхід OIDC за замовчуванням", "oidcSilentLoginDefaultDesc": "Автоматично перенаправляти на сторінку входу в OIDC під час кожного відвідування, повністю пропускаючи форму входу", + "oidcSilentLoginDefaultLockedDesc": "Цей параметр заблоковано змінною середовища OIDC_SILENT_LOGIN_DEFAULT і його не можна змінити тут.", "allowPasswordReset": "Дозволити скидання пароля", "allowPasswordResetDesc": "Скидання коду через журнали Docker", "commandHistoryEnabled": "Історія команд", @@ -3317,11 +3337,11 @@ "createRole": "Створити", "creating": "Створення...", "exportDatabase": "Експорт бази даних", - "exportDatabaseDesc": "Завантажте резервну копію всіх хостів, облікових даних та налаштувань", + "exportDatabaseDesc": "Завантажте портативну резервну копію SQLite всіх хостів, облікових даних та налаштувань", "export": "Експорт", "exporting": "Експорт...", "importDatabase": "Імпорт бази даних", - "importDatabaseDesc": "Відновлення з резервної копії файлу .sqlite", + "importDatabaseDesc": "Відновлення з портативної резервної копії .sqlite на будь-якому сервері бази даних", "importDatabaseSelected": "Вибрано: {{name}}", "selectFile": "Виберіть файл", "changeFile": "Зміна", @@ -4516,6 +4536,7 @@ "selectProvider": "Виберіть постачальника", "providerSettings": "Налаштування постачальника", "addProvider": "Додати постачальника", + "editProvider": "Редагувати постачальника", "removeProvider": "Видалити постачальника", "providerType": "Постачальник", "providerLabel": "Ім'я", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Наприклад: llama3.1 або claude-opus-5", "labelRequired": "Введіть назву цього постачальника", "providerSaveFailed": "Не вдалося зберегти постачальника", + "providerUpdated": "Постачальника оновлено", "providerDeleteFailed": "Не вдалося видалити постачальника", "privateEndpointHint": "Адреси у вашій приватній мережі спочатку потребують дозволу адміністратора хоста.", "providerOllama": "Оллама", diff --git a/src/ui/locales/translated/vi_VN.json b/src/ui/locales/translated/vi_VN.json index a05aed5f..873a74ab 100644 --- a/src/ui/locales/translated/vi_VN.json +++ b/src/ui/locales/translated/vi_VN.json @@ -111,8 +111,12 @@ "filterTypeKey": "Khóa SSH", "filterTagsGroup": "Thẻ", "copyDeployCommand": "Sao chép lệnh triển khai", + "cloneCredentialAction": "Sao chép thông tin đăng nhập", + "clonedCredential": "Đã sao chép {{name}}", + "clonedCredentialName": "{{name}} (copy)", "deleteCredentialAction": "Xóa thông tin đăng nhập", "editCredentialAction": "Chỉnh sửa thông tin đăng nhập", + "failedToCloneCredential": "Không thể sao chép thông tin xác thực.", "failedToDeleteCredential": "Không thể xóa thông tin đăng nhập", "idBadge": "NHẬN DẠNG", "keyBadge": "CHÌA KHÓA", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "Đường dẫn socket của Agent", "agentSocketPathPlaceholder": "Để trống để sử dụng SSH_AUTH_SOCK", "agentSocketPathHint": "Để trống để tự động phát hiện từ biến môi trường SSH_AUTH_SOCK, hoặc nhập đường dẫn socket tùy chỉnh (ví dụ: /run/user/1000/gnupg/S.gpg-agent.ssh).", + "agentIdentityLabel": "Thông tin nhận dạng của tác nhân (Khóa công khai)", + "agentIdentityPlaceholder": "Để trống để thử tất cả các phím từ trình quản lý.", + "agentIdentityHint": "Dán khóa công khai (ví dụ: ssh-ed25519 AAAA...) để chỉ cho phép sử dụng khóa đó từ tác nhân. Cách này được khuyến nghị khi tác nhân giữ nhiều khóa, để tránh vượt quá giới hạn MaxAuthTries của máy chủ.", "shareSshAuthLabel": "Chia sẻ xác thực SSH", "shareSshAuthDesc": "Cung cấp cho người nhận bản sao đã mã hóa thông tin xác thực SSH của máy chủ này. Thông tin xác thực cá nhân của người nhận vẫn được ưu tiên.", "tailscaleDeviceSelect": "Chọn thiết bị Tailscale", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "Không tìm thấy thông tin xác thực", "addCredentialBtn": "Thêm Chứng thực", "updateCredentialBtn": "Cập nhật Chứng thực", + "editCredentialAction": "Chỉnh sửa thông tin đăng nhập", + "backToHost": "Trở lại trang chủ", + "saveCredentialAsNewBtn": "Lưu dưới dạng mới", + "saveCredentialAsNewDesc": "Tạo một thông tin xác thực mới với những thay đổi này và gán nó cho máy chủ này. Thông tin xác thực ban đầu vẫn được giữ nguyên.", + "credentialSavedAsNew": "Đã lưu dưới dạng thông tin đăng nhập mới", + "failedToSaveCredentialAsNew": "Không thể lưu thông tin đăng nhập mới.", "features": "Tính năng", "noFolder": "(Không có thư mục)", "deleteSelected": "Xóa", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "Nhỏ gọn", "displayShowTags": "Hiển thị thẻ", "displayTrayOnClick": "Mở các thao tác khi nhấp chuột (thay vì khi di chuột)", - "actionsVisibility": "Hành động của máy chủ", - "actionsVisibilityDesc": "Khi các nút kết nối và quản lý trên mỗi máy chủ hiển thị", + "actionsVisibility": "Hiển thị hành động của máy chủ", + "actionsVisibilityDesc": "Chọn thời điểm các thao tác trên máy chủ mở rộng. Chọn nhấp chuột để khôi phục hành vi nhấp chuột để mở rộng trước đó.", "actionsAlways": "Luôn hiển thị", "actionsOnly": "Chỉ cần kết nối, phần còn lại chỉ cần nhấp chuột.", "actionsHover": "Hiển thị khi di chuột", - "actionsClick": "Hiển thị khi nhấp chuột", + "actionsClick": "Nhấp để mở rộng/thu gọn", "openOnDoubleClick": "Mở bằng cách nhấp đúp chuột", "openOnDoubleClickDesc": "Yêu cầu nhấp đúp chuột để mở phiên máy chủ thay vì chỉ nhấp chuột một lần.", "deletedHost": "Đã xóa {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "Đã tải lên {{name}}", "uploadFailed": "Tải lên không thành công {{name}}", "uploadDisabled": "Chức năng tải lên đã bị vô hiệu hóa cho kết nối này.", + "driveUnavailable": "Hãy bật tính năng chuyển hướng ổ đĩa RDP trước khi tải lên tệp.", "dropToUpload": "Kéo thả tệp để tải lên" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "Tên thư mục", "fileUploadedSuccessfully": "Tập tin \"{{name}}\" đã được tải lên thành công", "failedToUploadFile": "Tải tập tin lên thất bại", + "uploadingFolderFiles": "Đang tải lên {{count}} tập tin...", + "uploadedFolderFiles": "Đã tải lên {{count}} tập tin thành công", + "uploadedFolderPartial": "Đã tải lên {{uploaded}} tập tin, {{failed}} thất bại", "fileDownloadedSuccessfully": "Tập tin \"{{name}}\" đã được tải xuống thành công", "failedToDownloadFile": "Tải tập tin xuống thất bại", "fileCreatedSuccessfully": "Tập tin \"{{name}}\" đã được tạo thành công", @@ -2208,6 +2225,8 @@ "operationCompleted": "Đã {{operation}} {{count}} mục", "downloadFileSuccess": "Đã tải xuống tệp {{name}} thành công", "downloadFileFailed": "Tải xuống thất bại", + "downloadingFile": "Đang tải xuống {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "Di chuyển tới {{name}}", "diffCompareWith": "So sánh khác biệt với {{name}}", "dragOutsideToDownload": "Kéo ra ngoài cửa sổ để tải xuống ({{count}} tệp)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "Tự động tạo tài khoản cho người dùng OIDC/SSO khi đăng nhập lần đầu (độc lập với nút bật đăng ký)", "oidcSilentLoginDefault": "Đăng nhập OIDC tự động mặc định", "oidcSilentLoginDefaultDesc": "Tự động chuyển hướng đến đăng nhập OIDC mỗi lần truy cập, bỏ qua hoàn toàn biểu mẫu đăng nhập", + "oidcSilentLoginDefaultLockedDesc": "Cài đặt này bị khóa bởi biến môi trường OIDC_SILENT_LOGIN_DEFAULT và không thể thay đổi ở đây.", "allowPasswordReset": "Cho phép đặt lại mật khẩu", "allowPasswordResetDesc": "Mã đặt lại qua nhật ký Docker", "commandHistoryEnabled": "Lịch sử lệnh", @@ -3317,11 +3337,11 @@ "createRole": "Tạo", "creating": "Đang tạo...", "exportDatabase": "Xuất cơ sở dữ liệu", - "exportDatabaseDesc": "Tải về bản sao lưu tất cả máy chủ, thông tin đăng nhập và cài đặt", + "exportDatabaseDesc": "Tải xuống bản sao lưu SQLite di động chứa tất cả máy chủ, thông tin đăng nhập và cài đặt.", "export": "Xuất", "exporting": "Đang xuất...", "importDatabase": "Nhập cơ sở dữ liệu", - "importDatabaseDesc": "Khôi phục từ tệp sao lưu .sqlite", + "importDatabaseDesc": "Khôi phục từ bản sao lưu .sqlite di động trên bất kỳ hệ thống cơ sở dữ liệu nào.", "importDatabaseSelected": "Đã chọn: {{name}}", "selectFile": "Chọn tệp", "changeFile": "Đổi tệp", @@ -4516,6 +4536,7 @@ "selectProvider": "Chọn nhà cung cấp", "providerSettings": "Cài đặt nhà cung cấp", "addProvider": "Thêm nhà cung cấp", + "editProvider": "Chỉnh sửa nhà cung cấp", "removeProvider": "Xóa nhà cung cấp", "providerType": "Nhà cung cấp", "providerLabel": "Tên", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "Ví dụ: llama3.1 hoặc claude-opus-5", "labelRequired": "Đặt tên cho nhà cung cấp này", "providerSaveFailed": "Không thể lưu nhà cung cấp", + "providerUpdated": "Nhà cung cấp đã cập nhật", "providerDeleteFailed": "Không thể xóa nhà cung cấp", "privateEndpointHint": "Các địa chỉ trên mạng riêng của bạn cần được quản trị viên cho phép truy cập trước.", "providerOllama": "Ollama", diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index 1ce3ae0e..5fd9f6ca 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH 密钥", "filterTagsGroup": "标签", "copyDeployCommand": "复制部署命令", + "cloneCredentialAction": "克隆凭证", + "clonedCredential": "克隆 {{name}}", + "clonedCredentialName": "{{name}} (复制)", "deleteCredentialAction": "删除凭据", "editCredentialAction": "编辑凭证", + "failedToCloneCredential": "克隆凭证失败", "failedToDeleteCredential": "删除凭据失败", "idBadge": "ID", "keyBadge": "钥匙", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "代理套接字路径", "agentSocketPathPlaceholder": "留空以使用 SSH_AUTH_SOCK", "agentSocketPathHint": "留空以从 SSH_AUTH_SOCK 环境变量自动检测,或输入自定义套接字路径(例如 /run/user/1000/gnupg/S.gpg-agent.ssh)。", + "agentIdentityLabel": "代理身份(公钥)", + "agentIdentityPlaceholder": "留空以尝试代理提供的所有密钥", + "agentIdentityHint": "粘贴公钥(例如 ssh-ed25519 AAAA...)以仅允许代理提供该密钥。当代理持有大量密钥时,建议这样做,以避免超出服务器的最大身份验证尝试次数 (MaxAuthTries)。", "shareSshAuthLabel": "共享 SSH 身份验证", "shareSshAuthDesc": "向收件人提供此主机 SSH 身份验证信息的加密副本。收件人的个人凭证仍然优先。", "tailscaleDeviceSelect": "选择 Tailscale 设备", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "未找到凭据", "addCredentialBtn": "添加凭据", "updateCredentialBtn": "更新凭据", + "editCredentialAction": "编辑凭证", + "backToHost": "返回主机", + "saveCredentialAsNewBtn": "另存为新文件", + "saveCredentialAsNewDesc": "使用这些更改创建一个新的凭据,并将其分配给此主机。原始凭据保持不变。", + "credentialSavedAsNew": "已保存为新凭证", + "failedToSaveCredentialAsNew": "保存为新凭据失败", "features": "功能", "noFolder": "(无文件夹)", "deleteSelected": "删除", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "袖珍的", "displayShowTags": "显示标签", "displayTrayOnClick": "点击打开操作(而不是悬停打开)", - "actionsVisibility": "主机操作", - "actionsVisibilityDesc": "当每台主机上的连接和管理按钮都显示出来时", + "actionsVisibility": "主机动作显示", + "actionsVisibilityDesc": "选择主机操作何时展开。选择“单击”以恢复以前的单击展开行为。", "actionsAlways": "始终可见", "actionsOnly": "仅连接,其余操作请点击", "actionsHover": "鼠标悬停时显示", - "actionsClick": "点击显示", + "actionsClick": "点击展开/收起", "openOnDoubleClick": "双击打开", "openOnDoubleClickDesc": "打开主机会话需要双击,而不是单击。", "deletedHost": "已删除 {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "已上传 {{name}}", "uploadFailed": "上传失败 {{name}}", "uploadDisabled": "此连接已禁用上传功能", + "driveUnavailable": "上传文件前启用 RDP 驱动器重定向", "dropToUpload": "拖放文件即可上传" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "文件夹名称", "fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功", "failedToUploadFile": "上传文件失败", + "uploadingFolderFiles": "正在上传 {{count}} 文件...", + "uploadedFolderFiles": "已成功上传 {{count}} 个文件", + "uploadedFolderPartial": "上传了 {{uploaded}} 个文件, {{failed}} 个文件失败", "fileDownloadedSuccessfully": "文件 \"{{name}}\" 下载成功", "failedToDownloadFile": "下载文件失败", "fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}}了{{count}}个项目", "downloadFileSuccess": "文件 {{name}} 下载成功", "downloadFileFailed": "下载失败", + "downloadingFile": "正在下载 {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "移动到 {{name}}", "diffCompareWith": "与 {{name}} 对比差异", "dragOutsideToDownload": "拖到窗口外以下载 ({{count}} 个文件)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "首次登录时自动为 OIDC/SSO 用户创建账户(与注册开关无关)", "oidcSilentLoginDefault": "默认静默 OIDC 登录", "oidcSilentLoginDefaultDesc": "每次访问时自动重定向到 OIDC 登录,完全跳过登录表单", + "oidcSilentLoginDefaultLockedDesc": "此设置受 OIDC_SILENT_LOGIN_DEFAULT 环境变量锁定,无法在此处更改。", "allowPasswordReset": "允许密码重置", "allowPasswordResetDesc": "通过 Docker 日志获取重置码", "commandHistoryEnabled": "命令历史", @@ -3317,11 +3337,11 @@ "createRole": "创建", "creating": "正在创建...", "exportDatabase": "导出数据库", - "exportDatabaseDesc": "下载包含所有主机、凭据和设置的备份", + "exportDatabaseDesc": "下载包含所有主机、凭据和设置的便携式 SQLite 备份", "export": "导出", "exporting": "正在导出...", "importDatabase": "导入数据库", - "importDatabaseDesc": "从 .sqlite 备份文件恢复", + "importDatabaseDesc": "从任何数据库后端上的便携式 .sqlite 备份进行恢复", "importDatabaseSelected": "已选择:{{name}}", "selectFile": "选择文件", "changeFile": "更改", @@ -4516,6 +4536,7 @@ "selectProvider": "选择服务提供商", "providerSettings": "提供商设置", "addProvider": "添加提供商", + "editProvider": "编辑提供者", "removeProvider": "移除提供者", "providerType": "提供者", "providerLabel": "姓名", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "例如:llama3.1 或 claude-opus-5", "labelRequired": "给这家服务提供商起个名字", "providerSaveFailed": "无法保存提供商", + "providerUpdated": "提供商已更新", "providerDeleteFailed": "无法移除提供商", "privateEndpointHint": "您私有网络上的地址需要管理员先允许主机访问。", "providerOllama": "奥拉玛", diff --git a/src/ui/locales/translated/zh_TW.json b/src/ui/locales/translated/zh_TW.json index ff613f51..40860039 100644 --- a/src/ui/locales/translated/zh_TW.json +++ b/src/ui/locales/translated/zh_TW.json @@ -111,8 +111,12 @@ "filterTypeKey": "SSH 金鑰", "filterTagsGroup": "標籤", "copyDeployCommand": "複製部署命令", + "cloneCredentialAction": "克隆憑證", + "clonedCredential": "克隆 {{name}}", + "clonedCredentialName": "{{name}} (複製)", "deleteCredentialAction": "刪除憑證", "editCredentialAction": "編輯憑證", + "failedToCloneCredential": "克隆憑證失敗", "failedToDeleteCredential": "刪除憑證失敗", "idBadge": "ID", "keyBadge": "鑰匙", @@ -925,6 +929,9 @@ "agentSocketPathLabel": "代理 Socket 路徑", "agentSocketPathPlaceholder": "留空以使用 SSH_AUTH_SOCK", "agentSocketPathHint": "留空以從 SSH_AUTH_SOCK 環境變數自動偵測,或輸入自訂 socket 路徑(例如 /run/user/1000/gnupg/S.gpg-agent.ssh)。", + "agentIdentityLabel": "代理身分(公鑰)", + "agentIdentityPlaceholder": "留空以嘗試代理提供的所有密鑰", + "agentIdentityHint": "貼上公鑰(例如 ssh-ed25519 AAAA...)以僅允許代理提供該金鑰。當代理程式持有大量金鑰時,建議這樣做,以避免超出伺服器的最大驗證嘗試次數 (MaxAuthTries)。", "shareSshAuthLabel": "共用 SSH 驗證", "shareSshAuthDesc": "向收件者提供此主機 SSH 驗證資訊的加密副本。收件人的個人憑證仍然優先。", "tailscaleDeviceSelect": "選取 Tailscale 裝置", @@ -1234,6 +1241,12 @@ "noCredentialsFound": "找不到憑證", "addCredentialBtn": "新增憑證", "updateCredentialBtn": "更新憑證", + "editCredentialAction": "編輯憑證", + "backToHost": "返回主機", + "saveCredentialAsNewBtn": "另存為新文件", + "saveCredentialAsNewDesc": "使用這些變更建立新的憑證,並將其指派給此主機。原始憑證保持不變。", + "credentialSavedAsNew": "已儲存為新憑證", + "failedToSaveCredentialAsNew": "儲存為新憑證失敗", "features": "功能", "noFolder": "(無資料夾)", "deleteSelected": "刪除", @@ -1318,12 +1331,12 @@ "displayDensityCompact": "袖珍的", "displayShowTags": "顯示標籤", "displayTrayOnClick": "點擊開啟操作(而不是懸停打開)", - "actionsVisibility": "主機操作", - "actionsVisibilityDesc": "當每台主機上的連線和管理按鈕都顯示出來時", + "actionsVisibility": "主機動作顯示", + "actionsVisibilityDesc": "選擇主機操作何時展開。選擇“單擊”以恢復先前的單擊展開行為。", "actionsAlways": "始終可見", "actionsOnly": "僅連接,其餘操作請點擊", "actionsHover": "滑鼠懸停時顯示", - "actionsClick": "點擊顯示", + "actionsClick": "點擊展開/收起", "openOnDoubleClick": "按兩下打開", "openOnDoubleClickDesc": "開啟主機會話需要雙擊,而不是按一下。", "deletedHost": "已刪除 {{name}}", @@ -1867,6 +1880,7 @@ "uploaded": "已上傳 {{name}}", "uploadFailed": "上傳失敗 {{name}}", "uploadDisabled": "此連線已停用上傳功能", + "driveUnavailable": "上傳檔案前啟用 RDP 磁碟機重定向", "dropToUpload": "拖放檔案即可上傳" }, "toolbar": { @@ -2037,6 +2051,9 @@ "folderName": "資料夾名稱", "fileUploadedSuccessfully": "檔案「{{name}}」上傳成功", "failedToUploadFile": "上傳檔案失敗", + "uploadingFolderFiles": "正在上傳 {{count}} 檔案...", + "uploadedFolderFiles": "已成功上傳 {{count}} 個文件", + "uploadedFolderPartial": "上傳了 {{uploaded}} 個文件, {{failed}} 個文件失敗", "fileDownloadedSuccessfully": "檔案「{{name}}」下載成功", "failedToDownloadFile": "下載檔案失敗", "fileCreatedSuccessfully": "檔案「{{name}}」建立成功", @@ -2208,6 +2225,8 @@ "operationCompleted": "{{operation}} {{count}} 個項目", "downloadFileSuccess": "檔案 {{name}} 已成功下載", "downloadFileFailed": "下載失敗", + "downloadingFile": "正在下載 {{name}}…", + "downloadProgressBytes": "{{transferred}} / {{total}}", "moveTo": "移動至 {{name}}", "diffCompareWith": "與 {{name}} 進行差異比對", "dragOutsideToDownload": "拖曳至視窗外以下載 ({{count}} 個檔案)", @@ -3254,6 +3273,7 @@ "oidcAutoProvisionDesc": "首次登入時自動為 OIDC/SSO 使用者建立帳號 (與註冊開關無關)", "oidcSilentLoginDefault": "預設靜默 OIDC 登入", "oidcSilentLoginDefaultDesc": "每次造訪時自動重新導向至 OIDC 登入,完全略過登入表單", + "oidcSilentLoginDefaultLockedDesc": "此設定受 OIDC_SILENT_LOGIN_DEFAULT 環境變數鎖定,無法在此變更。", "allowPasswordReset": "允許密碼重設", "allowPasswordResetDesc": "透過 Docker 日誌取得重設代碼", "commandHistoryEnabled": "指令歷史記錄", @@ -3317,11 +3337,11 @@ "createRole": "建立", "creating": "建立中...", "exportDatabase": "匯出資料庫", - "exportDatabaseDesc": "下載所有主機、憑證和設定的備份", + "exportDatabaseDesc": "下載包含所有主機、憑證和設定的便攜式 SQLite 備份", "export": "匯出", "exporting": "匯出中...", "importDatabase": "匯入資料庫", - "importDatabaseDesc": "從 .sqlite 備份檔案還原", + "importDatabaseDesc": "從任何資料庫後端上的便攜式 .sqlite 備份進行恢復", "importDatabaseSelected": "已選取:{{name}}", "selectFile": "選取檔案", "changeFile": "變更", @@ -4516,6 +4536,7 @@ "selectProvider": "選擇服務提供者", "providerSettings": "提供者設定", "addProvider": "新增提供者", + "editProvider": "編輯提供者", "removeProvider": "移除提供者", "providerType": "提供者", "providerLabel": "姓名", @@ -4526,6 +4547,7 @@ "defaultModelPlaceholder": "例如:llama3.1 或 claude-opus-5", "labelRequired": "給這家服務提供者取個名字", "providerSaveFailed": "無法儲存提供者", + "providerUpdated": "提供者已更新", "providerDeleteFailed": "無法移除提供者", "privateEndpointHint": "您私有網路上的位址需要管理員先允許主機存取。", "providerOllama": "奧拉瑪", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 3472f4e5..9466f351 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -2093,6 +2093,7 @@ export { getCredentialDetails, createCredential, updateCredential, + duplicateCredential, deleteCredential, getCredentialHosts, getCredentialFolders, diff --git a/src/ui/settings/RemoteSyncServerPicker.tsx b/src/ui/settings/RemoteSyncServerPicker.tsx index 9cb0784e..cb97c7e3 100644 --- a/src/ui/settings/RemoteSyncServerPicker.tsx +++ b/src/ui/settings/RemoteSyncServerPicker.tsx @@ -98,6 +98,7 @@ export function RemoteSyncServerPicker({ const testResult = (await window.electronAPI?.invoke?.( "test-server-connection", normalizedUrl, + normalizedUrl.startsWith("https://") && allowInvalidCertificate, )) as { success?: boolean; error?: string; warning?: string } | undefined; if (!testResult?.success) { diff --git a/src/ui/sidebar/AdminImageStorageSection.tsx b/src/ui/sidebar/AdminImageStorageSection.tsx index b37dd0b8..92473d50 100644 --- a/src/ui/sidebar/AdminImageStorageSection.tsx +++ b/src/ui/sidebar/AdminImageStorageSection.tsx @@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next"; import { ImageIcon } from "lucide-react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; -import { SettingRow } from "@/components/section-card"; import type { TerminalImageStorageMode, TerminalImageStorageSettings, @@ -68,12 +67,17 @@ export function AdminImageStorageSection({ {t("common.loading")}
) : ( -
- -
+
+
+
+ + {t("admin.imageStorageMode")} + + + {t("admin.imageStorageModeDesc")} + +
+
{(Object.keys(MODE_LABEL_KEYS) as TerminalImageStorageMode[]).map( (mode) => ( ), )}
- +
-
+
- +
+
+ + {t("admin.imageStorageInstanceId")} + + + {t("admin.imageStorageInstanceIdDesc")} + +
setInstanceId(event.target.value)} - className="h-8 w-48 text-xs" + className="h-8 w-full @md:w-64 text-xs" /> - +
{testResult && (
diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index acf9e6ea..d54a0aef 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -163,6 +163,8 @@ export function AdminSettingsPanel({ const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true); const [analyticsEnabled, setAnalyticsEnabled] = useState(true); const [analyticsLocked, setAnalyticsLocked] = useState(false); + const [oidcSilentLoginDefaultLocked, setOidcSilentLoginDefaultLocked] = + useState(false); const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] = useState(true); const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false); @@ -389,8 +391,10 @@ export function AdminSettingsPanel({ setAllowPasswordLogin(pwLogin.value.allowed); if (oidcProv.status === "fulfilled") setOidcAutoProvision(oidcProv.value.enabled); - if (oidcSilent.status === "fulfilled") + if (oidcSilent.status === "fulfilled") { setOidcSilentLoginDefault(oidcSilent.value.enabled); + setOidcSilentLoginDefaultLocked(oidcSilent.value.locked ?? false); + } if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value); if (timeout.status === "fulfilled") setSessionTimeout(String(timeout.value.timeoutHours)); @@ -509,6 +513,7 @@ export function AdminSettingsPanel({ } async function handleToggleOidcSilentLoginDefault() { + if (oidcSilentLoginDefaultLocked) return; const newVal = !oidcSilentLoginDefault; setOidcSilentLoginDefault(newVal); try { @@ -1124,6 +1129,7 @@ export function AdminSettingsPanel({ oidcAutoProvision={oidcAutoProvision} handleToggleOidcAutoProvision={handleToggleOidcAutoProvision} oidcSilentLoginDefault={oidcSilentLoginDefault} + oidcSilentLoginDefaultLocked={oidcSilentLoginDefaultLocked} handleToggleOidcSilentLoginDefault={handleToggleOidcSilentLoginDefault} allowPasswordReset={allowPasswordReset} handleTogglePasswordReset={handleTogglePasswordReset} diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index ea538b3e..fb98f503 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -39,6 +39,7 @@ type GeneralSettingsSectionProps = { oidcAutoProvision: boolean; handleToggleOidcAutoProvision: () => void; oidcSilentLoginDefault: boolean; + oidcSilentLoginDefaultLocked: boolean; handleToggleOidcSilentLoginDefault: () => void; allowPasswordReset: boolean; handleTogglePasswordReset: () => void; @@ -87,6 +88,7 @@ export function AdminGeneralSettingsSection({ oidcAutoProvision, handleToggleOidcAutoProvision, oidcSilentLoginDefault, + oidcSilentLoginDefaultLocked, handleToggleOidcSilentLoginDefault, allowPasswordReset, handleTogglePasswordReset, @@ -211,11 +213,16 @@ export function AdminGeneralSettingsSection({ void; - onSave: (saved: Record) => void; + onSave: ( + saved: Record, + options?: { assignToHost?: boolean }, + ) => void; adminTargetUserId?: string; existingFolders?: string[]; + // When set, this credential is being edited from within a host's editor; + // shows a "Save as New" action that clones it and reassigns the host. + saveAsNewHost?: Host | "new"; }) { const [credForm, setCredForm] = useState(() => ({ name: credential?.name ?? "", @@ -61,21 +69,14 @@ export function CredentialEditorView({ v: (typeof credForm)[K], ) => setCredForm((p) => ({ ...p, [k]: v })); const [saving, setSaving] = useState(false); + const [savingAsNew, setSavingAsNew] = useState(false); - const handleSave = async () => { - if (!credForm.name.trim()) { - toast.error(t("hosts.credentialNameRequired")); - return; - } + const buildCredentialData = () => { const hasKey = credForm.value === "existing_key" || credForm.value.trim() !== ""; - if (!hasKey && !credForm.password) { - toast.error(t("hosts.credentialAuthRequired")); - return; - } - setSaving(true); - try { - const data = { + return { + hasKey, + data: { name: credForm.name, username: credForm.username, folder: credForm.folder || null, @@ -95,7 +96,29 @@ export function CredentialEditorView({ ? undefined : credForm.passphrase || null : null, - }; + }, + }; + }; + + const validateForm = () => { + if (!credForm.name.trim()) { + toast.error(t("hosts.credentialNameRequired")); + return false; + } + const hasKey = + credForm.value === "existing_key" || credForm.value.trim() !== ""; + if (!hasKey && !credForm.password) { + toast.error(t("hosts.credentialAuthRequired")); + return false; + } + return true; + }; + + const handleSave = async () => { + if (!validateForm()) return; + setSaving(true); + try { + const { data } = buildCredentialData(); let saved: Record; if (adminTargetUserId) { saved = credential @@ -127,6 +150,24 @@ export function CredentialEditorView({ } }; + const handleSaveAsNew = async () => { + if (!credential || adminTargetUserId) return; + if (!validateForm()) return; + setSavingAsNew(true); + try { + const { data } = buildCredentialData(); + const saved = await duplicateCredential(Number(credential.id), data); + toast.success(t("hosts.credentialSavedAsNew")); + window.dispatchEvent(new CustomEvent("termix:credentials-changed")); + onSave(saved, { assignToHost: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : null; + toast.error(msg || t("hosts.failedToSaveCredentialAsNew")); + } finally { + setSavingAsNew(false); + } + }; + return (
{activeTab === "general" && ( @@ -494,14 +535,31 @@ export function CredentialEditorView({ )}
- + {saveAsNewHost && credential && !adminTargetUserId && ( + + )} + )} +
{selectedCredential?.username && (
@@ -1104,6 +1125,21 @@ export function HostEditor({ {t("hosts.agentSocketPathHint")}

+
+ + + setField("agentIdentity", e.target.value) + } + /> +

+ {t("hosts.agentIdentityHint")} +

+
)} (null); const [editingCredFolderValue, setEditingCredFolderValue] = useState(""); + // Remembers the host being edited when its credential is opened for + // editing, so "back" can return to that host instead of the list. + const [credentialReturnHost, setCredentialReturnHost] = useState< + Host | "new" | null + >(null); const [termixIdLinkedIds, setTermixIdLinkedIds] = useState>( new Set(), ); @@ -312,6 +318,21 @@ export function HostManager({ } }; + const handleCloneCredential = async (cred: Credential) => { + try { + await duplicateCredential(Number(cred.id), { + name: t("credentials.clonedCredentialName", { name: cred.name }), + }); + const res = await getCredentials(); + setCredentials(mapCredentials(res)); + window.dispatchEvent(new CustomEvent("termix:credentials-changed")); + toast.success(t("credentials.clonedCredential", { name: cred.name })); + } catch (err) { + const msg = err instanceof Error ? err.message : null; + toast.error(msg || t("credentials.failedToCloneCredential")); + } + }; + const handleDeleteCredential = async (cred: Credential) => { await deleteCredential(Number(cred.id)); setCredentials((prev) => prev.filter((c) => c.id !== cred.id)); @@ -355,6 +376,18 @@ export function HostManager({ } } + // Opens a host's credential for editing from within the host editor, + // remembering the host so "back" returns to it instead of the list. + async function handleEditCredentialFromHost(credentialId: string) { + const cred = credentials.find((c) => String(c.id) === String(credentialId)); + if (!cred) return; + setCredentialReturnHost(editingHost); + await handleEditCredential(cred); + // The editor view keys off editingHost, so it has to be cleared or the + // host editor keeps rendering over the credential we just opened. + setEditingHost(null); + } + // Editor view: full-width with top tab bar instead of side nav const closeHostEditor = () => { setHostEditorDirty(false); @@ -423,6 +456,11 @@ export function HostManager({ onClick={() => { if (isHost) { requestCloseHostEditor(); + } else if (credentialReturnHost) { + setEditingHost(credentialReturnHost); + setCredentialReturnHost(null); + setEditingCredential(null); + setActiveCredentialTab("general"); } else { setEditingCredential(null); setActiveCredentialTab("general"); @@ -434,7 +472,9 @@ export function HostManager({ {isHost ? t("hosts.backToHosts") - : t("credentials.backToCredentials")} + : credentialReturnHost + ? t("hosts.backToHost") + : t("credentials.backToCredentials")} {isHost && editingHost !== "new" && ( ) : ( !!f), ), ).sort()} + saveAsNewHost={ + credentialReturnHost ? credentialReturnHost : undefined + } onBack={() => { + if (credentialReturnHost) { + setEditingHost(credentialReturnHost); + setCredentialReturnHost(null); + setEditingCredential(null); + setActiveCredentialTab("general"); + return; + } setEditingCredential(null); setActiveCredentialTab("general"); }} - onSave={(saved) => { + onSave={(saved, options) => { // The save endpoints are typed as an untyped record; these are // the fields this view reads back off the response. const result = saved as Partial & { @@ -579,6 +630,27 @@ export function HostManager({ } return [...prev, updated]; }); + if (options?.assignToHost && credentialReturnHost) { + const returnHost = credentialReturnHost; + setCredentialReturnHost(null); + if (returnHost !== "new") { + setHosts((prev) => + prev.map((h) => + h.id === returnHost.id + ? { ...h, credentialId: String(result.id) } + : h, + ), + ); + } + setEditingHost( + returnHost === "new" + ? "new" + : { ...returnHost, credentialId: String(result.id) }, + ); + setActiveHostTab("ssh"); + setEditingCredential(null); + return; + } setEditingCredential(null); setActiveCredentialTab("general"); }} @@ -662,6 +734,7 @@ export function HostManager({ setDeployDialog({ cred, hostId: "" }) } onEditCredential={handleEditCredential} + onCloneCredential={handleCloneCredential} onDeleteCredential={handleConfirmDeleteCredential} /> )} diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index f154fd24..2aaa5957 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -405,7 +405,7 @@ export function HostsPanel({
{!managerEditing && (
-
+
void; onDeploy: () => void; onEdit: () => void; + onClone: () => void; onDelete: () => void; }) { const { t } = useTranslation(); @@ -167,6 +170,16 @@ export function CredentialItem({ > + )} @@ -1115,7 +1121,7 @@ export function HostItem({ (alwaysShowTray || actionsOnly || (shouldUseClickTray && isTrayOpen)) && ( -
+
{connectionButtons}
)} @@ -1126,7 +1132,7 @@ export function HostItem({ isOnline && ((host.cpu != null && host.cpu > 0) || (host.ram != null && host.ram > 0)) && ( -
+
{host.cpu != null && host.cpu > 0 && (
@@ -1163,14 +1169,14 @@ export function HostItem({ > {/* Connection buttons — only shown here when not already shown above */} {!alwaysShowTray && !actionsOnly && !shouldUseClickTray && ( -
+
{connectionButtons}
)} {/* Management buttons row */}
{managementButtons}
diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index b9ffba43..a027ee5d 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -66,6 +66,7 @@ import { rowKind, ROOT_PARENT, } from "./visible-rows"; +import { RESOURCE_ROW_EXTRA, rendersResourceRow } from "./row-metrics"; import { ReorderIndicator } from "@/sidebar/ReorderIndicator"; import { useSidebarSelection } from "./hooks/useSidebarSelection"; import { useSidebarDragState } from "./hooks/useSidebarDragState"; @@ -699,34 +700,47 @@ export function SidebarTree({ const CLICK_CHEVRON_EXTRA = clickTrayActive ? 4.5 : 0; const HOST_ROW_HEIGHT = (isCompactDensity ? 27.5 : 45) + CLICK_CHEVRON_EXTRA; const FOLDER_ROW_HEIGHT = 31.5; - // "always" mode permanently renders the connection-buttons row (plus the - // management row/resource bars for online hosts) -- measured directly - // rather than derived, since it has its own fixed shape. + // "always" mode permanently renders the connection-buttons row plus the + // management row -- measured directly rather than derived, since it has its + // own fixed shape. The resource bars are NOT included: they only render for + // an online host with CPU/RAM, so rowHeight adds RESOURCE_ROW_EXTRA per row. // Carries the click-mode chevron so the open row lands at its measured // 104.75 rather than always mode's 100.25. The term cancels out of // OPEN_TRAY_EXTRA below, leaving HOST_ROW_HEIGHT to supply it once. const ALWAYS_ROW_HEIGHT = - (isCompactDensity ? 73.75 : 100.25) + CLICK_CHEVRON_EXTRA; + (isCompactDensity ? 73.75 : 83) + CLICK_CHEVRON_EXTRA; const OPEN_TRAY_EXTRA = ALWAYS_ROW_HEIGHT - HOST_ROW_HEIGHT; // "actionsOnly" permanently renders just the connection-buttons row above // the tray; the management row/resource bars stay collapsed until toggled. const ACTIONS_ONLY_ROW_HEIGHT = isCompactDensity ? 50.25 : 75.75; // Opening the management row from actionsOnly's closed state (which - // already includes the connection row). - const ACTIONS_ONLY_OPEN_ROW_HEIGHT = isCompactDensity ? 79.25 : 104.75; - // Hiding the resource bars only ever makes a row shorter than the height - // reserved for it, which the existing shapes already tolerate: the bars are - // conditional on the host being online with CPU/RAM data, so an offline host - // has always rendered short of ALWAYS_ROW_HEIGHT. Simple therefore pairs - // showResourceBars:false with trayTrigger:"actionsOnly", whose measured - // height excludes the resource/management row to begin with. + // already includes the connection row). Excludes the resource bars for the + // same reason as ALWAYS_ROW_HEIGHT -- rowHeight adds them per row. + const ACTIONS_ONLY_OPEN_ROW_HEIGHT = isCompactDensity ? 79.25 : 87.5; + // Tag pills are a separate flex row. Comfortable density adds the row plus + // its gap; compact density pulls it upward by 2px but still needs a slot. + const TAG_ROW_EXTRA = isCompactDensity ? 12.5 : 18.5; + // showResourceBars:false simply drops RESOURCE_ROW_EXTRA from every row. const rowHeight = useCallback( (index: number) => { const row = visibleRows[index]; if (!row) return FOLDER_ROW_HEIGHT; if (isFolder(row.item)) return FOLDER_ROW_HEIGHT; - if (alwaysShowActions) return ALWAYS_ROW_HEIGHT; + const tagExtra = showTags && row.item.tags?.length ? TAG_ROW_EXTRA : 0; + // The resource bars only render for an online host that reported CPU/RAM. + // Reserving their height unconditionally left a gap under every offline + // row. measureElement corrects any drift from live status this estimate + // can't see. + const resourceExtra = rendersResourceRow( + row.item, + showResourceBars, + isCompactDensity, + ) + ? RESOURCE_ROW_EXTRA + : 0; + if (alwaysShowActions) + return ALWAYS_ROW_HEIGHT + tagExtra + resourceExtra; const toggledOpen = (openTrayHostId === row.item.id || openMenuHostId === row.item.id) && (clickTrayActive || actionsOnly); @@ -738,10 +752,20 @@ export function SidebarTree({ !selectionMode && (hoveredHostId === row.item.id || openMenuHostId === row.item.id); const isOpen = toggledOpen || hoverOpen; + // The bars live inside the tray, so they only take space once it opens. + const openResourceExtra = isOpen ? resourceExtra : 0; if (actionsOnly) { - return isOpen ? ACTIONS_ONLY_OPEN_ROW_HEIGHT : ACTIONS_ONLY_ROW_HEIGHT; + return ( + (isOpen ? ACTIONS_ONLY_OPEN_ROW_HEIGHT : ACTIONS_ONLY_ROW_HEIGHT) + + tagExtra + + openResourceExtra + ); } - return isOpen ? HOST_ROW_HEIGHT + OPEN_TRAY_EXTRA : HOST_ROW_HEIGHT; + return ( + (isOpen ? HOST_ROW_HEIGHT + OPEN_TRAY_EXTRA : HOST_ROW_HEIGHT) + + tagExtra + + openResourceExtra + ); }, [ visibleRows, @@ -752,12 +776,16 @@ export function SidebarTree({ clickTrayActive, alwaysShowActions, actionsOnly, + showTags, HOST_ROW_HEIGHT, CLICK_CHEVRON_EXTRA, OPEN_TRAY_EXTRA, ALWAYS_ROW_HEIGHT, ACTIONS_ONLY_ROW_HEIGHT, ACTIONS_ONLY_OPEN_ROW_HEIGHT, + TAG_ROW_EXTRA, + showResourceBars, + isCompactDensity, ], ); @@ -766,6 +794,15 @@ export function SidebarTree({ getScrollElement: () => parentRef.current, estimateSize: rowHeight, overscan: 12, + // The library's default rounds every measurement to a whole pixel. Rows + // here land on fractions (a 63.84px row rounds to 64), and the rounded-up + // size becomes the slot pitch -- leaving a visible sliver under every + // single row, which stacks into the gaps the list is judged by. Keep the + // sub-pixel size so slots sit flush against the row above. + measureElement: (element, entry) => { + const box = entry?.borderBoxSize?.[0]; + return box ? box.blockSize : element.getBoundingClientRect().height; + }, getItemKey: (index) => { const row = visibleRows[index]; if (!row) return index; @@ -792,28 +829,21 @@ export function SidebarTree({ return reorderHoverEdge === "before" ? slot.start : slot.start + slot.size; })(); - // Fixed heights mean estimateSize already IS the real size. Do not attach - // measureElement here: its index-based ResizeObserver measurements can be - // reused for a different row after a deletion and override the fixed size. - // The virtualizer still needs telling when a row's height classification - // changes (tray opened/closed, tree reshaped, density/trigger changed). + // rowHeight is only an estimate: a row's real height also depends on live + // state the tree can't cheaply know per row (an offline host drops the + // resource bars "always" mode reserves space for). measureElement corrects + // it from the DOM, keyed by getItemKey rather than index so a deleted row's + // size is never reused for whatever shifts into its slot. + // + // measure() wipes the ENTIRE measurement cache, so it may only run when + // every row's shape changes at once -- a density/trigger/tag switch. Hover + // and tray state are deliberately absent: they change one row, which + // re-renders and re-measures itself through the observer anyway, whereas + // calling measure() for them threw all 5000 rows back to their estimates + // and made the list visibly jump apart on every pointer move. useLayoutEffect(() => { virtualizer.measure(); - }, [ - virtualizer, - openFolders, - closedHostParents, - openTrayHostId, - openMenuHostId, - hoveredHostId, - selectionMode, - query, - visibleRows.length, - density, - trayTrigger, - showTags, - showResourceBars, - ]); + }, [virtualizer, density, trayTrigger, showTags, showResourceBars]); if (loading) { return ( @@ -909,6 +939,8 @@ export function SidebarTree({ return (
, + showResourceBars: boolean, + isCompactDensity: boolean, +): boolean { + if (!showResourceBars || isCompactDensity) return false; + if (!host.online) return false; + return (host.cpu ?? 0) > 0 || (host.ram ?? 0) > 0; +} diff --git a/src/ui/ssh/dialogs/TOTPDialog.tsx b/src/ui/ssh/dialogs/TOTPDialog.tsx index f98a24ae..e327baec 100644 --- a/src/ui/ssh/dialogs/TOTPDialog.tsx +++ b/src/ui/ssh/dialogs/TOTPDialog.tsx @@ -120,7 +120,8 @@ export function TOTPDialog({ name="totpCode" type="text" autoFocus - maxLength={6} + // Some Secure Auth codes can be longer than 6 digits, so allow up to 8 digits here. + maxLength={8} pattern="[0-9]*" inputMode="numeric" placeholder="000000" diff --git a/src/ui/tests/api/guacamole-api.test.ts b/src/ui/tests/api/guacamole-api.test.ts index 00f8b34c..0871b9b3 100644 --- a/src/ui/tests/api/guacamole-api.test.ts +++ b/src/ui/tests/api/guacamole-api.test.ts @@ -9,6 +9,7 @@ const remoteApiMock = vi.hoisted(() => ({ post: vi.fn(async () => ({ data: { token: "remote-token" } })), })); const isElectronMock = vi.hoisted(() => vi.fn(() => false)); +const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn()); vi.mock("@/main-axios", () => ({ authApi: authApiMock, @@ -16,6 +17,9 @@ vi.mock("@/main-axios", () => ({ isElectron: isElectronMock, handleApiError: (error: unknown) => error, })); +vi.mock("@/lib/remote-server-api", () => ({ + resolveRemoteHostId: resolveRemoteHostIdMock, +})); import { getGuacdStatus, @@ -27,6 +31,7 @@ beforeEach(() => { authApiMock.post.mockClear(); remoteApiMock.get.mockClear(); remoteApiMock.post.mockClear(); + resolveRemoteHostIdMock.mockReset(); }); describe("guacamole API origin", () => { @@ -60,15 +65,21 @@ describe("guacamole API origin", () => { it("sends the connect-host payload unchanged to the remote server", async () => { isElectronMock.mockReturnValue(true); + resolveRemoteHostIdMock.mockResolvedValue(41); - await getGuacamoleTokenFromHost(9, "rdp", { - username: "admin", - password: "secret", - domain: "EXAMPLE", - }); + await getGuacamoleTokenFromHost( + 9, + "rdp", + { + username: "admin", + password: "secret", + domain: "EXAMPLE", + }, + "host-sync-id", + ); expect(remoteApiMock.post).toHaveBeenCalledWith( - "/guacamole/connect-host/9", + "/guacamole/connect-host/41", { protocol: "rdp", promptedUsername: "admin", @@ -76,6 +87,7 @@ describe("guacamole API origin", () => { promptedDomain: "EXAMPLE", }, ); + expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id"); }); it("sends an empty prompted domain for local RDP accounts", async () => { @@ -94,4 +106,14 @@ describe("guacamole API origin", () => { promptedDomain: "", }); }); + + it("does not fall back to a colliding local id when sync resolution fails", async () => { + isElectronMock.mockReturnValue(true); + resolveRemoteHostIdMock.mockResolvedValue(null); + + await expect( + getGuacamoleTokenFromHost(9, "rdp", undefined, "missing-sync-id"), + ).rejects.toThrow("The synced host does not exist on the remote server"); + expect(remoteApiMock.post).not.toHaveBeenCalled(); + }); }); diff --git a/src/ui/tests/api/webauthn-api.test.ts b/src/ui/tests/api/webauthn-api.test.ts new file mode 100644 index 00000000..c60f5c36 --- /dev/null +++ b/src/ui/tests/api/webauthn-api.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const authApiMock = vi.hoisted(() => ({ + get: vi.fn(async () => ({ data: {} })), + post: vi.fn(async () => ({ data: {} })), + delete: vi.fn(async () => ({ data: { success: true } })), +})); + +const browserMock = vi.hoisted(() => ({ + startAuthentication: vi.fn(async () => ({ id: "cred-1" })), + startRegistration: vi.fn(async () => ({ id: "cred-1" })), + browserSupportsWebAuthn: vi.fn(() => true), +})); + +vi.mock("@/main-axios", () => ({ + authApi: authApiMock, + handleApiError: (error: unknown) => error, +})); + +vi.mock("@simplewebauthn/browser", () => browserMock); + +import { isPasskeySupported, loginWithPasskey } from "../../api/webauthn-api"; + +beforeEach(() => { + authApiMock.get.mockClear(); + authApiMock.post.mockClear(); + authApiMock.delete.mockClear(); + browserMock.startAuthentication.mockClear(); + browserMock.browserSupportsWebAuthn.mockClear(); +}); + +describe("isPasskeySupported", () => { + it("reports what the browser helper returns", () => { + browserMock.browserSupportsWebAuthn.mockReturnValueOnce(false); + expect(isPasskeySupported()).toBe(false); + browserMock.browserSupportsWebAuthn.mockReturnValueOnce(true); + expect(isPasskeySupported()).toBe(true); + }); +}); + +describe("loginWithPasskey", () => { + it("passes the challenge from options through to verify", async () => { + authApiMock.post + .mockResolvedValueOnce({ + data: { options: { challenge: "abc" }, challengeId: "chal-1" }, + }) + .mockResolvedValueOnce({ data: { success: true, username: "luke" } }); + + const result = await loginWithPasskey("luke", true); + + expect(authApiMock.post).toHaveBeenNthCalledWith( + 1, + "/users/webauthn/authenticate/options", + { username: "luke" }, + ); + expect(browserMock.startAuthentication).toHaveBeenCalledWith({ + optionsJSON: { challenge: "abc" }, + }); + expect(authApiMock.post).toHaveBeenNthCalledWith( + 2, + "/users/webauthn/authenticate/verify", + { + challengeId: "chal-1", + response: { id: "cred-1" }, + rememberMe: true, + }, + ); + expect(result).toEqual({ success: true, username: "luke" }); + }); + + it("omits the username so discoverable passkeys work", async () => { + authApiMock.post + .mockResolvedValueOnce({ + data: { options: { challenge: "abc" }, challengeId: "chal-2" }, + }) + .mockResolvedValueOnce({ data: { success: true } }); + + await loginWithPasskey(); + + expect(authApiMock.post).toHaveBeenNthCalledWith( + 1, + "/users/webauthn/authenticate/options", + {}, + ); + expect(authApiMock.post).toHaveBeenNthCalledWith( + 2, + "/users/webauthn/authenticate/verify", + expect.objectContaining({ rememberMe: false }), + ); + }); + + it("returns the totp challenge instead of a session", async () => { + authApiMock.post + .mockResolvedValueOnce({ + data: { options: { challenge: "abc" }, challengeId: "chal-3" }, + }) + .mockResolvedValueOnce({ + data: { success: true, requires_totp: true, temp_token: "tmp" }, + }); + + const result = await loginWithPasskey("luke"); + + expect(result.requires_totp).toBe(true); + expect(result.temp_token).toBe("tmp"); + }); + + it("surfaces a cancelled prompt to the caller", async () => { + authApiMock.post.mockResolvedValueOnce({ + data: { options: { challenge: "abc" }, challengeId: "chal-4" }, + }); + const cancelled = Object.assign(new Error("cancelled"), { + name: "NotAllowedError", + }); + browserMock.startAuthentication.mockRejectedValueOnce(cancelled); + + await expect(loginWithPasskey("luke")).rejects.toMatchObject({ + name: "NotAllowedError", + }); + expect(authApiMock.post).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ui/tests/components/proxmox/proxmox-import-auth.test.ts b/src/ui/tests/components/proxmox/proxmox-import-auth.test.ts index 1bbeb4e1..67e26d5f 100644 --- a/src/ui/tests/components/proxmox/proxmox-import-auth.test.ts +++ b/src/ui/tests/components/proxmox/proxmox-import-auth.test.ts @@ -9,7 +9,7 @@ describe("resolveProxmoxImportAuth", () => { expect(resolveProxmoxImportAuth("password", 42)).toEqual({ authType: "credential", credentialId: 42, - overrideCredentialUsername: true, + overrideCredentialUsername: false, }); }); @@ -17,7 +17,7 @@ describe("resolveProxmoxImportAuth", () => { expect(resolveProxmoxImportAuth("credential", 7)).toEqual({ authType: "credential", credentialId: 7, - overrideCredentialUsername: true, + overrideCredentialUsername: false, }); }); @@ -25,7 +25,7 @@ describe("resolveProxmoxImportAuth", () => { expect(resolveProxmoxImportAuth(undefined, 5)).toEqual({ authType: "credential", credentialId: 5, - overrideCredentialUsername: true, + overrideCredentialUsername: false, }); }); diff --git a/src/ui/tests/features/ai/AiProviderSettings.test.tsx b/src/ui/tests/features/ai/AiProviderSettings.test.tsx new file mode 100644 index 00000000..9a1f9a39 --- /dev/null +++ b/src/ui/tests/features/ai/AiProviderSettings.test.tsx @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { AiProvider } from "@/api/ai-api"; + +const api = vi.hoisted(() => ({ + createAiProvider: vi.fn(), + deleteAiProvider: vi.fn(), + getAiProviderModels: vi.fn(), + probeAiModels: vi.fn(), + updateAiProvider: vi.fn(), +})); + +vi.mock("@/api/ai-api", () => api); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { AiProviderSettings } from "@/features/ai/AiProviderSettings"; + +const provider: AiProvider = { + id: 7, + providerType: "ollama", + label: "Local Ollama", + baseUrl: "http://localhost:11434", + apiKeyPrefix: null, + defaultModel: "llama3.1", + enabled: true, + createdAt: "2026-08-21T00:00:00Z", +}; + +beforeEach(() => { + api.getAiProviderModels.mockReset(); + api.getAiProviderModels.mockResolvedValue([]); + api.updateAiProvider.mockReset(); + api.updateAiProvider.mockResolvedValue(provider); +}); + +afterEach(cleanup); + +describe("AiProviderSettings", () => { + it("edits an existing provider name and configured model", async () => { + const onChanged = vi.fn(); + render(); + + expect(screen.getByText(/llama3\.1/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" })); + + const label = screen.getByLabelText("ai.providerLabel"); + const model = screen.getByLabelText("ai.defaultModel"); + fireEvent.change(label, { target: { value: "Production Ollama" } }); + fireEvent.change(model, { target: { value: "qwen3:32b" } }); + fireEvent.click(screen.getByRole("button", { name: "ai.save" })); + + await waitFor(() => { + expect(api.updateAiProvider).toHaveBeenCalledWith(7, { + label: "Production Ollama", + defaultModel: "qwen3:32b", + }); + }); + expect(onChanged).toHaveBeenCalledWith(7); + }); + + it("loads the saved provider model list when editing starts", async () => { + api.getAiProviderModels.mockResolvedValue(["llama3.1", "qwen3:32b"]); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" })); + + await waitFor(() => { + expect(api.getAiProviderModels).toHaveBeenCalledWith(7); + }); + expect( + screen.getByRole("combobox", { name: "ai.defaultModel" }), + ).toBeTruthy(); + }); +}); diff --git a/src/ui/tests/features/file-manager/hooks/useDragAndDrop.test.ts b/src/ui/tests/features/file-manager/hooks/useDragAndDrop.test.ts new file mode 100644 index 00000000..d78e64ee --- /dev/null +++ b/src/ui/tests/features/file-manager/hooks/useDragAndDrop.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useDragAndDrop } from "../../../../features/file-manager/hooks/useDragAndDrop.js"; + +function makeEntry(name: string, isDirectory: boolean) { + return { name, isDirectory, isFile: !isDirectory } as FileSystemEntry; +} + +function makeDropEvent(entries: FileSystemEntry[], files: File[] = []) { + const items = entries.map((entry) => ({ + webkitGetAsEntry: () => entry, + })); + + const dataTransfer = { + items, + files: Object.assign(files, { item: (i: number) => files[i] }), + }; + + return { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer, + } as unknown as React.DragEvent; +} + +describe("useDragAndDrop", () => { + it("hands directory entries to onItemsDropped", () => { + const onItemsDropped = vi.fn(); + const onFilesDropped = vi.fn(); + const { result } = renderHook(() => + useDragAndDrop({ onFilesDropped, onItemsDropped }), + ); + + const dir = makeEntry("myfolder", true); + act(() => result.current.dragHandlers.onDrop(makeDropEvent([dir]))); + + expect(onItemsDropped).toHaveBeenCalledWith([dir]); + expect(onFilesDropped).not.toHaveBeenCalled(); + }); + + it("reads entries before state updates clear dataTransfer", () => { + const onItemsDropped = vi.fn(); + const { result } = renderHook(() => + useDragAndDrop({ onFilesDropped: vi.fn(), onItemsDropped }), + ); + + const dir = makeEntry("myfolder", true); + const event = makeDropEvent([dir]); + + // Mimic the browser neutering dataTransfer once the handler unwinds. + act(() => { + result.current.dragHandlers.onDrop(event); + (event.dataTransfer as unknown as { items: unknown[] }).items = []; + }); + + expect(onItemsDropped).toHaveBeenCalledWith([dir]); + }); + + it("falls back to plain file upload when no directory is dropped", () => { + const onFilesDropped = vi.fn(); + const onItemsDropped = vi.fn(); + const { result } = renderHook(() => + useDragAndDrop({ onFilesDropped, onItemsDropped }), + ); + + const file = new File(["hi"], "a.txt"); + act(() => + result.current.dragHandlers.onDrop( + makeDropEvent([makeEntry("a.txt", false)], [file]), + ), + ); + + expect(onItemsDropped).not.toHaveBeenCalled(); + expect(onFilesDropped).toHaveBeenCalled(); + }); + + it("rejects files over the size limit", () => { + const onError = vi.fn(); + const onFilesDropped = vi.fn(); + const { result } = renderHook(() => + useDragAndDrop({ onFilesDropped, onError, maxFileSize: 1 }), + ); + + const big = new File(["x"], "big.bin"); + Object.defineProperty(big, "size", { value: 5 * 1024 * 1024 }); + + act(() => result.current.dragHandlers.onDrop(makeDropEvent([], [big]))); + + expect(onFilesDropped).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/tests/features/guacamole/guacamole-file-drop.test.ts b/src/ui/tests/features/guacamole/guacamole-file-drop.test.ts new file mode 100644 index 00000000..dd2968dd --- /dev/null +++ b/src/ui/tests/features/guacamole/guacamole-file-drop.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { + getFileDropDisposition, + hasDraggedFiles, +} from "@/features/guacamole/guacamole-file-drop.ts"; + +describe("Guacamole file drop", () => { + it("recognizes external file drags", () => { + expect(hasDraggedFiles(["Files"])).toBe(true); + expect(hasDraggedFiles(["text/plain"])).toBe(false); + }); + + it("rejects files when upload is unavailable instead of ignoring them", () => { + expect(getFileDropDisposition(["Files"], 1, false)).toBe("reject"); + expect(getFileDropDisposition(["Files"], 1, true)).toBe("upload"); + }); +}); diff --git a/src/ui/tests/features/terminal/android-ime.test.ts b/src/ui/tests/features/terminal/android-ime.test.ts index c3572d9d..54e1ba41 100644 --- a/src/ui/tests/features/terminal/android-ime.test.ts +++ b/src/ui/tests/features/terminal/android-ime.test.ts @@ -45,4 +45,32 @@ describe("Android IME composition", () => { expect(input.join("")).toBe("\x7f\x7f\x7fỏa"); }); + + it("coalesces rapid textarea changes without dropping or duplicating input", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + terminal = new Terminal(); + terminal.open(container); + + const input: string[] = []; + terminal.onData((data) => input.push(data)); + + const textarea = terminal.textarea!; + const type = (value: string) => { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit), + ); + textarea.value = value; + textarea.selectionStart = value.length; + textarea.selectionEnd = value.length; + }; + + type("t"); + type("te"); + type("ter"); + type("termix"); + await tick(); + + expect(input.join("")).toBe("termix"); + }); }); diff --git a/src/ui/tests/features/terminal/terminal-key-event.test.ts b/src/ui/tests/features/terminal/terminal-key-event.test.ts index ed3c5195..073abb48 100644 --- a/src/ui/tests/features/terminal/terminal-key-event.test.ts +++ b/src/ui/tests/features/terminal/terminal-key-event.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { isTabKeyEvent } from "@/features/terminal/terminal-key-event"; +import { + isPhysicalShortcutKey, + isTabKeyEvent, +} from "@/features/terminal/terminal-key-event"; describe("isTabKeyEvent", () => { it.each([ @@ -19,3 +22,26 @@ describe("isTabKeyEvent", () => { ); }); }); + +describe("isPhysicalShortcutKey", () => { + it("matches copy and paste by physical code under a Cyrillic layout", () => { + expect(isPhysicalShortcutKey({ key: "с", code: "KeyC" }, "KeyC", "c")).toBe( + true, + ); + expect(isPhysicalShortcutKey({ key: "м", code: "KeyV" }, "KeyV", "v")).toBe( + true, + ); + }); + + it("does not confuse another physical key with a translated character", () => { + expect(isPhysicalShortcutKey({ key: "c", code: "KeyV" }, "KeyC", "c")).toBe( + false, + ); + }); + + it("falls back to key when code is unavailable", () => { + expect(isPhysicalShortcutKey({ key: "C", code: "" }, "KeyC", "c")).toBe( + true, + ); + }); +}); diff --git a/src/ui/tests/sidebar/HostEditorData.test.ts b/src/ui/tests/sidebar/HostEditorData.test.ts index 4a6f1719..c91a6f0e 100644 --- a/src/ui/tests/sidebar/HostEditorData.test.ts +++ b/src/ui/tests/sidebar/HostEditorData.test.ts @@ -237,6 +237,56 @@ describe("buildHostEditorPayload auth field isolation", () => { expect(tc?.agentSocketPath).toBeNull(); }); + it("preserves agentIdentity in terminalConfig when authType is agent", () => { + const form = { + ...createHostEditorForm(null), + authType: "agent" as const, + agentIdentity: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA test-key", + }; + + const payload = buildHostEditorPayload(form, sshOnly); + const tc = payload.terminalConfig as unknown as Record< + string, + unknown + > | null; + + expect(tc?.agentIdentity).toBe( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA test-key", + ); + }); + + it("nulls out agentIdentity when switching away from agent auth", () => { + const form = { + ...createHostEditorForm(null), + authType: "password" as const, + password: "mypass", + agentIdentity: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA test-key", + }; + + const payload = buildHostEditorPayload(form, sshOnly); + const tc = payload.terminalConfig as unknown as Record< + string, + unknown + > | null; + + expect(tc?.agentIdentity).toBeNull(); + }); + + it("keeps agentIdentity in terminalConfig for shared edits (not owner-private)", () => { + const form = { + ...createHostEditorForm(null), + authType: "agent" as const, + agentIdentity: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA test-key", + }; + + const payload = buildHostEditorPayload(form, sshOnly); + const sharedEdit = omitOwnerSshAuthFromSharedEdit(payload); + + expect(sharedEdit.terminalConfig?.agentIdentity).toBe( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA test-key", + ); + }); + it("preserves sudo password autofill settings", () => { const form = { ...createHostEditorForm(null), @@ -559,3 +609,21 @@ describe("user connection defaults", () => { }); }); }); + +describe("createHostEditorForm credentialId", () => { + it("coerces a numeric credentialId to a string so credential lookups match", () => { + const form = createHostEditorForm({ + credentialId: 12, + } as unknown as Host); + expect(form.credentialId).toBe("12"); + }); + + it("keeps a string credentialId as is", () => { + const form = createHostEditorForm({ credentialId: "12" } as Host); + expect(form.credentialId).toBe("12"); + }); + + it("falls back to an empty string when there is no credential", () => { + expect(createHostEditorForm(null).credentialId).toBe(""); + }); +}); diff --git a/src/ui/tests/sidebar/credential-tree/credential-row-height.test.ts b/src/ui/tests/sidebar/credential-tree/credential-row-height.test.ts new file mode 100644 index 00000000..ba4f8d47 --- /dev/null +++ b/src/ui/tests/sidebar/credential-tree/credential-row-height.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { getCredentialRowHeight } from "@/sidebar/credential-tree/credential-row-height"; + +const shapes = [ + { alwaysShowActions: false, actionsOnly: false, isOpen: false }, + { alwaysShowActions: false, actionsOnly: false, isOpen: true }, + { alwaysShowActions: false, actionsOnly: true, isOpen: false }, + { alwaysShowActions: true, actionsOnly: false, isOpen: false }, +]; + +describe("getCredentialRowHeight", () => { + it.each([ + ["comfortable", 18.5], + ["compact", 12.5], + ] as const)( + "reserves the %s tag row for every row shape", + (density, extra) => { + for (const shape of shapes) { + for (const isKey of [false, true]) { + const base = getCredentialRowHeight({ + density, + isKey, + ...shape, + showTags: false, + tagCount: 1, + }); + const tagged = getCredentialRowHeight({ + density, + isKey, + ...shape, + showTags: true, + tagCount: 1, + }); + expect(tagged - base).toBe(extra); + } + } + }, + ); + + it("does not reserve space when tags are hidden or absent", () => { + const base = getCredentialRowHeight({ + density: "comfortable", + isKey: true, + ...shapes[0], + showTags: false, + tagCount: 0, + }); + expect( + getCredentialRowHeight({ + density: "comfortable", + isKey: true, + ...shapes[0], + showTags: true, + tagCount: 0, + }), + ).toBe(base); + }); +}); diff --git a/src/ui/tests/sidebar/tree/row-metrics.test.ts b/src/ui/tests/sidebar/tree/row-metrics.test.ts new file mode 100644 index 00000000..234fa248 --- /dev/null +++ b/src/ui/tests/sidebar/tree/row-metrics.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { + RESOURCE_ROW_EXTRA, + rendersResourceRow, +} from "@/sidebar/tree/row-metrics"; + +const host = ( + over: Partial<{ online: boolean; cpu: number; ram: number }>, +) => ({ + online: false, + cpu: null, + ram: null, + ...over, +}); + +describe("rendersResourceRow", () => { + it("reserves the bars for an online host reporting CPU", () => { + expect( + rendersResourceRow(host({ online: true, cpu: 42 }), true, false), + ).toBe(true); + }); + + it("reserves the bars for an online host reporting only RAM", () => { + expect( + rendersResourceRow(host({ online: true, ram: 70 }), true, false), + ).toBe(true); + }); + + // The gap this whole helper exists for: offline rows reserved bar height + // they never rendered, leaving dead space under every row in a long list. + it("reserves nothing for an offline host", () => { + expect( + rendersResourceRow(host({ online: false, cpu: 42 }), true, false), + ).toBe(false); + }); + + it("reserves nothing for an online host with no metrics yet", () => { + expect(rendersResourceRow(host({ online: true }), true, false)).toBe(false); + }); + + it("reserves nothing when cpu and ram are zero", () => { + expect( + rendersResourceRow(host({ online: true, cpu: 0, ram: 0 }), true, false), + ).toBe(false); + }); + + it("reserves nothing when the bars are turned off", () => { + expect( + rendersResourceRow(host({ online: true, cpu: 42 }), false, false), + ).toBe(false); + }); + + it("reserves nothing in compact density, which drops the row", () => { + expect( + rendersResourceRow(host({ online: true, cpu: 42 }), true, true), + ).toBe(false); + }); +}); + +describe("RESOURCE_ROW_EXTRA", () => { + it("matches the measured height of the bar row", () => { + expect(RESOURCE_ROW_EXTRA).toBe(17.25); + }); +});