diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d486744ce..db4cc7c64 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -41,9 +41,15 @@ on: default: "" jobs: + # One image per plugin profile (docker/bundled-plugins.json). standard keeps + # the plain tags; minimal and full add a suffix. build: runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + profile: [standard, minimal, full] steps: - name: Checkout repository uses: actions/checkout@v7 @@ -80,12 +86,17 @@ jobs: TAGS=() ALL_TAGS=() + SUFFIX="" + if [ "${{ matrix.profile }}" != "standard" ]; then + SUFFIX="-${{ matrix.profile }}" + fi + if [ "$BUILD_TYPE" = "Production" ]; then - TAGS+=("$VERSION" "latest") + TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX") elif [ "$BUILD_TYPE" = "Beta" ]; then - TAGS+=("beta" "$VERSION") + TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX") else - TAGS+=("dev-$VERSION") + TAGS+=("dev-$VERSION$SUFFIX") fi for tag in "${TAGS[@]}"; do @@ -124,6 +135,7 @@ jobs: tags: ${{ env.ALL_TAGS }} build-args: | BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 + PLUGIN_PROFILE=${{ matrix.profile }} labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ env.SOURCE_SHA }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e97d9b700..7b937d475 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,9 +161,10 @@ jobs: echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT" - # Translates new or changed strings with the Termix LLM server and commits - # them to the dev branch, so the beta or stable build includes them. If the - # server is down it warns and the release goes on with what is there. + # Translates new or changed strings with the Termix LLM server and pins the + # bundled plugins to their newest registry releases, then commits both to the + # dev branch so the beta or stable build includes them. If the server is down + # it warns and the release goes on with what is there. translate: needs: [prep] if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }} @@ -191,19 +192,26 @@ jobs: languages: src/ui/locales/languages.json api-key: ${{ secrets.TERMIX_LLM_API_KEY }} - - name: Commit translations + - name: Install dependencies + run: npm ci --ignore-scripts + + # Newest stable plugin release whose engine.termix allows this version. + - name: Pin bundled plugins + run: node scripts/pin-bundled-plugins.cjs --all --core-version "${{ needs.prep.outputs.version }}" + + - name: Commit translations and plugin pins id: commit run: | - git add src/ui/locales + git add src/ui/locales docker/bundled-plugins.json if git diff --cached --quiet; then echo "No translation changes." elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then - echo "DRY RUN: would commit these translation changes:" + echo "DRY RUN: would commit these translation and pin changes:" git --no-pager diff --cached --stat else git config user.name "LukeGus" git config user.email "bugattiguy527@gmail.com" - git commit -m "chore: update translations" + git commit -m "chore: update translations and plugin pins" git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}" fi echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" diff --git a/CHANGELOG.md b/CHANGELOG.md index 879c611fe..865948a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 26.10.5 + +### Added + +- Safe mode: start the server with TERMIX_SAFE_MODE=1 to run only layout and theme plugins, so a broken plugin can be turned off +- TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS lets plugin registries live on a private address, like a registry in your LAN +- Plugin registry browsing and downloads use HTTP_PROXY and HTTPS_PROXY when they are set +- Docker image sizes: the default image now ships the most used plugins, the minimal tag ships only the look plugins and the full tag ships every official plugin +- Plugins an older image shipped and you had on are downloaded from the registry on the first start of a smaller image, so nothing goes missing + +### Changed + +- Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row + +### Fixed + +- The macOS app asks for Local Network access, so it can reach servers on your LAN +- The desktop app can change the connection origin of a host shared with you, instead of failing to save + ## 26.10.4 ### Fixed diff --git a/README.md b/README.md index 0d3863da0..8f5a70799 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can | [Services](https://github.com/Termix-SSH/Plugin-Service-Manager) | Manage services, timers and logs on your hosts over SSH. | | [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. | | [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. | +| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. | | [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. | | [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. | | [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. | diff --git a/docker/Dockerfile b/docker/Dockerfile index 0d3e76f6a..ee8ccab54 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -34,9 +34,20 @@ WORKDIR /app COPY . . +RUN npm run build:backend:core + +# Stage 3b: Stage the bundled plugins for one profile (minimal, standard or +# full, see docker/bundled-plugins.json). Only this stage and the final one +# differ between profiles, so the other stages are built once. +FROM deps AS plugins +WORKDIR /app +ARG PLUGIN_PROFILE=standard +COPY scripts/build-plugins.cjs ./scripts/build-plugins.cjs +COPY scripts/lib/bundled-plugins.cjs ./scripts/lib/bundled-plugins.cjs +COPY docker/bundled-plugins.json ./docker/bundled-plugins.json # Bundled plugin downloads show in the plugin store, so keep them between builds. RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \ - npm run build:backend + node scripts/build-plugins.cjs --profile "$PLUGIN_PROFILE" # Stage 4: Download OPKSSH binary for the target platform so the image works offline FROM node:24-slim AS opkssh-downloader @@ -117,9 +128,9 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend -# Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by -# getBundledPluginsDir(). -COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins +# Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved +# as dist/plugins by getBundledPluginsDir(). +COPY --chown=node:node --from=plugins /app/dist/plugins ./dist/plugins # Target of node_modules/@termix-ssh/plugin-sdk, which core and the plugins import. COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist diff --git a/docker/bundled-plugins.json b/docker/bundled-plugins.json index 1eb4e0cb2..07bc58661 100644 --- a/docker/bundled-plugins.json +++ b/docker/bundled-plugins.json @@ -3,20 +3,23 @@ { "id": "acme-ssl", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug", - "sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f" + "url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug", + "sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a", + "tier": "full" }, { "id": "ai", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.2.0/ai-1.2.0.tmxplug", - "sha256": "3b486133af78ae5168e6f5b8a4c71f3db3403cfdae537ed1ca8d6cc16035d8dd" + "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug", + "sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6", + "tier": "full" }, { "id": "alerts", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug", - "sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa", + "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug", + "sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c", + "tier": "standard", "onboarding": { "recommended": true, "consent": false @@ -26,19 +29,22 @@ "id": "audit-tools", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug", - "sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b" + "sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b", + "tier": "full" }, { "id": "automations", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.3/automations-1.0.3.tmxplug", - "sha256": "b5e5b39a053010b72ad1ba03ea6bb05afbbb2b789a86e0e98634d9ada543d02d" + "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug", + "sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea", + "tier": "full" }, { "id": "classic-layout", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.1/classic-layout-1.0.1.tmxplug", - "sha256": "6b2d9acd77ef4b33fa0761c1aea7ed3c6171c325ee8febcedff94b5b2bc7be34", + "url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug", + "sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14", + "tier": "minimal", "defaultLook": true }, { @@ -46,19 +52,36 @@ "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug", "sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c", + "tier": "minimal", "defaultLook": true }, + { + "id": "cloud-providers", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Cloud-Providers/releases/download/v1.1.0/cloud-providers-1.1.0.tmxplug", + "sha256": "7f61eafc3b99bdf1328fffa95791db1d27f8e1b683e5a5925603b5840057a9c7", + "tier": "full" + }, { "id": "credential-tools", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug", - "sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce" + "sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce", + "tier": "standard" + }, + { + "id": "databases", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug", + "sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8", + "tier": "full" }, { "id": "docker", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.1.0/docker-1.1.0.tmxplug", - "sha256": "da32dfbe9d4cffec5d80c4a1037a79e7591a3d16c50f882db6a146322a50c2b7", + "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug", + "sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff", + "tier": "standard", "onboarding": { "recommended": true, "consent": false @@ -67,8 +90,9 @@ { "id": "file-manager", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.0/file-manager-1.1.0.tmxplug", - "sha256": "807e9ffa80d6054a5911522721a555e0daa3e50f6edffdebe97efe7c91aea7a2", + "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug", + "sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd", + "tier": "standard", "onboarding": { "recommended": true, "consent": false @@ -78,19 +102,22 @@ "id": "fleets", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug", - "sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59" + "sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59", + "tier": "full" }, { "id": "homepage", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug", - "sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00" + "sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00", + "tier": "full" }, { "id": "host-metrics", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.1.0/host-metrics-1.1.0.tmxplug", - "sha256": "4f1d422ec1d0565c79f94fa814ee0864d556d7367793b11035e68239e49e297f", + "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug", + "sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3", + "tier": "standard", "onboarding": { "recommended": true, "consent": false @@ -100,93 +127,142 @@ "id": "host-status", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug", - "sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170" + "sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170", + "tier": "standard" }, { "id": "hosts-config", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug", - "sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad" + "sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad", + "tier": "full" }, { "id": "import-export", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug", - "sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58" + "sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58", + "tier": "standard" + }, + { + "id": "kubernetes", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Kubernetes/releases/download/v1.0.1/kubernetes-1.0.1.tmxplug", + "sha256": "31d6b200579d326d07244712713ae291ff473270d44b478a884d07c863a394f0", + "tier": "full" }, { "id": "ldap", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug", - "sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03" + "sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03", + "tier": "full" }, { "id": "login-security", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug", - "sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa" + "sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa", + "tier": "full" }, { "id": "network-topology", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug", - "sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56" + "sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56", + "tier": "full" }, { "id": "opkssh", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug", - "sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219" + "sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219", + "tier": "full" }, { "id": "proxmox", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug", - "sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e" + "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug", + "sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf", + "tier": "full" }, { "id": "remote-desktop", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.0/remote-desktop-1.2.0.tmxplug", - "sha256": "0de40896bfeb4b1a702c3ce815355b7b7b0bb4bfe424f33e905bb5a0209d02f6", + "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug", + "sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7", + "tier": "standard", "onboarding": { "recommended": true, "consent": false } }, + { + "id": "reverse-proxy", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug", + "sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7", + "tier": "full" + }, + { + "id": "samba", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Samba/releases/download/v1.0.0/samba-1.0.0.tmxplug", + "sha256": "6e131ccdca998a9d0802becdd439a88305b64fcd7f07335bec9c7ed6791c0f53", + "tier": "full" + }, { "id": "secret-sources", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug", - "sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134" + "sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134", + "tier": "full" }, { "id": "serial", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug", "sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715", + "tier": "standard", "onboarding": { "recommended": "desktop", "consent": false } }, + { + "id": "service-manager", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug", + "sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf", + "tier": "full" + }, { "id": "session-recording", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug", - "sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c" + "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug", + "sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a", + "tier": "full" }, { "id": "session-sharing", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug", - "sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804" + "url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.1.0/session-sharing-1.1.0.tmxplug", + "sha256": "2988b39221d93b3dbc5e2a81e104e5dc01ce264afb1e66df3407492658ab9fdc", + "tier": "full" + }, + { + "id": "simple-layout", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Simple-Layout/releases/download/v1.0.1/simple-layout-1.0.1.tmxplug", + "sha256": "838f08050335b44b62295b87006b50d1db1029699bfe6a35fa75808d4aea2cde", + "tier": "full" }, { "id": "snippets", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug", - "sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5", + "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug", + "sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3", + "tier": "standard", "onboarding": { "recommended": true, "consent": false @@ -195,8 +271,9 @@ { "id": "ssh-terminal", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.0/ssh-terminal-1.1.0.tmxplug", - "sha256": "dacdbadb3c6a0d7014b1de4f62b499f80abf358c7799a6e6326674660b055e4a", + "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug", + "sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8", + "tier": "standard", "onboarding": { "recommended": true, "consent": false @@ -205,8 +282,9 @@ { "id": "sso", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug", - "sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e", + "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug", + "sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9", + "tier": "standard", "onboarding": { "recommended": false, "consent": false, @@ -217,25 +295,29 @@ "id": "step-ca", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug", - "sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a" + "sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a", + "tier": "full" }, { "id": "sync", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.0/sync-1.0.0.tmxplug", - "sha256": "12ced05523f7c1a9151b5e680726bd31f53888e8bb0047d7bd12a2570ea168bf" + "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug", + "sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e", + "tier": "standard" }, { "id": "tailscale", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug", - "sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97" + "url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug", + "sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf", + "tier": "full" }, { "id": "telemetry", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug", "sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec", + "tier": "standard", "onboarding": { "recommended": true, "consent": true @@ -245,19 +327,22 @@ "id": "termix-identity", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug", - "sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927" + "sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927", + "tier": "full" }, { "id": "tmux-monitor", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.4/tmux-monitor-1.0.4.tmxplug", - "sha256": "42b8a2284e68a321c1736984f5c641b1857c87c6a65ce8cf694c6bc946de2444" + "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug", + "sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006", + "tier": "full" }, { "id": "totp", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug", "sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0", + "tier": "standard", "onboarding": { "recommended": true, "consent": false @@ -266,8 +351,9 @@ { "id": "tunnels", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.1.0/tunnels-1.1.0.tmxplug", - "sha256": "b1e4f1b6dd2bbd15a9a240ae1c1d381111c3609258c8946d19493a83f861ea1e", + "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug", + "sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159", + "tier": "standard", "onboarding": { "recommended": true, "consent": false @@ -277,31 +363,36 @@ "id": "vault", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug", - "sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28" + "sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28", + "tier": "full" }, { "id": "wake-on-lan", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug", - "sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42" + "url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug", + "sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8", + "tier": "full" }, { "id": "warpgate", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug", - "sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47" + "sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47", + "tier": "full" }, { "id": "web-endpoint", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.3/web-endpoint-1.0.3.tmxplug", - "sha256": "014cbf23c82cab20e485bc4f4c9b8baa779aee9bb3ed644b41f22a9b1be95687" + "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug", + "sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c", + "tier": "full" }, { "id": "webauthn", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug", "sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de", + "tier": "standard", "onboarding": { "recommended": true, "consent": false @@ -310,8 +401,9 @@ { "id": "workspaces", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug", - "sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b", + "url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug", + "sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf", + "tier": "standard", "onboarding": { "recommended": true, "consent": false diff --git a/electron-builder.json b/electron-builder.json index 1c5bdb623..136fe4387 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -128,6 +128,10 @@ "category": "public.app-category.developer-tools", "hardenedRuntime": true, "gatekeeperAssess": false, + "extendInfo": { + "NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.", + "NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"] + }, "entitlements": "packaging/build/entitlements.mac.plist", "entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist", "type": "distribution", @@ -153,6 +157,8 @@ "artifactName": "termix_macos_${arch}_mas.${ext}", "extendInfo": { "ITSAppUsesNonExemptEncryption": false, + "NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.", + "NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"], "NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations." } }, diff --git a/env.catalog.json b/env.catalog.json index 8993819a3..38b5d7bf4 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -298,7 +298,7 @@ { "name": "HTTP_PROXY", "group": "proxy", - "description": "Proxy for outbound HTTP requests, like registry downloads and webhooks." + "description": "Proxy for outbound HTTP requests, like plugin registry downloads and webhooks." }, { "name": "HTTPS_PROXY", @@ -353,6 +353,11 @@ "group": "plugins", "description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index." }, + { + "name": "TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS", + "group": "plugins", + "description": "Comma-separated host names or IP addresses that plugin registry downloads may reach on a private address, like a registry in your LAN. List bare hosts: entries with a scheme, port or path are ignored. Matched by name only, on any port, so only list names your own DNS answers for. With HTTPS_PROXY set, add them to NO_PROXY too." + }, { "name": "TERMIX_REQUIRE_SIGNED_PLUGINS", "group": "plugins", @@ -365,6 +370,12 @@ "default": "false", "description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app." }, + { + "name": "TERMIX_SAFE_MODE", + "group": "plugins", + "default": "false", + "description": "Start only layout and theme plugins. Every other plugin stays off until a restart without it, so you can turn off a plugin that breaks the server or the app." + }, { "name": "TERMIX_BUNDLED_PLUGINS_DIR", "group": "plugins", diff --git a/package-lock.json b/package-lock.json index da59f4ca7..c5bcb6f90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "26.10.4", + "version": "26.10.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "26.10.4", + "version": "26.10.5", "hasInstallScript": true, "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 1cf5909b0..994cdc1d2 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "workspaces": [ "packages/*" ], - "version": "26.10.4", + "version": "26.10.5", "description": "Self-hosted, plugin-based server management.", "author": "Karmaa", "main": "electron/main.cjs", @@ -31,7 +31,8 @@ "dev:docker": "node scripts/dev.mjs docker", "dev:frontend": "vite", "build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", - "build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", + "build:backend:core": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"", + "build:backend": "npm run build:backend:core && npm run build:plugins", "generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs", "electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty", "build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 4fa91dc9f..5479f4c5c 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.20 + +Plugin API 1.12.0. + +### Added + +- `columns` and `onColumns` on `ViewToggle` show a cards per row picker: Auto fits as many as the pane has room for, or pick 1 to 6 +- `size` and `minCardWidth` on `DataView`, so a card grid follows that pick, and the `GridSize` type +- `usePluginPanelView` also keeps `columns`, the cards per row pick, with `setColumns` + ## 1.0.19 Plugin API 1.11.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 9b2ef1834..59ab078fe 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.19 | Plugin API 1.11.0 +SDK 1.0.20 | Plugin API 1.12.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -643,6 +643,9 @@ exports with props is `ui.api.txt` in the Termix repo `PanePrompt` for small prompts. - Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`. +- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same + value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per + row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props. - Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends. - Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors). - Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 113a45031..684db9831 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.19", + "version": "1.0.20", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index 845b44200..fcab9e871 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -1711,28 +1711,49 @@ export function usePluginUiPreferences< return bridge.usePluginUiPreferences(bridge.usePluginId()) as never; } +type PanelGridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6"; +const PANEL_GRID_SIZES: readonly string[] = [ + "auto", + "1", + "2", + "3", + "4", + "5", + "6", +]; + /** - * Grid or list and row density for one of this plugin's views, kept in its - * Appearance area as `viewMode` and `density`. Declare those - * keys in contributes.uiPresets so each preset level can pick a default. + * Grid or list, row density and cards per row for one of this plugin's + * views, kept in its Appearance area as `viewMode`, `density` + * and `columns`. Declare those keys in contributes.uiPresets so each + * preset level can pick a default. Hand columns to ViewToggle and DataView. */ export function usePluginPanelView(prefix = ""): { view: "grid" | "list"; density: "comfortable" | "compact"; compact: boolean; + columns: PanelGridSize; setView: (view: "grid" | "list") => void; setDensity: (density: "comfortable" | "compact") => void; + setColumns: (columns: PanelGridSize) => void; } { const { values, set } = usePluginUiPreferences(); const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid"; const density = values[`${prefix}density`] === "compact" ? "compact" : "comfortable"; + const saved = values[`${prefix}columns`]; + const columns = + typeof saved === "string" && PANEL_GRID_SIZES.includes(saved) + ? (saved as PanelGridSize) + : "auto"; return { view, density, compact: density === "compact", + columns, setView: (next) => set(`${prefix}viewMode`, next), setDensity: (next) => set(`${prefix}density`, next), + setColumns: (next) => set(`${prefix}columns`, next), }; } diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 37cb39b55..d4a364741 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -37,7 +37,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ -export const PLUGIN_API_VERSION = "1.11.0"; +export const PLUGIN_API_VERSION = "1.12.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index a2e578d7c..fdf3d3c9d 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -80,7 +80,7 @@ DEFAULT_FOLDER_COLOR (value): "#f59145" DEFAULT_FOLDER_ICON (value): "folder" DashboardCardId (type): DashboardCardId DataColumn (type): DataColumn -DataView (value): ({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element +DataView (value): ({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element DefaultOverrides (type): DefaultOverrides DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element DesktopUpdateMode (type): DesktopUpdateMode @@ -131,6 +131,7 @@ GridCardCatalogEntry (type): GridCardCatalogEntry GridColSpan (type): GridColSpan GridColumns (type): GridColumns GridLayout (type): GridLayout +GridSize (type): GridSize GridSlot (type): GridSlot GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element @@ -321,7 +322,7 @@ UserRole (type): UserRole UserSecondFactorSummary (type): UserSecondFactorSummary VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element VersionInfo (type): VersionInfo -ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element +ViewToggle (value): ({ view, onView, density, onDensity, columns, onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; columns?: GridSize; onColumns?: (next: GridSize) => void; }) => Element WebSocketConnectionTarget (type): WebSocketConnectionTarget WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element actionOrder (value): (action: HostActionDef) => number diff --git a/scripts/build-plugins.cjs b/scripts/build-plugins.cjs index f441490e8..c6109dfac 100644 --- a/scripts/build-plugins.cjs +++ b/scripts/build-plugins.cjs @@ -3,6 +3,8 @@ * * getBundledPluginsDir() in src/backend/plugins/paths.ts resolves * dist/backend/backend/plugins -> dist/plugins, which is where this writes. + * --profile minimal|standard|full (or TERMIX_PLUGIN_PROFILE, default standard) + * picks which tiers of the file are staged. * Each .tmxplug is downloaded (or read from a path), checked against its * pinned sha256 and unpacked. * @@ -25,13 +27,16 @@ const { localSdkBuildCommand, copyLocalBuild, buildBundledIndex, + resolveProfile, + pluginsForProfile, } = require("./lib/bundled-plugins.cjs"); const root = path.resolve(__dirname, ".."); const destination = path.join(root, "dist", "plugins"); async function main() { - const plugins = loadBundledPlugins(root); + const profile = resolveProfile(); + const plugins = pluginsForProfile(loadBundledPlugins(root), profile); const flag = process.argv.indexOf("--local"); const localArg = flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS; @@ -74,7 +79,7 @@ async function main() { ); console.log( - `Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`, + `Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`, ); } diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index 2d9e3969d..e84c12982 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -16,6 +16,8 @@ import { localSdkBuildCommand, rebuildLocalPlugins, copyLocalBuild, + resolveProfile, + pluginsForProfile, } from "./lib/bundled-plugins.cjs"; const cleanups: Array<() => void> = []; @@ -263,10 +265,52 @@ describe("pinsFromIndex", () => { source: "tmxplug", url: "https://example.test/beta-1.1.0.tmxplug", sha256: "b".repeat(64), + tier: "full", }); expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]); }); + it("keeps the tier of an existing pin", () => { + const pins = pinsFromIndex(index, null, [ + { id: "alpha", tier: "standard" }, + ]); + expect(pins[0].tier).toBe("standard"); + expect(pins[1].tier).toBe("full"); + }); + + it("skips releases whose engine.termix excludes the core version", () => { + const withEngine = { + plugins: [ + { + id: "gamma", + versions: [ + { + version: "2.0.0", + url: "https://example.test/gamma-2.0.0.tmxplug", + sha256: "d".repeat(64), + engine: { api: "^1", termix: ">=26.10.6" }, + }, + { + version: "1.0.0", + url: "https://example.test/gamma-1.0.0.tmxplug", + sha256: "e".repeat(64), + engine: { api: "^1", termix: ">=26.10.0" }, + }, + ], + }, + ], + }; + expect(pinsFromIndex(withEngine, null, [], "26.10.5")[0].url).toBe( + "https://example.test/gamma-1.0.0.tmxplug", + ); + expect(pinsFromIndex(withEngine, null, [], "26.10.6")[0].url).toBe( + "https://example.test/gamma-2.0.0.tmxplug", + ); + expect(pinsFromIndex(withEngine, null)[0].url).toBe( + "https://example.test/gamma-2.0.0.tmxplug", + ); + }); + it("keeps the onboarding defaults of an existing pin", () => { const current = [ { id: "beta", onboarding: { recommended: true, consent: false } }, @@ -511,3 +555,61 @@ describe("default look", () => { expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true); }); }); + +describe("plugin profiles", () => { + const entry = (id: string, tier?: string) => ({ + id, + source: "tmxplug", + url: `https://x/${id}.tmxplug`, + sha256: SHA, + ...(tier ? { tier } : {}), + }); + const { plugins } = parseBundledPlugins({ + plugins: [ + entry("look", "minimal"), + entry("term", "standard"), + entry("extra", "full"), + entry("untiered"), + ], + }); + const ids = (profile: string) => + pluginsForProfile(plugins, profile).map((p: { id: string }) => p.id); + + it("ships each tier and every smaller one", () => { + expect(ids("minimal")).toEqual(["look"]); + expect(ids("standard")).toEqual(["look", "term"]); + expect(ids("full")).toEqual(["look", "term", "extra", "untiered"]); + }); + + it("rejects an unknown tier", () => { + const { problems } = parseBundledPlugins({ plugins: [entry("x", "huge")] }); + expect(problems[0]).toMatch(/tier must be one of/); + }); + + it("reads the profile from the flag, then the environment", () => { + expect(resolveProfile(["node", "x"], {})).toBe("standard"); + expect( + resolveProfile(["node", "x"], { TERMIX_PLUGIN_PROFILE: "full" }), + ).toBe("full"); + expect( + resolveProfile(["node", "x", "--profile", "minimal"], { + TERMIX_PLUGIN_PROFILE: "full", + }), + ).toBe("minimal"); + expect(() => + resolveProfile(["node", "x", "--profile", "tiny"], {}), + ).toThrow(/unknown plugin profile/); + }); + + it("keeps the real file valid with a default look in the minimal tier", () => { + const real = loadBundledPlugins(path.resolve(__dirname, "..")); + const minimal = pluginsForProfile(real, "minimal"); + expect(minimal.length).toBeGreaterThan(0); + expect(minimal.every((p: { defaultLook: boolean }) => p.defaultLook)).toBe( + true, + ); + expect(pluginsForProfile(real, "standard").length).toBeLessThan( + real.length, + ); + }); +}); diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index bf020c3ef..aa3c37d13 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -151,6 +151,7 @@ const ALLOWED = [ "GridCardCatalogEntry", "GridColSpan", "GridColumns", + "GridSize", "GridLayout", "GridSlot", "GroupHeading", diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index fcb2be13d..deeb173ce 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -16,6 +16,11 @@ * environment variables is set, for a server configured before first boot. * Plugins without it start unchecked. * + * "tier" says which image profile ships the plugin: "minimal" (only what the + * app cannot start without, the default look), "standard" (the default image) + * or "full" (everything else). A profile includes its own tier and every + * smaller one. A plugin with no tier counts as "full". + * * "defaultLook": true marks the plugin whose layout (and that layout's * theme) a fresh install shows. Core finds the default look this way, so it * never names a plugin. Such a plugin always starts enabled and cannot be @@ -31,6 +36,8 @@ const ID = /^[a-z0-9][a-z0-9-]*$/; const ENV_NAME = /^[A-Z][A-Z0-9_]*$/; const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]); const SHA256 = /^[0-9a-f]{64}$/; +const PROFILES = ["minimal", "standard", "full"]; +const DEFAULT_PROFILE = "standard"; function parseOnboarding(value, id, problems) { if (value === undefined) return null; @@ -122,6 +129,9 @@ function parseBundledPlugins(raw) { ) { problems.push(`${entry.id} defaultLook must be a boolean`); } + if (entry.tier !== undefined && !PROFILES.includes(entry.tier)) { + problems.push(`${entry.id} tier must be one of ${PROFILES.join(", ")}`); + } if (problems.length > before) continue; plugins.push({ id: entry.id, @@ -131,6 +141,7 @@ function parseBundledPlugins(raw) { sha256: entry.sha256, onboarding, defaultLook: entry.defaultLook === true, + tier: entry.tier ?? "full", }); } else { problems.push(`${entry.id} has an unknown source "${entry.source}"`); @@ -241,12 +252,35 @@ function buildBundledIndex(plugins, stagedIds) { return { version: 1, plugins: out }; } +/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */ +function resolveProfile(argv = process.argv, env = process.env) { + const flag = argv.indexOf("--profile"); + const value = + (flag !== -1 ? argv[flag + 1] : env.TERMIX_PLUGIN_PROFILE) || + DEFAULT_PROFILE; + if (!PROFILES.includes(value)) { + throw new Error( + `unknown plugin profile "${value}", use ${PROFILES.join(", ")}`, + ); + } + return value; +} + +/** The plugins a profile ships: its own tier and every smaller one. */ +function pluginsForProfile(plugins, profile) { + const rank = PROFILES.indexOf(profile); + return plugins.filter((plugin) => PROFILES.indexOf(plugin.tier) <= rank); +} + /** * Pins the newest registry version of each wanted plugin. `ids` is the list * to pin, or null for every plugin in the index. `current` is the existing - * pin list, so fields the registry does not know (onboarding) carry over. + * pin list, so fields the registry does not know (onboarding, tier) carry over; + * a plugin new to the list starts in the full tier. coreVersion, when given, + * skips releases whose engine.termix range does not include it, so the image + * never ships a plugin its own core cannot run. */ -function pinsFromIndex(index, ids, current = []) { +function pinsFromIndex(index, ids, current = [], coreVersion = null) { const plugins = Array.isArray(index?.plugins) ? index.plugins : []; const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); const currentById = new Map(current.map((entry) => [entry.id, entry])); @@ -257,15 +291,25 @@ function pinsFromIndex(index, ids, current = []) { const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])] .filter((entry) => semver.valid(entry.version)) .filter((entry) => !semver.prerelease(entry.version)) + .filter( + (entry) => + !coreVersion || + !entry.engine?.termix || + semver.satisfies(coreVersion, entry.engine.termix, { + includePrerelease: true, + }), + ) .sort((a, b) => semver.rcompare(a.version, b.version))[0]; if (!latest) throw new Error(`${id} is not in the registry index`); const onboarding = currentById.get(id)?.onboarding; const defaultLook = currentById.get(id)?.defaultLook === true; + const tier = currentById.get(id)?.tier ?? "full"; return { id, source: "tmxplug", url: latest.url, sha256: latest.sha256, + tier, ...(onboarding ? { onboarding } : {}), ...(defaultLook ? { defaultLook } : {}), }; @@ -409,6 +453,9 @@ function copyLocalBuild(repo, destination) { } module.exports = { + PROFILES, + resolveProfile, + pluginsForProfile, findLocalPluginBuilds, isLocalBuildStale, localSdkBuildCommand, diff --git a/scripts/pin-bundled-plugins.cjs b/scripts/pin-bundled-plugins.cjs index 1f4362a96..5db6dfdb9 100644 --- a/scripts/pin-bundled-plugins.cjs +++ b/scripts/pin-bundled-plugins.cjs @@ -4,7 +4,10 @@ * re-released. * * node scripts/pin-bundled-plugins.cjs refresh the plugins already listed - * node scripts/pin-bundled-plugins.cjs --all list every plugin in the index + * node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full) + * --core-version X skip releases whose engine.termix excludes X + * + * The release workflow runs it with --all and the version being released. */ const fs = require("node:fs"); @@ -22,6 +25,8 @@ const configPath = path.join(root, "docker", "bundled-plugins.json"); async function main() { const all = process.argv.includes("--all"); + const flag = process.argv.indexOf("--core-version"); + const coreVersion = flag !== -1 ? process.argv[flag + 1] : null; const response = await fetch(INDEX_URL); if (!response.ok) { throw new Error(`registry index download failed with ${response.status}`); @@ -30,7 +35,7 @@ async function main() { const current = loadBundledPlugins(root); const ids = all ? null : current.map((plugin) => plugin.id); - const plugins = pinsFromIndex(index, ids, current); + const plugins = pinsFromIndex(index, ids, current, coreVersion); fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`); console.log( `Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`, diff --git a/src/backend/database/routes/look.ts b/src/backend/database/routes/look.ts index c008dc9ed..ff9196ebb 100644 --- a/src/backend/database/routes/look.ts +++ b/src/backend/database/routes/look.ts @@ -18,6 +18,7 @@ import { safeLayoutForced, unavailableLooks, } from "../../plugins/looks.js"; +import { safeModeOn } from "../../plugins/safe-mode.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); @@ -36,7 +37,7 @@ async function adminDefaults() { * /user-preferences/look: * get: * summary: Get the layout and theme to show the current user - * description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these. + * description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, whether TERMIX_SAFE_MODE runs only layout and theme plugins, and installed layout or theme plugins that could not load. The browser resolves the final look from these. * tags: * - User Preferences * responses: @@ -75,6 +76,8 @@ async function adminDefaults() { * nullable: true * safeLayout: * type: boolean + * safeMode: + * type: boolean * unavailable: * type: array * items: @@ -112,6 +115,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { defaults: { ...defaults, layout: known(defaults.layout) }, defaultLook: resolveDefaultLook(loader), safeLayout: safeLayoutForced(), + safeMode: safeModeOn(), unavailable: unavailableLooks(loader), }); } catch (error) { diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 03702851f..63aa1127e 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -16,6 +16,7 @@ import { getPluginRuntime } from "../../plugins/index.js"; import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; import { describePluginFrontend } from "../../plugins/assets.js"; import { isDefaultLookPlugin } from "../../plugins/bundled-index.js"; +import { mayRunInSafeMode } from "../../plugins/safe-mode.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; @@ -249,6 +250,9 @@ export async function listPreLoginPlugins(): Promise< * only run on that server and stay off here on purpose, and, for * admins, `managedByServer` marks plugins whose on/off state the server * decides. + * + * While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps + * off reports `state: "safe-mode"`. * tags: * - Plugins * responses: @@ -354,7 +358,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { name: record.name, version: record.version, enabled: record.state === "enabled", - state: loaded?.state ?? record.state, + state: + record.state === "enabled" && + loaded?.state !== "active" && + !mayRunInSafeMode(record.id, loader.list()) + ? "safe-mode" + : (loaded?.state ?? record.state), runsOnServer: linked && desktopMode === "server", contributes, icon, @@ -936,7 +945,7 @@ router.delete( * 404: * description: No such plugin. * 409: - * description: This desktop is linked to a server, which decides whether the plugin runs. + * description: This desktop is linked to a server, which decides whether the plugin runs, or safe mode is on and the plugin is not a layout or theme. */ router.patch( "/:id/state", @@ -1050,6 +1059,8 @@ router.patch( * description: The caller lacks admin.plugins.manage. * 404: * description: No such plugin, or it is not on disk. + * 409: + * description: Safe mode is on and the plugin is not a layout or theme. * 500: * description: The plugin failed to start again. */ @@ -1073,6 +1084,13 @@ router.post( res.status(404).json({ error: "Plugin is not present on disk" }); return; } + if (!mayRunInSafeMode(pluginId, loader.list())) { + res.status(409).json({ + error: "Safe mode is on, so only layout and theme plugins can start", + code: "SAFE_MODE", + }); + return; + } await repository.update(pluginId, { state: "enabled", diff --git a/src/backend/plugins/boot-state.ts b/src/backend/plugins/boot-state.ts index 334ef3f2e..5b1030d51 100644 --- a/src/backend/plugins/boot-state.ts +++ b/src/backend/plugins/boot-state.ts @@ -14,3 +14,24 @@ export function startablePluginIds( .map((record) => record.id), ); } + +/** + * Plugins that shipped in an earlier image and were left on, but whose code is + * not in this one (a smaller image tag). They get fetched from the registry. + */ +export function missingShippedPluginIds( + records: { id: string; tier: string; state: string }[], + presentIds: ReadonlySet, + ignored: ReadonlySet, +): string[] { + return records + .filter( + (record) => + record.tier === "bundled" && + record.state !== "disabled" && + !presentIds.has(record.id) && + !ignored.has(record.id), + ) + .map((record) => record.id) + .sort(); +} diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index a8dd5bead..4d8c170e5 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -23,6 +23,11 @@ import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js"; import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js"; import { recordConflict } from "./conflicts.js"; import { startablePluginIds } from "./boot-state.js"; +import { + mayRunInSafeMode, + safeModeOn, + safeModePluginIds, +} from "./safe-mode.js"; import { setPluginImpersonationCheck } from "../utils/auth-manager.js"; import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js"; @@ -237,6 +242,10 @@ export async function initializePlugins( if (loaded.length === 0) return []; const fresh = await seedPlugins(loaded, options.freshInstall === true); + if (!fresh) { + const { restoreMissingPlugins } = await import("./manage.js"); + loaded.push(...(await restoreMissingPlugins())); + } await syncCapabilityGrants(loaded); if (loaded.some((plugin) => plugin.source === "bundled")) { const { offerPluginSetup } = await import("./onboarding.js"); @@ -248,8 +257,17 @@ export async function initializePlugins( const records = await createCurrentPluginRepository().listAll(); const enabled = startablePluginIds(records); + const safe = safeModeOn() ? safeModePluginIds(loaded) : null; + if (safe) { + pluginLogger.warn( + "Safe mode is on (TERMIX_SAFE_MODE). Only layout and theme plugins start", + { operation: "plugin_init" }, + ); + } + const candidates = loaded .filter((plugin) => enabled.has(plugin.id)) + .filter((plugin) => !safe || safe.has(plugin.id)) .map((plugin) => plugin.id); for (const pluginId of candidates) { @@ -539,6 +557,8 @@ export async function activatePlugin(pluginId: string): Promise { // Before activate: a plugin's own activate() may already want to check one // of its permissions. const plugin = pluginLoader.get(pluginId); + // Stays enabled and starts on the next boot without safe mode. + if (!mayRunInSafeMode(pluginId, pluginLoader.list())) return; if (plugin) await registerPluginPermissions(plugin); await pluginLoader.activate(pluginId); diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index b87a55cc4..f5ad10c13 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -18,6 +18,7 @@ import path from "node:path"; import semver from "semver"; import { pluginLogger } from "../utils/logger.js"; import { isDefaultLookPlugin } from "./bundled-index.js"; +import { mayRunInSafeMode } from "./safe-mode.js"; import { activatePlugin, deactivatePlugin, @@ -25,6 +26,7 @@ import { registerLoadedPlugins, unloadPlugin, } from "./index.js"; +import { missingShippedPluginIds } from "./boot-state.js"; import type { LoadedPlugin, PluginLoader } from "./loader.js"; import { getBundledPluginsDir, @@ -763,6 +765,13 @@ export async function setPluginStateUnlocked( if (!loader.get(pluginId)) { throw new PluginManageError("Plugin not found", 404, "NOT_FOUND"); } + if (enabled && !mayRunInSafeMode(pluginId, loader.list())) { + throw new PluginManageError( + "Safe mode is on, so only layout and theme plugins can start", + 409, + "SAFE_MODE", + ); + } const plan = await planStateChange(pluginId, enabled); if (enabled) { @@ -959,7 +968,11 @@ export function installPlugin( return serialized(async () => { const { createCurrentPluginRepository } = await repos(); const repository = createCurrentPluginRepository(); - if (await repository.findById(pluginId)) { + // A record with no code is a plugin an older image shipped: install fills it in. + if ( + (await repository.findById(pluginId)) && + getPluginRuntime().loader.get(pluginId) + ) { throw new PluginManageError( "Plugin is already installed", 409, @@ -1018,6 +1031,63 @@ export function installPlugin( }); } +/** + * Called at boot before plugins start. A plugin the database has on but the + * image no longer ships (an update to a smaller image tag) is fetched from the + * official registry, so nothing an install used goes missing. One that cannot + * be fetched stays as it was and is tried again on the next start. + */ +export function restoreMissingPlugins(): Promise { + return serialized(async () => { + const { createCurrentPluginRepository } = await repos(); + const repository = createCurrentPluginRepository(); + const { loader } = getPluginRuntime(); + const ids = missingShippedPluginIds( + await repository.listAll(), + new Set(loader.list().map((plugin) => plugin.id)), + await readUninstalled(), + ); + const restored: LoadedPlugin[] = []; + for (const id of ids) { + try { + const record = await repository.findById(id); + const { source, release } = await requireRelease( + id, + record?.pinnedVersion ?? undefined, + channelOf(record), + OFFICIAL_REGISTRY_ID, + ); + const plugin = await swapIn(id, { release, source }); + await repository.update(id, { + registryId: source.id, + tier: tierFor(plugin, source), + }); + await grantDeclared(plugin, null); + restored.push(plugin); + pluginLogger.info( + `Restored ${id}@${release.version} from the registry`, + { + operation: "plugin_restore_missing", + }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + pluginLogger.warn(`Could not restore ${id}: ${message}`, { + operation: "plugin_restore_missing", + }); + // The registry is down or offline, so the rest would fail the same way. + if ( + error instanceof PluginManageError && + error.code === "REGISTRY_UNREACHABLE" + ) { + break; + } + } + } + return restored; + }); +} + export function updatePlugin( pluginId: string, options: InstallOptions, diff --git a/src/backend/plugins/registry-index.ts b/src/backend/plugins/registry-index.ts index db9433d9b..ef238bec6 100644 --- a/src/backend/plugins/registry-index.ts +++ b/src/backend/plugins/registry-index.ts @@ -12,6 +12,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; +import { isIP } from "node:net"; import path from "node:path"; import semver from "semver"; import { @@ -23,7 +24,11 @@ import { youtubeVideoId, } from "@termix-ssh/plugin-sdk/manifest"; import { getLocalVersion } from "../utils/app-version.js"; -import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js"; +import { + isBlockedAddress, + safeOutboundFetch, +} from "../utils/safe-outbound-fetch.js"; +import { fetchWithProxy, getProxyAgent } from "../utils/proxy-agent.js"; import { pluginLogger } from "../utils/logger.js"; import { COMMUNITY_PLUGIN_KEYS, @@ -278,15 +283,72 @@ export function canRunRelease( ); } +/** + * Hosts the admin lets registry fetches reach on a private address, like a + * registry in the LAN. Matched by host name on any port, also when another + * index or a redirect points there; every other host stays blocked. + */ +export function getRegistryPrivateHosts(): string[] { + return (process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + .map(normalizePrivateHost) + .filter((host): host is string => host !== undefined); +} + +/** + * The host in the form safeOutboundFetch compares against, or undefined when + * the entry carries more than a host, like a scheme, port, path or user. + */ +function normalizePrivateHost(entry: string): string | undefined { + const candidate = isIP(entry) === 6 ? `[${entry}]` : entry; + if (/[/?#@\\]|:\d*$/.test(candidate)) return undefined; + try { + return new URL(`http://${candidate}`).hostname.replace(/^\[|\]$/g, ""); + } catch { + return undefined; + } +} + +/** + * With HTTP_PROXY or HTTPS_PROXY set, registry traffic goes through the proxy, + * which does its own name lookup. Only the URL itself is checked here: web + * pages only, no logins in it, and no private address typed in as a literal. + */ +function fetchThroughProxy( + url: string, + options: RequestInit, + privateHosts: readonly string[], +): Promise { + const parsed = new URL(url); + const host = parsed.hostname.replace(/^\[|\]$/g, ""); + if ( + !["http:", "https:"].includes(parsed.protocol) || + parsed.username || + parsed.password + ) { + throw new Error("Invalid outbound URL"); + } + if (isIP(host) && isBlockedAddress(host) && !privateHosts.includes(host)) { + throw new Error("Private destinations are not allowed"); + } + return fetchWithProxy(url, options); +} + /** Follows redirects by hand so each hop still gets the outbound checks. */ async function fetchFollowing(url: string): Promise { + const privateHosts = getRegistryPrivateHosts(); let current = url; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { - const response = await safeOutboundFetch(current, { + const options: RequestInit = { method: "GET", redirect: "manual", headers: { "User-Agent": "Termix" }, - }); + }; + const response = getProxyAgent(current) + ? await fetchThroughProxy(current, options, privateHosts) + : await safeOutboundFetch(current, options, privateHosts); if (response.status >= 300 && response.status < 400) { const location = response.headers.get("location"); if (!location) throw new Error(`redirect without a location: ${current}`); diff --git a/src/backend/plugins/safe-mode.ts b/src/backend/plugins/safe-mode.ts new file mode 100644 index 000000000..358868194 --- /dev/null +++ b/src/backend/plugins/safe-mode.ts @@ -0,0 +1,42 @@ +import type { PluginManifest } from "./manifest.js"; + +/** TERMIX_SAFE_MODE=1 starts only the plugins that draw the app. */ +export function safeModeOn(): boolean { + const value = process.env.TERMIX_SAFE_MODE?.trim().toLowerCase(); + return value === "1" || value === "true"; +} + +function isLookPlugin(manifest: PluginManifest): boolean { + return ( + (manifest.contributes?.layouts?.length ?? 0) > 0 || + (manifest.contributes?.themes?.length ?? 0) > 0 + ); +} + +/** Layout and theme plugins, plus the plugins they need to start. */ +export function safeModePluginIds( + plugins: { id: string; manifest: PluginManifest }[], +): Set { + const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); + const allowed = new Set(); + const add = (id: string) => { + const plugin = byId.get(id); + if (!plugin || allowed.has(id)) return; + allowed.add(id); + for (const dependency of Object.keys(plugin.manifest.dependencies ?? {})) { + add(dependency); + } + }; + for (const plugin of plugins) { + if (isLookPlugin(plugin.manifest)) add(plugin.id); + } + return allowed; +} + +/** Whether a plugin may run now. Always true outside safe mode. */ +export function mayRunInSafeMode( + pluginId: string, + plugins: { id: string; manifest: PluginManifest }[], +): boolean { + return !safeModeOn() || safeModePluginIds(plugins).has(pluginId); +} diff --git a/src/backend/sync/shared-copy-guard.ts b/src/backend/sync/shared-copy-guard.ts index 5fc2b7270..cc218afd6 100644 --- a/src/backend/sync/shared-copy-guard.ts +++ b/src/backend/sync/shared-copy-guard.ts @@ -7,6 +7,8 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory * On a linked desktop, hosts and credentials someone shared with the account * are read-only copies kept in step by sync. Editing or deleting one here * would be undone by the next sync, so it is refused with a clear reason. + * A host's connection origin only means something on this device, so that one + * field can still be changed. */ export function rejectSharedCopyWrites( kind: "host" | "credential", @@ -25,6 +27,19 @@ export function rejectSharedCopyWrites( .where(eq(table.id, Number(match[1]))) .limit(1); if (row?.sharedSource) { + const origin = req.body?.connectionOrigin; + if ( + kind === "host" && + req.method === "PUT" && + (origin === "local" || origin === "remote") + ) { + await createCurrentRepositoryContext() + .drizzle.update(hosts) + .set({ connectionOrigin: origin }) + .where(eq(hosts.id, Number(match[1]))); + res.json({ id: Number(match[1]), connectionOrigin: origin }); + return; + } res.status(403).json({ error: "This was shared with you and is managed on the server", code: "SHARED_COPY", diff --git a/src/backend/tests/database/routes/look.test.ts b/src/backend/tests/database/routes/look.test.ts index bc7432564..c2ee44a4d 100644 --- a/src/backend/tests/database/routes/look.test.ts +++ b/src/backend/tests/database/routes/look.test.ts @@ -102,6 +102,7 @@ beforeEach(async () => { state.defaultLookIds = []; state.installed = []; delete process.env.TERMIX_SAFE_LAYOUT; + delete process.env.TERMIX_SAFE_MODE; const app = express(); app.use(express.json()); @@ -113,6 +114,7 @@ beforeEach(async () => { afterEach(async () => { delete process.env.TERMIX_SAFE_LAYOUT; + delete process.env.TERMIX_SAFE_MODE; await new Promise((resolve) => server.close(() => resolve())); }); @@ -138,6 +140,7 @@ describe("GET /user-preferences/look", () => { defaults: { layout: null, theme: null }, defaultLook: { layout: null, theme: null }, safeLayout: false, + safeMode: false, unavailable: [], }); }); @@ -159,6 +162,7 @@ describe("GET /user-preferences/look", () => { { id: "other", version: null, layouts: [], themes: [], error: "y" }, ]; process.env.TERMIX_SAFE_LAYOUT = "1"; + process.env.TERMIX_SAFE_MODE = "1"; const { body } = await getLook(); expect(body).toMatchObject({ @@ -167,6 +171,7 @@ describe("GET /user-preferences/look", () => { defaults: { layout: "admin-layout", theme: "gruvbox" }, defaultLook: { layout: "shipped", theme: "ink" }, safeLayout: true, + safeMode: true, }); expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual( ["broken"], diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index 74de8949a..9e684bdf4 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -488,6 +488,26 @@ describe("plugins route", () => { } }); + it("reports an enabled plugin that safe mode keeps off", async () => { + state.plugins.set("sample-plugin", makePlugin({ state: "enabled" })); + + const read = async () => + ( + (await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{ + state: string; + enabled: boolean; + }> + )[0]; + + expect((await read()).state).not.toBe("safe-mode"); + process.env.TERMIX_SAFE_MODE = "1"; + try { + expect(await read()).toMatchObject({ state: "safe-mode", enabled: true }); + } finally { + delete process.env.TERMIX_SAFE_MODE; + } + }); + it("grants a declared capability", async () => { state.plugins.set("sample-plugin", makePlugin()); diff --git a/src/backend/tests/plugins/boot-state.test.ts b/src/backend/tests/plugins/boot-state.test.ts index 9b23d8081..43343a157 100644 --- a/src/backend/tests/plugins/boot-state.test.ts +++ b/src/backend/tests/plugins/boot-state.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { startablePluginIds } from "../../plugins/boot-state.js"; +import { + missingShippedPluginIds, + startablePluginIds, +} from "../../plugins/boot-state.js"; describe("startablePluginIds", () => { it("starts enabled plugins and retries ones that failed or were blocked", () => { @@ -13,3 +16,25 @@ describe("startablePluginIds", () => { expect([...ids].sort()).toEqual(["a", "b", "c"]); }); }); + +describe("missingShippedPluginIds", () => { + const records = [ + { id: "gone", tier: "bundled", state: "enabled" }, + { id: "here", tier: "bundled", state: "enabled" }, + { id: "off", tier: "bundled", state: "disabled" }, + { id: "failed", tier: "bundled", state: "failed" }, + { id: "community", tier: "community", state: "enabled" }, + { id: "removed", tier: "bundled", state: "enabled" }, + ]; + + it("lists shipped plugins that are on but not in the image", () => { + expect( + missingShippedPluginIds(records, new Set(["here"]), new Set(["removed"])), + ).toEqual(["failed", "gone"]); + }); + + it("finds nothing when every plugin is present", () => { + const present = new Set(records.map((record) => record.id)); + expect(missingShippedPluginIds(records, present, new Set())).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/manage.test.ts b/src/backend/tests/plugins/manage.test.ts index 8f1fe9a34..a1a4ce826 100644 --- a/src/backend/tests/plugins/manage.test.ts +++ b/src/backend/tests/plugins/manage.test.ts @@ -621,6 +621,26 @@ describe("dependencies", () => { }); }); +describe("safe mode", () => { + afterEach(() => { + delete process.env.TERMIX_SAFE_MODE; + }); + + it("refuses to start a plugin that is not a layout or theme, but still stops one", async () => { + createFixturePlugin({ id: "base", root: bundled }); + const loader = await boot(); + await manage.setPluginState("base", false); + + process.env.TERMIX_SAFE_MODE = "1"; + await expect(manage.setPluginState("base", true)).rejects.toMatchObject({ + status: 409, + code: "SAFE_MODE", + }); + expect(loader.get("base")?.state).toBe("stopped"); + expect(db.rows.get("base")?.state).toBe("disabled"); + }); +}); + describe("reloading a bundled plugin for the dev runner", () => { it("loads the rebuilt copy and restarts what depends on it", async () => { createFixturePlugin({ id: "base", root: bundled }); diff --git a/src/backend/tests/plugins/registry-index.test.ts b/src/backend/tests/plugins/registry-index.test.ts index d196ec898..27abc3a35 100644 --- a/src/backend/tests/plugins/registry-index.test.ts +++ b/src/backend/tests/plugins/registry-index.test.ts @@ -8,10 +8,23 @@ import * as tar from "tar"; const fetchMock = vi.hoisted(() => vi.fn()); const testKey = vi.hoisted(() => ({ value: null as null | object })); -vi.mock("../../utils/safe-outbound-fetch.js", () => ({ +vi.mock("../../utils/safe-outbound-fetch.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../utils/safe-outbound-fetch.js") + >()), safeOutboundFetch: fetchMock, })); +const proxyMock = vi.hoisted(() => ({ + on: false, + fetch: vi.fn(), +})); + +vi.mock("../../utils/proxy-agent.js", () => ({ + getProxyAgent: () => (proxyMock.on ? {} : undefined), + fetchWithProxy: proxyMock.fetch, +})); + vi.mock("../../utils/app-version.js", () => ({ getLocalVersion: () => "26.10.3", })); @@ -43,9 +56,11 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => { import { communitySource, downloadRelease, + fetchIndexAt, fetchRegistryIndex, fetchRegistryStats, findRelease, + getRegistryPrivateHosts, latestOnChannel, getStatsUrl, canRunRelease, @@ -409,6 +424,91 @@ describe("fetchRegistryIndex", () => { }); }); +describe("registry fetches with a proxy", () => { + afterEach(() => { + proxyMock.on = false; + proxyMock.fetch.mockReset(); + }); + + it("goes through the proxy when one is configured", async () => { + proxyMock.on = true; + proxyMock.fetch.mockResolvedValueOnce( + response(JSON.stringify({ plugins: [] })), + ); + await fetchIndexAt("https://registry.example/index.json"); + expect(proxyMock.fetch).toHaveBeenCalledTimes(1); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("still refuses a private address typed in as a literal", async () => { + proxyMock.on = true; + await expect(fetchIndexAt("https://127.0.0.1/index.json")).rejects.toThrow( + "Private destinations are not allowed", + ); + expect(proxyMock.fetch).not.toHaveBeenCalled(); + }); +}); + +describe("private registry hosts", () => { + afterEach(() => { + delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS; + }); + + it("reads a comma separated list of host names", () => { + process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = + " GitLab.lan ,, 192.168.1.5 "; + expect(getRegistryPrivateHosts()).toEqual(["gitlab.lan", "192.168.1.5"]); + delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS; + expect(getRegistryPrivateHosts()).toEqual([]); + }); + + it("brings each entry into the form the fetch compares against", () => { + process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = + "GitLab.LAN,[FD00::0001],fd00::2,bücher.lan"; + expect(getRegistryPrivateHosts()).toEqual([ + "gitlab.lan", + "fd00::1", + "fd00::2", + "xn--bcher-kva.lan", + ]); + }); + + it("drops entries with more than a host", () => { + process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = + "gitlab.lan:8443,https://git.lan,[fd00::1]:80,nas.lan"; + expect(getRegistryPrivateHosts()).toEqual(["nas.lan"]); + }); + + it("allows the listed hosts on every hop", async () => { + process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan"; + fetchMock + .mockResolvedValueOnce( + response("", 302, { location: "https://gitlab.lan/raw/index.json" }), + ) + .mockResolvedValueOnce(response(JSON.stringify({ plugins: [] }))); + await fetchIndexAt("https://gitlab.lan/index.json"); + expect(fetchMock.mock.calls.map((call) => call[2])).toEqual([ + ["gitlab.lan"], + ["gitlab.lan"], + ]); + }); + + it("still blocks a redirect to a private host that is not listed", async () => { + process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan"; + const actual = await vi.importActual< + typeof import("../../utils/safe-outbound-fetch.js") + >("../../utils/safe-outbound-fetch.js"); + fetchMock + .mockResolvedValueOnce( + response("", 302, { location: "https://127.0.0.1/index.json" }), + ) + .mockImplementationOnce(actual.safeOutboundFetch); + await expect(fetchIndexAt("https://gitlab.lan/index.json")).rejects.toThrow( + "Private destinations are not allowed", + ); + }); +}); + /** A real .tmxplug holding only a manifest, enough for the checks. */ function pack(manifest: Record): Buffer { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-")); diff --git a/src/backend/tests/plugins/safe-mode.test.ts b/src/backend/tests/plugins/safe-mode.test.ts new file mode 100644 index 000000000..dacf0dca3 --- /dev/null +++ b/src/backend/tests/plugins/safe-mode.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + mayRunInSafeMode, + safeModeOn, + safeModePluginIds, +} from "../../plugins/safe-mode.js"; +import type { PluginManifest } from "../../plugins/manifest.js"; + +function plugin(id: string, manifest: Partial = {}) { + return { id, manifest: { id, ...manifest } as PluginManifest }; +} + +const plugins = [ + plugin("layout", { + contributes: { layouts: [{ id: "classic" }] }, + dependencies: { helper: "^1.0.0" }, + } as Partial), + plugin("themes", { + contributes: { themes: [{ id: "dark" }] }, + } as Partial), + plugin("helper"), + plugin("docker"), +]; + +afterEach(() => { + delete process.env.TERMIX_SAFE_MODE; +}); + +describe("safeModeOn", () => { + it("reads 1 and true, anything else is off", () => { + expect(safeModeOn()).toBe(false); + process.env.TERMIX_SAFE_MODE = "1"; + expect(safeModeOn()).toBe(true); + process.env.TERMIX_SAFE_MODE = " TRUE "; + expect(safeModeOn()).toBe(true); + process.env.TERMIX_SAFE_MODE = "0"; + expect(safeModeOn()).toBe(false); + }); +}); + +describe("safeModePluginIds", () => { + it("keeps layout and theme plugins and what they depend on", () => { + expect([...safeModePluginIds(plugins)].sort()).toEqual([ + "helper", + "layout", + "themes", + ]); + }); +}); + +describe("mayRunInSafeMode", () => { + it("lets every plugin run when safe mode is off", () => { + expect(mayRunInSafeMode("docker", plugins)).toBe(true); + }); + + it("only lets look plugins run when safe mode is on", () => { + process.env.TERMIX_SAFE_MODE = "1"; + expect(mayRunInSafeMode("docker", plugins)).toBe(false); + expect(mayRunInSafeMode("layout", plugins)).toBe(true); + expect(mayRunInSafeMode("helper", plugins)).toBe(true); + }); +}); diff --git a/src/backend/tests/sync/shared-copy-guard.test.ts b/src/backend/tests/sync/shared-copy-guard.test.ts new file mode 100644 index 000000000..cf6130dd5 --- /dev/null +++ b/src/backend/tests/sync/shared-copy-guard.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + row: { sharedSource: '{"owner":"x"}' } as { sharedSource: string | null }, + updates: [] as unknown[], +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentRepositoryContext: () => ({ + drizzle: { + select: () => ({ + from: () => ({ where: () => ({ limit: async () => [h.row] }) }), + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + h.updates.push(values); + }, + }), + }), + }, + }), +})); + +const { rejectSharedCopyWrites } = + await import("../../sync/shared-copy-guard.js"); + +function run(body: Record) { + const guard = rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/); + const res = { + code: 0, + payload: undefined as unknown, + status(code: number) { + this.code = code; + return this; + }, + json(payload: unknown) { + this.payload = payload; + return this; + }, + }; + const next = vi.fn(); + return guard( + { method: "PUT", path: "/db/host/5", body } as never, + res as never, + next, + ).then(() => ({ res, next })); +} + +beforeEach(() => { + process.env.ELECTRON_EMBEDDED = "true"; + h.row = { sharedSource: '{"owner":"x"}' }; + h.updates = []; +}); + +describe("shared copy guard", () => { + it("refuses edits to a shared copy", async () => { + const { res, next } = await run({ name: "new" }); + expect(res.code).toBe(403); + expect(next).not.toHaveBeenCalled(); + expect(h.updates).toEqual([]); + }); + + it("still lets the connection origin change on this device", async () => { + const { res, next } = await run({ name: "new", connectionOrigin: "local" }); + expect(res.code).toBe(0); + expect(res.payload).toMatchObject({ connectionOrigin: "local" }); + expect(next).not.toHaveBeenCalled(); + expect(h.updates).toEqual([{ connectionOrigin: "local" }]); + }); + + it("passes hosts that are not shared copies", async () => { + h.row = { sharedSource: null }; + const { next } = await run({ name: "new" }); + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/api/look-api.ts b/src/ui/api/look-api.ts index a2ae0a202..c532bed5e 100644 --- a/src/ui/api/look-api.ts +++ b/src/ui/api/look-api.ts @@ -19,6 +19,8 @@ export interface LookResponse { defaultLook: { layout: string | null; theme: string | null }; /** TERMIX_SAFE_LAYOUT is set on the server. */ safeLayout: boolean; + /** TERMIX_SAFE_MODE is set: only layout and theme plugins run. */ + safeMode?: boolean; unavailable: UnavailableLook[]; } diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 2a102ab07..2bec1eaa1 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -237,7 +237,7 @@ export interface PluginSummary { name: string; version: string; enabled: boolean; - /** enabled | disabled | blocked | failed, or the loader's live state. */ + /** enabled | disabled | blocked | failed | safe-mode, or the loader's live state. */ state: string; /** A linked desktop leaves this plugin to its server. */ runsOnServer?: boolean; diff --git a/src/ui/components/data-view.tsx b/src/ui/components/data-view.tsx index e2dac1fa6..d61f65625 100644 --- a/src/ui/components/data-view.tsx +++ b/src/ui/components/data-view.tsx @@ -2,6 +2,8 @@ import type React from "react"; import { cn } from "@/lib/utils"; import { PANEL, + gridSizeStyle, + type GridSize, type PanelDensity, type PanelViewMode, } from "@/components/panel-layout"; @@ -67,6 +69,8 @@ export function DataView({ getKey, renderCard, columns, + size, + minCardWidth, listColumns, onRowClick, isRowActive, @@ -78,7 +82,12 @@ export function DataView({ density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => React.ReactNode; + /** Breakpoint columns, used when no size is given. */ columns: GridColumns; + /** Columns the user picked with ViewToggle. Wins over columns. */ + size?: GridSize; + /** Narrowest card in auto mode, in px. */ + minCardWidth?: number; listColumns: DataColumn[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; @@ -93,9 +102,19 @@ export function DataView({ if (view === "grid") { return ( -
+
{items.map((item) => ( -
{renderCard(item)}
+
+ {renderCard(item)} +
))}
); diff --git a/src/ui/components/panel-layout.tsx b/src/ui/components/panel-layout.tsx index fe1b99c50..de28a1905 100644 --- a/src/ui/components/panel-layout.tsx +++ b/src/ui/components/panel-layout.tsx @@ -1,11 +1,26 @@ /* eslint-disable react-refresh/only-export-components */ import { Children, Fragment, isValidElement, useRef } from "react"; import type React from "react"; -import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react"; +import { + ArrowLeft, + Columns3, + Grid3X3, + List, + Rows3, + Search, + X, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/separator"; import { DocsLink } from "@/components/docs-link"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/select"; /** One spacing rhythm for every panel and tab. */ export const PANEL = { @@ -19,6 +34,37 @@ export const PANEL = { export type PanelViewMode = "grid" | "list"; export type PanelDensity = "comfortable" | "compact"; +/** Cards per row. "auto" fits as many as the pane has room for. */ +export type GridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6"; + +export const GRID_SIZES: readonly GridSize[] = [ + "auto", + "1", + "2", + "3", + "4", + "5", + "6", +]; + +// Sized off the pane's own width, not the window. Cards keep a min width, +// so narrow panes drop columns on their own. +export function gridSizeStyle( + size: GridSize, + minCardWidth = 320, + gap = "0.5rem", +): React.CSSProperties { + if (size === "auto") { + return { + gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`, + }; + } + const n = Number(size); + return { + gridTemplateColumns: `repeat(auto-fill, minmax(max(min(100%, 220px), calc((100% - ${n - 1} * ${gap}) / ${n} - 0.5px)), 1fr))`, + }; +} + /** The header, toolbar band and body every tab and panel draws. */ export function PanelShell({ chrome = true, @@ -308,20 +354,25 @@ export function Segmented({ ); } -/** Grid or list, plus the compact rows toggle. */ +/** Grid or list, plus the compact rows toggle and the cards per row picker. */ export function ViewToggle({ view, onView, density, onDensity, + columns, + onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; + /** Cards per row in grid view. Pass with onColumns to show the picker. */ + columns?: GridSize; + onColumns?: (next: GridSize) => void; }) { const { t } = useTranslation(); - return ( + const toggle = (
); + + if (!columns || !onColumns || view !== "grid") return toggle; + + return ( +
+ + {toggle} +
+ ); } function ToggleButton({ diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 8bbfc961b..0db4ad63a 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} is ready to install", "readyHint": "It installs the next time Termix restarts." }, + "safeMode": { + "title": "Safe mode", + "description": "Only layout and theme plugins are running. Restart the server without TERMIX_SAFE_MODE to turn the rest back on." + }, "companion": { "close": "Close panel", "moveSide": "Move panel", @@ -1311,7 +1315,8 @@ "disabled": "Disabled", "failed": "Stopped unexpectedly", "blocked": "Waiting on another plugin", - "server": "Runs on your server" + "server": "Runs on your server", + "safeMode": "Off in safe mode" }, "groups": { "running": "Running", @@ -1325,11 +1330,6 @@ "updated": "Recently updated", "category": "Category" }, - "columns": { - "title": "Columns", - "auto": "Auto", - "count": "{{count}} wide" - }, "table": { "name": "Name", "description": "Description", @@ -1654,7 +1654,12 @@ "gridView": "Grid", "listView": "List", "compactRows": "Compact rows", - "comfortableRows": "Comfortable rows" + "comfortableRows": "Comfortable rows", + "columns": { + "title": "Columns", + "auto": "Auto", + "count": "{{count}} wide" + } }, "fields": { "secretSaved": "Saved. Type to replace it." diff --git a/src/ui/locales/translated/af_ZA.json b/src/ui/locales/translated/af_ZA.json index ed6559e81..39c1454f6 100644 --- a/src/ui/locales/translated/af_ZA.json +++ b/src/ui/locales/translated/af_ZA.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} is gereed om te installeer", "readyHint": "Dit word die volgende keer wat Termix herbegin geïnstalleer." }, + "safeMode": { + "title": "Veilige modus", + "description": "Slegs uitleg- en tema-plug-ins word uitgevoer. Herbegin die bediener sonder TERMIX_SAFE_MODE om die res weer aan te skakel." + }, "companion": { "close": "Maak paneel toe", "moveSide": "Skuif paneel", @@ -1098,6 +1102,8 @@ "resetEveryone": "Stel die verstek vir almal terug", "update": "Dateer {{name}} op na {{version}}", "disable": "Skakel {{name}} af", + "enable": "Skakel {{name}} aan", + "startAgain": "Begin {{name}} weer", "install": "Vind lêe en temas", "installTitle": "Lêe en temas", "installButton": "Installeer", @@ -1309,7 +1315,8 @@ "disabled": "Afgeskakel", "failed": "Onverwags gestop", "blocked": "Wag op 'n ander plugin", - "server": "Loop op jou bediener" + "server": "Loop op jou bediener", + "safeMode": "Af in veilige modus" }, "groups": { "running": "Aktief", @@ -1323,11 +1330,6 @@ "updated": "Onlangs opgedateer", "category": "Kategorie" }, - "columns": { - "title": "Kolomme", - "auto": "Outo", - "count": "{{count}} breed" - }, "table": { "name": "Naam", "description": "Beskrywing", @@ -1652,7 +1654,12 @@ "gridView": "Rooster", "listView": "Lys", "compactRows": "Kompakte rye", - "comfortableRows": "Gerieflike rye" + "comfortableRows": "Gerieflike rye", + "columns": { + "title": "Kolomme", + "auto": "Outo", + "count": "{{count}} breed" + } }, "fields": { "secretSaved": "Opgeslaan. Tik om dit te vervang." diff --git a/src/ui/locales/translated/ar_SA.json b/src/ui/locales/translated/ar_SA.json index 2fe907aa5..13d8315b3 100644 --- a/src/ui/locales/translated/ar_SA.json +++ b/src/ui/locales/translated/ar_SA.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} جاهز للتثبيت", "readyHint": "سيتم تثبيته في المرة القادمة التي يعاد فيها تشغيل Termix." }, + "safeMode": { + "title": "الوضع الآمن", + "description": "لا تعمل سوى إضافات التخطيط والمظهر. أعد تشغيل الخادم بدون TERMIX_SAFE_MODE لإعادة تشغيل الباقي." + }, "companion": { "close": "إغلاق اللوحة", "moveSide": "نقل اللوحة", @@ -1166,6 +1170,8 @@ "resetEveryone": "إعادة تعيين الافتراضي للجميع", "update": "تحديث {{name}} إلى {{version}}", "disable": "إيقاف {{name}}", + "enable": "تفعيل {{name}}", + "startAgain": "إعادة تشغيل {{name}}", "install": "البحث عن التخطيطات والسمات", "installTitle": "التخطيطات والسمات", "installButton": "تثبيت", @@ -1377,7 +1383,8 @@ "disabled": "معطلة", "failed": "توقفت بشكل غير متوقع", "blocked": "في انتظار إضافة أخرى", - "server": "يعمل على خادمك" + "server": "يعمل على خادمك", + "safeMode": "معطل في الوضع الآمن" }, "groups": { "running": "تعمل", @@ -1391,11 +1398,6 @@ "updated": "تم تحديثها مؤخراً", "category": "الفئة" }, - "columns": { - "title": "الأعمدة", - "auto": "تلقائي", - "count": "عرض {{count}}" - }, "table": { "name": "الاسم", "description": "الوصف", @@ -1820,7 +1822,12 @@ "gridView": "شبكة", "listView": "قائمة", "compactRows": "صفوف مدمجة", - "comfortableRows": "صفوف مريحة" + "comfortableRows": "صفوف مريحة", + "columns": { + "title": "الأعمدة", + "auto": "تلقائي", + "count": "عرض {{count}}" + } }, "fields": { "secretSaved": "تم الحفظ. اكتب لاستبداله." diff --git a/src/ui/locales/translated/bg_BG.json b/src/ui/locales/translated/bg_BG.json index 861aa59f5..443b1c0fb 100644 --- a/src/ui/locales/translated/bg_BG.json +++ b/src/ui/locales/translated/bg_BG.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} е готов за инсталация", "readyHint": "Ще се инсталира следващия път, когато Termix се рестартира." }, + "safeMode": { + "title": "Режим на безопасност", + "description": "Само плъгините за подредба и тема са активни. Рестартирайте сървъра без променливата TERMIX_SAFE_MODE, за да активирате останалите." + }, "companion": { "close": "Затвори панела", "moveSide": "Премести панела", @@ -1098,6 +1102,8 @@ "resetEveryone": "Върни настройките по подразбиране за всички", "update": "Актуализирай {{name}} до {{version}}", "disable": "Изключи {{name}}", + "enable": "Активиране на {{name}}", + "startAgain": "Стартиране на {{name}} отново", "install": "Намери подредби и теми", "installTitle": "Подредби и теми", "installButton": "Инсталирай", @@ -1309,7 +1315,8 @@ "disabled": "Изключена", "failed": "Спряна неочаквано", "blocked": "Чака друга добавка", - "server": "Работи на вашия сървър" + "server": "Работи на вашия сървър", + "safeMode": "Изключен в режим на безопасност" }, "groups": { "running": "Работещи", @@ -1323,11 +1330,6 @@ "updated": "Наскоро актуализирани", "category": "Категория" }, - "columns": { - "title": "Колони", - "auto": "Автоматично", - "count": "{{count}} колони" - }, "table": { "name": "Име", "description": "Описание", @@ -1652,7 +1654,12 @@ "gridView": "Мрежа", "listView": "Списък", "compactRows": "Компактни редове", - "comfortableRows": "Комфортни редове" + "comfortableRows": "Комфортни редове", + "columns": { + "title": "Колони", + "auto": "Автоматично", + "count": "ширина {{count}}" + } }, "fields": { "secretSaved": "Запазено. Въведете ново, за да го замените." diff --git a/src/ui/locales/translated/bn_BD.json b/src/ui/locales/translated/bn_BD.json index 4b4493b29..8c8d5cfa2 100644 --- a/src/ui/locales/translated/bn_BD.json +++ b/src/ui/locales/translated/bn_BD.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} ইনস্টল করার জন্য প্রস্তুত", "readyHint": "এটি পরবর্তীবার Termix রিস্টার্ট করলে ইনস্টল হবে।" }, + "safeMode": { + "title": "সেফ মোড", + "description": "শুধুমাত্র লেআউট এবং থিম প্লাগইন চলছে। বাকিগুলো আবার চালু করতে TERMIX_SAFE_MODE ছাড়া সার্ভারটি রিস্টার্ট করুন।" + }, "companion": { "close": "প্যানেল বন্ধ করুন", "moveSide": "প্যানেল সরান", @@ -1098,6 +1102,8 @@ "resetEveryone": "সবার জন্য ডিফল্ট রিসেট করুন", "update": "{{name}} কে {{version}} এ আপডেট করুন", "disable": "{{name}} বন্ধ করুন", + "enable": "{{name}} চালু করুন", + "startAgain": "{{name}} আবার শুরু করুন", "install": "লেআউট এবং থিম খুঁজুন", "installTitle": "লেআউট এবং থিম", "installButton": "ইনস্টল করুন", @@ -1309,7 +1315,8 @@ "disabled": "নিষ্ক্রিয়", "failed": "অপ্রত্যাশিতভাবে থেমে গেছে", "blocked": "অন্য প্লাগইনের জন্য অপেক্ষা করছে", - "server": "আপনার সার্ভারে চলে" + "server": "আপনার সার্ভারে চলে", + "safeMode": "সেফ মোডে বন্ধ" }, "groups": { "running": "চলমান", @@ -1323,11 +1330,6 @@ "updated": "সাম্প্রতিক আপডেট", "category": "ক্যাটাগরি" }, - "columns": { - "title": "কলাম", - "auto": "স্বয়ংক্রিয়", - "count": "{{count}} প্রশস্ত" - }, "table": { "name": "নাম", "description": "বিবরণ", @@ -1652,7 +1654,12 @@ "gridView": "গ্রিড", "listView": "তালিকা", "compactRows": "কমপ্যাক্ট সারি", - "comfortableRows": "আরামদায়ক সারি" + "comfortableRows": "আরামদায়ক সারি", + "columns": { + "title": "কলাম", + "auto": "স্বয়ংক্রিয়", + "count": "{{count}} প্রশস্ত" + } }, "fields": { "secretSaved": "সংরক্ষিত হয়েছে। এটি প্রতিস্থাপন করতে টাইপ করুন।" diff --git a/src/ui/locales/translated/ca_ES.json b/src/ui/locales/translated/ca_ES.json index fc87aa0bb..e819e04cc 100644 --- a/src/ui/locales/translated/ca_ES.json +++ b/src/ui/locales/translated/ca_ES.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} està llest per instal·lar-se", "readyHint": "S'instal·larà la propera vegada que reiniciïs Termix." }, + "safeMode": { + "title": "Mode segur", + "description": "Només s'estan executant els complements de disseny i tema. Reinicia el servidor sense TERMIX_SAFE_MODE per activar la resta." + }, "companion": { "close": "Tanca el panell", "moveSide": "Mou el panell", @@ -1115,6 +1119,8 @@ "resetEveryone": "Restableix el predeterminat per a tothom", "update": "Actualitza {{name}} a {{version}}", "disable": "Desactiva {{name}}", + "enable": "Activar {{name}}", + "startAgain": "Reiniciar {{name}}", "install": "Cerca dissenys i temes", "installTitle": "Disseny i temes", "installButton": "Instal·la", @@ -1326,7 +1332,8 @@ "disabled": "Desactivat", "failed": "Aturat inesperadament", "blocked": "Esperant un altre plugin", - "server": "S'executa al teu servidor" + "server": "S'executa al teu servidor", + "safeMode": "Desactivat en mode segur" }, "groups": { "running": "En execució", @@ -1340,11 +1347,6 @@ "updated": "Actualitzats recentment", "category": "Categoria" }, - "columns": { - "title": "Columnes", - "auto": "Automàtic", - "count": "{{count}} d'amplada" - }, "table": { "name": "Nom", "description": "Descripció", @@ -1694,7 +1696,12 @@ "gridView": "Graella", "listView": "Llista", "compactRows": "Files compactes", - "comfortableRows": "Files còmodes" + "comfortableRows": "Files còmodes", + "columns": { + "title": "Columnes", + "auto": "Automàtic", + "count": "{{count}} d'ample" + } }, "fields": { "secretSaved": "Desat. Escriu per substituir-lo." diff --git a/src/ui/locales/translated/cs_CZ.json b/src/ui/locales/translated/cs_CZ.json index 45d769d42..b79517e45 100644 --- a/src/ui/locales/translated/cs_CZ.json +++ b/src/ui/locales/translated/cs_CZ.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} je připraven k instalaci", "readyHint": "Nainstaluje se při příštím restartu Termixu." }, + "safeMode": { + "title": "Režim nouzového spuštění", + "description": "Spouštějí se pouze zásuvné moduly rozložení a motivu. Restartujte server bez proměnné TERMIX_SAFE_MODE, abyste znovu povolili zbytek." + }, "companion": { "close": "Zavřít panel", "moveSide": "Přesunout panel", @@ -1132,6 +1136,8 @@ "resetEveryone": "Obnovit výchozí nastavení pro všechny", "update": "Aktualizovat {{name}} na {{version}}", "disable": "Vypnout {{name}}", + "enable": "Zapnout {{name}}", + "startAgain": "Spustit {{name}} znovu", "install": "Hledat rozložení a motivy", "installTitle": "Rozložení a motivy", "installButton": "Instalovat", @@ -1343,7 +1349,8 @@ "disabled": "Zakázáno", "failed": "Neočekávaně zastaveno", "blocked": "Čeká na jiný plugin", - "server": "Běží na vašem serveru" + "server": "Běží na vašem serveru", + "safeMode": "Vypnuto v režimu nouzového spuštění" }, "groups": { "running": "Spuštěné", @@ -1357,11 +1364,6 @@ "updated": "Nedávno aktualizované", "category": "Kategorie" }, - "columns": { - "title": "Sloupce", - "auto": "Automaticky", - "count": "Šířka {{count}}" - }, "table": { "name": "Název", "description": "Popis", @@ -1736,7 +1738,12 @@ "gridView": "Mřížka", "listView": "Seznam", "compactRows": "Kompaktní řádky", - "comfortableRows": "Pohodlné řádky" + "comfortableRows": "Pohodlné řádky", + "columns": { + "title": "Sloupce", + "auto": "Automaticky", + "count": "šířka {{count}}" + } }, "fields": { "secretSaved": "Uloženo. Napište pro nahrazení." diff --git a/src/ui/locales/translated/da_DK.json b/src/ui/locales/translated/da_DK.json index c493020c7..0b0187232 100644 --- a/src/ui/locales/translated/da_DK.json +++ b/src/ui/locales/translated/da_DK.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} er klar til installation", "readyHint": "Den installeres næste gang Termix genstartes." }, + "safeMode": { + "title": "Sikker tilstand", + "description": "Kun layout- og tema-plugins kører. Genstart serveren uden TERMIX_SAFE_MODE for at aktivere de øvrige plugins igen." + }, "companion": { "close": "Luk panel", "moveSide": "Flyt panel", @@ -1098,6 +1102,8 @@ "resetEveryone": "Nulstil standardindstillingen for alle", "update": "Opdater {{name}} til {{version}}", "disable": "Sluk {{name}}", + "enable": "Aktivér {{name}}", + "startAgain": "Start {{name}} igen", "install": "Find layouts og temaer", "installTitle": "Layouts og temaer", "installButton": "Installer", @@ -1309,7 +1315,8 @@ "disabled": "Deaktiveret", "failed": "Stoppet uventet", "blocked": "Venter på et andet plugin", - "server": "Kører på din server" + "server": "Kører på din server", + "safeMode": "Deaktiveret i sikker tilstand" }, "groups": { "running": "Kører", @@ -1323,11 +1330,6 @@ "updated": "Nyligt opdateret", "category": "Kategori" }, - "columns": { - "title": "Kolonner", - "auto": "Auto", - "count": "{{count}} bred" - }, "table": { "name": "Navn", "description": "Beskrivelse", @@ -1652,7 +1654,12 @@ "gridView": "Gitter", "listView": "Liste", "compactRows": "Kompakte rækker", - "comfortableRows": "Komfortable rækker" + "comfortableRows": "Komfortable rækker", + "columns": { + "title": "Søjler", + "auto": "Automatisk", + "count": "{{count}} bred" + } }, "fields": { "secretSaved": "Gemt. Skriv for at erstatte det." diff --git a/src/ui/locales/translated/de_DE.json b/src/ui/locales/translated/de_DE.json index 1b2c8c0cf..75757db1b 100644 --- a/src/ui/locales/translated/de_DE.json +++ b/src/ui/locales/translated/de_DE.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} ist bereit zur Installation", "readyHint": "Es wird beim nächsten Neustart von Termix installiert." }, + "safeMode": { + "title": "Sicherer Modus", + "description": "Nur Layout- und Themen-Plugins sind aktiv. Starten Sie den Server ohne TERMIX_SAFE_MODE neu, um die restlichen Plugins wieder zu aktivieren." + }, "companion": { "close": "Panel schließen", "moveSide": "Panel verschieben", @@ -1098,6 +1102,8 @@ "resetEveryone": "Standard für alle zurücksetzen", "update": "{{name}} auf {{version}} aktualisieren", "disable": "{{name}} deaktivieren", + "enable": "{{name}} aktivieren", + "startAgain": "{{name}} neu starten", "install": "Layouts und Themes finden", "installTitle": "Layouts und Themes", "installButton": "Installieren", @@ -1309,7 +1315,8 @@ "disabled": "Deaktiviert", "failed": "Unerwartet gestoppt", "blocked": "Wartet auf ein anderes Plugin", - "server": "Läuft auf deinem Server" + "server": "Läuft auf deinem Server", + "safeMode": "Im sicheren Modus deaktiviert" }, "groups": { "running": "Aktiv", @@ -1323,11 +1330,6 @@ "updated": "Kürzlich aktualisiert", "category": "Kategorie" }, - "columns": { - "title": "Spalten", - "auto": "Auto", - "count": "{{count}} Spalten breit" - }, "table": { "name": "Name", "description": "Beschreibung", @@ -1652,7 +1654,12 @@ "gridView": "Raster", "listView": "Liste", "compactRows": "Kompakte Zeilen", - "comfortableRows": "Bequeme Zeilen" + "comfortableRows": "Bequeme Zeilen", + "columns": { + "title": "Spalten", + "auto": "Automatisch", + "count": "{{count}} Spalten breit" + } }, "fields": { "secretSaved": "Gespeichert. Tippen, um es zu ersetzen." diff --git a/src/ui/locales/translated/el_GR.json b/src/ui/locales/translated/el_GR.json index 25aa8581a..12cb6707b 100644 --- a/src/ui/locales/translated/el_GR.json +++ b/src/ui/locales/translated/el_GR.json @@ -235,6 +235,10 @@ "ready": "Το Termix {{version}} είναι έτοιμο για εγκατάσταση", "readyHint": "Εγκαθίσταται την επόμενη φορά που θα επανεκκινήσει το Termix." }, + "safeMode": { + "title": "Λειτουργία ασφαλείας", + "description": "Τρέχουν μόνο οι προσθήκες διάταξης και θέματος. Επανεκκινήστε τον διακομιστή χωρίς το TERMIX_SAFE_MODE για να ενεργοποιήσετε ξανά τις υπόλοιπες." + }, "companion": { "close": "Κλείσιμο πάνελ", "moveSide": "Μετακίνηση πάνελ", @@ -1098,6 +1102,8 @@ "resetEveryone": "Επαναφορά της προεπιλογής για όλους", "update": "Ενημέρωση του {{name}} στην έκδοση {{version}}", "disable": "Απενεργοποίηση του {{name}}", + "enable": "Ενεργοποίηση {{name}}", + "startAgain": "Εκκίνηση {{name}} ξανά", "install": "Εύρεση layouts και θεμάτων", "installTitle": "Layouts και θέματα", "installButton": "Εγκατάσταση", @@ -1309,7 +1315,8 @@ "disabled": "Απενεργοποιημένο", "failed": "Διακόπηκε απροσδόκητα", "blocked": "Περιμένει άλλο plugin", - "server": "Τρέχει στον διακομιστή σας" + "server": "Τρέχει στον διακομιστή σας", + "safeMode": "Ανενεργό στη λειτουργία ασφαλείας" }, "groups": { "running": "Εν ενεργεία", @@ -1323,11 +1330,6 @@ "updated": "Πρόσφατα ενημερωμένα", "category": "Κατηγορία" }, - "columns": { - "title": "Στήλες", - "auto": "Αυτόματο", - "count": "{{count}} στήλες" - }, "table": { "name": "Όνομα", "description": "Περιγραφή", @@ -1652,7 +1654,12 @@ "gridView": "Πλέγμα", "listView": "Λίστα", "compactRows": "Συμπαγείς γραμμές", - "comfortableRows": "Άνετες γραμμές" + "comfortableRows": "Άνετες γραμμές", + "columns": { + "title": "Στήλες", + "auto": "Αυτόματο", + "count": "Πλάτος {{count}}" + } }, "fields": { "secretSaved": "Αποθηκεύτηκε. Πληκτρολογήστε για να το αντικαταστήσετε." diff --git a/src/ui/locales/translated/es_ES.json b/src/ui/locales/translated/es_ES.json index e123c5329..b3cb43144 100644 --- a/src/ui/locales/translated/es_ES.json +++ b/src/ui/locales/translated/es_ES.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} está listo para instalar", "readyHint": "Se instalará la próxima vez que reinicies Termix." }, + "safeMode": { + "title": "Modo seguro", + "description": "Solo se están ejecutando los complementos de diseño y tema. Reinicia el servidor sin TERMIX_SAFE_MODE para volver a activar el resto." + }, "companion": { "close": "Cerrar panel", "moveSide": "Mover panel", @@ -1115,6 +1119,8 @@ "resetEveryone": "Restablecer el valor predeterminado para todos", "update": "Actualizar {{name}} a {{version}}", "disable": "Desactivar {{name}}", + "enable": "Activar {{name}}", + "startAgain": "Reiniciar {{name}}", "install": "Buscar diseños y temas", "installTitle": "Diseños y temas", "installButton": "Instalar", @@ -1326,7 +1332,8 @@ "disabled": "Desactivado", "failed": "Detenido inesperadamente", "blocked": "Esperando a otro complemento", - "server": "Se ejecuta en tu servidor" + "server": "Se ejecuta en tu servidor", + "safeMode": "Desactivado en modo seguro" }, "groups": { "running": "En ejecución", @@ -1340,11 +1347,6 @@ "updated": "Actualizados recientemente", "category": "Categoría" }, - "columns": { - "title": "Columnas", - "auto": "Automático", - "count": "{{count}} de ancho" - }, "table": { "name": "Nombre", "description": "Descripción", @@ -1694,7 +1696,12 @@ "gridView": "Cuadrícula", "listView": "Lista", "compactRows": "Filas compactas", - "comfortableRows": "Filas cómodas" + "comfortableRows": "Filas cómodas", + "columns": { + "title": "Columnas", + "auto": "Automático", + "count": "{{count}} de ancho" + } }, "fields": { "secretSaved": "Guardado. Escribe para reemplazarlo." diff --git a/src/ui/locales/translated/et_EE.json b/src/ui/locales/translated/et_EE.json index 17fde316a..f3d053d90 100644 --- a/src/ui/locales/translated/et_EE.json +++ b/src/ui/locales/translated/et_EE.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} on paigaldamiseks valmis", "readyHint": "See paigaldatakse järgmisel Termixi taaskäivitamisel." }, + "safeMode": { + "title": "Turvarežiim", + "description": "Töötavad ainult paigutuse ja teema lisad. Taaskäivitage server ilma TERMIX_SAFE_MODE määramata, et ülejäänud lisad uuesti lubada." + }, "companion": { "close": "Sulge paneel", "moveSide": "Liiguta paneeli", @@ -1098,6 +1102,8 @@ "resetEveryone": "Taasta vaikimisi seadistus kõigile", "update": "Uuenda {{name}} versioonile {{version}}", "disable": "Lülita {{name}} välja", + "enable": "Lülita {{name}} sisse", + "startAgain": "Käivita {{name}} uuesti", "install": "Otsi paigutusi ja teemasid", "installTitle": "Paigutused ja teemad", "installButton": "Paigalda", @@ -1309,7 +1315,8 @@ "disabled": "Keelatud", "failed": "Peatati ootamatult", "blocked": "Ootab teist pistikprogrammi", - "server": "Töötab sinu serveris" + "server": "Töötab sinu serveris", + "safeMode": "Turvarežiimis välja lülitatud" }, "groups": { "running": "Käimas", @@ -1323,11 +1330,6 @@ "updated": "Hiljuti uuendatud", "category": "Kategooria" }, - "columns": { - "title": "Veerud", - "auto": "Automaatne", - "count": "{{count}} lai" - }, "table": { "name": "Nimi", "description": "Kirjeldus", @@ -1652,7 +1654,12 @@ "gridView": "Ruudustik", "listView": "Loend", "compactRows": "Kompaktsead read", - "comfortableRows": "Mugavad read" + "comfortableRows": "Mugavad read", + "columns": { + "title": "Veerud", + "auto": "Automaatne", + "count": "{{count}} veeru laiune" + } }, "fields": { "secretSaved": "Salvestatud. Tüüpi, et asendada." diff --git a/src/ui/locales/translated/fi_FI.json b/src/ui/locales/translated/fi_FI.json index d299df56a..a60f0b30f 100644 --- a/src/ui/locales/translated/fi_FI.json +++ b/src/ui/locales/translated/fi_FI.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} on valmis asennettavaksi", "readyHint": "Se asennetaan seuraavan kerran, kun Termix käynnistetään uudelleen." }, + "safeMode": { + "title": "Turvatoiminto", + "description": "Vain asettelun ja teeman lisäosat ovat käytössä. Käynnistä palvelin uudelleen ilman TERMIX_SAFE_MODE-muuttujaa ottaaksesi muut lisäosat takaisin käyttöön." + }, "companion": { "close": "Sulje paneeli", "moveSide": "Siirrä paneeli", @@ -1098,6 +1102,8 @@ "resetEveryone": "Palauta oletus kaikille", "update": "Päivitä {{name}} versioon {{version}}", "disable": "Sammuta {{name}}", + "enable": "Ota {{name}} käyttöön", + "startAgain": "Käynnistä {{name}} uudelleen", "install": "Etsi asetteluja ja teemoja", "installTitle": "Asettelut ja teemat", "installButton": "Asenna", @@ -1309,7 +1315,8 @@ "disabled": "Poistettu käytöstä", "failed": "Pysähtyi odottamatta", "blocked": "Odottaa toista lisäosaa", - "server": "Käynnistyy palvelimellasi" + "server": "Käynnistyy palvelimellasi", + "safeMode": "Pois käytöstä turvatoiminnossa" }, "groups": { "running": "Käynnissä", @@ -1323,11 +1330,6 @@ "updated": "Viimeksi päivitetty", "category": "Kategoria" }, - "columns": { - "title": "Sarakkeet", - "auto": "Automaattinen", - "count": "{{count}} saraketta leveä" - }, "table": { "name": "Nimi", "description": "Kuvaus", @@ -1652,7 +1654,12 @@ "gridView": "Ruudukko", "listView": "Lista", "compactRows": "Tiiviit rivit", - "comfortableRows": "Mukavat rivit" + "comfortableRows": "Mukavat rivit", + "columns": { + "title": "Sarakkeet", + "auto": "Automaattinen", + "count": "{{count}} saraketta leveä" + } }, "fields": { "secretSaved": "Tallennettu. Kirjoita korvataksesi sen." diff --git a/src/ui/locales/translated/fr_FR.json b/src/ui/locales/translated/fr_FR.json index a2db75861..afaafb5c0 100644 --- a/src/ui/locales/translated/fr_FR.json +++ b/src/ui/locales/translated/fr_FR.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} est prêt à être installé", "readyHint": "Il sera installé au prochain redémarrage de Termix." }, + "safeMode": { + "title": "Mode sans échec", + "description": "Seuls les plugins de mise en page et de thème sont actifs. Redémarrez le serveur sans la variable TERMIX_SAFE_MODE pour réactiver les autres." + }, "companion": { "close": "Fermer le panneau", "moveSide": "Déplacer le panneau", @@ -1115,6 +1119,8 @@ "resetEveryone": "Réinitialiser la valeur par défaut pour tout le monde", "update": "Mettre à jour {{name}} vers {{version}}", "disable": "Désactiver {{name}}", + "enable": "Activer {{name}}", + "startAgain": "Relancer {{name}}", "install": "Trouver des dispositions et des thèmes", "installTitle": "Dispositions et thèmes", "installButton": "Installer", @@ -1326,7 +1332,8 @@ "disabled": "Désactivé", "failed": "Arrêt inattendu", "blocked": "En attente d'un autre plugin", - "server": "Exécute sur votre serveur" + "server": "Exécute sur votre serveur", + "safeMode": "Désactivé en mode sans échec" }, "groups": { "running": "En cours d'exécution", @@ -1340,11 +1347,6 @@ "updated": "Récemment mis à jour", "category": "Catégorie" }, - "columns": { - "title": "Colonnes", - "auto": "Auto", - "count": "{{count}} de large" - }, "table": { "name": "Nom", "description": "Description", @@ -1694,7 +1696,12 @@ "gridView": "Grille", "listView": "Liste", "compactRows": "Lignes compactes", - "comfortableRows": "Lignes confortables" + "comfortableRows": "Lignes confortables", + "columns": { + "title": "Colonnes", + "auto": "Auto", + "count": "{{count}} de large" + } }, "fields": { "secretSaved": "Enregistré. Tapez pour le remplacer." diff --git a/src/ui/locales/translated/he_IL.json b/src/ui/locales/translated/he_IL.json index 013a9d6e9..22dc0ab3d 100644 --- a/src/ui/locales/translated/he_IL.json +++ b/src/ui/locales/translated/he_IL.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} מוכן להתקנה", "readyHint": "הוא מותקן בפעם הבאה ש-Termix מופעל מחדש." }, + "safeMode": { + "title": "מצב בטוח", + "description": "רק תוספי פריסה ועיצוב פועלים. הפעל מחדש את השרת ללא המשתנה TERMIX_SAFE_MODE כדי להפעיל מחדש את שאר התוספים." + }, "companion": { "close": "סגור לוח", "moveSide": "הזז לוח", @@ -1115,6 +1119,8 @@ "resetEveryone": "אפס את ברירת המחדל לכולם", "update": "עדכן את {{name}} ל-{{version}}", "disable": "כבה את {{name}}", + "enable": "הפעל את {{name}}", + "startAgain": "הפעל מחדש את {{name}}", "install": "חפש פריסות וערכות נושא", "installTitle": "פריסות וערכות נושא", "installButton": "התקן", @@ -1326,7 +1332,8 @@ "disabled": "מושבת", "failed": "הופסק ללא ציפייה", "blocked": "ממתין לתוסף אחר", - "server": "רץ על השרת שלך" + "server": "רץ על השרת שלך", + "safeMode": "כבוי במצב בטוח" }, "groups": { "running": "פעילים", @@ -1340,11 +1347,6 @@ "updated": "עודכן לאחרונה", "category": "קטגוריה" }, - "columns": { - "title": "עמודות", - "auto": "אוטומטי", - "count": "רוחב של {{count}}" - }, "table": { "name": "שם", "description": "תיאור", @@ -1694,7 +1696,12 @@ "gridView": "רשת", "listView": "רשימה", "compactRows": "שורות צפופות", - "comfortableRows": "שורות נוחות" + "comfortableRows": "שורות נוחות", + "columns": { + "title": "עמודות", + "auto": "אוטומטי", + "count": "רוחב של {{count}}" + } }, "fields": { "secretSaved": "נשמר. הקלד כדי להחליף אותו." diff --git a/src/ui/locales/translated/hi_IN.json b/src/ui/locales/translated/hi_IN.json index c40c59b19..77f102a69 100644 --- a/src/ui/locales/translated/hi_IN.json +++ b/src/ui/locales/translated/hi_IN.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} इंस्टॉल करने के लिए तैयार है", "readyHint": "यह अगली बार जब Termix पुनरारंभ होगा तब इंस्टॉल होगा।" }, + "safeMode": { + "title": "सुरक्षित मोड", + "description": "केवल लेआउट और थीम प्लगइन चल रहे हैं। बाकी सब कुछ वापस चालू करने के लिए TERMIX_SAFE_MODE के बिना सर्वर को पुनः आरंभ करें।" + }, "companion": { "close": "पैनल बंद करें", "moveSide": "पैनल स्थानांतरित करें", @@ -1098,6 +1102,8 @@ "resetEveryone": "सभी के लिए डिफ़ॉल्ट रीसेट करें", "update": "{{name}} को {{version}} पर अपडेट करें", "disable": "{{name}} बंद करें", + "enable": "{{name}} चालू करें", + "startAgain": "{{name}} को फिर से शुरू करें", "install": "लेआउट और थीम खोजें", "installTitle": "लेआउट और थीम", "installButton": "इंस्टॉल करें", @@ -1309,7 +1315,8 @@ "disabled": "अक्षम", "failed": "अप्रत्याशित रूप से रुका", "blocked": "दूसरे प्लगइन का इंतज़ार कर रहा है", - "server": "आपके सर्वर पर चलता है" + "server": "आपके सर्वर पर चलता है", + "safeMode": "सुरक्षित मोड में बंद" }, "groups": { "running": "चल रहे", @@ -1323,11 +1330,6 @@ "updated": "हाल ही में अपडेट किया गया", "category": "श्रेणी" }, - "columns": { - "title": "कॉलम", - "auto": "स्वचालित", - "count": "{{count}} चौड़ा" - }, "table": { "name": "नाम", "description": "विवरण", @@ -1652,7 +1654,12 @@ "gridView": "ग्रिड", "listView": "सूची", "compactRows": "संक्षिप्त पंक्तियाँ", - "comfortableRows": "आरामदायक पंक्तियाँ" + "comfortableRows": "आरामदायक पंक्तियाँ", + "columns": { + "title": "कॉलम", + "auto": "स्वचालित", + "count": "{{count}} चौड़ा" + } }, "fields": { "secretSaved": "सहेजा गया। इसे बदलने के लिए टाइप करें।" diff --git a/src/ui/locales/translated/hr_HR.json b/src/ui/locales/translated/hr_HR.json index 4c99d5bf4..f546a6f54 100644 --- a/src/ui/locales/translated/hr_HR.json +++ b/src/ui/locales/translated/hr_HR.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} je spreman za instalaciju", "readyHint": "Instalirat će se sljedeći put kada se Termix ponovno pokrene." }, + "safeMode": { + "title": "Sigurni način rada", + "description": "Pokreću se samo dodaci za raspored i temu. Ponovno pokrenite poslužitelj bez postavke TERMIX_SAFE_MODE da biste ponovno omogućili ostale dodatke." + }, "companion": { "close": "Zatvori panel", "moveSide": "Pomakni panel", @@ -1115,6 +1119,8 @@ "resetEveryone": "Vrati zadano za sve", "update": "Ažuriraj {{name}} na {{version}}", "disable": "Isključi {{name}}", + "enable": "Uključi {{name}}", + "startAgain": "Ponovno pokreni {{name}}", "install": "Pronađi rasporede i teme", "installTitle": "Rasporedi i teme", "installButton": "Instaliraj", @@ -1326,7 +1332,8 @@ "disabled": "Onemogućeno", "failed": "Neočekivano zaustavljeno", "blocked": "Čeka drugi dodatak", - "server": "Radi na vašem poslužitelju" + "server": "Radi na vašem poslužitelju", + "safeMode": "Isključeno u sigurnom načinu rada" }, "groups": { "running": "Pokrenuto", @@ -1340,11 +1347,6 @@ "updated": "Nedavno ažurirano", "category": "Kategorija" }, - "columns": { - "title": "Stupci", - "auto": "Automatski", - "count": "{{count}} široko" - }, "table": { "name": "Naziv", "description": "Opis", @@ -1694,7 +1696,12 @@ "gridView": "Mreža", "listView": "Popis", "compactRows": "Kompaktni redovi", - "comfortableRows": "Prostrani redovi" + "comfortableRows": "Prostrani redovi", + "columns": { + "title": "Stupci", + "auto": "Automatski", + "count": "Širina: {{count}}" + } }, "fields": { "secretSaved": "Spremljeno. Upišite za zamjenu." diff --git a/src/ui/locales/translated/hu_HU.json b/src/ui/locales/translated/hu_HU.json index e5cd50046..c0a614f3b 100644 --- a/src/ui/locales/translated/hu_HU.json +++ b/src/ui/locales/translated/hu_HU.json @@ -235,6 +235,10 @@ "ready": "A Termix {{version}} verziója készen áll a telepítésre", "readyHint": "A következő újraindításkor települ." }, + "safeMode": { + "title": "Biztonságos mód", + "description": "Csak a elrendezés- és témabővítmények futnak. Indítsa újra a szervert a TERMIX_SAFE_MODE nélkül, hogy a többit is újra bekapcsolja." + }, "companion": { "close": "Panel bezárása", "moveSide": "Panel áthelyezése", @@ -1098,6 +1102,8 @@ "resetEveryone": "Az alapértelmezett visszaállítása mindenkinek", "update": "{{name}} frissítése a {{version}} verzióra", "disable": "{{name}} kikapcsolása", + "enable": "{{name}} bekapcsolása", + "startAgain": "{{name}} újraindítása", "install": "Elrendezések és témák keresése", "installTitle": "Elrendezések és témák", "installButton": "Telepítés", @@ -1309,7 +1315,8 @@ "disabled": "Letiltva", "failed": "Váratlanul leállt", "blocked": "Várakozás egy másik bővítményre", - "server": "A szerverén fut" + "server": "A szerverén fut", + "safeMode": "Kikapcsolva biztonságos módban" }, "groups": { "running": "Fut", @@ -1323,11 +1330,6 @@ "updated": "Legutóbb frissítve", "category": "Kategória" }, - "columns": { - "title": "Oszlopok", - "auto": "Automatikus", - "count": "{{count}} oszlop széles" - }, "table": { "name": "Név", "description": "Leírás", @@ -1652,7 +1654,12 @@ "gridView": "Rács", "listView": "Lista", "compactRows": "Kompakt sorok", - "comfortableRows": "Kényelmes sorok" + "comfortableRows": "Kényelmes sorok", + "columns": { + "title": "Oszlopok", + "auto": "Automatikus", + "count": "{{count}} oszlop széles" + } }, "fields": { "secretSaved": "Mentve. Írjon be új értéket a helyettesítéshez." diff --git a/src/ui/locales/translated/id_ID.json b/src/ui/locales/translated/id_ID.json index 7ca587df8..783269d9e 100644 --- a/src/ui/locales/translated/id_ID.json +++ b/src/ui/locales/translated/id_ID.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} siap untuk diinstal", "readyHint": "Ini akan diinstal saat Termix dimulai ulang berikutnya." }, + "safeMode": { + "title": "Mode aman", + "description": "Hanya plugin tata letak dan tema yang berjalan. Mulai ulang server tanpa TERMIX_SAFE_MODE untuk mengaktifkan kembali yang lainnya." + }, "companion": { "close": "Tutup panel", "moveSide": "Pindahkan panel", @@ -1081,6 +1085,8 @@ "resetEveryone": "Atur ulang default untuk semua orang", "update": "Perbarui {{name}} ke {{version}}", "disable": "Matikan {{name}}", + "enable": "Aktifkan {{name}}", + "startAgain": "Mulai ulang {{name}}", "install": "Cari tata letak dan tema", "installTitle": "Tata letak dan tema", "installButton": "Instal", @@ -1292,7 +1298,8 @@ "disabled": "Dinonaktifkan", "failed": "Berhenti secara tak terduga", "blocked": "Menunggu plugin lain", - "server": "Berjalan di server Anda" + "server": "Berjalan di server Anda", + "safeMode": "Nonaktif di mode aman" }, "groups": { "running": "Berjalan", @@ -1306,11 +1313,6 @@ "updated": "Baru diperbarui", "category": "Kategori" }, - "columns": { - "title": "Kolom", - "auto": "Otomatis", - "count": "Lebar {{count}}" - }, "table": { "name": "Nama", "description": "Deskripsi", @@ -1610,7 +1612,12 @@ "gridView": "Grid", "listView": "Daftar", "compactRows": "Baris ringkas", - "comfortableRows": "Baris nyaman" + "comfortableRows": "Baris nyaman", + "columns": { + "title": "Kolom", + "auto": "Otomatis", + "count": "Lebar {{count}}" + } }, "fields": { "secretSaved": "Tersimpan. Ketik untuk menggantinya." diff --git a/src/ui/locales/translated/it_IT.json b/src/ui/locales/translated/it_IT.json index 8af0f5dc5..6ebe5d5b1 100644 --- a/src/ui/locales/translated/it_IT.json +++ b/src/ui/locales/translated/it_IT.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} è pronto per l'installazione", "readyHint": "Verrà installato al prossimo riavvio di Termix." }, + "safeMode": { + "title": "Modalità sicura", + "description": "Sono attivi solo i plugin per layout e temi. Riavvia il server senza TERMIX_SAFE_MODE per riattivare gli altri." + }, "companion": { "close": "Chiudi pannello", "moveSide": "Sposta pannello", @@ -1115,6 +1119,8 @@ "resetEveryone": "Reimposta il predefinito per tutti", "update": "Aggiorna {{name}} a {{version}}", "disable": "Disattiva {{name}}", + "enable": "Attiva {{name}}", + "startAgain": "Riavvia {{name}}", "install": "Trova layout e temi", "installTitle": "Layout e temi", "installButton": "Installa", @@ -1326,7 +1332,8 @@ "disabled": "Disabilitato", "failed": "Arrestato inaspettatamente", "blocked": "In attesa di un altro plugin", - "server": "Eseguito sul tuo server" + "server": "Eseguito sul tuo server", + "safeMode": "Disattivato in modalità sicura" }, "groups": { "running": "In esecuzione", @@ -1340,11 +1347,6 @@ "updated": "Aggiornati di recente", "category": "Categoria" }, - "columns": { - "title": "Colonne", - "auto": "Automatico", - "count": "Larghezza {{count}}" - }, "table": { "name": "Nome", "description": "Descrizione", @@ -1694,7 +1696,12 @@ "gridView": "Griglia", "listView": "Elenco", "compactRows": "Righe compatte", - "comfortableRows": "Righe spaziose" + "comfortableRows": "Righe spaziose", + "columns": { + "title": "Colonne", + "auto": "Automatico", + "count": "{{count}} colonne" + } }, "fields": { "secretSaved": "Salvato. Digita per sostituirlo." diff --git a/src/ui/locales/translated/ja_JP.json b/src/ui/locales/translated/ja_JP.json index a5cb72b87..39dd8f7fa 100644 --- a/src/ui/locales/translated/ja_JP.json +++ b/src/ui/locales/translated/ja_JP.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} のインストール準備ができました", "readyHint": "Termix を再起動したときにインストールされます。" }, + "safeMode": { + "title": "セーフモード", + "description": "レイアウトとテーマのプラグインのみが実行されています。TERMIX_SAFE_MODE を設定せずにサーバーを再起動すると、他のプラグインが再度有効になります。" + }, "companion": { "close": "パネルを閉じる", "moveSide": "パネルを移動", @@ -1081,6 +1085,8 @@ "resetEveryone": "すべてのユーザーのデフォルトをリセット", "update": "{{name}} を {{version}} に更新", "disable": "{{name}} を無効にする", + "enable": "{{name}} を有効にする", + "startAgain": "{{name}} を再起動する", "install": "レイアウトとテーマを探す", "installTitle": "レイアウトとテーマ", "installButton": "インストール", @@ -1292,7 +1298,8 @@ "disabled": "無効", "failed": "予期せず停止", "blocked": "他のプラグインを待機中", - "server": "サーバー上で実行" + "server": "サーバー上で実行", + "safeMode": "セーフモードで無効" }, "groups": { "running": "実行中", @@ -1306,11 +1313,6 @@ "updated": "最近更新", "category": "カテゴリ" }, - "columns": { - "title": "列", - "auto": "自動", - "count": "{{count}} 列" - }, "table": { "name": "名前", "description": "説明", @@ -1610,7 +1612,12 @@ "gridView": "グリッド", "listView": "リスト", "compactRows": "コンパクトな行", - "comfortableRows": "ゆったりした行" + "comfortableRows": "ゆったりした行", + "columns": { + "title": "カラム", + "auto": "自動", + "count": "{{count}} カラム" + } }, "fields": { "secretSaved": "保存しました。入力して置き換えてください。" diff --git a/src/ui/locales/translated/ko_KR.json b/src/ui/locales/translated/ko_KR.json index 7dfed2718..f366b18bd 100644 --- a/src/ui/locales/translated/ko_KR.json +++ b/src/ui/locales/translated/ko_KR.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} 설치 준비 완료", "readyHint": "Termix가 다음에 재시작될 때 설치됩니다." }, + "safeMode": { + "title": "안전 모드", + "description": "레이아웃 및 테마 플러그인만 실행 중입니다. TERMIX_SAFE_MODE 없이 서버를 다시 시작하여 나머지 기능을 다시 활성화하세요." + }, "companion": { "close": "패널 닫기", "moveSide": "패널 이동", @@ -1081,6 +1085,8 @@ "resetEveryone": "모두의 기본값 초기화", "update": "{{name}}을(를) {{version}}(으)로 업데이트", "disable": "{{name}} 끄기", + "enable": "{{name}} 켜기", + "startAgain": "{{name}} 다시 시작", "install": "레이아웃 및 테마 찾기", "installTitle": "레이아웃 및 테마", "installButton": "설치", @@ -1292,7 +1298,8 @@ "disabled": "비활성화됨", "failed": "예기치 않게 중지됨", "blocked": "다른 플러그인 대기 중", - "server": "서버에서 실행 중" + "server": "서버에서 실행 중", + "safeMode": "안전 모드에서 꺼짐" }, "groups": { "running": "실행 중", @@ -1306,11 +1313,6 @@ "updated": "최근 업데이트순", "category": "카테고리순" }, - "columns": { - "title": "열", - "auto": "자동", - "count": "{{count}}열 너비" - }, "table": { "name": "이름", "description": "설명", @@ -1610,7 +1612,12 @@ "gridView": "그리드", "listView": "목록", "compactRows": "간결한 행", - "comfortableRows": "넓은 행" + "comfortableRows": "넓은 행", + "columns": { + "title": "열", + "auto": "자동", + "count": "{{count}}열 너비" + } }, "fields": { "secretSaved": "저장되었습니다. 입력하여 교체하세요." diff --git a/src/ui/locales/translated/lt_LT.json b/src/ui/locales/translated/lt_LT.json index 824ebe578..203d7dbaf 100644 --- a/src/ui/locales/translated/lt_LT.json +++ b/src/ui/locales/translated/lt_LT.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} paruošta įdiegimui", "readyHint": "Ji bus įdiegta kitą kartą paleidus Termix iš naujo." }, + "safeMode": { + "title": "Saugus režimas", + "description": "Veikia tik išdėstymo ir temos priedai. Paleiskite serverį iš naujo be kintamojo TERMIX_SAFE_MODE, kad įjungtumėte likusius priedus." + }, "companion": { "close": "Uždaryti skydelį", "moveSide": "Perkelti skydelį", @@ -1132,6 +1136,8 @@ "resetEveryone": "Atstatyti numatytąjį visiems", "update": "Atnaujinti {{name}} į {{version}}", "disable": "Išjungti {{name}}", + "enable": "Įjungti „{{name}}“", + "startAgain": "Paleisti „{{name}}“ iš naujo", "install": "Ieškoti išdėstymų ir temų", "installTitle": "Išdėstymai ir temos", "installButton": "Įdiegti", @@ -1343,7 +1349,8 @@ "disabled": "Išjungta", "failed": "Nenumatomai sustabdyta", "blocked": "Laukiama kito priedo", - "server": "Veikia jūsų serveryje" + "server": "Veikia jūsų serveryje", + "safeMode": "Išjungta saugiu režimu" }, "groups": { "running": "Veikia", @@ -1357,11 +1364,6 @@ "updated": "Neseniai atnaujinti", "category": "Kategorija" }, - "columns": { - "title": "Stulpeliai", - "auto": "Automatiškai", - "count": "{{count}} pločio" - }, "table": { "name": "Pavadinimas", "description": "Aprašymas", @@ -1736,7 +1738,12 @@ "gridView": "Tinklelis", "listView": "Sąrašas", "compactRows": "Kompaktiškos eilutės", - "comfortableRows": "Patogios eilutės" + "comfortableRows": "Patogios eilutės", + "columns": { + "title": "Stulpeliai", + "auto": "Automatiškai", + "count": "{{count}} pločio" + } }, "fields": { "secretSaved": "Išsaugota. Įveskite, kad pakeistumėte." diff --git a/src/ui/locales/translated/lv_LV.json b/src/ui/locales/translated/lv_LV.json index 09679eb2a..e88d27607 100644 --- a/src/ui/locales/translated/lv_LV.json +++ b/src/ui/locales/translated/lv_LV.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} ir gatavs instalēšanai", "readyHint": "Tas tiks instalēts nākamajā reizi, kad Termix tiks restartēts." }, + "safeMode": { + "title": "Drošais režīms", + "description": "Darbojas tikai izkārtojuma un temas spraudņi. Restartējiet serveri bez TERMIX_SAFE_MODE, lai atkal ieslēgtu pārējos." + }, "companion": { "close": "Aizvērt paneli", "moveSide": "Pārvietot paneli", @@ -1115,6 +1119,8 @@ "resetEveryone": "Atiestatīt noklusējumu visiem", "update": "Atjaunināt {{name}} uz {{version}}", "disable": "Izslēgt {{name}}", + "enable": "Ieslēgt {{name}}", + "startAgain": "Atkārtoti palaist {{name}}", "install": "Atrast izkārtojumus un tēmas", "installTitle": "Izkārtojumi un tēmas", "installButton": "Instalēt", @@ -1326,7 +1332,8 @@ "disabled": "Atspējots", "failed": "Gaidāms apstājās", "blocked": "Gaida citu spraudni", - "server": "Darbojas jūsu serverī" + "server": "Darbojas jūsu serverī", + "safeMode": "Izslēgts drošajā režīmā" }, "groups": { "running": "Darbojas", @@ -1340,11 +1347,6 @@ "updated": "Nesen atjaunināti", "category": "Kategorija" }, - "columns": { - "title": "Kolonnas", - "auto": "Automātiski", - "count": "{{count}} kolonnu platums" - }, "table": { "name": "Nosaukums", "description": "Apraksts", @@ -1694,7 +1696,12 @@ "gridView": "Režģis", "listView": "Saraksts", "compactRows": "Kompaktas rindas", - "comfortableRows": "Plašas rindas" + "comfortableRows": "Plašas rindas", + "columns": { + "title": "Kolonnas", + "auto": "Automātiski", + "count": "{{count}} kolonnu platums" + } }, "fields": { "secretSaved": "Saglabāts. Ierakstiet, lai to aizvietotu." diff --git a/src/ui/locales/translated/ms_MY.json b/src/ui/locales/translated/ms_MY.json index 2ff5c2f52..d2d1dd532 100644 --- a/src/ui/locales/translated/ms_MY.json +++ b/src/ui/locales/translated/ms_MY.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} sedia untuk dipasang", "readyHint": "Ia akan dipasang pada kali seterusnya Termix dimulakan semula." }, + "safeMode": { + "title": "Modus selamat", + "description": "Hanya plugin susun atur dan tema yang sedang berjalan. Mulakan semula pelayan tanpa TERMIX_SAFE_MODE untuk menghidupkan kembali yang lain." + }, "companion": { "close": "Tutup panel", "moveSide": "Pindahkan panel", @@ -1081,6 +1085,8 @@ "resetEveryone": "Set semula lalai untuk semua orang", "update": "Kemas kini {{name}} ke {{version}}", "disable": "Matikan {{name}}", + "enable": "Hidupkan {{name}}", + "startAgain": "Mulakan {{name}} semula", "install": "Cari susun atur dan tema", "installTitle": "Susun atur dan tema", "installButton": "Pasang", @@ -1292,7 +1298,8 @@ "disabled": "Dilumpuhkan", "failed": "Berhenti secara tidak dijangka", "blocked": "Menunggu plugin lain", - "server": "Berjalan pada pelayan anda" + "server": "Berjalan pada pelayan anda", + "safeMode": "Mati dalam modus selamat" }, "groups": { "running": "Berjalan", @@ -1306,11 +1313,6 @@ "updated": "Dikemas kini baru-baru ini", "category": "Kategori" }, - "columns": { - "title": "Lajur", - "auto": "Auto", - "count": "Lebar {{count}}" - }, "table": { "name": "Nama", "description": "Keterangan", @@ -1610,7 +1612,12 @@ "gridView": "Grid", "listView": "Senarai", "compactRows": "Baris padat", - "comfortableRows": "Baris selesa" + "comfortableRows": "Baris selesa", + "columns": { + "title": "Lajur", + "auto": "Auto", + "count": "Lebar {{count}}" + } }, "fields": { "secretSaved": "Disimpan. Taip untuk menggantikannya." diff --git a/src/ui/locales/translated/nl_NL.json b/src/ui/locales/translated/nl_NL.json index 1e25c51f2..de0215251 100644 --- a/src/ui/locales/translated/nl_NL.json +++ b/src/ui/locales/translated/nl_NL.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} is klaar om te installeren", "readyHint": "Het wordt geïnstalleerd de volgende keer dat Termix herstart." }, + "safeMode": { + "title": "Veilige modus", + "description": "Alleen layout- en thema-plugins zijn actief. Herstart de server zonder TERMIX_SAFE_MODE om de rest weer in te schakelen." + }, "companion": { "close": "Sluit paneel", "moveSide": "Verplaats paneel", @@ -1098,6 +1102,8 @@ "resetEveryone": "Standaard voor iedereen terugzetten", "update": "Update {{name}} naar {{version}}", "disable": "Zet {{name}} uit", + "enable": "{{name}} inschakelen", + "startAgain": "{{name}} opnieuw starten", "install": "Lay-outs en thema's zoeken", "installTitle": "Lay-outs en thema's", "installButton": "Installeren", @@ -1309,7 +1315,8 @@ "disabled": "Uitgeschakeld", "failed": "Onverwacht gestopt", "blocked": "Wachten op een andere plugin", - "server": "Wordt uitgevoerd op je server" + "server": "Wordt uitgevoerd op je server", + "safeMode": "Uitgeschakeld in veilige modus" }, "groups": { "running": "Actief", @@ -1323,11 +1330,6 @@ "updated": "Recent bijgewerkt", "category": "Categorie" }, - "columns": { - "title": "Kolommen", - "auto": "Automatisch", - "count": "{{count}} kolommen breed" - }, "table": { "name": "Naam", "description": "Beschrijving", @@ -1652,7 +1654,12 @@ "gridView": "Raster", "listView": "Lijst", "compactRows": "Compacte rijen", - "comfortableRows": "Ruime rijen" + "comfortableRows": "Ruime rijen", + "columns": { + "title": "Kolommen", + "auto": "Automatisch", + "count": "{{count}} kolommen breed" + } }, "fields": { "secretSaved": "Opgeslagen. Typ om het te vervangen." diff --git a/src/ui/locales/translated/no_NO.json b/src/ui/locales/translated/no_NO.json index 2f2339ec3..94e5a49b9 100644 --- a/src/ui/locales/translated/no_NO.json +++ b/src/ui/locales/translated/no_NO.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} er klar til installasjon", "readyHint": "Den installeres neste gang Termix starter på nytt." }, + "safeMode": { + "title": "Sikker modus", + "description": "Kun utforming- og tematillegg kjører. Start serveren på nytt uten TERMIX_SAFE_MODE for å slå på resten igjen." + }, "companion": { "close": "Lukk panel", "moveSide": "Flytt panel", @@ -1098,6 +1102,8 @@ "resetEveryone": "Tilbakestill standard for alle", "update": "Oppdater {{name}} til {{version}}", "disable": "Slå av {{name}}", + "enable": "Slå på {{name}}", + "startAgain": "Start {{name}} på nytt", "install": "Finn oppsett og temaer", "installTitle": "Oppsett og temaer", "installButton": "Installer", @@ -1309,7 +1315,8 @@ "disabled": "Deaktivert", "failed": "Stoppet uventet", "blocked": "Venter på et annet plugin", - "server": "Kjører på serveren din" + "server": "Kjører på serveren din", + "safeMode": "Av i sikker modus" }, "groups": { "running": "Kjører", @@ -1323,11 +1330,6 @@ "updated": "Nylig oppdatert", "category": "Kategori" }, - "columns": { - "title": "Kolonner", - "auto": "Auto", - "count": "{{count}} bred" - }, "table": { "name": "Navn", "description": "Beskrivelse", @@ -1652,7 +1654,12 @@ "gridView": "Rutenett", "listView": "Liste", "compactRows": "Kompakte rader", - "comfortableRows": "Komfortable rader" + "comfortableRows": "Komfortable rader", + "columns": { + "title": "Kolonner", + "auto": "Auto", + "count": "{{count}} bred" + } }, "fields": { "secretSaved": "Lagret. Skriv for å erstatte den." diff --git a/src/ui/locales/translated/pl_PL.json b/src/ui/locales/translated/pl_PL.json index d9d4a0f87..18787eaae 100644 --- a/src/ui/locales/translated/pl_PL.json +++ b/src/ui/locales/translated/pl_PL.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} jest gotowy do instalacji", "readyHint": "Zostanie zainstalowany przy następnym ponownym uruchomieniu Termix." }, + "safeMode": { + "title": "Tryb awaryjny", + "description": "Uruchomione są tylko wtyczki układu i motywu. Uruchom ponownie serwer bez zmiennej środowiskowej TERMIX_SAFE_MODE, aby włączyć pozostałe wtyczki." + }, "companion": { "close": "Zamknij panel", "moveSide": "Przenieś panel", @@ -1132,6 +1136,8 @@ "resetEveryone": "Przywróć domyślne ustawienia dla wszystkich", "update": "Aktualizuj {{name}} do wersji {{version}}", "disable": "Wyłącz {{name}}", + "enable": "Włącz {{name}}", + "startAgain": "Uruchom ponownie {{name}}", "install": "Szukaj układów i motywów", "installTitle": "Układy i motywy", "installButton": "Zainstaluj", @@ -1343,7 +1349,8 @@ "disabled": "Wyłączone", "failed": "Zatrzymane nieoczekiwanie", "blocked": "Oczekiwanie na inną wtyczkę", - "server": "Uruchamiane na Twoim serwerze" + "server": "Uruchamiane na Twoim serwerze", + "safeMode": "Wyłączone w trybie awaryjnym" }, "groups": { "running": "Uruchomione", @@ -1357,11 +1364,6 @@ "updated": "Ostatnio zaktualizowane", "category": "Kategoria" }, - "columns": { - "title": "Kolumny", - "auto": "Automatycznie", - "count": "{{count}} kolumn" - }, "table": { "name": "Nazwa", "description": "Opis", @@ -1736,7 +1738,12 @@ "gridView": "Siatka", "listView": "Lista", "compactRows": "Zwięzłe wiersze", - "comfortableRows": "Przestrzenne wiersze" + "comfortableRows": "Przestrzenne wiersze", + "columns": { + "title": "Kolumny", + "auto": "Automatycznie", + "count": "Szerokość: {{count}}" + } }, "fields": { "secretSaved": "Zapisano. Wpisz nową wartość, aby ją zmienić." diff --git a/src/ui/locales/translated/pt_BR.json b/src/ui/locales/translated/pt_BR.json index 30d6ee472..71d9e33b0 100644 --- a/src/ui/locales/translated/pt_BR.json +++ b/src/ui/locales/translated/pt_BR.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} está pronto para instalação", "readyHint": "Será instalado na próxima vez que o Termix reiniciar." }, + "safeMode": { + "title": "Modo de segurança", + "description": "Apenas os plugins de layout e tema estão ativos. Reinicie o servidor sem a variável TERMIX_SAFE_MODE para reativar os demais." + }, "companion": { "close": "Fechar painel", "moveSide": "Mover painel", @@ -1115,6 +1119,8 @@ "resetEveryone": "Restaurar o padrão para todos", "update": "Atualizar {{name}} para {{version}}", "disable": "Desativar {{name}}", + "enable": "Ativar {{name}}", + "startAgain": "Reiniciar {{name}}", "install": "Encontrar layouts e temas", "installTitle": "Layouts e temas", "installButton": "Instalar", @@ -1326,7 +1332,8 @@ "disabled": "Desativado", "failed": "Parado inesperadamente", "blocked": "Aguardando outro plugin", - "server": "Executa no seu servidor" + "server": "Executa no seu servidor", + "safeMode": "Desativado no modo de segurança" }, "groups": { "running": "Em execução", @@ -1340,11 +1347,6 @@ "updated": "Atualizados recentemente", "category": "Categoria" }, - "columns": { - "title": "Colunas", - "auto": "Automático", - "count": "{{count}} de largura" - }, "table": { "name": "Nome", "description": "Descrição", @@ -1694,7 +1696,12 @@ "gridView": "Grade", "listView": "Lista", "compactRows": "Linhas compactas", - "comfortableRows": "Linhas confortáveis" + "comfortableRows": "Linhas confortáveis", + "columns": { + "title": "Colunas", + "auto": "Automático", + "count": "{{count}} colunas" + } }, "fields": { "secretSaved": "Salvo. Digite para substituí-lo." diff --git a/src/ui/locales/translated/pt_PT.json b/src/ui/locales/translated/pt_PT.json index c53e7c217..346103959 100644 --- a/src/ui/locales/translated/pt_PT.json +++ b/src/ui/locales/translated/pt_PT.json @@ -235,6 +235,10 @@ "ready": "O Termix {{version}} está pronto para instalar", "readyHint": "Será instalado na próxima reinicialização do Termix." }, + "safeMode": { + "title": "Modo seguro", + "description": "Apenas os plugins de layout e tema estão em execução. Reinicie o servidor sem a variável TERMIX_SAFE_MODE para reativar os restantes." + }, "companion": { "close": "Fechar painel", "moveSide": "Mover painel", @@ -1115,6 +1119,8 @@ "resetEveryone": "Repor a predefinição para todos", "update": "Atualizar {{name}} para {{version}}", "disable": "Desativar {{name}}", + "enable": "Ativar {{name}}", + "startAgain": "Reiniciar {{name}}", "install": "Procurar layouts e temas", "installTitle": "Layouts e temas", "installButton": "Instalar", @@ -1326,7 +1332,8 @@ "disabled": "Desativado", "failed": "Parado inesperadamente", "blocked": "À espera de outro plugin", - "server": "Executa no seu servidor" + "server": "Executa no seu servidor", + "safeMode": "Desativado no modo seguro" }, "groups": { "running": "Em execução", @@ -1340,11 +1347,6 @@ "updated": "Atualizados recentemente", "category": "Categoria" }, - "columns": { - "title": "Colunas", - "auto": "Automático", - "count": "{{count}} de largura" - }, "table": { "name": "Nome", "description": "Descrição", @@ -1694,7 +1696,12 @@ "gridView": "Grelha", "listView": "Lista", "compactRows": "Linhas compactas", - "comfortableRows": "Linhas confortáveis" + "comfortableRows": "Linhas confortáveis", + "columns": { + "title": "Colunas", + "auto": "Automático", + "count": "{{count}} de largura" + } }, "fields": { "secretSaved": "Guardado. Escreva para substituir." diff --git a/src/ui/locales/translated/ro_RO.json b/src/ui/locales/translated/ro_RO.json index f306270b9..c87a42e8c 100644 --- a/src/ui/locales/translated/ro_RO.json +++ b/src/ui/locales/translated/ro_RO.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} este gata de instalare", "readyHint": "Se instalează data viitoare când Termix se repornește." }, + "safeMode": { + "title": "Mod sigur", + "description": "Doar plugin-urile de aspect și temă sunt active. Repornește serverul fără variabila TERMIX_SAFE_MODE pentru a reactiva restul." + }, "companion": { "close": "Închide panoul", "moveSide": "Mută panoul", @@ -1115,6 +1119,8 @@ "resetEveryone": "Resetează implicitul pentru toți", "update": "Actualizează {{name}} la {{version}}", "disable": "Dezactivează {{name}}", + "enable": "Activează {{name}}", + "startAgain": "Pornește din nou {{name}}", "install": "Găsește aspecte și teme", "installTitle": "Aspecte și teme", "installButton": "Instalează", @@ -1326,7 +1332,8 @@ "disabled": "Dezactivat", "failed": "Oprit neașteptat", "blocked": "Așteaptă un alt plugin", - "server": "Rulează pe serverul tău" + "server": "Rulează pe serverul tău", + "safeMode": "Oprit în modul sigur" }, "groups": { "running": "În execuție", @@ -1340,11 +1347,6 @@ "updated": "Actualizate recent", "category": "Categorie" }, - "columns": { - "title": "Coloane", - "auto": "Automat", - "count": "{{count}} coloane" - }, "table": { "name": "Nume", "description": "Descriere", @@ -1694,7 +1696,12 @@ "gridView": "Grilă", "listView": "Listă", "compactRows": "Rânduri compacte", - "comfortableRows": "Rânduri confortabile" + "comfortableRows": "Rânduri confortabile", + "columns": { + "title": "Coloane", + "auto": "Automat", + "count": "{{count}} coloane" + } }, "fields": { "secretSaved": "Salvat. Tastează pentru a-l înlocui." diff --git a/src/ui/locales/translated/ru_RU.json b/src/ui/locales/translated/ru_RU.json index 10314d9c6..8f1eea40c 100644 --- a/src/ui/locales/translated/ru_RU.json +++ b/src/ui/locales/translated/ru_RU.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} готова к установке", "readyHint": "Она будет установлена при следующем перезапуске Termix." }, + "safeMode": { + "title": "Безопасный режим", + "description": "Работают только плагины макета и темы. Перезапустите сервер без флага TERMIX_SAFE_MODE, чтобы включить остальные плагины." + }, "companion": { "close": "Закрыть панель", "moveSide": "Переместить панель", @@ -1132,6 +1136,8 @@ "resetEveryone": "Сбросить настройки по умолчанию для всех", "update": "Обновить {{name}} до версии {{version}}", "disable": "Отключить {{name}}", + "enable": "Включить {{name}}", + "startAgain": "Запустить {{name}} заново", "install": "Найти макеты и темы", "installTitle": "Макеты и темы", "installButton": "Установить", @@ -1343,7 +1349,8 @@ "disabled": "Отключен", "failed": "Неожиданно остановлен", "blocked": "Ожидание другого плагина", - "server": "Работает на вашем сервере" + "server": "Работает на вашем сервере", + "safeMode": "Отключено в безопасном режиме" }, "groups": { "running": "Запущенные", @@ -1357,11 +1364,6 @@ "updated": "Недавно обновленные", "category": "Категория" }, - "columns": { - "title": "Столбцы", - "auto": "Авто", - "count": "{{count}} столбцов" - }, "table": { "name": "Имя", "description": "Описание", @@ -1736,7 +1738,12 @@ "gridView": "Сетка", "listView": "Список", "compactRows": "Компактные строки", - "comfortableRows": "Удобные строки" + "comfortableRows": "Удобные строки", + "columns": { + "title": "Колонки", + "auto": "Авто", + "count": "{{count}} колонок" + } }, "fields": { "secretSaved": "Сохранено. Введите новый текст, чтобы заменить." diff --git a/src/ui/locales/translated/sk_SK.json b/src/ui/locales/translated/sk_SK.json index c06ba39e2..3f1951f59 100644 --- a/src/ui/locales/translated/sk_SK.json +++ b/src/ui/locales/translated/sk_SK.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} je pripravený na inštaláciu", "readyHint": "Nainštaluje sa pri ďalšom reštarte Termixu." }, + "safeMode": { + "title": "Režim bezpečnosti", + "description": "Bežia len zásuvné moduly rozloženia a témy. Reštartujte server bez premennej TERMIX_SAFE_MODE, aby ste ostatné moduly opäť povolili." + }, "companion": { "close": "Zatvoriť panel", "moveSide": "Presunúť panel", @@ -1132,6 +1136,8 @@ "resetEveryone": "Obnoviť predvolené nastavenie pre všetkých", "update": "Aktualizovať {{name}} na verziu {{version}}", "disable": "Vypnúť {{name}}", + "enable": "Povoliť {{name}}", + "startAgain": "Spustiť {{name}} znova", "install": "Nájsť rozloženia a témy", "installTitle": "Rozloženia a témy", "installButton": "Nainštalovať", @@ -1343,7 +1349,8 @@ "disabled": "Zakázané", "failed": "Neočakávane zastavené", "blocked": "Čaká na iný plugin", - "server": "Beží na vašom serveri" + "server": "Beží na vašom serveri", + "safeMode": "Vypnuté v režime bezpečnosti" }, "groups": { "running": "Bežiaci", @@ -1357,11 +1364,6 @@ "updated": "Nedávno aktualizované", "category": "Kategória" }, - "columns": { - "title": "Stĺpce", - "auto": "Automaticky", - "count": "{{count}} stĺpcov široké" - }, "table": { "name": "Názov", "description": "Popis", @@ -1736,7 +1738,12 @@ "gridView": "Mriežka", "listView": "Zoznam", "compactRows": "Kompaktné riadky", - "comfortableRows": "Pohodlné riadky" + "comfortableRows": "Pohodlné riadky", + "columns": { + "title": "Stĺpce", + "auto": "Automaticky", + "count": "Šírka {{count}}" + } }, "fields": { "secretSaved": "Uložené. Pre nahradenie zadajte novú hodnotu." diff --git a/src/ui/locales/translated/sl_SI.json b/src/ui/locales/translated/sl_SI.json index 2fa4c5d67..e50ffbbe4 100644 --- a/src/ui/locales/translated/sl_SI.json +++ b/src/ui/locales/translated/sl_SI.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} je pripravljen za namestitev", "readyHint": "Namesti se naslednjič, ko se Termix ponovno zažene." }, + "safeMode": { + "title": "Varen način", + "description": "Zagnani so le vtičniki za postavitev in temo. Ponovno zaženite strežnik brez TERMIX_SAFE_MODE, da ponovno vklopite ostale." + }, "companion": { "close": "Zapri ploščo", "moveSide": "Premakni ploščo", @@ -1132,6 +1136,8 @@ "resetEveryone": "Ponastavi privzeto za vse", "update": "Posodobi {{name}} na {{version}}", "disable": "Izklopi {{name}}", + "enable": "Vklopi {{name}}", + "startAgain": "Znova zaženi {{name}}", "install": "Poišči razporede in teme", "installTitle": "Razporedi in teme", "installButton": "Namesti", @@ -1343,7 +1349,8 @@ "disabled": "Onemogočen", "failed": "Nepričakovano ustavljen", "blocked": "Čaka na drug vtičnik", - "server": "Zažene se na vašem strežniku" + "server": "Zažene se na vašem strežniku", + "safeMode": "Izklopljeno v varnem načinu" }, "groups": { "running": "Delujoči", @@ -1357,11 +1364,6 @@ "updated": "Nedavno posodobljeni", "category": "Kategorija" }, - "columns": { - "title": "Stolpci", - "auto": "Samodejno", - "count": "{{count}} širok" - }, "table": { "name": "Ime", "description": "Opis", @@ -1736,7 +1738,12 @@ "gridView": "Mreža", "listView": "Seznam", "compactRows": "Kompaktni vrstice", - "comfortableRows": "Udobne vrstice" + "comfortableRows": "Udobne vrstice", + "columns": { + "title": "Stolpci", + "auto": "Samodejno", + "count": "{{count}} širok" + } }, "fields": { "secretSaved": "Shranjeno. Vnesite za zamenjavo." diff --git a/src/ui/locales/translated/sr_SP.json b/src/ui/locales/translated/sr_SP.json index f7ecdcb2e..8c991a1e8 100644 --- a/src/ui/locales/translated/sr_SP.json +++ b/src/ui/locales/translated/sr_SP.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} је спреман за инсталацију", "readyHint": "Инсталира се следећи пут када се Termix поново покрене." }, + "safeMode": { + "title": "Безбедан режим", + "description": "Раде само додаци за распоред и тему. Поново покрените сервер без TERMIX_SAFE_MODE да бисте укључили остале." + }, "companion": { "close": "Затвори панел", "moveSide": "Помери панел", @@ -1115,6 +1119,8 @@ "resetEveryone": "Ресетуј подразумевано за све", "update": "Ажурирај {{name}} на {{version}}", "disable": "Искључи {{name}}", + "enable": "Укључи {{name}}", + "startAgain": "Поново покрените {{name}}", "install": "Пронађи распоред и теме", "installTitle": "Распореди и теме", "installButton": "Инсталирај", @@ -1326,7 +1332,8 @@ "disabled": "Онемогућено", "failed": "Неочекивано заустављено", "blocked": "Чека на други додатак", - "server": "Ради на вашем серверу" + "server": "Ради на вашем серверу", + "safeMode": "Искључено у безбедном режиму" }, "groups": { "running": "Покренуто", @@ -1340,11 +1347,6 @@ "updated": "Недавно ажурирано", "category": "Категорија" }, - "columns": { - "title": "Колоне", - "auto": "Аутоматски", - "count": "{{count}} широка" - }, "table": { "name": "Име", "description": "Опис", @@ -1694,7 +1696,12 @@ "gridView": "Мрежа", "listView": "Листа", "compactRows": "Компактни редови", - "comfortableRows": "Удобни редови" + "comfortableRows": "Удобни редови", + "columns": { + "title": "Колоне", + "auto": "Аутоматски", + "count": "{{count}} широка" + } }, "fields": { "secretSaved": "Сачувано. Унесите да бисте га заменили." diff --git a/src/ui/locales/translated/sv_SE.json b/src/ui/locales/translated/sv_SE.json index 0671d7957..7e2cda82f 100644 --- a/src/ui/locales/translated/sv_SE.json +++ b/src/ui/locales/translated/sv_SE.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} är redo att installeras", "readyHint": "Den installeras nästa gång Termix startas om." }, + "safeMode": { + "title": "Säkerhetsläge", + "description": "Endast layout- och temapluggar är aktiva. Starta om servern utan TERMIX_SAFE_MODE för att aktivera de övriga igen." + }, "companion": { "close": "Stäng panel", "moveSide": "Flytta panel", @@ -1098,6 +1102,8 @@ "resetEveryone": "Återställ standarden för alla", "update": "Uppdatera {{name}} till {{version}}", "disable": "Stäng av {{name}}", + "enable": "Aktivera {{name}}", + "startAgain": "Starta {{name}} igen", "install": "Hitta layouter och teman", "installTitle": "Layouter och teman", "installButton": "Installera", @@ -1309,7 +1315,8 @@ "disabled": "Inaktiverad", "failed": "Stoppades oväntat", "blocked": "Väntar på ett annat plugin", - "server": "Kör på din server" + "server": "Kör på din server", + "safeMode": "Avstängd i säkert läge" }, "groups": { "running": "Körs", @@ -1323,11 +1330,6 @@ "updated": "Nyligen uppdaterade", "category": "Kategori" }, - "columns": { - "title": "Kolumner", - "auto": "Auto", - "count": "{{count}} breda" - }, "table": { "name": "Namn", "description": "Beskrivning", @@ -1652,7 +1654,12 @@ "gridView": "Rutnät", "listView": "Lista", "compactRows": "Kompakta rader", - "comfortableRows": "Bekväma rader" + "comfortableRows": "Bekväma rader", + "columns": { + "title": "Kolumner", + "auto": "Automatiskt", + "count": "{{count}} bred" + } }, "fields": { "secretSaved": "Sparat. Skriv för att ersätta det." diff --git a/src/ui/locales/translated/th_TH.json b/src/ui/locales/translated/th_TH.json index 2e31e6d0d..a68583ee0 100644 --- a/src/ui/locales/translated/th_TH.json +++ b/src/ui/locales/translated/th_TH.json @@ -235,6 +235,10 @@ "ready": "Termix เวอร์ชัน {{version}} พร้อมติดตั้ง", "readyHint": "จะติดตั้งเมื่อ Termix รีสตาร์ทครั้งถัดไป" }, + "safeMode": { + "title": "โหมดปลอดภัย", + "description": "กำลังทำงานเฉพาะปลั๊กอินเลย์เอาต์และธีมเท่านั้น โปรดรีสตาร์ทเซิร์ฟเวอร์โดยยกเลิกการตั้งค่า TERMIX_SAFE_MODE เพื่อเปิดใช้งานส่วนที่เหลือ" + }, "companion": { "close": "ปิดแผง", "moveSide": "ย้ายแผง", @@ -1081,6 +1085,8 @@ "resetEveryone": "รีเซ็ตค่าเริ่มต้นสำหรับทุกคน", "update": "อัปเดต {{name}} เป็น {{version}}", "disable": "ปิดใช้งาน {{name}}", + "enable": "เปิดใช้งาน {{name}}", + "startAgain": "เริ่ม {{name}} อีกครั้ง", "install": "ค้นหาการจัดวางและธีม", "installTitle": "การจัดวางและธีม", "installButton": "ติดตั้ง", @@ -1292,7 +1298,8 @@ "disabled": "ปิดใช้งาน", "failed": "หยุดทำงานโดยไม่คาดคิด", "blocked": "กำลังรอปลั๊กอินอื่น", - "server": "ทำงานบนเซิร์ฟเวอร์ของคุณ" + "server": "ทำงานบนเซิร์ฟเวอร์ของคุณ", + "safeMode": "ปิดใช้งานในโหมดปลอดภัย" }, "groups": { "running": "กำลังทำงาน", @@ -1306,11 +1313,6 @@ "updated": "อัปเดตล่าสุด", "category": "หมวดหมู่" }, - "columns": { - "title": "คอลัมน์", - "auto": "อัตโนมัติ", - "count": "กว้าง {{count}} คอลัมน์" - }, "table": { "name": "ชื่อ", "description": "คำอธิบาย", @@ -1610,7 +1612,12 @@ "gridView": "ตาราง", "listView": "รายการ", "compactRows": "แถวแบบกะทัดรัด", - "comfortableRows": "แถวแบบสบายตา" + "comfortableRows": "แถวแบบสบายตา", + "columns": { + "title": "คอลัมน์", + "auto": "อัตโนมัติ", + "count": "กว้าง {{count}} คอลัมน์" + } }, "fields": { "secretSaved": "บันทึกแล้ว พิมพ์เพื่อแทนที่" diff --git a/src/ui/locales/translated/tr_TR.json b/src/ui/locales/translated/tr_TR.json index e640948fe..00370bae0 100644 --- a/src/ui/locales/translated/tr_TR.json +++ b/src/ui/locales/translated/tr_TR.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} yüklenmeye hazır", "readyHint": "Termix yeniden başladığında yüklenir." }, + "safeMode": { + "title": "Güvenli mod", + "description": "Sadece düzen ve tema eklentileri çalışıyor. Geri kalanını tekrar açmak için sunucuyu TERMIX_SAFE_MODE olmadan yeniden başlatın." + }, "companion": { "close": "Paneli kapat", "moveSide": "Paneli taşı", @@ -1098,6 +1102,8 @@ "resetEveryone": "Herkes için varsayılanı sıfırla", "update": "{{name}}'i {{version}} sürümüne güncelle", "disable": "{{name}}'i kapat", + "enable": "{{name}}'i aç", + "startAgain": "{{name}}'i yeniden başlat", "install": "Düzen ve temaları bul", "installTitle": "Düzenler ve temalar", "installButton": "Yükle", @@ -1309,7 +1315,8 @@ "disabled": "Devre dışı", "failed": "Beklenmedik şekilde durdu", "blocked": "Başka bir eklentiyi bekliyor", - "server": "Sunucunuzda çalışır" + "server": "Sunucunuzda çalışır", + "safeMode": "Güvenli modda kapalı" }, "groups": { "running": "Çalışıyor", @@ -1323,11 +1330,6 @@ "updated": "Son güncellenen", "category": "Kategori" }, - "columns": { - "title": "Sütunlar", - "auto": "Otomatik", - "count": "{{count}} geniş" - }, "table": { "name": "Ad", "description": "Açıklama", @@ -1652,7 +1654,12 @@ "gridView": "Izgara", "listView": "Liste", "compactRows": "Sıkı satırlar", - "comfortableRows": "Rahat satırlar" + "comfortableRows": "Rahat satırlar", + "columns": { + "title": "Sütunlar", + "auto": "Otomatik", + "count": "{{count}} geniş" + } }, "fields": { "secretSaved": "Kaydedildi. Değiştirmek için yazın." diff --git a/src/ui/locales/translated/uk_UA.json b/src/ui/locales/translated/uk_UA.json index efd7429e9..84bc19dd5 100644 --- a/src/ui/locales/translated/uk_UA.json +++ b/src/ui/locales/translated/uk_UA.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} готова до встановлення", "readyHint": "Вона встановиться під час наступного перезапуску Termix." }, + "safeMode": { + "title": "Безпечний режим", + "description": "Працюють лише плагіни макету та теми. Перезапустіть сервер без змінної TERMIX_SAFE_MODE, щоб увімкнути решту плагінів." + }, "companion": { "close": "Закрити панель", "moveSide": "Перемістити панель", @@ -1132,6 +1136,8 @@ "resetEveryone": "Скинути значення за замовчуванням для всіх", "update": "Оновити {{name}} до {{version}}", "disable": "Вимкнути {{name}}", + "enable": "Увімкнути {{name}}", + "startAgain": "Запустити {{name}} знову", "install": "Знайти макети та теми", "installTitle": "Макети та теми", "installButton": "Встановити", @@ -1343,7 +1349,8 @@ "disabled": "Вимкнено", "failed": "Зупинено несподівано", "blocked": "Очікує на інший плагін", - "server": "Працює на вашому сервері" + "server": "Працює на вашому сервері", + "safeMode": "Вимкнено в безпечному режимі" }, "groups": { "running": "Запущено", @@ -1357,11 +1364,6 @@ "updated": "Нещодавно оновлено", "category": "Категорія" }, - "columns": { - "title": "Стовпці", - "auto": "Авто", - "count": "{{count}} стовпців" - }, "table": { "name": "Назва", "description": "Опис", @@ -1736,7 +1738,12 @@ "gridView": "Сітка", "listView": "Список", "compactRows": "Компактні рядки", - "comfortableRows": "Зручні рядки" + "comfortableRows": "Зручні рядки", + "columns": { + "title": "Стовпці", + "auto": "Авто", + "count": "{{count}} стовпців" + } }, "fields": { "secretSaved": "Збережено. Введіть текст, щоб замінити його." diff --git a/src/ui/locales/translated/vi_VN.json b/src/ui/locales/translated/vi_VN.json index 047b55d47..341c2a0c6 100644 --- a/src/ui/locales/translated/vi_VN.json +++ b/src/ui/locales/translated/vi_VN.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} đã sẵn sàng để cài đặt", "readyHint": "Nó sẽ được cài đặt khi Termix khởi động lại lần tới." }, + "safeMode": { + "title": "Chế độ an toàn", + "description": "Chỉ có các plugin bố cục và giao diện đang chạy. Khởi động lại máy chủ mà không có TERMIX_SAFE_MODE để bật lại các plugin còn lại." + }, "companion": { "close": "Đóng bảng", "moveSide": "Di chuyển bảng", @@ -1081,6 +1085,8 @@ "resetEveryone": "Đặt lại mặc định cho mọi người", "update": "Cập nhật {{name}} lên {{version}}", "disable": "Tắt {{name}}", + "enable": "Bật {{name}}", + "startAgain": "Khởi động lại {{name}}", "install": "Tìm bố cục và giao diện", "installTitle": "Bố cục và giao diện", "installButton": "Cài đặt", @@ -1292,7 +1298,8 @@ "disabled": "Đã tắt", "failed": "Dừng bất ngờ", "blocked": "Đang chờ plugin khác", - "server": "Chạy trên máy chủ của bạn" + "server": "Chạy trên máy chủ của bạn", + "safeMode": "Tắt trong chế độ an toàn" }, "groups": { "running": "Đang chạy", @@ -1306,11 +1313,6 @@ "updated": "Mới cập nhật", "category": "Danh mục" }, - "columns": { - "title": "Cột", - "auto": "Tự động", - "count": "Rộng {{count}}" - }, "table": { "name": "Tên", "description": "Mô tả", @@ -1610,7 +1612,12 @@ "gridView": "Lưới", "listView": "Danh sách", "compactRows": "Hàng gọn", - "comfortableRows": "Hàng rộng" + "comfortableRows": "Hàng rộng", + "columns": { + "title": "Cột", + "auto": "Tự động", + "count": "Rộng {{count}}" + } }, "fields": { "secretSaved": "Đã lưu. Nhập để thay thế." diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index c021b5bb7..4954cd40b 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} 已准备好安装", "readyHint": "下次重启 Termix 时将进行安装。" }, + "safeMode": { + "title": "安全模式", + "description": "仅运行布局和主题插件。重启服务器并移除 TERMIX_SAFE_MODE 环境变量以恢复其他插件。" + }, "companion": { "close": "关闭面板", "moveSide": "移动面板", @@ -1081,6 +1085,8 @@ "resetEveryone": "重置所有人的默认设置", "update": "将 {{name}} 更新到 {{version}}", "disable": "关闭 {{name}}", + "enable": "启用 {{name}}", + "startAgain": "重新启动 {{name}}", "install": "查找布局和主题", "installTitle": "布局和主题", "installButton": "安装", @@ -1292,7 +1298,8 @@ "disabled": "已禁用", "failed": "意外停止", "blocked": "等待其他插件", - "server": "在您的服务器上运行" + "server": "在您的服务器上运行", + "safeMode": "安全模式下已关闭" }, "groups": { "running": "正在运行", @@ -1306,11 +1313,6 @@ "updated": "最近更新", "category": "类别" }, - "columns": { - "title": "列数", - "auto": "自动", - "count": "{{count}} 列宽" - }, "table": { "name": "名称", "description": "描述", @@ -1610,7 +1612,12 @@ "gridView": "网格", "listView": "列表", "compactRows": "紧凑行", - "comfortableRows": "宽松行" + "comfortableRows": "宽松行", + "columns": { + "title": "列数", + "auto": "自动", + "count": "{{count}} 列宽" + } }, "fields": { "secretSaved": "已保存。输入以替换。" diff --git a/src/ui/locales/translated/zh_TW.json b/src/ui/locales/translated/zh_TW.json index 5125194cc..649af44c0 100644 --- a/src/ui/locales/translated/zh_TW.json +++ b/src/ui/locales/translated/zh_TW.json @@ -235,6 +235,10 @@ "ready": "Termix {{version}} 已準備好安裝", "readyHint": "下次重新啟動 Termix 時將進行安裝。" }, + "safeMode": { + "title": "安全模式", + "description": "目前僅執行版面配置與主題外掛。請在移除 TERMIX_SAFE_MODE 環境變數後重新啟動伺服器,以恢復其他外掛。" + }, "companion": { "close": "關閉面板", "moveSide": "移動面板", @@ -1081,6 +1085,8 @@ "resetEveryone": "重設所有人的預設值", "update": "將 {{name}} 更新至 {{version}}", "disable": "關閉 {{name}}", + "enable": "啟用 {{name}}", + "startAgain": "重新啟動 {{name}}", "install": "尋找版面配置與主題", "installTitle": "版面配置與主題", "installButton": "安裝", @@ -1292,7 +1298,8 @@ "disabled": "已停用", "failed": "意外停止", "blocked": "等待其他外掛", - "server": "在您的伺服器上執行" + "server": "在您的伺服器上執行", + "safeMode": "安全模式下已停用" }, "groups": { "running": "執行中", @@ -1306,11 +1313,6 @@ "updated": "最近更新", "category": "類別" }, - "columns": { - "title": "欄數", - "auto": "自動", - "count": "寬度 {{count}}" - }, "table": { "name": "名稱", "description": "說明", @@ -1610,7 +1612,12 @@ "gridView": "網格", "listView": "清單", "compactRows": "緊湊列", - "comfortableRows": "寬鬆列" + "comfortableRows": "寬鬆列", + "columns": { + "title": "欄位", + "auto": "自動", + "count": "寬度 {{count}}" + } }, "fields": { "secretSaved": "已儲存。輸入以取代。" diff --git a/src/ui/locales/translations-lock.json b/src/ui/locales/translations-lock.json index dd7aff777..256679816 100644 --- a/src/ui/locales/translations-lock.json +++ b/src/ui/locales/translations-lock.json @@ -221,6 +221,8 @@ "desktopUpdates.manualHint": "48b49fd6307c", "desktopUpdates.ready": "faadfb86e8e8", "desktopUpdates.readyHint": "dd55ccd0ee8d", + "safeMode.title": "c730d398556a", + "safeMode.description": "f6746478af76", "companion.close": "fa0b7b3ac0f0", "companion.moveSide": "4e7610d6ac13", "companion.resize": "c3689b810f1d", @@ -1005,6 +1007,8 @@ "looks.repair.resetEveryone": "219daf273b31", "looks.repair.update": "9930e068ecbe", "looks.repair.disable": "f5be6c90fa8d", + "looks.repair.enable": "507cd8f53166", + "looks.repair.startAgain": "24dbed327ec1", "looks.repair.install": "904997c1d819", "looks.repair.installTitle": "378817c80794", "looks.repair.installButton": "fd6c3ebf7bef", @@ -1124,6 +1128,7 @@ "plugins.manager.status.failed": "71f9a28d7960", "plugins.manager.status.blocked": "911f8f3cbad4", "plugins.manager.status.server": "212cbe47e65e", + "plugins.manager.status.safeMode": "e850fafec9cd", "plugins.manager.groups.running": "73989d9c5926", "plugins.manager.groups.notRunning": "2a2cd4b515b7", "plugins.manager.groups.onServer": "63ca7153c2d1", @@ -1132,9 +1137,6 @@ "plugins.manager.sort.name": "709a23220f2c", "plugins.manager.sort.updated": "5f0500ccf919", "plugins.manager.sort.category": "a3c686e711e4", - "plugins.manager.columns.title": "cf723c59b62a", - "plugins.manager.columns.auto": "c614ba7c453c", - "plugins.manager.columns.count": "e8f2c5983611", "plugins.manager.table.name": "709a23220f2c", "plugins.manager.table.description": "55f8ebc805e6", "plugins.manager.table.version": "2da600bf9404", @@ -1392,6 +1394,9 @@ "panel.listView": "a1fffaaafb7c", "panel.compactRows": "b17f552f3b98", "panel.comfortableRows": "4c5b39191ead", + "panel.columns.title": "cf723c59b62a", + "panel.columns.auto": "c614ba7c453c", + "panel.columns.count": "e8f2c5983611", "fields.secretSaved": "81d793988e7a", "manage.needsAttention": "75674c819d3e", "manage.hostCount": "4279c8d92ede", diff --git a/src/ui/plugin-host/loader.ts b/src/ui/plugin-host/loader.ts index 3f3bc2231..d4ccc61e2 100644 --- a/src/ui/plugin-host/loader.ts +++ b/src/ui/plugin-host/loader.ts @@ -130,6 +130,7 @@ function serverAllowsFrontend(summary: PluginSummary): boolean { summary.enabled && summary.state !== "failed" && summary.state !== "blocked" && + summary.state !== "safe-mode" && !!summary.frontend ); } diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 77857bb29..4f0b8b93d 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -289,6 +289,7 @@ export { PanelShell, Segmented, ViewToggle, + type GridSize, type PanelDensity, type PanelViewMode, } from "@/components/panel-layout"; diff --git a/src/ui/plugin-host/view-ownership.ts b/src/ui/plugin-host/view-ownership.ts index 86fdb4d17..a72dd01a7 100644 --- a/src/ui/plugin-host/view-ownership.ts +++ b/src/ui/plugin-host/view-ownership.ts @@ -74,7 +74,9 @@ export function unregisteredViewStatus( return { status: getPluginStoreState().loaded ? "missing" : "loading" }; } const serverState = owner.summary.state; - if (!owner.summary.enabled) return { status: "disabled", owner }; + if (!owner.summary.enabled || serverState === "safe-mode") { + return { status: "disabled", owner }; + } if ( owner.frontend === "failed" || owner.frontend === "blocked" || diff --git a/src/ui/plugins/PluginsTab.tsx b/src/ui/plugins/PluginsTab.tsx index 9e8f83396..61639e178 100644 --- a/src/ui/plugins/PluginsTab.tsx +++ b/src/ui/plugins/PluginsTab.tsx @@ -5,7 +5,6 @@ import { Check, ChevronLeft, ChevronRight, - Columns3, Download, Globe, Library, @@ -37,7 +36,9 @@ import { GroupHeading, PANEL, PanelShell, + GRID_SIZES, ViewToggle, + type GridSize, type PanelViewMode, } from "@/components/panel-layout"; import { DataView, type DataColumn } from "@/components/data-view"; @@ -72,9 +73,6 @@ import { DocsLink } from "@/components/docs-link"; type Section = "installed" | "browse" | "updates" | "registries"; -const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const; -type GridSize = (typeof GRID_SIZES)[number]; - interface Layout { view: PanelViewMode; columns: GridSize; @@ -364,54 +362,16 @@ function LayoutControls({ onView: (next: PanelViewMode) => void; onColumns: (next: GridSize) => void; }) { - const { t } = useTranslation(); return ( - <> - {layout.view === "grid" && ( - - )} - - + ); } -// Sized off the tab's own width, not the window. Cards keep a min width, -// so narrow panes drop columns on their own. -function gridStyle(columns: GridSize): React.CSSProperties { - if (columns === "auto") { - return { - gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 400px), 1fr))", - }; - } - const n = Number(columns); - const gaps = (n - 1) * 12; - return { - gridTemplateColumns: `repeat(auto-fill, minmax(max(220px, calc((100% - ${gaps}px) / ${n} - 0.5px)), 1fr))`, - }; -} - function PluginCollection({ plugins, layout, @@ -440,13 +400,17 @@ function PluginCollection({ ); } return ( -
- {plugins.map((p) => ( -
- {renderCard(p)} -
- ))} -
+ p.id} + renderCard={renderCard} + columns={{}} + size={layout.columns} + minCardWidth={400} + listColumns={listColumns} + empty={null} + /> ); } @@ -1133,7 +1097,9 @@ function InstalledCard({ {plugin.status === "server" ? t("plugins.manager.status.server") - : t("plugins.manager.status.disabled")} + : plugin.status === "safeMode" + ? t("plugins.manager.status.safeMode") + : t("plugins.manager.status.disabled")}
) : ( @@ -1226,11 +1192,15 @@ function StatusCell({ plugin }: { plugin: PluginEntry }) { ? t("plugins.manager.status.failed") : plugin.status === "server" ? t("plugins.manager.status.server") - : !running - ? t("plugins.manager.status.disabled") - : plugin.pinnedVersion - ? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion }) - : t("plugins.manager.status.running"); + : plugin.status === "safeMode" + ? t("plugins.manager.status.safeMode") + : !running + ? t("plugins.manager.status.disabled") + : plugin.pinnedVersion + ? t("plugins.manager.pinnedTo", { + version: plugin.pinnedVersion, + }) + : t("plugins.manager.status.running"); return ( { + if (!safeMode) return; + toast.warning(t("safeMode.title"), { + id: TOAST_ID, + description: t("safeMode.description"), + duration: Infinity, + closeButton: true, + }); + }, [safeMode, t]); + + return null; +} diff --git a/src/ui/shell/ShellRoot.tsx b/src/ui/shell/ShellRoot.tsx index 1f6a4ce0c..1e28834a2 100644 --- a/src/ui/shell/ShellRoot.tsx +++ b/src/ui/shell/ShellRoot.tsx @@ -3,6 +3,7 @@ import { ServerStatusProvider } from "@/lib/ServerStatusContext"; import { ComponentSlot } from "@/shell/ActionSlot"; import { OnboardingHost } from "@/onboarding/OnboardingHost"; import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts"; +import { SafeModeNotice } from "@/shell/SafeModeNotice"; import { TabPortals } from "@/shell/stage/TabPortals"; import { LayoutHost } from "@/shell/layout/LayoutHost"; import { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider"; @@ -25,6 +26,7 @@ export function ShellRoot({ + {isElectron() && } diff --git a/src/ui/tests/components/data-view.test.tsx b/src/ui/tests/components/data-view.test.tsx index c572e208f..d4a8c4bc5 100644 --- a/src/ui/tests/components/data-view.test.tsx +++ b/src/ui/tests/components/data-view.test.tsx @@ -34,6 +34,24 @@ describe("DataView", () => { expect(screen.queryByText("Name")).toBeNull(); }); + it("uses the picked size over breakpoint columns", () => { + const { container } = render( + item.id} + renderCard={(item) =>
card {item.name}
} + columns={{ base: 1, md: 2 }} + size="3" + listColumns={[]} + empty={null} + />, + ); + const grid = container.firstElementChild as HTMLElement; + expect(grid.className).not.toContain("md:grid-cols-2"); + expect(grid.style.gridTemplateColumns).toContain("/ 3"); + }); + it("renders a header and clickable rows in list mode", () => { const onRowClick = vi.fn(); renderView("list", onRowClick); diff --git a/src/ui/tests/components/panel-layout.test.tsx b/src/ui/tests/components/panel-layout.test.tsx index 905aba277..039e156b1 100644 --- a/src/ui/tests/components/panel-layout.test.tsx +++ b/src/ui/tests/components/panel-layout.test.tsx @@ -11,6 +11,7 @@ import { PanelShell, Segmented, ViewToggle, + gridSizeStyle, } from "@/components/panel-layout"; afterEach(cleanup); @@ -72,6 +73,34 @@ describe("ViewToggle", () => { fireEvent.click(screen.getByLabelText("panel.compactRows")); expect(onDensity).toHaveBeenCalledWith("compact"); }); + + it("shows the columns picker only in grid view", () => { + const props = { onView: vi.fn(), columns: "auto" as const }; + const { rerender } = render( + , + ); + expect(screen.getByLabelText("panel.columns.title")).toBeTruthy(); + + rerender(); + expect(screen.queryByLabelText("panel.columns.title")).toBeNull(); + + rerender(); + expect(screen.queryByLabelText("panel.columns.title")).toBeNull(); + }); +}); + +describe("gridSizeStyle", () => { + it("fits auto cards to the pane", () => { + expect(gridSizeStyle("auto", 280).gridTemplateColumns).toBe( + "repeat(auto-fill, minmax(min(100%, 280px), 1fr))", + ); + }); + + it("splits the width for a fixed count", () => { + expect(gridSizeStyle("4").gridTemplateColumns).toContain( + "(100% - 3 * 0.5rem) / 4", + ); + }); }); describe("Facts", () => { diff --git a/src/ui/tests/plugin-host/loader.test.tsx b/src/ui/tests/plugin-host/loader.test.tsx index 421d7d401..3b7c390b5 100644 --- a/src/ui/tests/plugin-host/loader.test.tsx +++ b/src/ui/tests/plugin-host/loader.test.tsx @@ -236,6 +236,13 @@ describe("plugin loader", () => { expect(railIds()).not.toContain("headless-panel"); }); + it("skips plugins safe mode keeps off", async () => { + modules.paused = railPlugin("paused"); + plugins = [summary("paused", { state: "safe-mode" })]; + await syncPlugins(); + expect(railIds()).not.toContain("paused-panel"); + }); + it("keeps every plugin when the pre-login list lands after sign-in", async () => { modules.login = railPlugin("login"); modules.alpha = railPlugin("alpha"); diff --git a/src/ui/tests/plugins/PluginsTab.test.tsx b/src/ui/tests/plugins/PluginsTab.test.tsx index 1b5ae65e6..64d5fd0a0 100644 --- a/src/ui/tests/plugins/PluginsTab.test.tsx +++ b/src/ui/tests/plugins/PluginsTab.test.tsx @@ -183,17 +183,13 @@ describe("PluginsTab", () => { it("switches to a table and remembers it", async () => { await renderTab(); expect(screen.queryByRole("table")).toBeNull(); - expect( - screen.getByLabelText(en.plugins.manager.columns.title), - ).toBeTruthy(); + expect(screen.getByLabelText(en.panel.columns.title)).toBeTruthy(); fireEvent.click(screen.getByLabelText(en.panel.listView)); expect(screen.getByRole("table")).toBeTruthy(); expect(screen.getByText(en.plugins.manager.table.version)).toBeTruthy(); - expect( - screen.queryByLabelText(en.plugins.manager.columns.title), - ).toBeNull(); + expect(screen.queryByLabelText(en.panel.columns.title)).toBeNull(); expect(localStorage.getItem("pluginsTab.view")).toBe("list"); cleanup(); diff --git a/src/ui/tests/plugins/plugin-model.test.ts b/src/ui/tests/plugins/plugin-model.test.ts index 8a87b7783..ea95a7f20 100644 --- a/src/ui/tests/plugins/plugin-model.test.ts +++ b/src/ui/tests/plugins/plugin-model.test.ts @@ -157,6 +157,7 @@ describe("pluginStatus", () => { "disabled", ); expect(pluginStatus(summary())).toBe("running"); + expect(pluginStatus(summary({ state: "safe-mode" }))).toBe("safeMode"); expect(pluginStatus(summary({ state: "failed", runsOnServer: true }))).toBe( "server", ); diff --git a/src/ui/tests/shell/SafeModeNotice.test.tsx b/src/ui/tests/shell/SafeModeNotice.test.tsx new file mode 100644 index 000000000..36079bb44 --- /dev/null +++ b/src/ui/tests/shell/SafeModeNotice.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; + +const toast = vi.hoisted(() => ({ warning: vi.fn() })); +vi.mock("sonner", () => ({ toast })); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const look = vi.hoisted(() => ({ safeMode: false })); +vi.mock("@/look/look-store", () => ({ + useLookState: () => ({ + loaded: true, + response: { safeMode: look.safeMode }, + }), +})); + +const { SafeModeNotice } = await import("@/shell/SafeModeNotice"); + +afterEach(() => { + cleanup(); + toast.warning.mockReset(); +}); + +describe("SafeModeNotice", () => { + it("shows nothing when safe mode is off", () => { + look.safeMode = false; + render(); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it("shows a lasting warning when the server runs in safe mode", () => { + look.safeMode = true; + render(); + expect(toast.warning).toHaveBeenCalledWith( + "safeMode.title", + expect.objectContaining({ + id: "safe-mode", + description: "safeMode.description", + duration: Infinity, + }), + ); + }); +});