Compare commits

...
8 Commits
Author SHA1 Message Date
LukeGus a4d8a52928 feat: add host-scripts entry to the plugin sdk 2026-10-24 14:30:17 -05:00
LukeGus 3d92725ce6 feat: let the login page send the token back to the mobile app 2026-10-23 15:52:36 -05:00
LukeGus 73c15f7c58 chore: bump Homebrew cask to 26.10.5 2026-10-22 19:26:51 +00:00
Luke GustafsonandLukas 96b8520fb4 v26.10.5 (#1710)
* feat: add an auto or fixed columns picker to card grids

* feat: add safe mode that only runs layout and theme plugins

* docs: add simple layout to plugin table

* fix: ask for local network access in the macOS app

* feat: allow plugin registries on listed private hosts (#1705)

* chore: add private registry hosts to the changelog

* feat: use the proxy for plugin registry downloads

* fix: allow changing connection origin on shared hosts

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.5

---------

Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
2026-10-22 13:30:48 -05:00
LukeGus 7b7b17e31f chore: add six new plugins to the readme table 2026-10-21 16:25:59 -05:00
LukeGus 53c4e5fe9c fix: let the repair screen turn the layout plugin back on 2026-10-21 11:55:50 -05:00
LukeGus 28cb1bc554 feat: make all buttons brand orange and lint against white ones 2026-10-21 10:44:53 -05:00
LukeGus b691d4f7cb fix: retry dmg detach in macOS sharp check 2026-10-21 09:54:43 -05:00
123 changed files with 5280 additions and 514 deletions

No files matched your search

+15 -3
View File
@@ -41,9 +41,15 @@ on:
default: "" default: ""
jobs: jobs:
# One image per plugin profile (docker/bundled-plugins.json). standard keeps
# the plain tags; minimal and full add a suffix.
build: build:
runs-on: blacksmith-8vcpu-ubuntu-2404 runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20 timeout-minutes: 20
strategy:
fail-fast: false
matrix:
profile: [standard, minimal, full]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
@@ -80,12 +86,17 @@ jobs:
TAGS=() TAGS=()
ALL_TAGS=() ALL_TAGS=()
SUFFIX=""
if [ "${{ matrix.profile }}" != "standard" ]; then
SUFFIX="-${{ matrix.profile }}"
fi
if [ "$BUILD_TYPE" = "Production" ]; then if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("$VERSION" "latest") TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX")
elif [ "$BUILD_TYPE" = "Beta" ]; then elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta" "$VERSION") TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX")
else else
TAGS+=("dev-$VERSION") TAGS+=("dev-$VERSION$SUFFIX")
fi fi
for tag in "${TAGS[@]}"; do for tag in "${TAGS[@]}"; do
@@ -124,6 +135,7 @@ jobs:
tags: ${{ env.ALL_TAGS }} tags: ${{ env.ALL_TAGS }}
build-args: | build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
PLUGIN_PROFILE=${{ matrix.profile }}
labels: | labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }} org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
+15 -7
View File
@@ -161,9 +161,10 @@ jobs:
echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT" echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
# Translates new or changed strings with the Termix LLM server and commits # Translates new or changed strings with the Termix LLM server and pins the
# them to the dev branch, so the beta or stable build includes them. If the # bundled plugins to their newest registry releases, then commits both to the
# server is down it warns and the release goes on with what is there. # 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: translate:
needs: [prep] needs: [prep]
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }} if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
@@ -191,19 +192,26 @@ jobs:
languages: src/ui/locales/languages.json languages: src/ui/locales/languages.json
api-key: ${{ secrets.TERMIX_LLM_API_KEY }} 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 id: commit
run: | run: |
git add src/ui/locales git add src/ui/locales docker/bundled-plugins.json
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No translation changes." echo "No translation changes."
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then 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 git --no-pager diff --cached --stat
else else
git config user.name "LukeGus" git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com" 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 }}" git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
fi fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+25
View File
@@ -1,5 +1,30 @@
# Changelog # Changelog
## 26.10.6
### Added
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
## 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 ## 26.10.4
### Fixed ### Fixed
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do cask "termix" do
version "26.10.3" version "26.10.5"
sha256 "a0c9db16f144c1e2be6ce2f4ead9268a010d12e0d0608feb34976750661a2239" sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg" url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix" name "Termix"
+7
View File
@@ -66,7 +66,9 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. | | [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. | | [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. | | [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
| [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. |
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. | | [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | | [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | | [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. | | [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
@@ -76,6 +78,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. | | [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. | | [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. | | [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
| [Kubernetes](https://github.com/Termix-SSH/Plugin-Kubernetes) | Manage Kubernetes clusters from your hosts over SSH, with logs, metrics, YAML and a shell. |
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. | | [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. | | [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. | | [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
@@ -83,10 +86,14 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. | | [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. | | [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. | | [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
| [Reverse Proxy](https://github.com/Termix-SSH/Plugin-Reverse-Proxy) | Reverse proxy for TCP, UDP, HTTP and HTTPS with free certificates and Termix login, managed per host. |
| [Samba Shares](https://github.com/Termix-SSH/Plugin-Samba) | Create and manage Samba (SMB) file shares, users and permissions on your hosts from one tab. |
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. | | [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. | | [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
| [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 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. | | [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. | | [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. | | [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. | | [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. |
+15 -4
View File
@@ -34,9 +34,20 @@ WORKDIR /app
COPY . . 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. # 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 \ 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 # Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:24-slim AS opkssh-downloader 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=production-deps /app/node_modules /app/node_modules
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
# Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by # Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved
# getBundledPluginsDir(). # as dist/plugins by getBundledPluginsDir().
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins 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. # 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/package.json ./packages/plugin-sdk/package.json
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
+152 -60
View File
@@ -3,20 +3,23 @@
{ {
"id": "acme-ssl", "id": "acme-ssl",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug",
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f" "sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a",
"tier": "full"
}, },
{ {
"id": "ai", "id": "ai",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.2.0/ai-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug",
"sha256": "3b486133af78ae5168e6f5b8a4c71f3db3403cfdae537ed1ca8d6cc16035d8dd" "sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6",
"tier": "full"
}, },
{ {
"id": "alerts", "id": "alerts",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa", "sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -26,19 +29,22 @@
"id": "audit-tools", "id": "audit-tools",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.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", "id": "automations",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.3/automations-1.0.3.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug",
"sha256": "b5e5b39a053010b72ad1ba03ea6bb05afbbb2b789a86e0e98634d9ada543d02d" "sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea",
"tier": "full"
}, },
{ {
"id": "classic-layout", "id": "classic-layout",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.1/classic-layout-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
"sha256": "6b2d9acd77ef4b33fa0761c1aea7ed3c6171c325ee8febcedff94b5b2bc7be34", "sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
"tier": "minimal",
"defaultLook": true "defaultLook": true
}, },
{ {
@@ -46,19 +52,36 @@
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c", "sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
"tier": "minimal",
"defaultLook": true "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", "id": "credential-tools",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.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", "id": "docker",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.1.0/docker-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug",
"sha256": "da32dfbe9d4cffec5d80c4a1037a79e7591a3d16c50f882db6a146322a50c2b7", "sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -67,8 +90,9 @@
{ {
"id": "file-manager", "id": "file-manager",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.0/file-manager-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
"sha256": "807e9ffa80d6054a5911522721a555e0daa3e50f6edffdebe97efe7c91aea7a2", "sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -78,19 +102,22 @@
"id": "fleets", "id": "fleets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.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", "id": "homepage",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.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", "id": "host-metrics",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.1.0/host-metrics-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
"sha256": "4f1d422ec1d0565c79f94fa814ee0864d556d7367793b11035e68239e49e297f", "sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -100,93 +127,142 @@
"id": "host-status", "id": "host-status",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.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", "id": "hosts-config",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.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", "id": "import-export",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.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", "id": "ldap",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.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", "id": "login-security",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.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", "id": "network-topology",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.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", "id": "opkssh",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.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", "id": "proxmox",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e" "sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
"tier": "full"
}, },
{ {
"id": "remote-desktop", "id": "remote-desktop",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.0/remote-desktop-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug",
"sha256": "0de40896bfeb4b1a702c3ce815355b7b7b0bb4bfe424f33e905bb5a0209d02f6", "sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "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", "id": "secret-sources",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.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", "id": "serial",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715", "sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": "desktop", "recommended": "desktop",
"consent": false "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", "id": "session-recording",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug",
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c" "sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a",
"tier": "full"
}, },
{ {
"id": "session-sharing", "id": "session-sharing",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.1.0/session-sharing-1.1.0.tmxplug",
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804" "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", "id": "snippets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug",
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5", "sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -195,8 +271,9 @@
{ {
"id": "ssh-terminal", "id": "ssh-terminal",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.0/ssh-terminal-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
"sha256": "dacdbadb3c6a0d7014b1de4f62b499f80abf358c7799a6e6326674660b055e4a", "sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -205,8 +282,9 @@
{ {
"id": "sso", "id": "sso",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug",
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e", "sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": false, "recommended": false,
"consent": false, "consent": false,
@@ -217,25 +295,29 @@
"id": "step-ca", "id": "step-ca",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.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", "id": "sync",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.0/sync-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
"sha256": "12ced05523f7c1a9151b5e680726bd31f53888e8bb0047d7bd12a2570ea168bf" "sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
"tier": "standard"
}, },
{ {
"id": "tailscale", "id": "tailscale",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug",
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97" "sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf",
"tier": "full"
}, },
{ {
"id": "telemetry", "id": "telemetry",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec", "sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": true "consent": true
@@ -245,19 +327,22 @@
"id": "termix-identity", "id": "termix-identity",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.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", "id": "tmux-monitor",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.4/tmux-monitor-1.0.4.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug",
"sha256": "42b8a2284e68a321c1736984f5c641b1857c87c6a65ce8cf694c6bc946de2444" "sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006",
"tier": "full"
}, },
{ {
"id": "totp", "id": "totp",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0", "sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -266,8 +351,9 @@
{ {
"id": "tunnels", "id": "tunnels",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.1.0/tunnels-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
"sha256": "b1e4f1b6dd2bbd15a9a240ae1c1d381111c3609258c8946d19493a83f861ea1e", "sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -277,31 +363,36 @@
"id": "vault", "id": "vault",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.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", "id": "wake-on-lan",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug",
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42" "sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8",
"tier": "full"
}, },
{ {
"id": "warpgate", "id": "warpgate",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.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", "id": "web-endpoint",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.3/web-endpoint-1.0.3.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug",
"sha256": "014cbf23c82cab20e485bc4f4c9b8baa779aee9bb3ed644b41f22a9b1be95687" "sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c",
"tier": "full"
}, },
{ {
"id": "webauthn", "id": "webauthn",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de", "sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -310,8 +401,9 @@
{ {
"id": "workspaces", "id": "workspaces",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug",
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b", "sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
+6
View File
@@ -128,6 +128,10 @@
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"hardenedRuntime": true, "hardenedRuntime": true,
"gatekeeperAssess": false, "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", "entitlements": "packaging/build/entitlements.mac.plist",
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist", "entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution", "type": "distribution",
@@ -153,6 +157,8 @@
"artifactName": "termix_macos_${arch}_mas.${ext}", "artifactName": "termix_macos_${arch}_mas.${ext}",
"extendInfo": { "extendInfo": {
"ITSAppUsesNonExemptEncryption": false, "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." "NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
} }
}, },
+12 -1
View File
@@ -298,7 +298,7 @@
{ {
"name": "HTTP_PROXY", "name": "HTTP_PROXY",
"group": "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", "name": "HTTPS_PROXY",
@@ -353,6 +353,11 @@
"group": "plugins", "group": "plugins",
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index." "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", "name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
"group": "plugins", "group": "plugins",
@@ -365,6 +370,12 @@
"default": "false", "default": "false",
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app." "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", "name": "TERMIX_BUNDLED_PLUGINS_DIR",
"group": "plugins", "group": "plugins",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "26.10.4", "version": "26.10.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "26.10.4", "version": "26.10.6",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
+4 -3
View File
@@ -4,7 +4,7 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"version": "26.10.4", "version": "26.10.6",
"description": "Self-hosted, plugin-based server management.", "description": "Self-hosted, plugin-based server management.",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
@@ -18,7 +18,7 @@
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs", "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
"prepare": "husky || true", "prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs", "prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs", "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-button-colors.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint:fix": "eslint --fix .", "lint:fix": "eslint --fix .",
"type-check": "npm run build:sdk && tsc -b --force", "type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run", "test": "vitest run",
@@ -31,7 +31,8 @@
"dev:docker": "node scripts/dev.mjs docker", "dev:docker": "node scripts/dev.mjs docker",
"dev:frontend": "vite", "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": "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", "generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty", "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", "build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
+18
View File
@@ -1,5 +1,23 @@
# Changelog # Changelog
## 1.0.21
Plugin API 1.12.0.
### Added
- `@termix-ssh/plugin-sdk/host-scripts`: Docker, Linux metrics, systemd, journal and OpenRC command builders and parsers, plus `scriptShell()` and `passwordSudoScript()` to send scripts to `bash -s` on stdin. It has no Node or ssh2 imports, so the Termix mobile app runs the same code
## 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 ## 1.0.19
Plugin API 1.11.0. Plugin API 1.11.0.
+8 -2
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.19 | Plugin API 1.11.0 SDK 1.0.21 | Plugin API 1.12.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside 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. `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -335,7 +335,9 @@ try {
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short `ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`. sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal
commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from
`ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
### The desktop app ### The desktop app
@@ -643,6 +645,9 @@ exports with props is `ui.api.txt` in the Termix repo
`PanePrompt` for small prompts. `PanePrompt` for small prompts.
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`, - Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
forms end with `FormFooter`, empty lists show `EmptyState`. 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. - Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors). - 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 - Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
@@ -902,6 +907,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 | | `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
| `@termix-ssh/plugin-sdk/host-scripts` | Docker, metrics, systemd and journal command builders and parsers, scripts sent to `bash -s` |
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth | | `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` | | `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use | | `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.19", "version": "1.0.21",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -81,6 +81,10 @@
"types": "./dist/host-commands.d.ts", "types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js" "default": "./dist/host-commands.js"
}, },
"./host-scripts": {
"types": "./dist/host-scripts/index.d.ts",
"default": "./dist/host-scripts/index.js"
},
"./ssh-certs": { "./ssh-certs": {
"types": "./dist/ssh-certs.d.ts", "types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js" "default": "./dist/ssh-certs.js"
+24 -3
View File
@@ -1711,28 +1711,49 @@ export function usePluginUiPreferences<
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never; 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 * Grid or list, row density and cards per row for one of this plugin's
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those * views, kept in its Appearance area as `<prefix>viewMode`, `<prefix>density`
* keys in contributes.uiPresets so each preset level can pick a default. * and `<prefix>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 = ""): { export function usePluginPanelView(prefix = ""): {
view: "grid" | "list"; view: "grid" | "list";
density: "comfortable" | "compact"; density: "comfortable" | "compact";
compact: boolean; compact: boolean;
columns: PanelGridSize;
setView: (view: "grid" | "list") => void; setView: (view: "grid" | "list") => void;
setDensity: (density: "comfortable" | "compact") => void; setDensity: (density: "comfortable" | "compact") => void;
setColumns: (columns: PanelGridSize) => void;
} { } {
const { values, set } = usePluginUiPreferences(); const { values, set } = usePluginUiPreferences();
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid"; const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
const density = const density =
values[`${prefix}density`] === "compact" ? "compact" : "comfortable"; 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 { return {
view, view,
density, density,
compact: density === "compact", compact: density === "compact",
columns,
setView: (next) => set(`${prefix}viewMode`, next), setView: (next) => set(`${prefix}viewMode`, next),
setDensity: (next) => set(`${prefix}density`, next), setDensity: (next) => set(`${prefix}density`, next),
setColumns: (next) => set(`${prefix}columns`, next),
}; };
} }
+3 -8
View File
@@ -11,6 +11,9 @@
export type { Client, ClientChannel } from "ssh2"; export type { Client, ClientChannel } from "ssh2";
import type { Client, ClientChannel } from "ssh2"; import type { Client, ClientChannel } from "ssh2";
// Homebrew and OrbStack paths a non-interactive shell misses.
import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
export { EXTRA_PATH_DIRS };
export interface ExecResult { export interface ExecResult {
stdout: string; stdout: string;
@@ -114,14 +117,6 @@ export interface PlatformInfo {
osPrettyName: string | null; osPrettyName: string | null;
} }
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
* Extend PATH with their common install locations before probing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
const DOCKER_PROBE = const DOCKER_PROBE =
`command -v docker >/dev/null 2>&1` + `command -v docker >/dev/null 2>&1` +
` || [ -S /var/run/docker.sock ]` + ` || [ -S /var/run/docker.sock ]` +
@@ -0,0 +1,468 @@
/**
* Docker and Podman command builders and output parsers.
*
* Container ids and names are checked with CONTAINER_ID_RE before they go on
* a command line, so they need no quoting. Anything free form is quoted.
*/
import { HostScriptError } from "./text.js";
export type ContainerRuntime = "docker" | "podman";
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getRuntimeLabel(runtime: ContainerRuntime): string {
return runtime === "podman" ? "Podman" : "Docker";
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
export function containerCommand(
runtime: ContainerRuntime | undefined,
args: string,
isWindows = false,
): string {
const cli = `${normalizeContainerRuntime(runtime)} ${args}`;
// cmd and PowerShell don't understand the POSIX env prefix.
return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`;
}
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
export function isContainerId(value: unknown): value is string {
return (
typeof value === "string" &&
value.length <= 256 &&
CONTAINER_ID_RE.test(value)
);
}
function requireContainer(id: string): string {
if (!isContainerId(id))
throw new HostScriptError("That is not a valid container id");
return id;
}
export const CONTAINER_ACTIONS = [
"start",
"stop",
"restart",
"pause",
"unpause",
] as const;
export type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
export function listFormat(isWindows: boolean): string {
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
}
export function statsFormat(isWindows: boolean): string {
return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
}
/** One row of `ps --format listFormat()`. */
export interface ContainerRow {
id: string;
name: string;
image: string;
status: string;
state: string;
ports: string;
created: string;
}
/** CLI arguments, without the runtime, for each container command. */
export const dockerArgs = {
version: () => "--version",
ping: () => "ps",
list: (all: boolean, isWindows = false) =>
`ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`,
inspect: (id: string) => `inspect ${requireContainer(id)}`,
stats: (id: string, isWindows = false) =>
`stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`,
action: (action: ContainerAction, id: string) => {
if (!CONTAINER_ACTIONS.includes(action))
throw new HostScriptError("Unknown action");
return `${action} ${requireContainer(id)}`;
},
remove: (id: string, force: boolean) =>
`rm ${force ? "-f " : ""}${requireContainer(id)}`,
logs: (
id: string,
options: {
tail?: number;
timestamps?: boolean;
since?: string;
until?: string;
} = {},
) => {
let args = `logs ${requireContainer(id)}`;
if (options.tail && options.tail > 0)
args += ` --tail ${Math.floor(options.tail)}`;
if (options.timestamps) args += " --timestamps";
if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) {
args += ` --since ${options.since}`;
}
if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) {
args += ` --until ${options.until}`;
}
return `${args} 2>&1`;
},
states: () => `ps -a --format ${PS_STATE_FORMAT}`,
composeProjects: () => "compose ls -a --format json",
};
/** The version from `docker --version` or `podman --version`. */
export function parseRuntimeVersion(output: string): string {
return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown";
}
export type RuntimeProblem =
"NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR";
/** Sorts a failed `docker ps` into a reason the UI can explain. */
export function classifyRuntimeError(message: string): RuntimeProblem {
if (
/command not found|not found: (docker|podman)|No such file or directory/i.test(
message,
)
) {
return "NOT_INSTALLED";
}
if (message.includes("Cannot connect to the Docker daemon"))
return "DAEMON_NOT_RUNNING";
if (/permission denied/i.test(message)) return "PERMISSION_DENIED";
return "DOCKER_ERROR";
}
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
export function parseContainerList(output: string): Record<string, unknown>[] {
return output
.split("\n")
.filter((line) => line.trim())
.flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
export interface ContainerStats {
cpu: string;
memoryUsed: string;
memoryLimit: string;
memoryPercent: string;
netInput: string;
netOutput: string;
blockRead: string;
blockWrite: string;
pids: string;
}
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
export function parseContainerStats(output: string): ContainerStats {
const raw = JSON.parse(output.trim()) as Record<string, string>;
const split = (value: string | undefined) => {
const [first, second] = (value ?? "").split(" / ");
return [first?.trim() || "0B", second?.trim() || "0B"];
};
const [memoryUsed, memoryLimit] = split(raw.memory);
const [netInput, netOutput] = split(raw.netIO);
const [blockRead, blockWrite] = split(raw.blockIO);
return {
cpu: raw.cpu,
memoryUsed,
memoryLimit,
memoryPercent: raw.memoryPercent,
netInput,
netOutput,
blockRead,
blockWrite,
pids: raw.pids,
};
}
export interface ContainerState {
/** Docker's own state word: running, exited, restarting, ... */
state: string;
/** Health from the status text, when the image declares a healthcheck. */
unhealthy: boolean;
}
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
export function parseContainerStates(
output: string,
): Map<string, ContainerState> {
const states = new Map<string, ContainerState>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as {
name?: string;
state?: string;
status?: string;
};
if (!parsed.name) continue;
states.set(parsed.name, {
state: (parsed.state ?? "").toLowerCase(),
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
});
} catch {
// A partial line is not worth failing the whole poll over.
}
}
return states;
}
/**
* The events a pair of snapshots implies. A container missing from the
* previous snapshot counts as newly seen rather than started, so the first
* poll after a restart does not replay every running container.
*/
export function diffContainerStates(
previous: Map<string, ContainerState>,
current: Map<string, ContainerState>,
): Array<{ container: string; event: DockerEventName }> {
const events: Array<{ container: string; event: DockerEventName }> = [];
for (const [name, now] of current) {
const before = previous.get(name);
if (!before) continue;
if (before.state !== now.state) {
if (now.state === "exited")
events.push({ container: name, event: "exited" });
else if (now.state === "running")
events.push({ container: name, event: "started" });
else if (now.state === "restarting") {
events.push({ container: name, event: "restarting" });
}
}
// A container can go unhealthy while it stays up.
if (!before.unhealthy && now.unhealthy) {
events.push({ container: name, event: "unhealthy" });
}
}
return events;
}
export interface ContainerCounts {
running: number;
stopped: number;
unhealthy: number;
total: number;
}
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
export function countContainers(output: string): ContainerCounts {
const states = [...parseContainerStates(output).values()];
const running = states.filter((s) => s.state === "running").length;
return {
running,
stopped: states.length - running,
unhealthy: states.filter((s) => s.unhealthy).length,
total: states.length,
};
}
// Compose stacks
export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/;
export const COMPOSE_FILES = [
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
];
export const STACK_ACTIONS = [
"deploy",
"redeploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
"down",
"destroy",
] as const;
export type StackAction = (typeof STACK_ACTIONS)[number];
/** Actions that can target one service of a stack. */
export const STACK_SERVICE_ACTIONS: StackAction[] = [
"deploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
];
export type StackState =
"running" | "partial" | "stopped" | "paused" | "not-deployed";
export interface StackSummary {
name: string;
managed: boolean;
state: StackState;
running: number;
total: number;
composeFile: string | null;
}
export interface StackService {
service: string;
name: string;
id: string;
image: string;
state: string;
status: string;
health: string;
ports: string;
}
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
export function parseJsonLines(output: string): Record<string, unknown>[] {
const text = output.trim();
if (!text) return [];
if (text.startsWith("[")) {
try {
return JSON.parse(text) as Record<string, unknown>[];
} catch {
return [];
}
}
return text.split("\n").flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
const str = (value: unknown): string =>
typeof value === "string" ? value : "";
export function parseServices(output: string): StackService[] {
return parseJsonLines(output).map((row) => {
const publishers = Array.isArray(row.Publishers)
? (row.Publishers as Record<string, unknown>[])
: [];
const ports = publishers
.filter((p) => Number(p.PublishedPort) > 0)
.map((p) => `${p.PublishedPort}:${p.TargetPort}`)
.filter((value, index, all) => all.indexOf(value) === index)
.join(", ");
return {
service: str(row.Service),
name: str(row.Name),
id: str(row.ID),
image: str(row.Image),
state: str(row.State),
status: str(row.Status),
health: str(row.Health),
ports,
};
});
}
function stateOf(running: number, paused: number, total: number): StackState {
if (total === 0) return "not-deployed";
if (paused === total) return "paused";
if (running === total) return "running";
if (running > 0) return "partial";
return "stopped";
}
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
export function parseStackStatus(status: string): {
state: StackState;
running: number;
total: number;
} {
let running = 0;
let paused = 0;
let total = 0;
for (const part of status.split(",")) {
const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i);
if (!match) continue;
const count = Number(match[2]);
total += count;
if (match[1] === "running") running += count;
if (match[1] === "paused") paused += count;
}
return { state: stateOf(running, paused, total), running, total };
}
export function stackStateFromServices(services: StackService[]) {
const total = services.length;
const running = services.filter((s) => s.state === "running").length;
const paused = services.filter((s) => s.state === "paused").length;
return { state: stateOf(running, paused, total), running, total };
}
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
export function parseComposeProjects(output: string): StackSummary[] {
return parseJsonLines(output)
.flatMap((project) => {
const name = str(project.Name);
if (!STACK_NAME_RE.test(name)) return [];
return [
{
name,
managed: false,
...parseStackStatus(str(project.Status)),
composeFile: str(project.ConfigFiles).split(",")[0] || null,
},
];
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/** The compose commands one action runs, in order. */
export function composeArgsFor(
action: StackAction,
service: string | undefined,
options: { removeVolumes?: boolean },
): string[] {
const target = service ? ` ${service}` : "";
switch (action) {
case "deploy":
return [`up -d --remove-orphans${target}`];
case "redeploy":
return [`pull${target}`, `up -d --remove-orphans${target}`];
case "pull":
return [`pull${target}`];
case "start":
case "stop":
case "restart":
case "pause":
case "unpause":
return [`${action}${target}`];
case "down":
return ["down --remove-orphans"];
case "destroy":
return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`];
}
}
@@ -0,0 +1,15 @@
/**
* Host scripts: command builders and output parsers for Docker, Linux
* metrics, systemd, the journal and OpenRC.
*
* Pure TypeScript with no Node or ssh2 imports, so the same code runs in a
* plugin backend and in the mobile app. Run a script by sending it on stdin
* to the command from `scriptShell()`.
*/
export * from "./text.js";
export * from "./shell.js";
export * from "./docker.js";
export * from "./metrics.js";
export * from "./systemd.js";
export * from "./journal.js";
export * from "./openrc.js";
@@ -0,0 +1,242 @@
/** journalctl scripts and parsers. */
import { isUnit } from "./systemd.js";
import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js";
export const MAX_JOURNAL_LINES = 5000;
const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM";
export interface JournalQuery {
units: string[];
priority?: number;
since?: string;
until?: string;
boot?: string;
grep?: string;
lines: number;
kernel: boolean;
}
export interface JournalEntry {
/** Unix ms. */
time: number;
priority: number;
unit: string;
ident: string;
pid: string;
message: string;
}
export interface BootView {
offset: number;
id: string;
first: string;
last: string;
}
const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/;
const WORDS = ["today", "yesterday", "now"];
export function isJournalTime(value: string): boolean {
return (
RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value)
);
}
function str(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** Reads journal filters from a query string. Anything odd throws a HostScriptError. */
export function parseJournalQuery(
input: Record<string, unknown>,
): JournalQuery {
const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit];
const units = rawUnits.map(str).filter(Boolean);
for (const unit of units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
}
if (units.length > 20) throw new HostScriptError("Too many units");
const query: JournalQuery = {
units,
lines: 300,
kernel: input.kernel === "true" || input.kernel === true,
};
const priority = str(input.priority);
if (priority) {
const value = Number(priority);
if (!Number.isInteger(value) || value < 0 || value > 7) {
throw new HostScriptError("priority must be 0 to 7");
}
query.priority = value;
}
for (const key of ["since", "until"] as const) {
const value = str(input[key]);
if (!value) continue;
if (!isJournalTime(value))
throw new HostScriptError(`${key} is not a valid time`);
query[key] = value;
}
const boot = str(input.boot);
if (boot) {
if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot))
throw new HostScriptError("boot is not valid");
query.boot = boot;
}
const grep = typeof input.grep === "string" ? input.grep : "";
if (grep) {
if (grep.length > 200 || /[\r\n\0]/.test(grep)) {
throw new HostScriptError("The search is too long");
}
query.grep = grep;
}
const lines = Number(str(input.lines) || 300);
if (Number.isFinite(lines) && lines > 0) {
query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines));
}
return query;
}
/** journalctl arguments for a query, each one quoted. */
export function journalArgs(
query: Omit<JournalQuery, "lines"> & { lines?: number },
options: { follow?: boolean; useGrep?: boolean } = {},
): string {
const args = ["journalctl", "--no-pager", "-q", "-o", "json"];
args.push(`--output-fields=${FIELDS}`);
if (options.follow) args.push("-f");
if (query.lines) args.push("-n", String(Math.floor(query.lines)));
for (const unit of query.units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
args.push("-u", q(unit));
}
if (query.kernel) args.push("-k");
if (query.priority !== undefined)
args.push("-p", String(Math.floor(query.priority)));
if (query.since) args.push("--since", q(query.since));
if (query.until) args.push("--until", q(query.until));
if (query.boot) args.push("-b", q(query.boot));
if (query.grep && options.useGrep !== false) {
args.push("--case-sensitive=false", "-g", q(query.grep));
}
return args.join(" ");
}
export function journalScript(query: JournalQuery): string {
// Older journalctl builds have no -g. Then the search runs on our side instead.
const noGrep = journalArgs(
{ ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines },
{ useGrep: false },
);
return `
command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; }
${journalArgs(query)} 2>/tmp/termix-journal.$$
RC=$?
if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then
rm -f /tmp/termix-journal.$$
echo "@@TERMIX_NOGREP@@"
${noGrep}
exit 0
fi
cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$
exit $RC
`;
}
function messageOf(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return utf8Decode(value.filter((n): n is number => typeof n === "number"));
}
return value == null ? "" : String(value);
}
/** One `journalctl -o json` line, or null when it is not an entry. */
export function parseEntry(line: string): JournalEntry | null {
const text = line.trim();
if (!text.startsWith("{")) return null;
let raw: Record<string, unknown>;
try {
raw = JSON.parse(text) as Record<string, unknown>;
} catch {
return null;
}
const micros = Number(raw.__REALTIME_TIMESTAMP);
const priority = Number(raw.PRIORITY);
return {
time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0,
priority: Number.isInteger(priority) ? priority : 6,
unit: messageOf(raw._SYSTEMD_UNIT),
ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM),
pid: messageOf(raw._PID),
message: messageOf(raw.MESSAGE),
};
}
export function parseJournal(
output: string,
query: JournalQuery,
): JournalEntry[] {
const fallback = output.includes("@@TERMIX_NOGREP@@");
const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output;
let entries = body
.split("\n")
.map(parseEntry)
.filter((entry): entry is JournalEntry => entry !== null);
if (fallback && query.grep) {
const needle = query.grep.toLowerCase();
entries = entries
.filter((entry) => entry.message.toLowerCase().includes(needle))
.slice(-query.lines);
}
return entries;
}
export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`;
export function parseBoots(output: string): BootView[] {
const boots: BootView[] = [];
for (const line of output.split("\n")) {
const match =
/^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec(
line,
);
if (!match) continue;
boots.push({
offset: Number(match[1]),
id: match[2],
first: match[3],
last: match[4] ?? "",
});
}
return boots.reverse();
}
export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`;
export function parseUsage(output: string): string {
return /take up (\S+)/.exec(output)?.[1] ?? "";
}
const SIZE_RE = /^\d{1,6}(K|M|G)$/;
const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/;
export function vacuumScript(body: Record<string, unknown>): string {
const size = str(body.size);
const time = str(body.time);
if (!size && !time) throw new HostScriptError("Give a size or an age");
if (size && !SIZE_RE.test(size))
throw new HostScriptError("Size looks like 500M or 2G");
if (time && !AGE_RE.test(time))
throw new HostScriptError("Age looks like 7d or 4w");
const flags = [
size ? `--vacuum-size=${size}` : "",
time ? `--vacuum-time=${time}` : "",
]
.filter(Boolean)
.join(" ");
return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`;
}
@@ -0,0 +1,433 @@
/**
* Linux metrics: one awk sample of /proc for CPU, memory, load, network and
* processes, plus df parsing for disks.
*/
// One awk reads every /proc file itself, so a sample is one exec of awk instead
// of a shell plus six tools. Output sections are split by @@; each process line
// is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm.
const AWK = String.raw`BEGIN{f="/proc/stat";while((getline l<f)>0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l<f)>0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l<f;print l;close(f);f="/proc/uptime";getline l<f;print l;close(f);print "@@";f="/proc/net/dev";while((getline l<f)>0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i<ARGC;i++){f=ARGV[i];if((getline s<f)>0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`;
/** The live sample command. The page size never changes on a host, so pass it back once known. */
export const liveCmd = (pageBytes?: number) =>
"awk -v pg=" +
(pageBytes ?? '"$(getconf PAGESIZE)"') +
" '" +
AWK +
"' /proc/[0-9]*/stat";
export interface LiveSample {
/** The admin minimum between samples; the client never polls faster. */
minIntervalS?: number;
cpu: number | null;
cpuMix?: { user: number; system: number; iowait: number; steal: number };
cores: Array<number | null>;
coreIds: string[];
memTotalKb: number;
memCachedKb: number;
memFreeKb: number;
memUsedKb: number;
swapTotalKb: number;
swapUsedKb: number;
load: [number, number, number];
net: Array<{ name: string; rxBps: number | null; txBps: number | null }>;
count: number;
running: number;
procs: Array<{
pid: number;
name: string;
cpu: number;
memKb: number;
state: string;
ppid: number;
}>;
/** Seconds since boot. */
uptime?: number;
/** Total bytes received and sent on every interface but lo, since boot. */
netTotals?: { rx: number; tx: number };
}
/** What the next call needs from this one. */
export interface LivePrev {
at: number;
uptime: number;
pageBytes: number;
cpu: Map<string, { total: number; idle: number }>;
/** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */
cpuRaw?: number[];
/** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */
ticks: Map<string, number>;
net: Map<string, { rx: number; tx: number }>;
}
/** Total and idle ticks from a /proc/stat `cpu` line. */
export function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
const TOP_N = 60;
/** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
export function parseLive(
output: string,
prev: LivePrev | undefined,
at = Date.now(),
): { sample: LiveSample; next: LivePrev } {
const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] =
output.split("@@\n");
// Only the first 8 fields: guest and guest_nice are already inside user and nice.
const cpu = new Map<string, { total: number; idle: number }>();
let cpuRaw: number[] | undefined;
for (const l of cpuS.split("\n")) {
const name = l.split(/\s+/)[0];
if (!name.startsWith("cpu")) continue;
if (name === "cpu") cpuRaw = l.split(/\s+/).slice(1, 9).map(Number);
const c = parseCpuLine(
l
.replace(/^cpu\d*/, "cpu")
.split(/\s+/)
.slice(0, 9)
.join(" "),
);
if (c) cpu.set(name, c);
}
// Matched by name, so an offline core does not shift the ones after it.
const pct = (name: string) => {
const p = prev?.cpu.get(name);
const c = cpu.get(name);
if (!p || !c || c.total <= p.total) return null;
const used = c.total - p.total - (c.idle - p.idle);
return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100));
};
// Where the busy time went since the last sample, as percent of all ticks.
let cpuMix: LiveSample["cpuMix"];
if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) {
const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0));
const total = d.reduce((a, b) => a + b, 0);
if (total > 0 && d.every((v) => v >= 0)) {
const p = (n: number) => (n / total) * 100;
cpuMix = {
user: p(d[0] + d[1]),
system: p(d[2] + d[5] + d[6]),
iowait: p(d[4]),
steal: p(d[7]),
};
}
}
const mem: Record<string, number> = {};
for (const l of memS.split("\n")) {
const m = l.match(/^(\w+):\s+(\d+)/);
if (m) mem[m[1]] = Number(m[2]);
}
const [la = "", up = ""] = loadS.trim().split("\n");
const laParts = la.split(/\s+/).map(Number);
const uptime = Number(up.split(/\s+/)[0]) || 0;
const nets = new Map<string, { rx: number; tx: number }>();
for (const l of netS.split("\n")) {
const [name, rest] = l.split(":");
if (!rest || name.trim() === "lo") continue;
const f = rest.trim().split(/\s+/);
nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 });
}
// A reboot makes uptime go backwards, so rates start again.
const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0;
const rate = (now: number, before: number | undefined) =>
dt > 0 && before !== undefined && now >= before
? (now - before) / dt
: null;
const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096;
const pageKb = pageBytes / 1024;
const aggDelta =
prev && cpu.has("cpu") && prev.cpu.has("cpu")
? cpu.get("cpu")!.total - prev.cpu.get("cpu")!.total
: 0;
const cores = [...cpu.keys()].filter((k) => k !== "cpu");
const ncpu = Math.max(1, cores.length);
const ticks = new Map<string, number>();
const procs: LiveSample["procs"] = [];
for (const l of procS.split("\n")) {
const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t");
const name = rest.join("\t");
if (!name) continue;
const key = pid + ":" + start;
const tk = Number(t) || 0;
ticks.set(key, tk);
const before = prev?.ticks.get(key);
procs.push({
pid: Number(pid),
name,
cpu:
before !== undefined && aggDelta > 0 && tk >= before
? ((tk - before) / aggDelta) * ncpu * 100
: 0,
memKb: (Number(rss) || 0) * pageKb,
state: state || "?",
ppid: Number(ppid) || 0,
});
}
procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb);
// Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample.
if (!cpu.has("cpu") || !mem.MemTotal || !uptime) {
throw new Error("live sample unavailable: /proc data missing");
}
const memTotalKb = mem.MemTotal;
const swapTotalKb = mem.SwapTotal ?? 0;
let rxTotal = 0;
let txTotal = 0;
for (const c of nets.values()) {
rxTotal += c.rx;
txTotal += c.tx;
}
return {
sample: {
cpu: pct("cpu"),
cores: cores.map(pct),
coreIds: cores.map((k) => k.slice(3)),
cpuMix,
memTotalKb,
memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0),
memFreeKb: mem.MemFree ?? 0,
memUsedKb: memTotalKb - (mem.MemAvailable ?? 0),
swapTotalKb,
swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0),
load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0],
net: [...nets].map(([name, c]) => ({
name,
rxBps: rate(c.rx, prev?.net.get(name)?.rx),
txBps: rate(c.tx, prev?.net.get(name)?.tx),
})),
count: procs.length,
running: procs.filter((p) => p.state === "R").length,
procs: procs.slice(0, TOP_N),
uptime,
netTotals: { rx: rxTotal, tx: txTotal },
},
next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets },
};
}
// Disks
const DARWIN_DATA_MOUNT = "/System/Volumes/Data";
const PSEUDO_FS_RE =
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
/** GNU df sizes in KiB, without the header. */
export const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
/** GNU df sizes as short human text, without the header. */
export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
export interface DfRow {
filesystem: string;
type: string;
mount: string;
parts: string[];
}
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
export interface MonitoredMount {
path: string;
label?: string;
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
/**
* Parses `df -T -P` style output: Filesystem, Type, then the size columns,
* with Mounted-on last. Pseudo filesystems are left out.
*/
export function parseDfLines(output: string): DfRow[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
return {
filesystem: parts[0] || "",
type: parts[1] || "",
mount: parts[6] || "",
parts,
};
})
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
}
/** The most used real filesystem in a `df -TkP` row set. */
export function findWorstMountIndex(bytesRows: DfRow[]): {
index: number;
usedBytes: number;
totalBytes: number;
} {
let worstIndex = -1;
let worstUsedBytes = -1;
let worstTotalBytes = 0;
bytesRows.forEach((row, index) => {
const totalBytes = Number(row.parts[2]);
const usedBytes = Number(row.parts[3]);
if (
!Number.isFinite(totalBytes) ||
!Number.isFinite(usedBytes) ||
totalBytes <= 0
) {
return;
}
const usedRatio = usedBytes / totalBytes;
const worstRatio =
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
if (usedRatio > worstRatio) {
worstIndex = index;
worstUsedBytes = usedBytes;
worstTotalBytes = totalBytes;
}
});
return {
index: worstIndex,
usedBytes: worstUsedBytes,
totalBytes: worstTotalBytes,
};
}
/**
* Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
* `df -hT -P` human rows into one list. Human rows only supply the display
* text, matched by mount point so a mismatched row count can't shift them.
*/
export function buildFilesystemList(
bytesRows: DfRow[],
humanRows: DfRow[],
blockSizeBytes = 1,
): DiskFilesystem[] {
const aligned = humanRows.length === bytesRows.length;
return bytesRows
.map((row, index) => {
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
const humanRow = aligned
? humanRows[index]
: humanRows.find((h) => h.mount === row.mount);
const percent = Number.isFinite(usedBytes)
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
: null;
return {
filesystem: row.filesystem,
type: row.type,
mount: row.mount,
percent: toFixedNum(percent, 0),
usedHuman: humanRow?.parts[3] || null,
totalHuman: humanRow?.parts[2] || null,
availableHuman: humanRow?.parts[4] || null,
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
totalBytes,
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
};
})
.filter((fs): fs is DiskFilesystem => fs !== null);
}
/**
* The headline disk is the root filesystem. On macOS "/" is the small sealed
* system volume, so the data volume wins there. With no root mount the most
* used one is shown.
*/
export function selectPrimaryFilesystem(
filesystems: DiskFilesystem[],
): DiskFilesystem | null {
if (filesystems.length === 0) return null;
const root =
filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ??
filesystems.find((fs) => fs.mount === "/");
if (root) return root;
let best = filesystems[0];
for (const fs of filesystems) {
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
if (ratio > bestRatio) best = fs;
}
return best;
}
/**
* Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
* ("nfs" matches nfs and nfs4), so network shares can be hidden.
*/
export function filterExcludedFilesystems(
filesystems: DiskFilesystem[],
excludedMounts?: string[] | null,
): DiskFilesystem[] {
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
const normalized = excludedMounts
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
if (normalized.length === 0) return filesystems;
return filesystems.filter((fs) => {
const mount = fs.mount.toLowerCase();
const type = fs.type.toLowerCase();
return !normalized.some(
(entry) => mount === entry || (type && type.includes(entry)),
);
});
}
export function mergeMonitoredFilesystems(
filesystems: DiskFilesystem[],
monitored: MonitoredMount[],
customFilesystems: Array<DiskFilesystem | null>,
): DiskFilesystem[] {
const result = [...filesystems];
monitored.forEach((entry, index) => {
const path = entry.path.trim();
const custom = customFilesystems[index];
if (!path || !custom) return;
const existing = result.find((fs) => fs.mount === path);
if (existing) {
existing.label = entry.label?.trim() || undefined;
return;
}
result.push({
...custom,
mount: path,
label: entry.label?.trim() || undefined,
});
});
return result;
}
/** Host name, OS, kernel and arch in one exec, as key=value lines. */
export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`;
@@ -0,0 +1,94 @@
/** OpenRC scripts, with states mapped onto the words systemd uses. */
import { isOpenRcName, type UnitAction, type UnitSummary } from "./systemd.js";
import { HostScriptError, shellQuote as q } from "./text.js";
export const OPENRC_LIST_SCRIPT = `
rc-status --servicelist 2>/dev/null
echo "@@TERMIX_RUNLEVELS@@"
rc-update show -v 2>/dev/null
exit 0
`;
function activeOf(state: string): { active: string; sub: string } {
switch (state) {
case "started":
return { active: "active", sub: "running" };
case "crashed":
return { active: "failed", sub: "crashed" };
case "starting":
return { active: "activating", sub: "starting" };
case "stopping":
return { active: "deactivating", sub: "stopping" };
case "inactive":
case "hotplugged":
return { active: "inactive", sub: state };
default:
return { active: "inactive", sub: state || "stopped" };
}
}
export function parseOpenRcList(output: string): UnitSummary[] {
const [list = "", levels = ""] = output.split("@@TERMIX_RUNLEVELS@@");
const units = new Map<string, UnitSummary>();
for (const line of list.split("\n")) {
const match = /^\s*(\S+)\s+\[\s*([a-z ]+?)\s*\]/.exec(line);
if (!match) continue;
const { active, sub } = activeOf(match[2]);
units.set(match[1], {
name: match[1],
type: "service",
load: "loaded",
active,
sub,
description: "",
fileState: "disabled",
preset: "",
});
}
for (const line of levels.split("\n")) {
const match = /^\s*(\S+)\s*\|\s*(.*)$/.exec(line);
if (!match) continue;
const runlevels = match[2].trim();
const known = units.get(match[1]) ?? {
name: match[1],
type: "service",
load: "loaded",
active: "inactive",
sub: "stopped",
description: "",
fileState: "disabled",
preset: "",
};
known.fileState = runlevels ? "enabled" : "disabled";
known.description = runlevels ? `Runlevels: ${runlevels}` : "";
units.set(match[1], known);
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function requireName(name: string): string {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
export function openRcActionScript(name: string, action: UnitAction): string {
const s = q(requireName(name));
switch (action) {
case "start":
case "stop":
case "restart":
return `rc-service ${s} ${action} 2>&1 || { echo "SCRIPT_ERR:rc-service ${action} failed"; exit 1; }\n`;
case "enable":
return `rc-update add ${s} default 2>&1 || { echo "SCRIPT_ERR:rc-update add failed"; exit 1; }\n`;
case "disable":
return `rc-update del ${s} 2>&1 || { echo "SCRIPT_ERR:rc-update del failed"; exit 1; }\n`;
default:
throw new HostScriptError("OpenRC can't do that", 400);
}
}
export function openRcStatusScript(name: string): string {
const s = q(requireName(name));
return `rc-service ${s} status 2>&1; rc-update show -v 2>/dev/null | grep -E '^ *'${s}' *\\|'; exit 0\n`;
}
@@ -0,0 +1,117 @@
/**
* How a script reaches the host: the command line runs a shell that reads
* the script from stdin (`bash -s`), so names and values in a script are
* never parsed out of argv.
*/
import { shellQuote, toBase64 } from "./text.js";
// systemctl and friends often live in sbin, which a normal login skips.
// TZ=UTC so tools print every time in UTC, which parses the same anywhere.
const ENV = "SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC";
/** Puts sbin on PATH and sets a plain, UTC environment. */
export const SCRIPT_PRELUDE = `export PATH="$PATH:/usr/sbin:/sbin:/usr/local/sbin"; export ${ENV}; `;
const SUDO = `sudo -n env PATH="$PATH" ${ENV}`;
/**
* - `plain`: the login user.
* - `auto`: root when it can be had without a password, else the login user.
* - `root`: root or passwordless sudo, fails on anything else.
* - `root-only`: root logins only, for hosts where sudo is turned off.
*/
export type ScriptShellMode = "plain" | "auto" | "root" | "root-only";
const ERR = "SCRIPT_ERR:";
/** The command line that runs a script sent on stdin. */
export function scriptShell(mode: ScriptShellMode): string {
switch (mode) {
case "plain":
return `${SCRIPT_PRELUDE}exec bash -s`;
case "auto":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -s; else exec bash -s; fi`;
case "root":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else exec ${SUDO} bash -s; fi`;
case "root-only":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else echo "${ERR}This needs root, or turn on Use sudo for this host"; exit 1; fi`;
}
}
/**
* Runs a script as root with a sudo password. The command is `plain`; all of
* the returned stdin goes to the host. The password is read by the first
* line into a shell variable and piped to `sudo -S`, so it is never in argv,
* never in the environment and never run as a command. Root logins and
* passwordless sudo skip the password. The script itself travels as base64.
*/
export function passwordSudoScript(
script: string,
password: string,
): { command: string; stdin: string } {
if (/[\r\n\0]/.test(password)) {
throw new Error("A sudo password can't contain a line break");
}
const run = `bash -c "$TERMIX_S"`;
const stdin = `IFS= read -r TERMIX_PW
${password}
TERMIX_S=$(printf '%s' ${shellQuote(toBase64(script))} | base64 -d)
if [ "$(id -u)" = "0" ]; then unset TERMIX_PW; exec ${run}
elif sudo -n true 2>/dev/null; then unset TERMIX_PW; exec ${SUDO} ${run}
else P=$TERMIX_PW; unset TERMIX_PW; printf '%s\\n' "$P" | sudo -k -S -p '' env PATH="$PATH" ${ENV} ${run}; exit $?
fi
`;
return { command: scriptShell("plain"), stdin };
}
/** sudo asked for a password or a terminal it did not get. */
export const SUDO_REFUSED_RE =
/password is required|a terminal is required|no tty present/i;
/** sudo got a password and it was wrong. */
export const SUDO_WRONG_RE =
/incorrect password|sorry, try again|no password was provided/i;
/** The user may not use sudo at all. */
export const NOT_SUDOER_RE =
/is not in the sudoers file|not allowed to run sudo/i;
/**
* Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a
* non zero exit to explain a failure.
*/
export function scriptError(stdout: string): string {
return stdout
.split("\n")
.map((line) => {
if (line.startsWith(ERR)) return line.slice(ERR.length);
if (line.startsWith("SERVICES_ERR:")) return line.slice(13);
return null;
})
.filter((line): line is string => line !== null)
.join("\n")
.trim();
}
/** A line a script prints to explain why it stops. */
export function errLine(message: string): string {
return `echo ${shellQuote(ERR + message)}`;
}
/**
* Runs a long command in the background and stops it once stdin closes,
* which happens when the SSH channel ends. A pty can't be trusted for this:
* sudo keeps its own and the command would outlive the viewer.
*/
export function watchdog(command: string): string {
return `${command} &
P=$!
trap 'kill $P 2>/dev/null' EXIT
cat >/dev/null
`;
}
/** The wrapper a streamed command runs in, as one shell command line. */
export function streamCommand(script: string, sudo: boolean): string {
const quoted = shellQuote(`${SCRIPT_PRELUDE}${script}`);
return sudo
? `if [ "$(id -u)" = "0" ]; then exec bash -c ${quoted}; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -c ${quoted}; else exec bash -c ${quoted}; fi`
: `exec bash -c ${quoted}`;
}
@@ -0,0 +1,389 @@
/**
* systemd unit scripts and parsers, plus init system detection.
* Every script is meant for `scriptShell()`, sent on stdin.
*/
import { HostScriptError, parseKeys, shellQuote as q } from "./text.js";
export const UNIT_TYPES = [
"service",
"socket",
"timer",
"target",
"mount",
"automount",
"path",
"swap",
"slice",
"scope",
"device",
] as const;
export type UnitType = (typeof UNIT_TYPES)[number];
// Backslash is allowed for escaped names like dev-disk-by\x2duuid.
const UNIT_RE = new RegExp(
`^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`,
);
const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/;
export function isUnit(name: unknown): name is string {
return typeof name === "string" && UNIT_RE.test(name);
}
export function unitType(name: string): UnitType {
return name.slice(name.lastIndexOf(".") + 1) as UnitType;
}
/** Units a file may be written for. Templates and device style names are out. */
export function isWritableUnitName(name: string): boolean {
return (
isUnit(name) &&
!name.includes("\\") &&
["service", "socket", "timer", "target", "path", "mount"].includes(
unitType(name),
)
);
}
export function isOpenRcName(name: unknown): name is string {
return typeof name === "string" && OPENRC_RE.test(name);
}
export function requireUnit(name: unknown): string {
if (!isUnit(name)) throw new HostScriptError("That is not a valid unit name");
return name;
}
export type InitSystem = "systemd" | "openrc" | "none";
/** A unit name or, on OpenRC, a service name. */
export function requireService(name: unknown, init: string): string {
if (init === "openrc") {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
return requireUnit(name);
}
export interface UnitSummary {
name: string;
type: string;
load: string;
active: string;
sub: string;
description: string;
/** enabled, disabled, static, masked and so on. Empty when unknown. */
fileState: string;
preset: string;
}
export interface UnitDetail {
name: string;
props: Record<string, string>;
status: string;
}
export interface TimerView {
name: string;
description: string;
active: string;
fileState: string;
/** Unix ms, or null when not set. */
next: number | null;
last: number | null;
activates: string;
schedule: string[];
}
export const UNIT_ACTIONS = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
"enable-now",
"disable-now",
"mask",
"unmask",
"reset-failed",
"revert",
] as const;
export type UnitAction = (typeof UNIT_ACTIONS)[number];
/** Actions OpenRC can do. */
export const OPENRC_ACTIONS: readonly UnitAction[] = [
"start",
"stop",
"restart",
"enable",
"disable",
];
export const DETECT_SCRIPT = `
INIT=none
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd
elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi
echo "init=$INIT"
if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi
if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi
echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"
echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"
echo "uid=$(id -u)"
if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi
if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi
if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi
exit 0
`;
export interface Detected {
init: InitSystem;
version: string;
os: string;
hostname: string;
root: boolean;
journal: boolean;
failed: number;
}
export function parseDetect(output: string): Detected {
const keys = parseKeys(output);
const init =
keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none";
return {
init,
version: keys.version ?? "",
os: keys.os ?? "",
hostname: keys.hostname ?? "",
root: keys.uid === "0",
journal: keys.journal === "1",
failed: Number(keys.failed) || 0,
};
}
/** Types the list can show. */
export const LIST_TYPES = [
"service",
"socket",
"timer",
"target",
"path",
"mount",
] as const;
export type ListType = (typeof LIST_TYPES)[number] | "all";
export function listUnitsScript(type: ListType): string {
const types = type === "all" ? LIST_TYPES.join(",") : type;
if (type !== "all" && !LIST_TYPES.includes(type))
throw new HostScriptError("Unknown unit type");
return `
systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
echo "@@TERMIX_FILES@@"
systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
exit 0
`;
}
/** Merges list-units and list-unit-files. Template units are left out. */
export function parseUnitList(output: string): UnitSummary[] {
const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@");
const units = new Map<string, UnitSummary>();
for (const raw of loaded.split("\n")) {
const line = raw.replace(/^[●*\s]+/, "").trimEnd();
if (!line) continue;
const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
if (!match) continue;
const [, name, load, active, sub, description] = match;
if (!name.includes(".") || name.includes("@.")) continue;
units.set(name, {
name,
type: unitType(name),
load,
active,
sub,
description: description.trim(),
fileState: "",
preset: "",
});
}
for (const raw of files.split("\n")) {
const line = raw.trim();
if (!line) continue;
const [name, state = "", preset = ""] = line.split(/\s+/);
if (!name || !name.includes(".") || name.includes("@.")) continue;
const known = units.get(name);
const cleanPreset = preset === "-" ? "" : preset;
if (known) {
known.fileState = state;
known.preset = cleanPreset;
} else {
units.set(name, {
name,
type: unitType(name),
load: state === "masked" ? "masked" : "unloaded",
active: "inactive",
sub: "dead",
description: "",
fileState: state,
preset: cleanPreset,
});
}
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export const DETAIL_PROPS = [
"Id",
"Description",
"LoadState",
"ActiveState",
"SubState",
"UnitFileState",
"UnitFilePreset",
"Result",
"MainPID",
"ExecMainStartTimestamp",
"ActiveEnterTimestamp",
"InactiveEnterTimestamp",
"MemoryCurrent",
"MemoryPeak",
"CPUUsageNSec",
"TasksCurrent",
"NRestarts",
"FragmentPath",
"DropInPaths",
"Requires",
"Wants",
"After",
"WantedBy",
"TriggeredBy",
"Triggers",
"User",
"Group",
"Restart",
"Type",
"ExecStart",
"CanStart",
"CanStop",
"CanReload",
] as const;
export function detailScript(unit: string): string {
const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" ");
return `
systemctl show --no-pager ${props} -- ${q(requireUnit(unit))}
echo "@@TERMIX_STATUS@@"
systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1
exit 0
`;
}
export function parseDetail(unit: string, output: string): UnitDetail {
const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@");
return { name: unit, props: parseKeys(props), status: status.trim() };
}
const VERBS: Record<UnitAction, string> = {
start: "start",
stop: "stop",
restart: "restart",
reload: "reload",
enable: "enable",
disable: "disable",
"enable-now": "enable --now",
"disable-now": "disable --now",
mask: "mask",
unmask: "unmask",
"reset-failed": "reset-failed",
revert: "revert",
};
/** The systemctl call for an action. Prints `state=` after it. */
export function actionScript(unit: string, action: UnitAction): string {
const verb = VERBS[action];
if (!verb) throw new HostScriptError("Unknown action");
const u = q(requireUnit(unit));
return `
systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; }
echo "state=$(systemctl is-active -- ${u} 2>/dev/null)"
`;
}
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
export function parseSystemdTime(value: string | undefined): number | null {
if (!value) return null;
const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value);
if (!match) return null;
const [, y, mo, d, h, mi, s] = match.map(Number);
return Date.UTC(y, mo - 1, d, h, mi, s);
}
export const TIMERS_SCRIPT = `
UNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u)
[ -n "$UNITS" ] || exit 0
systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS
exit 0
`;
/** Reads systemctl show output for many units, split by blank lines. */
export function parseShowBlocks(output: string): Array<Record<string, string>> {
const blocks: Array<Record<string, string>> = [];
for (const block of output.split(/\n\s*\n/)) {
const lines = block.split("\n").filter((line) => line.includes("="));
if (!lines.length) continue;
// A unit can have several TimersCalendar lines; keep all of them.
const keys: Record<string, string> = {};
for (const line of lines) {
const eq = line.indexOf("=");
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
keys[key] = key in keys ? `${keys[key]}\n${value}` : value;
}
blocks.push(keys);
}
return blocks;
}
function schedules(value: string | undefined): string[] {
if (!value) return [];
const out: string[] = [];
for (const line of value.split("\n")) {
const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line);
if (match) out.push(`${match[1]}=${match[2]}`);
}
return out;
}
export function parseTimers(output: string): TimerView[] {
return parseShowBlocks(output)
.filter((keys) => keys.Id?.endsWith(".timer"))
.map((keys) => ({
name: keys.Id,
description: keys.Description ?? "",
active: keys.ActiveState ?? "",
fileState: keys.UnitFileState ?? "",
next: parseSystemdTime(keys.NextElapseUSecRealtime),
last: parseSystemdTime(keys.LastTriggerUSec),
activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "",
schedule: [
...schedules(keys.TimersCalendar),
...schedules(keys.TimersMonotonic),
],
}))
.sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity));
}
/** One line per unit from systemctl is-active, in the order asked. */
export function isActiveScript(units: string[]): string {
return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`;
}
export function parseIsActive(
units: string[],
output: string,
): Map<string, string> {
const lines = output.split("\n").map((line) => line.trim());
const out = new Map<string, string>();
units.forEach((unit, i) => out.set(unit, lines[i] || "unknown"));
return out;
}
@@ -0,0 +1,118 @@
/**
* Quoting, encoding and the error type the host scripts share.
*
* No Node APIs here: this entry also runs in the mobile app.
*/
/** A bad input to a host script builder. `status` is the HTTP status to answer with. */
export class HostScriptError extends Error {
constructor(
message: string,
readonly status = 400,
readonly extra: Record<string, unknown> = {},
) {
super(message);
this.name = "HostScriptError";
}
}
/** Wraps a value in single quotes for a POSIX shell. */
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
// Written by hand because some JS engines (Hermes) have no TextDecoder.
/** UTF-8 bytes of a string. */
export function utf8Encode(text: string): Uint8Array {
const out: number[] = [];
for (const char of text) {
const cp = char.codePointAt(0) ?? 0xfffd;
if (cp < 0x80) out.push(cp);
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 63));
else if (cp < 0x10000) {
out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63));
} else {
out.push(
0xf0 | (cp >> 18),
0x80 | ((cp >> 12) & 63),
0x80 | ((cp >> 6) & 63),
0x80 | (cp & 63),
);
}
}
return Uint8Array.from(out);
}
/** A string from UTF-8 bytes. Bad sequences become U+FFFD. */
export function utf8Decode(bytes: ArrayLike<number>): string {
let out = "";
let i = 0;
while (i < bytes.length) {
const b = bytes[i];
let need = 0;
let cp = 0;
if (b < 0x80) cp = b;
else if (b >= 0xc2 && b < 0xe0) [need, cp] = [1, b & 31];
else if (b >= 0xe0 && b < 0xf0) [need, cp] = [2, b & 15];
else if (b >= 0xf0 && b < 0xf5) [need, cp] = [3, b & 7];
else {
out += "\ufffd";
i++;
continue;
}
let j = 1;
for (; j <= need; j++) {
const next = bytes[i + j];
if (next === undefined || (next & 0xc0) !== 0x80) break;
cp = (cp << 6) | (next & 63);
}
const tooSmall = [0, 0x80, 0x800, 0x10000][need];
if (
j <= need ||
cp < tooSmall ||
cp > 0x10ffff ||
(cp >= 0xd800 && cp < 0xe000)
) {
out += "\ufffd";
i += Math.max(1, j);
continue;
}
out += String.fromCodePoint(cp);
i += need + 1;
}
return out;
}
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/** Base64 of the UTF-8 bytes of a string, for `base64 -d` on the host. */
export function toBase64(text: string): string {
const bytes = utf8Encode(text);
let out = "";
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i];
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
const n = (a << 16) | (b << 8) | c;
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : "=";
out += i + 2 < bytes.length ? B64[n & 63] : "=";
}
return out;
}
/** Reads key=value lines. Only the first = splits a line. */
export function parseKeys(output: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of output.split("\n")) {
const eq = line.indexOf("=");
if (eq > 0) out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return out;
}
/** Trims output and keeps it short enough to show. */
export function scrub(text: string, max = 600): string {
return text.trim().slice(0, max);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core * "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server. * 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. */ /** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String( export const SUPPORTED_PLUGIN_API_VERSION = String(
+3 -2
View File
@@ -80,7 +80,7 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder" DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId DashboardCardId (type): DashboardCardId
DataColumn (type): DataColumn<T> DataColumn (type): DataColumn<T>
DataView (value): <T>({ 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<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element DataView (value): <T>({ 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<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DefaultOverrides (type): DefaultOverrides DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
DesktopUpdateMode (type): DesktopUpdateMode DesktopUpdateMode (type): DesktopUpdateMode
@@ -131,6 +131,7 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan GridColSpan (type): GridColSpan
GridColumns (type): GridColumns GridColumns (type): GridColumns
GridLayout (type): GridLayout GridLayout (type): GridLayout
GridSize (type): GridSize
GridSlot (type): GridSlot GridSlot (type): GridSlot
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element 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 HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
@@ -321,7 +322,7 @@ UserRole (type): UserRole
UserSecondFactorSummary (type): UserSecondFactorSummary UserSecondFactorSummary (type): UserSecondFactorSummary
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
VersionInfo (type): VersionInfo 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 WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
actionOrder (value): (action: HostActionDef) => number actionOrder (value): (action: HostActionDef) => number
+7 -2
View File
@@ -3,6 +3,8 @@
* *
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves * getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes. * 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 * Each .tmxplug is downloaded (or read from a path), checked against its
* pinned sha256 and unpacked. * pinned sha256 and unpacked.
* *
@@ -25,13 +27,16 @@ const {
localSdkBuildCommand, localSdkBuildCommand,
copyLocalBuild, copyLocalBuild,
buildBundledIndex, buildBundledIndex,
resolveProfile,
pluginsForProfile,
} = require("./lib/bundled-plugins.cjs"); } = require("./lib/bundled-plugins.cjs");
const root = path.resolve(__dirname, ".."); const root = path.resolve(__dirname, "..");
const destination = path.join(root, "dist", "plugins"); const destination = path.join(root, "dist", "plugins");
async function main() { async function main() {
const plugins = loadBundledPlugins(root); const profile = resolveProfile();
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
const flag = process.argv.indexOf("--local"); const flag = process.argv.indexOf("--local");
const localArg = const localArg =
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS; flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
@@ -74,7 +79,7 @@ async function main() {
); );
console.log( console.log(
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`, `Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`,
); );
} }
+102
View File
@@ -16,6 +16,8 @@ import {
localSdkBuildCommand, localSdkBuildCommand,
rebuildLocalPlugins, rebuildLocalPlugins,
copyLocalBuild, copyLocalBuild,
resolveProfile,
pluginsForProfile,
} from "./lib/bundled-plugins.cjs"; } from "./lib/bundled-plugins.cjs";
const cleanups: Array<() => void> = []; const cleanups: Array<() => void> = [];
@@ -263,10 +265,52 @@ describe("pinsFromIndex", () => {
source: "tmxplug", source: "tmxplug",
url: "https://example.test/beta-1.1.0.tmxplug", url: "https://example.test/beta-1.1.0.tmxplug",
sha256: "b".repeat(64), sha256: "b".repeat(64),
tier: "full",
}); });
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]); 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", () => { it("keeps the onboarding defaults of an existing pin", () => {
const current = [ const current = [
{ id: "beta", onboarding: { recommended: true, consent: false } }, { 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); 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,
);
});
});
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* Buttons are never white.
*
* Every Button variant is the brand orange with a different background
* opacity (or red for destructive, or transparent for ghost). Fails when a
* variant in src/ui/components/button.tsx uses a neutral fill, or when a
* <Button> anywhere in core or the local plugin checkouts sets a neutral fill
* in its className.
*/
const fs = require("node:fs");
const path = require("node:path");
const NEUTRAL =
/(?:^|[\s"'`])(?:dark:)?(?:bg-(?:primary|white|foreground|background|secondary|card|popover|muted|accent|input|neutral-\d+|zinc-\d+|gray-\d+|slate-\d+|stone-\d+)(?![\w-])|text-primary-foreground)/;
/** The opening tag text of each <Button ...> in a source file. */
function buttonTags(source) {
const tags = [];
const re = /<Button(?=[\s/>])/g;
let match;
while ((match = re.exec(source))) {
let depth = 0;
let quote = "";
let i = match.index + match[0].length;
for (; i < source.length; i++) {
const c = source[i];
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = "";
} else if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) break;
}
tags.push(source.slice(match.index, i + 1));
}
return tags;
}
/** Variant strings from the cva block of button.tsx. */
function variantLines(source) {
const start = source.indexOf("variant: {");
const end = source.indexOf("size: {");
if (start < 0 || end < 0) return [];
return source
.slice(start, end)
.split("\n")
.filter((line) => /["`]/.test(line) && !line.includes("variant: {"));
}
function walk(dir, out) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (["node_modules", "dist", "tests", "__fixtures__"].includes(entry.name))
continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.name.endsWith(".tsx")) out.push(full);
}
}
/** Problems found under the given roots. */
function scan(roots, buttonFile) {
const problems = [];
if (buttonFile && fs.existsSync(buttonFile)) {
for (const line of variantLines(fs.readFileSync(buttonFile, "utf8"))) {
if (NEUTRAL.test(line)) {
problems.push(
`${buttonFile}: neutral fill in a variant: ${line.trim()}`,
);
}
}
}
const files = [];
for (const root of roots) walk(root, files);
for (const file of files) {
for (const tag of buttonTags(fs.readFileSync(file, "utf8"))) {
if (NEUTRAL.test(tag)) {
problems.push(
`${file}: <Button> with a neutral fill: ${tag.slice(0, 120)}`,
);
}
}
}
return problems;
}
module.exports = { scan, buttonTags, variantLines };
if (require.main === module) {
const core = path.resolve(__dirname, "..");
const plugins = path.resolve(core, "..", "Plugins");
const roots = [path.join(core, "src", "ui")];
if (fs.existsSync(plugins)) {
for (const entry of fs.readdirSync(plugins)) {
roots.push(path.join(plugins, entry, "src"));
}
}
const problems = scan(
roots,
path.join(core, "src", "ui", "components", "button.tsx"),
);
if (problems.length) {
console.error("Buttons must be the brand orange, never white or neutral:");
for (const p of problems) console.error(" " + p);
process.exit(1);
}
console.log("ok button colors");
}
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { scan, buttonTags } = require("./check-button-colors.cjs") as {
scan: (roots: string[], buttonFile?: string) => string[];
buttonTags: (source: string) => string[];
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "button-colors-"));
for (const [file, content] of Object.entries(files)) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
return root;
}
describe("buttonTags", () => {
it("reads a tag with arrow functions inside", () => {
const tags = buttonTags(
`<Button onClick={() => go(a > b)} className="x">Go</Button>`,
);
expect(tags).toHaveLength(1);
expect(tags[0]).toContain('className="x"');
});
});
describe("check-button-colors", () => {
it("accepts orange buttons and hover fills", () => {
const dir = fixture({
"a.tsx": `<Button className="bg-accent-brand/15 text-accent-brand hover:bg-muted" />`,
});
expect(scan([dir])).toEqual([]);
});
it("rejects a white or neutral fill on a Button", () => {
for (const cls of [
"bg-primary",
"bg-white",
"bg-foreground text-background",
"dark:bg-secondary",
"bg-muted",
"text-primary-foreground",
]) {
const dir = fixture({ "a.tsx": `<Button className="${cls}">x</Button>` });
expect(scan([dir]), cls).toHaveLength(1);
}
});
it("rejects a neutral fill in a variant", () => {
const dir = fixture({
"button.tsx": `variant: {\n default: "bg-primary text-primary-foreground",\n ghost: "hover:bg-muted",\n},\nsize: {`,
});
expect(scan([], path.join(dir, "button.tsx"))).toHaveLength(1);
});
it("passes on the real button", () => {
const button = path.resolve(
__dirname,
"..",
"src",
"ui",
"components",
"button.tsx",
);
expect(scan([], button)).toEqual([]);
});
});
+1
View File
@@ -151,6 +151,7 @@ const ALLOWED = [
"GridCardCatalogEntry", "GridCardCatalogEntry",
"GridColSpan", "GridColSpan",
"GridColumns", "GridColumns",
"GridSize",
"GridLayout", "GridLayout",
"GridSlot", "GridSlot",
"GroupHeading", "GroupHeading",
+49 -2
View File
@@ -16,6 +16,11 @@
* environment variables is set, for a server configured before first boot. * environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked. * 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 * "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 * 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 * 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 ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]); const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
const SHA256 = /^[0-9a-f]{64}$/; const SHA256 = /^[0-9a-f]{64}$/;
const PROFILES = ["minimal", "standard", "full"];
const DEFAULT_PROFILE = "standard";
function parseOnboarding(value, id, problems) { function parseOnboarding(value, id, problems) {
if (value === undefined) return null; if (value === undefined) return null;
@@ -122,6 +129,9 @@ function parseBundledPlugins(raw) {
) { ) {
problems.push(`${entry.id} defaultLook must be a boolean`); 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; if (problems.length > before) continue;
plugins.push({ plugins.push({
id: entry.id, id: entry.id,
@@ -131,6 +141,7 @@ function parseBundledPlugins(raw) {
sha256: entry.sha256, sha256: entry.sha256,
onboarding, onboarding,
defaultLook: entry.defaultLook === true, defaultLook: entry.defaultLook === true,
tier: entry.tier ?? "full",
}); });
} else { } else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`); problems.push(`${entry.id} has an unknown source "${entry.source}"`);
@@ -241,12 +252,35 @@ function buildBundledIndex(plugins, stagedIds) {
return { version: 1, plugins: out }; 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 * 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 * 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 plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry])); 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 ?? [])] const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
.filter((entry) => semver.valid(entry.version)) .filter((entry) => semver.valid(entry.version))
.filter((entry) => !semver.prerelease(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]; .sort((a, b) => semver.rcompare(a.version, b.version))[0];
if (!latest) throw new Error(`${id} is not in the registry index`); if (!latest) throw new Error(`${id} is not in the registry index`);
const onboarding = currentById.get(id)?.onboarding; const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true; const defaultLook = currentById.get(id)?.defaultLook === true;
const tier = currentById.get(id)?.tier ?? "full";
return { return {
id, id,
source: "tmxplug", source: "tmxplug",
url: latest.url, url: latest.url,
sha256: latest.sha256, sha256: latest.sha256,
tier,
...(onboarding ? { onboarding } : {}), ...(onboarding ? { onboarding } : {}),
...(defaultLook ? { defaultLook } : {}), ...(defaultLook ? { defaultLook } : {}),
}; };
@@ -409,6 +453,9 @@ function copyLocalBuild(repo, destination) {
} }
module.exports = { module.exports = {
PROFILES,
resolveProfile,
pluginsForProfile,
findLocalPluginBuilds, findLocalPluginBuilds,
isLocalBuildStale, isLocalBuildStale,
localSdkBuildCommand, localSdkBuildCommand,
+7 -2
View File
@@ -4,7 +4,10 @@
* re-released. * re-released.
* *
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed * 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"); const fs = require("node:fs");
@@ -22,6 +25,8 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
async function main() { async function main() {
const all = process.argv.includes("--all"); 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); const response = await fetch(INDEX_URL);
if (!response.ok) { if (!response.ok) {
throw new Error(`registry index download failed with ${response.status}`); throw new Error(`registry index download failed with ${response.status}`);
@@ -30,7 +35,7 @@ async function main() {
const current = loadBundledPlugins(root); const current = loadBundledPlugins(root);
const ids = all ? null : current.map((plugin) => plugin.id); 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`); fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
console.log( console.log(
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`, `Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
+12 -2
View File
@@ -137,9 +137,19 @@ function verifyArtifact(artifact) {
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`); console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
} finally { } finally {
if (mountedAt) { if (mountedAt) {
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" }); for (let attempt = 0; attempt < 5; attempt++) {
const args =
attempt < 3 ? ["detach", mountedAt] : ["detach", "-force", mountedAt];
if (spawnSync("hdiutil", args, { stdio: "ignore" }).status === 0) break;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
}
} }
fs.rmSync(temporaryRoot, { recursive: true, force: true }); fs.rmSync(temporaryRoot, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 1000,
});
} }
} }
+5 -1
View File
@@ -18,6 +18,7 @@ import {
safeLayoutForced, safeLayoutForced,
unavailableLooks, unavailableLooks,
} from "../../plugins/looks.js"; } from "../../plugins/looks.js";
import { safeModeOn } from "../../plugins/safe-mode.js";
const router = express.Router(); const router = express.Router();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
@@ -36,7 +37,7 @@ async function adminDefaults() {
* /user-preferences/look: * /user-preferences/look:
* get: * get:
* summary: Get the layout and theme to show the current user * 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: * tags:
* - User Preferences * - User Preferences
* responses: * responses:
@@ -75,6 +76,8 @@ async function adminDefaults() {
* nullable: true * nullable: true
* safeLayout: * safeLayout:
* type: boolean * type: boolean
* safeMode:
* type: boolean
* unavailable: * unavailable:
* type: array * type: array
* items: * items:
@@ -112,6 +115,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
defaults: { ...defaults, layout: known(defaults.layout) }, defaults: { ...defaults, layout: known(defaults.layout) },
defaultLook: resolveDefaultLook(loader), defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(), safeLayout: safeLayoutForced(),
safeMode: safeModeOn(),
unavailable: unavailableLooks(loader), unavailable: unavailableLooks(loader),
}); });
} catch (error) { } catch (error) {
+20 -2
View File
@@ -16,6 +16,7 @@ import { getPluginRuntime } from "../../plugins/index.js";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { describePluginFrontend } from "../../plugins/assets.js"; import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js"; import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { mayRunInSafeMode } from "../../plugins/safe-mode.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.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 * only run on that server and stay off here on purpose, and, for
* admins, `managedByServer` marks plugins whose on/off state the server * admins, `managedByServer` marks plugins whose on/off state the server
* decides. * decides.
*
* While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps
* off reports `state: "safe-mode"`.
* tags: * tags:
* - Plugins * - Plugins
* responses: * responses:
@@ -354,7 +358,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
name: record.name, name: record.name,
version: record.version, version: record.version,
enabled: record.state === "enabled", 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", runsOnServer: linked && desktopMode === "server",
contributes, contributes,
icon, icon,
@@ -936,7 +945,7 @@ router.delete(
* 404: * 404:
* description: No such plugin. * description: No such plugin.
* 409: * 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( router.patch(
"/:id/state", "/:id/state",
@@ -1050,6 +1059,8 @@ router.patch(
* description: The caller lacks admin.plugins.manage. * description: The caller lacks admin.plugins.manage.
* 404: * 404:
* description: No such plugin, or it is not on disk. * 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: * 500:
* description: The plugin failed to start again. * description: The plugin failed to start again.
*/ */
@@ -1073,6 +1084,13 @@ router.post(
res.status(404).json({ error: "Plugin is not present on disk" }); res.status(404).json({ error: "Plugin is not present on disk" });
return; 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, { await repository.update(pluginId, {
state: "enabled", state: "enabled",
+21
View File
@@ -14,3 +14,24 @@ export function startablePluginIds(
.map((record) => record.id), .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<string>,
ignored: ReadonlySet<string>,
): string[] {
return records
.filter(
(record) =>
record.tier === "bundled" &&
record.state !== "disabled" &&
!presentIds.has(record.id) &&
!ignored.has(record.id),
)
.map((record) => record.id)
.sort();
}
+20
View File
@@ -23,6 +23,11 @@ import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js"; import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js"; import { recordConflict } from "./conflicts.js";
import { startablePluginIds } from "./boot-state.js"; import { startablePluginIds } from "./boot-state.js";
import {
mayRunInSafeMode,
safeModeOn,
safeModePluginIds,
} from "./safe-mode.js";
import { setPluginImpersonationCheck } from "../utils/auth-manager.js"; import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js"; import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
@@ -237,6 +242,10 @@ export async function initializePlugins(
if (loaded.length === 0) return []; if (loaded.length === 0) return [];
const fresh = await seedPlugins(loaded, options.freshInstall === true); const fresh = await seedPlugins(loaded, options.freshInstall === true);
if (!fresh) {
const { restoreMissingPlugins } = await import("./manage.js");
loaded.push(...(await restoreMissingPlugins()));
}
await syncCapabilityGrants(loaded); await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) { if (loaded.some((plugin) => plugin.source === "bundled")) {
const { offerPluginSetup } = await import("./onboarding.js"); const { offerPluginSetup } = await import("./onboarding.js");
@@ -248,8 +257,17 @@ export async function initializePlugins(
const records = await createCurrentPluginRepository().listAll(); const records = await createCurrentPluginRepository().listAll();
const enabled = startablePluginIds(records); 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 const candidates = loaded
.filter((plugin) => enabled.has(plugin.id)) .filter((plugin) => enabled.has(plugin.id))
.filter((plugin) => !safe || safe.has(plugin.id))
.map((plugin) => plugin.id); .map((plugin) => plugin.id);
for (const pluginId of candidates) { for (const pluginId of candidates) {
@@ -539,6 +557,8 @@ export async function activatePlugin(pluginId: string): Promise<void> {
// Before activate: a plugin's own activate() may already want to check one // Before activate: a plugin's own activate() may already want to check one
// of its permissions. // of its permissions.
const plugin = pluginLoader.get(pluginId); 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); if (plugin) await registerPluginPermissions(plugin);
await pluginLoader.activate(pluginId); await pluginLoader.activate(pluginId);
+71 -1
View File
@@ -18,6 +18,7 @@ import path from "node:path";
import semver from "semver"; import semver from "semver";
import { pluginLogger } from "../utils/logger.js"; import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js"; import { isDefaultLookPlugin } from "./bundled-index.js";
import { mayRunInSafeMode } from "./safe-mode.js";
import { import {
activatePlugin, activatePlugin,
deactivatePlugin, deactivatePlugin,
@@ -25,6 +26,7 @@ import {
registerLoadedPlugins, registerLoadedPlugins,
unloadPlugin, unloadPlugin,
} from "./index.js"; } from "./index.js";
import { missingShippedPluginIds } from "./boot-state.js";
import type { LoadedPlugin, PluginLoader } from "./loader.js"; import type { LoadedPlugin, PluginLoader } from "./loader.js";
import { import {
getBundledPluginsDir, getBundledPluginsDir,
@@ -763,6 +765,13 @@ export async function setPluginStateUnlocked(
if (!loader.get(pluginId)) { if (!loader.get(pluginId)) {
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND"); 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); const plan = await planStateChange(pluginId, enabled);
if (enabled) { if (enabled) {
@@ -959,7 +968,11 @@ export function installPlugin(
return serialized(async () => { return serialized(async () => {
const { createCurrentPluginRepository } = await repos(); const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository(); 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( throw new PluginManageError(
"Plugin is already installed", "Plugin is already installed",
409, 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<LoadedPlugin[]> {
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( export function updatePlugin(
pluginId: string, pluginId: string,
options: InstallOptions, options: InstallOptions,
+65 -3
View File
@@ -12,6 +12,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { isIP } from "node:net";
import path from "node:path"; import path from "node:path";
import semver from "semver"; import semver from "semver";
import { import {
@@ -23,7 +24,11 @@ import {
youtubeVideoId, youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest"; } from "@termix-ssh/plugin-sdk/manifest";
import { getLocalVersion } from "../utils/app-version.js"; 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 { pluginLogger } from "../utils/logger.js";
import { import {
COMMUNITY_PLUGIN_KEYS, 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<Response> {
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. */ /** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> { async function fetchFollowing(url: string): Promise<Response> {
const privateHosts = getRegistryPrivateHosts();
let current = url; let current = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response = await safeOutboundFetch(current, { const options: RequestInit = {
method: "GET", method: "GET",
redirect: "manual", redirect: "manual",
headers: { "User-Agent": "Termix" }, 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) { if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location"); const location = response.headers.get("location");
if (!location) throw new Error(`redirect without a location: ${current}`); if (!location) throw new Error(`redirect without a location: ${current}`);
+42
View File
@@ -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<string> {
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const allowed = new Set<string>();
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);
}
+15
View File
@@ -7,6 +7,8 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory
* On a linked desktop, hosts and credentials someone shared with the account * 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 * 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. * 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( export function rejectSharedCopyWrites(
kind: "host" | "credential", kind: "host" | "credential",
@@ -25,6 +27,19 @@ export function rejectSharedCopyWrites(
.where(eq(table.id, Number(match[1]))) .where(eq(table.id, Number(match[1])))
.limit(1); .limit(1);
if (row?.sharedSource) { 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({ res.status(403).json({
error: "This was shared with you and is managed on the server", error: "This was shared with you and is managed on the server",
code: "SHARED_COPY", code: "SHARED_COPY",
@@ -102,6 +102,7 @@ beforeEach(async () => {
state.defaultLookIds = []; state.defaultLookIds = [];
state.installed = []; state.installed = [];
delete process.env.TERMIX_SAFE_LAYOUT; delete process.env.TERMIX_SAFE_LAYOUT;
delete process.env.TERMIX_SAFE_MODE;
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@@ -113,6 +114,7 @@ beforeEach(async () => {
afterEach(async () => { afterEach(async () => {
delete process.env.TERMIX_SAFE_LAYOUT; delete process.env.TERMIX_SAFE_LAYOUT;
delete process.env.TERMIX_SAFE_MODE;
await new Promise<void>((resolve) => server.close(() => resolve())); await new Promise<void>((resolve) => server.close(() => resolve()));
}); });
@@ -138,6 +140,7 @@ describe("GET /user-preferences/look", () => {
defaults: { layout: null, theme: null }, defaults: { layout: null, theme: null },
defaultLook: { layout: null, theme: null }, defaultLook: { layout: null, theme: null },
safeLayout: false, safeLayout: false,
safeMode: false,
unavailable: [], unavailable: [],
}); });
}); });
@@ -159,6 +162,7 @@ describe("GET /user-preferences/look", () => {
{ id: "other", version: null, layouts: [], themes: [], error: "y" }, { id: "other", version: null, layouts: [], themes: [], error: "y" },
]; ];
process.env.TERMIX_SAFE_LAYOUT = "1"; process.env.TERMIX_SAFE_LAYOUT = "1";
process.env.TERMIX_SAFE_MODE = "1";
const { body } = await getLook(); const { body } = await getLook();
expect(body).toMatchObject({ expect(body).toMatchObject({
@@ -167,6 +171,7 @@ describe("GET /user-preferences/look", () => {
defaults: { layout: "admin-layout", theme: "gruvbox" }, defaults: { layout: "admin-layout", theme: "gruvbox" },
defaultLook: { layout: "shipped", theme: "ink" }, defaultLook: { layout: "shipped", theme: "ink" },
safeLayout: true, safeLayout: true,
safeMode: true,
}); });
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual( expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
["broken"], ["broken"],
@@ -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 () => { it("grants a declared capability", async () => {
state.plugins.set("sample-plugin", makePlugin()); state.plugins.set("sample-plugin", makePlugin());
+26 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { startablePluginIds } from "../../plugins/boot-state.js"; import {
missingShippedPluginIds,
startablePluginIds,
} from "../../plugins/boot-state.js";
describe("startablePluginIds", () => { describe("startablePluginIds", () => {
it("starts enabled plugins and retries ones that failed or were blocked", () => { 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"]); 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([]);
});
});
@@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import {
classifyRuntimeError,
composeArgsFor,
containerCommand,
countContainers,
diffContainerStates,
dockerArgs,
parseComposeProjects,
parseContainerList,
parseContainerStates,
parseContainerStats,
parseRuntimeVersion,
parseServices,
parseStackStatus,
stackStateFromServices,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("containerCommand", () => {
it("extends PATH on POSIX hosts", () => {
const command = containerCommand("podman", "ps");
expect(command.startsWith('PATH="')).toBe(true);
expect(command.endsWith(" podman ps")).toBe(true);
});
it("runs the plain CLI on Windows hosts", () => {
expect(containerCommand("docker", "ps -a", true)).toBe("docker ps -a");
});
it("falls back to docker for an unknown runtime", () => {
expect(containerCommand(undefined, "ps", true)).toBe("docker ps");
});
});
describe("dockerArgs", () => {
it("builds the container commands", () => {
expect(dockerArgs.inspect("web")).toBe("inspect web");
expect(dockerArgs.action("restart", "web")).toBe("restart web");
expect(dockerArgs.remove("web", true)).toBe("rm -f web");
expect(dockerArgs.list(true)).toContain("ps -a --format '{");
expect(dockerArgs.stats("web")).toContain("stats web --no-stream");
});
it("refuses container ids that could break the command line", () => {
expect(() => dockerArgs.inspect("web; rm -rf /")).toThrow();
expect(() => dockerArgs.logs("$(id)")).toThrow();
expect(() => dockerArgs.action("restart", "-rf")).toThrow();
expect(() => dockerArgs.action("kill" as never, "web")).toThrow();
});
it("only passes sane log filters", () => {
expect(
dockerArgs.logs("web", {
tail: 50.7,
timestamps: true,
since: "2026-10-01T00:00:00Z",
}),
).toBe("logs web --tail 50 --timestamps --since 2026-10-01T00:00:00Z 2>&1");
expect(dockerArgs.logs("web", { since: "1h; id" })).toBe("logs web 2>&1");
});
});
describe("docker parsing", () => {
it("reads the version and sorts errors", () => {
expect(parseRuntimeVersion("Docker version 27.3.1, build ce12230")).toBe(
"27.3.1",
);
expect(parseRuntimeVersion("podman version 5.2.0")).toBe("5.2.0");
expect(parseRuntimeVersion("nope")).toBe("unknown");
expect(classifyRuntimeError("bash: docker: command not found")).toBe(
"NOT_INSTALLED",
);
expect(
classifyRuntimeError(
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
),
).toBe("DAEMON_NOT_RUNNING");
expect(
classifyRuntimeError("permission denied while trying to connect"),
).toBe("PERMISSION_DENIED");
expect(classifyRuntimeError("something else")).toBe("DOCKER_ERROR");
});
it("skips lines that are not JSON in the list", () => {
const rows = parseContainerList(
'{"id":"a","name":"web"}\nWARNING: x\n{"id":"b","name":"db"}\n',
);
expect(rows.map((r) => r.name)).toEqual(["web", "db"]);
});
it("splits the stats columns", () => {
const stats = parseContainerStats(
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
);
expect(stats).toMatchObject({
cpu: "1.5%",
memoryUsed: "10MiB",
memoryLimit: "1GiB",
netInput: "1kB",
netOutput: "2kB",
pids: "3",
});
});
const ps = [
`{"name":"web","state":"running","status":"Up 2 hours"}`,
`{"name":"db","state":"running","status":"Up 2 hours (unhealthy)"}`,
`{"name":"old","state":"exited","status":"Exited (0)"}`,
].join("\n");
it("counts running, stopped and unhealthy containers", () => {
expect(countContainers(ps)).toEqual({
running: 2,
stopped: 1,
unhealthy: 1,
total: 3,
});
});
it("turns two snapshots into events", () => {
const before = parseContainerStates(ps);
const after = parseContainerStates(
[
`{"name":"web","state":"exited","status":"Exited (1)"}`,
`{"name":"db","state":"running","status":"Up (unhealthy)"}`,
`{"name":"old","state":"running","status":"Up 1 second"}`,
`{"name":"new","state":"running","status":"Up"}`,
].join("\n"),
);
expect(diffContainerStates(before, after)).toEqual([
{ container: "web", event: "exited" },
{ container: "old", event: "started" },
]);
});
});
describe("compose", () => {
it("reads stack status text", () => {
expect(parseStackStatus("running(2), exited(1)")).toEqual({
state: "partial",
running: 2,
total: 3,
});
expect(parseStackStatus("paused(2)")).toMatchObject({ state: "paused" });
expect(parseStackStatus("")).toMatchObject({ state: "not-deployed" });
});
it("reads projects from compose ls", () => {
const stacks = parseComposeProjects(
JSON.stringify([
{
Name: "web",
Status: "running(2)",
ConfigFiles: "/opt/stacks/web/compose.yaml",
},
{ Name: "Bad Name", Status: "running(1)", ConfigFiles: "" },
]),
);
expect(stacks).toEqual([
{
name: "web",
managed: false,
state: "running",
running: 2,
total: 2,
composeFile: "/opt/stacks/web/compose.yaml",
},
]);
});
it("reads services from compose ps, one line or one array", () => {
const row = {
Service: "app",
Name: "web-app-1",
ID: "abc",
Image: "nginx",
State: "running",
Status: "Up",
Health: "",
Publishers: [
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 0, TargetPort: 443 },
],
};
const lines = parseServices(JSON.stringify(row));
expect(lines[0]).toMatchObject({ service: "app", ports: "8080:80" });
expect(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
).toHaveLength(2);
expect(
stackStateFromServices(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
),
).toEqual({
state: "partial",
running: 1,
total: 2,
});
});
it("maps actions to compose commands", () => {
expect(composeArgsFor("redeploy", "app", {})).toEqual([
"pull app",
"up -d --remove-orphans app",
]);
expect(
composeArgsFor("destroy", undefined, { removeVolumes: true }),
).toEqual(["down --remove-orphans -v"]);
});
});
@@ -0,0 +1,400 @@
import { describe, it, expect } from "vitest";
import {
buildFilesystemList,
filterExcludedFilesystems,
findWorstMountIndex,
liveCmd,
mergeMonitoredFilesystems,
parseCpuLine,
parseDfLines,
parseLive,
selectPrimaryFilesystem,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
const output =
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[0].type).toBe("ext4");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay overlay 100 50 50 50% /\n" +
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
it("captures the filesystem type for network shares", () => {
const output =
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
const rows = parseDfLines(output);
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
it("keeps full image and firmware mounts out of disk usage", () => {
const rows = parseDfLines(
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
"/dev/sda1 ext4 1000 400 600 40% /\n",
);
expect(rows.map((row) => row.mount)).toEqual(["/"]);
expect(findWorstMountIndex(rows)).toEqual({
index: 0,
usedBytes: 400,
totalBytes: 1000,
});
});
it("retains real storage without requiring a /dev source", () => {
const rows = parseDfLines(
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
"tank/data zfs 2000 1000 1000 50% /data\n" +
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
);
expect(rows.map((row) => row.type)).toEqual([
"btrfs",
"zfs",
"nfs4",
"cifs",
]);
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
"/dev/sda2 ext4 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
const BYTES_OUTPUT =
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
const HUMAN_OUTPUT =
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
describe("buildFilesystemList", () => {
it("returns every real filesystem with byte maths and human strings", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
mount: "/",
type: "ext4",
percent: 40,
usedHuman: "400",
totalHuman: "1.0K",
availableHuman: "600",
usedBytes: 400,
totalBytes: 1000,
});
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
});
it("matches human rows by mount when the row counts differ", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
);
expect(list[0].totalHuman).toBeNull();
expect(list[1].totalHuman).toBe("2.0K");
});
it("drops filesystems with a zero or invalid total", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
),
[],
);
expect(list).toHaveLength(1);
expect(list[0].mount).toBe("/ok");
});
});
describe("selectPrimaryFilesystem", () => {
it("prefers root over a fuller secondary mount", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
});
it("falls back to the most-utilized mount when there is no root", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
),
[],
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
});
it("returns null for an empty list", () => {
expect(selectPrimaryFilesystem([])).toBeNull();
});
});
describe("filterExcludedFilesystems", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 400 600 40% /\n" +
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
),
[],
);
it("returns the same list when no mounts are excluded", () => {
expect(filterExcludedFilesystems(list)).toHaveLength(3);
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
});
it("excludes an exact mount path match", () => {
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("excludes by filesystem type substring, case-insensitively", () => {
const filtered = filterExcludedFilesystems(list, ["NFS"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("supports excluding multiple network filesystem types at once", () => {
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
});
it("ignores blank/whitespace-only entries", () => {
const filtered = filterExcludedFilesystems(list, [" ", ""]);
expect(filtered).toHaveLength(3);
});
});
describe("mergeMonitoredFilesystems", () => {
it("adds an arbitrary path with a user label", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const custom = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/config", label: "Home Assistant" }],
custom,
);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
mount: "/config",
label: "Home Assistant",
totalBytes: 1000,
});
});
it("labels a path that is already a detected mount", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/data", label: "Media" }],
detected,
);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("Media");
});
});
const out = (
agg: [number, number],
core: [number, number],
rx: number,
up: number,
procs: string,
page = "4096",
) =>
[
`cpu ${agg[0]} 0 0 ${agg[1]} 0 0 0 0 0 0\ncpu0 ${core[0]} 0 0 ${core[1]} 0 0 0 0 0 0`,
"MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB",
`0.1 0.2 0.3 1/2 3\n${up} 5`,
`Inter-|\n eth0: ${rx} 0 0 0 0 0 0 0 ${rx * 2} 0\n lo: 9 0 0 0 0 0 0 0 9 0`,
page,
procs,
].join("\n@@\n");
const proc = (pid: number, ticks: number, start: number, name = "java") =>
`${pid}\t${ticks}\t10\t${start}\tR\t1\t${name}\n`;
describe("parseLive extras", () => {
it("reports state, parent, CPU mix, cache and the running count", () => {
const mk = (user: number, sys: number, wait: number, idle: number) =>
out([user, idle], [user, idle], 1, 10, procs).replace(
/cpu {2}\d+ 0 0 \d+ 0 0 0 0/,
`cpu ${user} 0 ${sys} ${idle} ${wait} 0 0 0`,
);
const procs = "5\t1\t10\t7\tR\t2\tjava\n6\t1\t10\t8\tS\t5\tsh\n";
const a = parseLive(mk(0, 0, 0, 0), undefined, 0);
const b = parseLive(mk(30, 10, 10, 50), a.next, 1000);
expect(b.sample.cpuMix).toMatchObject({ user: 30, system: 10, iowait: 10 });
expect(b.sample.running).toBe(1);
expect(b.sample.procs.find((p) => p.pid === 6)).toMatchObject({
state: "S",
ppid: 5,
});
expect(b.sample.memCachedKb).toBe(0);
});
});
describe("liveCmd", () => {
it("asks for the page size once, then passes the cached number", () => {
expect(liveCmd()).toContain('pg="$(getconf PAGESIZE)"');
expect(liveCmd(16384)).toContain("pg=16384 ");
expect(liveCmd(16384)).not.toContain("getconf");
});
});
describe("parseLive", () => {
it("has no rates on the first sample", () => {
const { sample } = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
expect(sample.memUsedKb).toBe(600);
});
it("derives cpu, per-core, net and process rates from the delta", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
// aggregate: +50 busy, +50 idle; core 0: +25 busy, +75 idle
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 50, 1)),
a.next,
);
expect(sample.cpu).toBeCloseTo(50, 0);
expect(sample.cores[0]).toBeCloseTo(25, 0);
expect(sample.net).toEqual([{ name: "eth0", rxBps: 2000, txBps: 4000 }]);
expect(sample.procs[0]).toMatchObject({ pid: 7, name: "java", memKb: 40 });
expect(sample.procs[0].cpu).toBeCloseTo(50, 0);
});
it("does not charge a reused pid with the old process's ticks", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 5, 1)),
undefined,
);
// same pid, new start time, lower ticks must not look like a huge delta
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 9000, 2)),
a.next,
);
expect(sample.procs[0].cpu).toBe(0);
});
it("keeps the cached page size when a run omits it, and keeps names with tabs", () => {
const a = parseLive(
out([1, 1], [1, 1], 1, 10, proc(1, 0, 1), "16384"),
undefined,
);
expect(a.next.pageBytes).toBe(16384);
const b = parseLive(
out([2, 2], [2, 2], 2, 11, proc(1, 0, 1, "a b"), ""),
a.next,
);
expect(b.next.pageBytes).toBe(16384);
expect(b.sample.procs[0]).toMatchObject({ name: "a\tb", memKb: 160 });
});
it("starts rates again after a reboot", () => {
const a = parseLive(
out([100, 100], [100, 100], 5000, 900, proc(7, 0, 1)),
undefined,
);
const { sample } = parseLive(
out([10, 10], [10, 10], 20, 5, proc(7, 0, 1)),
a.next,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
});
});
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import {
isUnit,
journalArgs,
parseBoots,
parseDetect,
parseEntry,
parseIsActive,
parseJournal,
parseJournalQuery,
parseOpenRcList,
parseSystemdTime,
parseTimers,
parseUnitList,
parseUsage,
shellQuote as q,
vacuumScript,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("names", () => {
it("quotes for bash", () => {
expect(q("a'b")).toBe(`'a'\\''b'`);
});
it("takes unit names and refuses anything else", () => {
expect(isUnit("nginx.service")).toBe(true);
expect(isUnit("getty@tty1.service")).toBe(true);
expect(isUnit("dev-disk-by\\x2duuid-1.device")).toBe(true);
expect(isUnit("-rf.service")).toBe(false);
expect(isUnit("a;b.service")).toBe(false);
expect(isUnit("a b.service")).toBe(false);
expect(isUnit("nginx")).toBe(false);
expect(isUnit("$(id).service")).toBe(false);
});
});
describe("systemd parsing", () => {
const list = [
"cron.service loaded active running Regular background program",
"● bad.service loaded failed failed Broken thing",
"getty@tty1.service loaded active running Getty on tty1",
"@@TERMIX_FILES@@",
"cron.service enabled enabled",
"bad.service disabled enabled",
"getty@.service enabled enabled",
"old.service masked -",
"",
].join("\n");
it("merges loaded units with unit files and skips templates", () => {
const units = parseUnitList(list);
expect(units.map((u) => u.name)).toEqual([
"bad.service",
"cron.service",
"getty@tty1.service",
"old.service",
]);
const cron = units.find((u) => u.name === "cron.service")!;
expect(cron).toMatchObject({
active: "active",
sub: "running",
description: "Regular background program",
fileState: "enabled",
preset: "enabled",
});
expect(units.find((u) => u.name === "bad.service")!.active).toBe("failed");
expect(units.find((u) => u.name === "old.service")).toMatchObject({
load: "masked",
active: "inactive",
preset: "",
});
});
it("reads UTC times", () => {
expect(parseSystemdTime("Thu 2026-10-22 02:03:51 UTC")).toBe(
Date.UTC(2026, 9, 22, 2, 3, 51),
);
expect(parseSystemdTime("")).toBeNull();
expect(parseSystemdTime("n/a")).toBeNull();
});
it("reads timers from systemctl show blocks", () => {
const out = [
"TimersCalendar={ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=@1792605600 }",
"NextElapseUSecRealtime=Thu 2026-10-22 02:03:51 UTC",
"LastTriggerUSec=Wed 2026-10-21 17:02:58 UTC",
"Id=apt-daily.timer",
"Triggers=apt-daily.service",
"ActiveState=active",
"",
"TimersMonotonic={ OnUnitActiveUSec=1d ; next_elapse=1d }",
"TimersMonotonic={ OnBootUSec=15min ; next_elapse=15min }",
"NextElapseUSecRealtime=",
"LastTriggerUSec=",
"Id=clean.timer",
"Triggers=clean.service",
].join("\n");
const timers = parseTimers(out);
expect(timers[0]).toMatchObject({
name: "apt-daily.timer",
activates: "apt-daily.service",
next: Date.UTC(2026, 9, 22, 2, 3, 51),
schedule: ["OnCalendar=*-*-* 06,18:00:00"],
});
expect(timers[1]).toMatchObject({
name: "clean.timer",
next: null,
last: null,
schedule: ["OnUnitActiveUSec=1d", "OnBootUSec=15min"],
});
});
it("maps is-active lines to units", () => {
const states = parseIsActive(
["a.service", "b.service"],
"active\nfailed\n",
);
expect(states.get("a.service")).toBe("active");
expect(states.get("b.service")).toBe("failed");
});
it("reads what detect prints", () => {
expect(
parseDetect(
"init=systemd\nversion=257\nos=Debian GNU/Linux 13 (trixie)\nuid=0\njournal=1\nfailed=2\n",
),
).toMatchObject({
init: "systemd",
version: "257",
root: true,
journal: true,
failed: 2,
});
expect(parseDetect("init=weird\nuid=1000\n")).toMatchObject({
init: "none",
root: false,
});
});
});
describe("journal", () => {
it("checks filters", () => {
const query = parseJournalQuery({
unit: ["nginx.service", "ssh.service"],
priority: "3",
since: "-1h",
boot: "-1",
grep: "fail'ed",
lines: "99999",
});
expect(query.lines).toBe(5000);
const args = journalArgs(query);
expect(args).toContain("-u 'nginx.service' -u 'ssh.service'");
expect(args).toContain("-p 3");
expect(args).toContain("--since '-1h'");
expect(args).toContain("-b '-1'");
expect(args).toContain(`-g 'fail'\\''ed'`);
expect(() => parseJournalQuery({ unit: "x;rm" })).toThrow();
expect(() => parseJournalQuery({ priority: "9" })).toThrow();
expect(() => parseJournalQuery({ since: "$(id)" })).toThrow();
expect(() => parseJournalQuery({ boot: "abc" })).toThrow();
});
it("parses entries, binary messages included", () => {
const entry = parseEntry(
JSON.stringify({
__REALTIME_TIMESTAMP: "1792612429684000",
PRIORITY: "3",
_SYSTEMD_UNIT: "x.service",
SYSLOG_IDENTIFIER: "x",
_PID: "12",
MESSAGE: [104, 105],
}),
);
expect(entry).toEqual({
time: 1792612429684,
priority: 3,
unit: "x.service",
ident: "x",
pid: "12",
message: "hi",
});
expect(parseEntry("not json")).toBeNull();
});
it("filters here when journalctl has no -g", () => {
const line = (message: string) =>
JSON.stringify({ __REALTIME_TIMESTAMP: "1000", MESSAGE: message });
const out = `@@TERMIX_NOGREP@@\n${line("Disk Error")}\n${line("ok")}\n`;
const query = parseJournalQuery({ grep: "error", lines: "10" });
expect(parseJournal(out, query).map((e) => e.message)).toEqual([
"Disk Error",
]);
});
it("reads boots and disk use", () => {
const boots = parseBoots(
[
"IDX BOOT ID FIRST ENTRY LAST ENTRY",
"-1 d2785a657f4c4561a1f436db27555b50 Tue 2026-10-20 13:56:45 UTC Wed 2026-10-21 10:37:00 UTC",
" 0 61b15588a75b4c0cb8dea321fcc438ab Wed 2026-10-21 10:38:10 UTC Wed 2026-10-21 20:00:00 UTC",
].join("\n"),
);
expect(boots.map((b) => b.offset)).toEqual([0, -1]);
expect(boots[0].first).toBe("Wed 2026-10-21 10:38:10 UTC");
expect(
parseUsage(
"Archived and active journals take up 378.6M in the file system.",
),
).toBe("378.6M");
});
it("only takes sane vacuum values", () => {
expect(vacuumScript({ size: "500M" })).toContain("--vacuum-size=500M");
expect(vacuumScript({ time: "4w" })).toContain("--vacuum-time=4w");
expect(() => vacuumScript({ size: "1; rm -rf /" })).toThrow();
expect(() => vacuumScript({})).toThrow();
});
});
describe("openrc", () => {
it("maps services and runlevels", () => {
const units = parseOpenRcList(
[
" sshd [ started ]",
" crond [ stopped ]",
" nginx [ crashed ]",
"@@TERMIX_RUNLEVELS@@",
" sshd | default",
" nginx | ",
"",
].join("\n"),
);
expect(units.find((u) => u.name === "sshd")).toMatchObject({
active: "active",
fileState: "enabled",
});
expect(units.find((u) => u.name === "nginx")).toMatchObject({
active: "failed",
fileState: "disabled",
});
expect(units.find((u) => u.name === "crond")!.active).toBe("inactive");
});
});
@@ -0,0 +1,130 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
actionScript,
errLine,
isActiveScript,
listUnitsScript,
openRcActionScript,
parseKeys,
passwordSudoScript,
scriptError,
scriptShell,
SUDO_REFUSED_RE,
SUDO_WRONG_RE,
toBase64,
utf8Decode,
utf8Encode,
watchdog,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("text helpers", () => {
it("encodes base64 like Buffer does", () => {
for (const text of [
"",
"a",
"ab",
"abc",
"héllo wörld",
"emoji 🙂 here",
"line\nbreak",
]) {
expect(toBase64(text)).toBe(Buffer.from(text, "utf8").toString("base64"));
}
});
it("round trips UTF-8 and replaces bad bytes", () => {
const text = "plain, ünïcode, 日本, 🙂";
expect(utf8Decode(utf8Encode(text))).toBe(text);
expect(Array.from(utf8Encode(text))).toEqual(
Array.from(Buffer.from(text, "utf8")),
);
expect(utf8Decode([0x61, 0xff, 0x62])).toBe("a\ufffdb");
expect(utf8Decode([0xe6, 0x97])).toBe("\ufffd");
});
it("reads key=value lines", () => {
expect(parseKeys("a=1\nb = two=2\njunk\n")).toEqual({ a: "1", b: "two=2" });
});
});
describe("script shells", () => {
it("always reads the script from stdin", () => {
for (const mode of ["plain", "auto", "root"] as const) {
expect(scriptShell(mode)).toContain("bash -s");
}
expect(scriptShell("auto")).toContain("sudo -n true");
expect(scriptShell("root-only")).toContain("SCRIPT_ERR:");
});
it("collects error lines, old and new markers", () => {
expect(scriptError("x\nSCRIPT_ERR:one\nSERVICES_ERR:two\n")).toBe(
"one\ntwo",
);
expect(errLine("it's broken")).toBe(`echo 'SCRIPT_ERR:it'\\''s broken'`);
});
it("matches sudo refusals", () => {
expect(SUDO_REFUSED_RE.test("sudo: a password is required")).toBe(true);
expect(SUDO_WRONG_RE.test("sudo: 1 incorrect password attempt")).toBe(true);
});
it("keeps the sudo password out of the command line", () => {
const { command, stdin } = passwordSudoScript(
"systemctl restart 'a.service'\n",
"s3cr'et $(id)",
);
expect(command).not.toContain("s3cr");
expect(stdin.split("\n")[0]).toBe("IFS= read -r TERMIX_PW");
expect(stdin.split("\n")[1]).toBe("s3cr'et $(id)");
expect(stdin).toContain("sudo -k -S -p ''");
expect(stdin).not.toContain("systemctl restart");
expect(() => passwordSudoScript("x", "a\nb")).toThrow();
});
it.runIf(process.platform !== "win32")(
"runs the script and never runs the password",
() => {
const { stdin } = passwordSudoScript(
'echo "ran:$1${TERMIX_PW:-none}"\n',
"touch /tmp/should-not-exist",
);
// No sudo here: swap it for a fake that drops its own stdin line.
const fake = stdin.replace(
/printf '%s\\n' "\$P" \| sudo -k -S -p '' env PATH="\$PATH" [^\n]* bash -c "\$TERMIX_S"/,
'printf "%s\\n" "$P" | { IFS= read -r _; bash -c "$TERMIX_S"; }',
);
expect(fake).not.toBe(stdin);
const out = execFileSync(
"bash",
["-c", "sudo() { return 1; }; export -f sudo; exec bash -s"],
{
input: fake,
encoding: "utf8",
},
);
expect(out.trim()).toBe("ran:none");
},
);
});
describe("unit scripts", () => {
it("quotes units and refuses bad names", () => {
expect(actionScript("a.service", "enable-now")).toContain(
"systemctl enable --now -- 'a.service'",
);
expect(() => actionScript("a;b.service", "start")).toThrow();
expect(isActiveScript(["a.service", "b.timer"])).toContain(
"-- 'a.service' 'b.timer'",
);
expect(() => listUnitsScript("bogus" as never)).toThrow();
expect(openRcActionScript("sshd", "enable")).toContain(
"rc-update add 'sshd' default",
);
expect(() => openRcActionScript("sshd", "mask")).toThrow();
});
it("stops a watched command once stdin closes", () => {
expect(watchdog("journalctl -f")).toContain("cat >/dev/null");
});
});
+20
View File
@@ -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", () => { describe("reloading a bundled plugin for the dev runner", () => {
it("loads the rebuilt copy and restarts what depends on it", async () => { it("loads the rebuilt copy and restarts what depends on it", async () => {
createFixturePlugin({ id: "base", root: bundled }); createFixturePlugin({ id: "base", root: bundled });
@@ -8,10 +8,23 @@ import * as tar from "tar";
const fetchMock = vi.hoisted(() => vi.fn()); const fetchMock = vi.hoisted(() => vi.fn());
const testKey = vi.hoisted(() => ({ value: null as null | object })); 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, 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", () => ({ vi.mock("../../utils/app-version.js", () => ({
getLocalVersion: () => "26.10.3", getLocalVersion: () => "26.10.3",
})); }));
@@ -43,9 +56,11 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
import { import {
communitySource, communitySource,
downloadRelease, downloadRelease,
fetchIndexAt,
fetchRegistryIndex, fetchRegistryIndex,
fetchRegistryStats, fetchRegistryStats,
findRelease, findRelease,
getRegistryPrivateHosts,
latestOnChannel, latestOnChannel,
getStatsUrl, getStatsUrl,
canRunRelease, 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. */ /** A real .tmxplug holding only a manifest, enough for the checks. */
function pack(manifest: Record<string, unknown>): Buffer { function pack(manifest: Record<string, unknown>): Buffer {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
@@ -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<PluginManifest> = {}) {
return { id, manifest: { id, ...manifest } as PluginManifest };
}
const plugins = [
plugin("layout", {
contributes: { layouts: [{ id: "classic" }] },
dependencies: { helper: "^1.0.0" },
} as Partial<PluginManifest>),
plugin("themes", {
contributes: { themes: [{ id: "dark" }] },
} as Partial<PluginManifest>),
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);
});
});
@@ -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<string, unknown>) {
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();
});
});
+5 -1
View File
@@ -12,6 +12,7 @@ import { ThemeProvider } from "@/components/theme-provider";
import "./ui/i18n/i18n"; import "./ui/i18n/i18n";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame"; import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
import { captureAppReturn } from "@/lib/app-return";
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume"; import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status"; import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
import { Toaster } from "@/components/sonner"; import { Toaster } from "@/components/sonner";
@@ -168,8 +169,11 @@ function App() {
// Keeps the window controls the system draws in the colors of the theme. // Keeps the window controls the system draws in the colors of the theme.
useTitleBarOverlayColors(); useTitleBarOverlayColors();
const stored = getStoredAuth(); const stored = getStoredAuth();
// A sign in for the mobile app always shows the login form, so the app gets
// its own fresh session.
const [appReturn] = useState(captureAppReturn);
const [phase, setPhase] = useState<Phase>( const [phase, setPhase] = useState<Phase>(
stored?.loggedIn ? "verifying" : "idle-auth", stored?.loggedIn && !appReturn ? "verifying" : "idle-auth",
); );
const [authUsername, setAuthUsername] = useState(stored?.username ?? ""); const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
const [verifyRetryCount, setVerifyRetryCount] = useState(0); const [verifyRetryCount, setVerifyRetryCount] = useState(0);
+2
View File
@@ -19,6 +19,8 @@ export interface LookResponse {
defaultLook: { layout: string | null; theme: string | null }; defaultLook: { layout: string | null; theme: string | null };
/** TERMIX_SAFE_LAYOUT is set on the server. */ /** TERMIX_SAFE_LAYOUT is set on the server. */
safeLayout: boolean; safeLayout: boolean;
/** TERMIX_SAFE_MODE is set: only layout and theme plugins run. */
safeMode?: boolean;
unavailable: UnavailableLook[]; unavailable: UnavailableLook[];
} }
+1 -1
View File
@@ -237,7 +237,7 @@ export interface PluginSummary {
name: string; name: string;
version: string; version: string;
enabled: boolean; 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; state: string;
/** A linked desktop leaves this plugin to its server. */ /** A linked desktop leaves this plugin to its server. */
runsOnServer?: boolean; runsOnServer?: boolean;
+33 -19
View File
@@ -1,9 +1,11 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { import {
isEmbeddedAuthFrame,
isInElectronWebView, isInElectronWebView,
isInMobileWebView, isInMobileWebView,
type EmbeddedFrameWindow, type EmbeddedFrameWindow,
} from "@/lib/embedded-frame"; } from "@/lib/embedded-frame";
import { handOffToApp, pendingAppReturn } from "@/lib/app-return";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
@@ -145,6 +147,13 @@ export function storeAuth(username: string) {
); );
} }
/** A browser login the mobile app asked for goes back to the app. */
async function handOffLogin(token?: string | null): Promise<boolean> {
if (!pendingAppReturn() || isEmbeddedAuthFrame()) return false;
const value = token || (await getCurrentToken());
return handOffToApp(value ? { token: value } : { error: "no_token" });
}
type AuthView = "login" | "register" | "reset" | "second-factor" | "external"; type AuthView = "login" | "register" | "reset" | "second-factor" | "external";
type ResetStep = "email" | "code" | "newpass"; type ResetStep = "email" | "code" | "newpass";
@@ -331,8 +340,9 @@ export function Auth({ onLogin }: AuthProps) {
if (proxySigninHandledRef.current || isElectron()) return; if (proxySigninHandledRef.current || isElectron()) return;
proxySigninHandledRef.current = true; proxySigninHandledRef.current = true;
requestTrustedProxyLogin() requestTrustedProxyLogin()
.then((result) => { .then(async (result) => {
if (!result.enabled || !result.success) return; if (!result.enabled || !result.success) return;
if (await handOffLogin()) return;
storeAuth(result.username || ""); storeAuth(result.username || "");
onLogin( onLogin(
result.username || "", result.username || "",
@@ -625,24 +635,27 @@ export function Auth({ onLogin }: AuthProps) {
} }
return; return;
} }
getUserInfo() void handOffLogin(urlParams.get("token")).then((handedOff) => {
.then((meRes) => { if (handedOff) return;
storeAuth(meRes.username || ""); getUserInfo()
clearDesktopManualLogout(); .then((meRes) => {
onLogin( storeAuth(meRes.username || "");
meRes.username || "", clearDesktopManualLogout();
meRes.userId || undefined, onLogin(
!!meRes.is_admin, meRes.username || "",
); meRes.userId || undefined,
window.history.replaceState( !!meRes.is_admin,
{}, );
document.title, window.history.replaceState(
window.location.pathname, {},
); document.title,
}) window.location.pathname,
.catch(() => { );
toast.error(t("errors.failedUserInfo")); })
}); .catch(() => {
toast.error(t("errors.failedUserInfo"));
});
});
} }
}, [onLogin, t]); }, [onLogin, t]);
@@ -746,6 +759,7 @@ export function Auth({ onLogin }: AuthProps) {
setWebviewAuthSuccess(true); setWebviewAuthSuccess(true);
return; return;
} }
if (await handOffLogin(res?.token)) return;
const meRes = await getUserInfo(); const meRes = await getUserInfo();
const name = const name =
meRes.username || res.username || options.fallbackUsername || ""; meRes.username || res.username || options.fallbackUsername || "";
+5 -4
View File
@@ -10,16 +10,17 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default:
"border-accent-brand/40 bg-accent-brand/15 text-accent-brand hover:bg-accent-brand/25 aria-expanded:bg-accent-brand/25",
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-accent-brand/40 bg-transparent text-accent-brand hover:bg-accent-brand/10 aria-expanded:bg-accent-brand/10",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "border-accent-brand/20 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 aria-expanded:bg-accent-brand/20",
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline", link: "text-accent-brand underline-offset-4 hover:underline",
}, },
size: { size: {
default: default:
+21 -2
View File
@@ -2,6 +2,8 @@ import type React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
PANEL, PANEL,
gridSizeStyle,
type GridSize,
type PanelDensity, type PanelDensity,
type PanelViewMode, type PanelViewMode,
} from "@/components/panel-layout"; } from "@/components/panel-layout";
@@ -67,6 +69,8 @@ export function DataView<T>({
getKey, getKey,
renderCard, renderCard,
columns, columns,
size,
minCardWidth,
listColumns, listColumns,
onRowClick, onRowClick,
isRowActive, isRowActive,
@@ -78,7 +82,12 @@ export function DataView<T>({
density?: PanelDensity; density?: PanelDensity;
getKey: (item: T) => string; getKey: (item: T) => string;
renderCard: (item: T) => React.ReactNode; renderCard: (item: T) => React.ReactNode;
/** Breakpoint columns, used when no size is given. */
columns: GridColumns; columns: GridColumns;
/** Columns the user picked with ViewToggle. Wins over columns. */
size?: GridSize;
/** Narrowest card in auto mode, in px. */
minCardWidth?: number;
listColumns: DataColumn<T>[]; listColumns: DataColumn<T>[];
onRowClick?: (item: T) => void; onRowClick?: (item: T) => void;
isRowActive?: (item: T) => boolean; isRowActive?: (item: T) => boolean;
@@ -93,9 +102,19 @@ export function DataView<T>({
if (view === "grid") { if (view === "grid") {
return ( return (
<div className={cn("grid", PANEL.gap, gridClass(columns), className)}> <div
className={cn(
"grid",
PANEL.gap,
!size && gridClass(columns),
className,
)}
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
>
{items.map((item) => ( {items.map((item) => (
<div key={getKey(item)}>{renderCard(item)}</div> <div key={getKey(item)} className="flex flex-col *:flex-1">
{renderCard(item)}
</div>
))} ))}
</div> </div>
); );
+82 -3
View File
@@ -1,11 +1,26 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, isValidElement, useRef } from "react"; import { Children, Fragment, isValidElement, useRef } from "react";
import type React 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 { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator"; import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link"; import { DocsLink } from "@/components/docs-link";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
/** One spacing rhythm for every panel and tab. */ /** One spacing rhythm for every panel and tab. */
export const PANEL = { export const PANEL = {
@@ -19,6 +34,37 @@ export const PANEL = {
export type PanelViewMode = "grid" | "list"; export type PanelViewMode = "grid" | "list";
export type PanelDensity = "comfortable" | "compact"; 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. */ /** The header, toolbar band and body every tab and panel draws. */
export function PanelShell({ export function PanelShell({
chrome = true, chrome = true,
@@ -308,20 +354,25 @@ export function Segmented<T extends string>({
); );
} }
/** 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({ export function ViewToggle({
view, view,
onView, onView,
density, density,
onDensity, onDensity,
columns,
onColumns,
}: { }: {
view: PanelViewMode; view: PanelViewMode;
onView: (next: PanelViewMode) => void; onView: (next: PanelViewMode) => void;
density?: PanelDensity; density?: PanelDensity;
onDensity?: (next: PanelDensity) => void; 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(); const { t } = useTranslation();
return ( const toggle = (
<div className="flex items-center border border-border"> <div className="flex items-center border border-border">
<ToggleButton <ToggleButton
active={view === "grid"} active={view === "grid"}
@@ -356,6 +407,34 @@ export function ViewToggle({
)} )}
</div> </div>
); );
if (!columns || !onColumns || view !== "grid") return toggle;
return (
<div className="flex items-center gap-2">
<Select value={columns} onValueChange={(v) => onColumns(v as GridSize)}>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
title={t("panel.columns.title")}
aria-label={t("panel.columns.title")}
>
<Columns3 className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRID_SIZES.map((size) => (
<SelectItem key={size} value={size} className="text-xs">
{size === "auto"
? t("panel.columns.auto")
: t("panel.columns.count", { count: Number(size) })}
</SelectItem>
))}
</SelectContent>
</Select>
{toggle}
</div>
);
} }
function ToggleButton({ function ToggleButton({
+61
View File
@@ -0,0 +1,61 @@
/**
* Sign in for the Termix mobile app in a real browser. The app opens the
* login page with ?return_to=termix-mobile://..., and after any login method
* the page sends the token back to that address. Only the app's own scheme
* is accepted, so the value can never send a token to a website.
*/
const STORAGE_KEY = "termix-app-return";
const PARAM = "return_to";
const APP_RETURN = /^termix-mobile:\/\/[A-Za-z0-9._~/-]*$/;
export function isAppReturn(value: unknown): value is string {
return typeof value === "string" && APP_RETURN.test(value);
}
/** Reads return_to from the address bar once and keeps it for this tab. */
export function captureAppReturn(): string | null {
try {
const url = new URL(window.location.href);
const value = url.searchParams.get(PARAM);
if (value !== null) {
url.searchParams.delete(PARAM);
window.history.replaceState({}, document.title, url.toString());
if (isAppReturn(value)) sessionStorage.setItem(STORAGE_KEY, value);
else sessionStorage.removeItem(STORAGE_KEY);
}
return pendingAppReturn();
} catch {
return null;
}
}
export function pendingAppReturn(): string | null {
try {
const value = sessionStorage.getItem(STORAGE_KEY);
return isAppReturn(value) ? value : null;
} catch {
return null;
}
}
export function appReturnUrl(
base: string,
params: Record<string, string>,
): string {
const query = new URLSearchParams(params).toString();
return query ? `${base}?${query}` : base;
}
/** Sends the result to the app. False when no app asked for it. */
export function handOffToApp(params: Record<string, string>): boolean {
const base = pendingAppReturn();
if (!base) return false;
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// The redirect still goes out.
}
window.location.replace(appReturnUrl(base, params));
return true;
}
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} is ready to install", "ready": "Termix {{version}} is ready to install",
"readyHint": "It installs the next time Termix restarts." "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": { "companion": {
"close": "Close panel", "close": "Close panel",
"moveSide": "Move panel", "moveSide": "Move panel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Reset the default for everyone", "resetEveryone": "Reset the default for everyone",
"update": "Update {{name}} to {{version}}", "update": "Update {{name}} to {{version}}",
"disable": "Turn off {{name}}", "disable": "Turn off {{name}}",
"enable": "Turn on {{name}}",
"startAgain": "Start {{name}} again",
"install": "Find layouts and themes", "install": "Find layouts and themes",
"installTitle": "Layouts and themes", "installTitle": "Layouts and themes",
"installButton": "Install", "installButton": "Install",
@@ -1309,7 +1315,8 @@
"disabled": "Disabled", "disabled": "Disabled",
"failed": "Stopped unexpectedly", "failed": "Stopped unexpectedly",
"blocked": "Waiting on another plugin", "blocked": "Waiting on another plugin",
"server": "Runs on your server" "server": "Runs on your server",
"safeMode": "Off in safe mode"
}, },
"groups": { "groups": {
"running": "Running", "running": "Running",
@@ -1323,11 +1330,6 @@
"updated": "Recently updated", "updated": "Recently updated",
"category": "Category" "category": "Category"
}, },
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
},
"table": { "table": {
"name": "Name", "name": "Name",
"description": "Description", "description": "Description",
@@ -1652,7 +1654,12 @@
"gridView": "Grid", "gridView": "Grid",
"listView": "List", "listView": "List",
"compactRows": "Compact rows", "compactRows": "Compact rows",
"comfortableRows": "Comfortable rows" "comfortableRows": "Comfortable rows",
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
}
}, },
"fields": { "fields": {
"secretSaved": "Saved. Type to replace it." "secretSaved": "Saved. Type to replace it."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} is gereed om te installeer", "ready": "Termix {{version}} is gereed om te installeer",
"readyHint": "Dit word die volgende keer wat Termix herbegin geïnstalleer." "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": { "companion": {
"close": "Maak paneel toe", "close": "Maak paneel toe",
"moveSide": "Skuif paneel", "moveSide": "Skuif paneel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Stel die verstek vir almal terug", "resetEveryone": "Stel die verstek vir almal terug",
"update": "Dateer {{name}} op na {{version}}", "update": "Dateer {{name}} op na {{version}}",
"disable": "Skakel {{name}} af", "disable": "Skakel {{name}} af",
"enable": "Skakel {{name}} aan",
"startAgain": "Begin {{name}} weer",
"install": "Vind lêe en temas", "install": "Vind lêe en temas",
"installTitle": "Lêe en temas", "installTitle": "Lêe en temas",
"installButton": "Installeer", "installButton": "Installeer",
@@ -1309,7 +1315,8 @@
"disabled": "Afgeskakel", "disabled": "Afgeskakel",
"failed": "Onverwags gestop", "failed": "Onverwags gestop",
"blocked": "Wag op 'n ander plugin", "blocked": "Wag op 'n ander plugin",
"server": "Loop op jou bediener" "server": "Loop op jou bediener",
"safeMode": "Af in veilige modus"
}, },
"groups": { "groups": {
"running": "Aktief", "running": "Aktief",
@@ -1323,11 +1330,6 @@
"updated": "Onlangs opgedateer", "updated": "Onlangs opgedateer",
"category": "Kategorie" "category": "Kategorie"
}, },
"columns": {
"title": "Kolomme",
"auto": "Outo",
"count": "{{count}} breed"
},
"table": { "table": {
"name": "Naam", "name": "Naam",
"description": "Beskrywing", "description": "Beskrywing",
@@ -1652,7 +1654,12 @@
"gridView": "Rooster", "gridView": "Rooster",
"listView": "Lys", "listView": "Lys",
"compactRows": "Kompakte rye", "compactRows": "Kompakte rye",
"comfortableRows": "Gerieflike rye" "comfortableRows": "Gerieflike rye",
"columns": {
"title": "Kolomme",
"auto": "Outo",
"count": "{{count}} breed"
}
}, },
"fields": { "fields": {
"secretSaved": "Opgeslaan. Tik om dit te vervang." "secretSaved": "Opgeslaan. Tik om dit te vervang."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} جاهز للتثبيت", "ready": "Termix {{version}} جاهز للتثبيت",
"readyHint": "سيتم تثبيته في المرة القادمة التي يعاد فيها تشغيل Termix." "readyHint": "سيتم تثبيته في المرة القادمة التي يعاد فيها تشغيل Termix."
}, },
"safeMode": {
"title": "الوضع الآمن",
"description": "لا تعمل سوى إضافات التخطيط والمظهر. أعد تشغيل الخادم بدون TERMIX_SAFE_MODE لإعادة تشغيل الباقي."
},
"companion": { "companion": {
"close": "إغلاق اللوحة", "close": "إغلاق اللوحة",
"moveSide": "نقل اللوحة", "moveSide": "نقل اللوحة",
@@ -1166,6 +1170,8 @@
"resetEveryone": "إعادة تعيين الافتراضي للجميع", "resetEveryone": "إعادة تعيين الافتراضي للجميع",
"update": "تحديث {{name}} إلى {{version}}", "update": "تحديث {{name}} إلى {{version}}",
"disable": "إيقاف {{name}}", "disable": "إيقاف {{name}}",
"enable": "تفعيل {{name}}",
"startAgain": "إعادة تشغيل {{name}}",
"install": "البحث عن التخطيطات والسمات", "install": "البحث عن التخطيطات والسمات",
"installTitle": "التخطيطات والسمات", "installTitle": "التخطيطات والسمات",
"installButton": "تثبيت", "installButton": "تثبيت",
@@ -1377,7 +1383,8 @@
"disabled": "معطلة", "disabled": "معطلة",
"failed": "توقفت بشكل غير متوقع", "failed": "توقفت بشكل غير متوقع",
"blocked": "في انتظار إضافة أخرى", "blocked": "في انتظار إضافة أخرى",
"server": "يعمل على خادمك" "server": "يعمل على خادمك",
"safeMode": "معطل في الوضع الآمن"
}, },
"groups": { "groups": {
"running": "تعمل", "running": "تعمل",
@@ -1391,11 +1398,6 @@
"updated": "تم تحديثها مؤخراً", "updated": "تم تحديثها مؤخراً",
"category": "الفئة" "category": "الفئة"
}, },
"columns": {
"title": "الأعمدة",
"auto": "تلقائي",
"count": "عرض {{count}}"
},
"table": { "table": {
"name": "الاسم", "name": "الاسم",
"description": "الوصف", "description": "الوصف",
@@ -1820,7 +1822,12 @@
"gridView": "شبكة", "gridView": "شبكة",
"listView": "قائمة", "listView": "قائمة",
"compactRows": "صفوف مدمجة", "compactRows": "صفوف مدمجة",
"comfortableRows": "صفوف مريحة" "comfortableRows": "صفوف مريحة",
"columns": {
"title": "الأعمدة",
"auto": "تلقائي",
"count": "عرض {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "تم الحفظ. اكتب لاستبداله." "secretSaved": "تم الحفظ. اكتب لاستبداله."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} е готов за инсталация", "ready": "Termix {{version}} е готов за инсталация",
"readyHint": "Ще се инсталира следващия път, когато Termix се рестартира." "readyHint": "Ще се инсталира следващия път, когато Termix се рестартира."
}, },
"safeMode": {
"title": "Режим на безопасност",
"description": "Само плъгините за подредба и тема са активни. Рестартирайте сървъра без променливата TERMIX_SAFE_MODE, за да активирате останалите."
},
"companion": { "companion": {
"close": "Затвори панела", "close": "Затвори панела",
"moveSide": "Премести панела", "moveSide": "Премести панела",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Върни настройките по подразбиране за всички", "resetEveryone": "Върни настройките по подразбиране за всички",
"update": "Актуализирай {{name}} до {{version}}", "update": "Актуализирай {{name}} до {{version}}",
"disable": "Изключи {{name}}", "disable": "Изключи {{name}}",
"enable": "Активиране на {{name}}",
"startAgain": "Стартиране на {{name}} отново",
"install": "Намери подредби и теми", "install": "Намери подредби и теми",
"installTitle": "Подредби и теми", "installTitle": "Подредби и теми",
"installButton": "Инсталирай", "installButton": "Инсталирай",
@@ -1309,7 +1315,8 @@
"disabled": "Изключена", "disabled": "Изключена",
"failed": "Спряна неочаквано", "failed": "Спряна неочаквано",
"blocked": "Чака друга добавка", "blocked": "Чака друга добавка",
"server": "Работи на вашия сървър" "server": "Работи на вашия сървър",
"safeMode": "Изключен в режим на безопасност"
}, },
"groups": { "groups": {
"running": "Работещи", "running": "Работещи",
@@ -1323,11 +1330,6 @@
"updated": "Наскоро актуализирани", "updated": "Наскоро актуализирани",
"category": "Категория" "category": "Категория"
}, },
"columns": {
"title": "Колони",
"auto": "Автоматично",
"count": "{{count}} колони"
},
"table": { "table": {
"name": "Име", "name": "Име",
"description": "Описание", "description": "Описание",
@@ -1652,7 +1654,12 @@
"gridView": "Мрежа", "gridView": "Мрежа",
"listView": "Списък", "listView": "Списък",
"compactRows": "Компактни редове", "compactRows": "Компактни редове",
"comfortableRows": "Комфортни редове" "comfortableRows": "Комфортни редове",
"columns": {
"title": "Колони",
"auto": "Автоматично",
"count": "ширина {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Запазено. Въведете ново, за да го замените." "secretSaved": "Запазено. Въведете ново, за да го замените."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} ইনস্টল করার জন্য প্রস্তুত", "ready": "Termix {{version}} ইনস্টল করার জন্য প্রস্তুত",
"readyHint": "এটি পরবর্তীবার Termix রিস্টার্ট করলে ইনস্টল হবে।" "readyHint": "এটি পরবর্তীবার Termix রিস্টার্ট করলে ইনস্টল হবে।"
}, },
"safeMode": {
"title": "সেফ মোড",
"description": "শুধুমাত্র লেআউট এবং থিম প্লাগইন চলছে। বাকিগুলো আবার চালু করতে TERMIX_SAFE_MODE ছাড়া সার্ভারটি রিস্টার্ট করুন।"
},
"companion": { "companion": {
"close": "প্যানেল বন্ধ করুন", "close": "প্যানেল বন্ধ করুন",
"moveSide": "প্যানেল সরান", "moveSide": "প্যানেল সরান",
@@ -1098,6 +1102,8 @@
"resetEveryone": "সবার জন্য ডিফল্ট রিসেট করুন", "resetEveryone": "সবার জন্য ডিফল্ট রিসেট করুন",
"update": "{{name}} কে {{version}} এ আপডেট করুন", "update": "{{name}} কে {{version}} এ আপডেট করুন",
"disable": "{{name}} বন্ধ করুন", "disable": "{{name}} বন্ধ করুন",
"enable": "{{name}} চালু করুন",
"startAgain": "{{name}} আবার শুরু করুন",
"install": "লেআউট এবং থিম খুঁজুন", "install": "লেআউট এবং থিম খুঁজুন",
"installTitle": "লেআউট এবং থিম", "installTitle": "লেআউট এবং থিম",
"installButton": "ইনস্টল করুন", "installButton": "ইনস্টল করুন",
@@ -1309,7 +1315,8 @@
"disabled": "নিষ্ক্রিয়", "disabled": "নিষ্ক্রিয়",
"failed": "অপ্রত্যাশিতভাবে থেমে গেছে", "failed": "অপ্রত্যাশিতভাবে থেমে গেছে",
"blocked": "অন্য প্লাগইনের জন্য অপেক্ষা করছে", "blocked": "অন্য প্লাগইনের জন্য অপেক্ষা করছে",
"server": "আপনার সার্ভারে চলে" "server": "আপনার সার্ভারে চলে",
"safeMode": "সেফ মোডে বন্ধ"
}, },
"groups": { "groups": {
"running": "চলমান", "running": "চলমান",
@@ -1323,11 +1330,6 @@
"updated": "সাম্প্রতিক আপডেট", "updated": "সাম্প্রতিক আপডেট",
"category": "ক্যাটাগরি" "category": "ক্যাটাগরি"
}, },
"columns": {
"title": "কলাম",
"auto": "স্বয়ংক্রিয়",
"count": "{{count}} প্রশস্ত"
},
"table": { "table": {
"name": "নাম", "name": "নাম",
"description": "বিবরণ", "description": "বিবরণ",
@@ -1652,7 +1654,12 @@
"gridView": "গ্রিড", "gridView": "গ্রিড",
"listView": "তালিকা", "listView": "তালিকা",
"compactRows": "কমপ্যাক্ট সারি", "compactRows": "কমপ্যাক্ট সারি",
"comfortableRows": "আরামদায়ক সারি" "comfortableRows": "আরামদায়ক সারি",
"columns": {
"title": "কলাম",
"auto": "স্বয়ংক্রিয়",
"count": "{{count}} প্রশস্ত"
}
}, },
"fields": { "fields": {
"secretSaved": "সংরক্ষিত হয়েছে। এটি প্রতিস্থাপন করতে টাইপ করুন।" "secretSaved": "সংরক্ষিত হয়েছে। এটি প্রতিস্থাপন করতে টাইপ করুন।"
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} està llest per instal·lar-se", "ready": "Termix {{version}} està llest per instal·lar-se",
"readyHint": "S'instal·larà la propera vegada que reiniciïs Termix." "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": { "companion": {
"close": "Tanca el panell", "close": "Tanca el panell",
"moveSide": "Mou el panell", "moveSide": "Mou el panell",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Restableix el predeterminat per a tothom", "resetEveryone": "Restableix el predeterminat per a tothom",
"update": "Actualitza {{name}} a {{version}}", "update": "Actualitza {{name}} a {{version}}",
"disable": "Desactiva {{name}}", "disable": "Desactiva {{name}}",
"enable": "Activar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Cerca dissenys i temes", "install": "Cerca dissenys i temes",
"installTitle": "Disseny i temes", "installTitle": "Disseny i temes",
"installButton": "Instal·la", "installButton": "Instal·la",
@@ -1326,7 +1332,8 @@
"disabled": "Desactivat", "disabled": "Desactivat",
"failed": "Aturat inesperadament", "failed": "Aturat inesperadament",
"blocked": "Esperant un altre plugin", "blocked": "Esperant un altre plugin",
"server": "S'executa al teu servidor" "server": "S'executa al teu servidor",
"safeMode": "Desactivat en mode segur"
}, },
"groups": { "groups": {
"running": "En execució", "running": "En execució",
@@ -1340,11 +1347,6 @@
"updated": "Actualitzats recentment", "updated": "Actualitzats recentment",
"category": "Categoria" "category": "Categoria"
}, },
"columns": {
"title": "Columnes",
"auto": "Automàtic",
"count": "{{count}} d'amplada"
},
"table": { "table": {
"name": "Nom", "name": "Nom",
"description": "Descripció", "description": "Descripció",
@@ -1694,7 +1696,12 @@
"gridView": "Graella", "gridView": "Graella",
"listView": "Llista", "listView": "Llista",
"compactRows": "Files compactes", "compactRows": "Files compactes",
"comfortableRows": "Files còmodes" "comfortableRows": "Files còmodes",
"columns": {
"title": "Columnes",
"auto": "Automàtic",
"count": "{{count}} d'ample"
}
}, },
"fields": { "fields": {
"secretSaved": "Desat. Escriu per substituir-lo." "secretSaved": "Desat. Escriu per substituir-lo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} je připraven k instalaci", "ready": "Termix {{version}} je připraven k instalaci",
"readyHint": "Nainstaluje se při příštím restartu Termixu." "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": { "companion": {
"close": "Zavřít panel", "close": "Zavřít panel",
"moveSide": "Přesunout panel", "moveSide": "Přesunout panel",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Obnovit výchozí nastavení pro všechny", "resetEveryone": "Obnovit výchozí nastavení pro všechny",
"update": "Aktualizovat {{name}} na {{version}}", "update": "Aktualizovat {{name}} na {{version}}",
"disable": "Vypnout {{name}}", "disable": "Vypnout {{name}}",
"enable": "Zapnout {{name}}",
"startAgain": "Spustit {{name}} znovu",
"install": "Hledat rozložení a motivy", "install": "Hledat rozložení a motivy",
"installTitle": "Rozložení a motivy", "installTitle": "Rozložení a motivy",
"installButton": "Instalovat", "installButton": "Instalovat",
@@ -1343,7 +1349,8 @@
"disabled": "Zakázáno", "disabled": "Zakázáno",
"failed": "Neočekávaně zastaveno", "failed": "Neočekávaně zastaveno",
"blocked": "Čeká na jiný plugin", "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": { "groups": {
"running": "Spuštěné", "running": "Spuštěné",
@@ -1357,11 +1364,6 @@
"updated": "Nedávno aktualizované", "updated": "Nedávno aktualizované",
"category": "Kategorie" "category": "Kategorie"
}, },
"columns": {
"title": "Sloupce",
"auto": "Automaticky",
"count": "Šířka {{count}}"
},
"table": { "table": {
"name": "Název", "name": "Název",
"description": "Popis", "description": "Popis",
@@ -1736,7 +1738,12 @@
"gridView": "Mřížka", "gridView": "Mřížka",
"listView": "Seznam", "listView": "Seznam",
"compactRows": "Kompaktní řádky", "compactRows": "Kompaktní řádky",
"comfortableRows": "Pohodlné řádky" "comfortableRows": "Pohodlné řádky",
"columns": {
"title": "Sloupce",
"auto": "Automaticky",
"count": "šířka {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Uloženo. Napište pro nahrazení." "secretSaved": "Uloženo. Napište pro nahrazení."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} er klar til installation", "ready": "Termix {{version}} er klar til installation",
"readyHint": "Den installeres næste gang Termix genstartes." "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": { "companion": {
"close": "Luk panel", "close": "Luk panel",
"moveSide": "Flyt panel", "moveSide": "Flyt panel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Nulstil standardindstillingen for alle", "resetEveryone": "Nulstil standardindstillingen for alle",
"update": "Opdater {{name}} til {{version}}", "update": "Opdater {{name}} til {{version}}",
"disable": "Sluk {{name}}", "disable": "Sluk {{name}}",
"enable": "Aktivér {{name}}",
"startAgain": "Start {{name}} igen",
"install": "Find layouts og temaer", "install": "Find layouts og temaer",
"installTitle": "Layouts og temaer", "installTitle": "Layouts og temaer",
"installButton": "Installer", "installButton": "Installer",
@@ -1309,7 +1315,8 @@
"disabled": "Deaktiveret", "disabled": "Deaktiveret",
"failed": "Stoppet uventet", "failed": "Stoppet uventet",
"blocked": "Venter på et andet plugin", "blocked": "Venter på et andet plugin",
"server": "Kører på din server" "server": "Kører på din server",
"safeMode": "Deaktiveret i sikker tilstand"
}, },
"groups": { "groups": {
"running": "Kører", "running": "Kører",
@@ -1323,11 +1330,6 @@
"updated": "Nyligt opdateret", "updated": "Nyligt opdateret",
"category": "Kategori" "category": "Kategori"
}, },
"columns": {
"title": "Kolonner",
"auto": "Auto",
"count": "{{count}} bred"
},
"table": { "table": {
"name": "Navn", "name": "Navn",
"description": "Beskrivelse", "description": "Beskrivelse",
@@ -1652,7 +1654,12 @@
"gridView": "Gitter", "gridView": "Gitter",
"listView": "Liste", "listView": "Liste",
"compactRows": "Kompakte rækker", "compactRows": "Kompakte rækker",
"comfortableRows": "Komfortable rækker" "comfortableRows": "Komfortable rækker",
"columns": {
"title": "Søjler",
"auto": "Automatisk",
"count": "{{count}} bred"
}
}, },
"fields": { "fields": {
"secretSaved": "Gemt. Skriv for at erstatte det." "secretSaved": "Gemt. Skriv for at erstatte det."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} ist bereit zur Installation", "ready": "Termix {{version}} ist bereit zur Installation",
"readyHint": "Es wird beim nächsten Neustart von Termix installiert." "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": { "companion": {
"close": "Panel schließen", "close": "Panel schließen",
"moveSide": "Panel verschieben", "moveSide": "Panel verschieben",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Standard für alle zurücksetzen", "resetEveryone": "Standard für alle zurücksetzen",
"update": "{{name}} auf {{version}} aktualisieren", "update": "{{name}} auf {{version}} aktualisieren",
"disable": "{{name}} deaktivieren", "disable": "{{name}} deaktivieren",
"enable": "{{name}} aktivieren",
"startAgain": "{{name}} neu starten",
"install": "Layouts und Themes finden", "install": "Layouts und Themes finden",
"installTitle": "Layouts und Themes", "installTitle": "Layouts und Themes",
"installButton": "Installieren", "installButton": "Installieren",
@@ -1309,7 +1315,8 @@
"disabled": "Deaktiviert", "disabled": "Deaktiviert",
"failed": "Unerwartet gestoppt", "failed": "Unerwartet gestoppt",
"blocked": "Wartet auf ein anderes Plugin", "blocked": "Wartet auf ein anderes Plugin",
"server": "Läuft auf deinem Server" "server": "Läuft auf deinem Server",
"safeMode": "Im sicheren Modus deaktiviert"
}, },
"groups": { "groups": {
"running": "Aktiv", "running": "Aktiv",
@@ -1323,11 +1330,6 @@
"updated": "Kürzlich aktualisiert", "updated": "Kürzlich aktualisiert",
"category": "Kategorie" "category": "Kategorie"
}, },
"columns": {
"title": "Spalten",
"auto": "Auto",
"count": "{{count}} Spalten breit"
},
"table": { "table": {
"name": "Name", "name": "Name",
"description": "Beschreibung", "description": "Beschreibung",
@@ -1652,7 +1654,12 @@
"gridView": "Raster", "gridView": "Raster",
"listView": "Liste", "listView": "Liste",
"compactRows": "Kompakte Zeilen", "compactRows": "Kompakte Zeilen",
"comfortableRows": "Bequeme Zeilen" "comfortableRows": "Bequeme Zeilen",
"columns": {
"title": "Spalten",
"auto": "Automatisch",
"count": "{{count}} Spalten breit"
}
}, },
"fields": { "fields": {
"secretSaved": "Gespeichert. Tippen, um es zu ersetzen." "secretSaved": "Gespeichert. Tippen, um es zu ersetzen."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Το Termix {{version}} είναι έτοιμο για εγκατάσταση", "ready": "Το Termix {{version}} είναι έτοιμο για εγκατάσταση",
"readyHint": "Εγκαθίσταται την επόμενη φορά που θα επανεκκινήσει το Termix." "readyHint": "Εγκαθίσταται την επόμενη φορά που θα επανεκκινήσει το Termix."
}, },
"safeMode": {
"title": "Λειτουργία ασφαλείας",
"description": "Τρέχουν μόνο οι προσθήκες διάταξης και θέματος. Επανεκκινήστε τον διακομιστή χωρίς το TERMIX_SAFE_MODE για να ενεργοποιήσετε ξανά τις υπόλοιπες."
},
"companion": { "companion": {
"close": "Κλείσιμο πάνελ", "close": "Κλείσιμο πάνελ",
"moveSide": "Μετακίνηση πάνελ", "moveSide": "Μετακίνηση πάνελ",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Επαναφορά της προεπιλογής για όλους", "resetEveryone": "Επαναφορά της προεπιλογής για όλους",
"update": "Ενημέρωση του {{name}} στην έκδοση {{version}}", "update": "Ενημέρωση του {{name}} στην έκδοση {{version}}",
"disable": "Απενεργοποίηση του {{name}}", "disable": "Απενεργοποίηση του {{name}}",
"enable": "Ενεργοποίηση {{name}}",
"startAgain": "Εκκίνηση {{name}} ξανά",
"install": "Εύρεση layouts και θεμάτων", "install": "Εύρεση layouts και θεμάτων",
"installTitle": "Layouts και θέματα", "installTitle": "Layouts και θέματα",
"installButton": "Εγκατάσταση", "installButton": "Εγκατάσταση",
@@ -1309,7 +1315,8 @@
"disabled": "Απενεργοποιημένο", "disabled": "Απενεργοποιημένο",
"failed": "Διακόπηκε απροσδόκητα", "failed": "Διακόπηκε απροσδόκητα",
"blocked": "Περιμένει άλλο plugin", "blocked": "Περιμένει άλλο plugin",
"server": "Τρέχει στον διακομιστή σας" "server": "Τρέχει στον διακομιστή σας",
"safeMode": "Ανενεργό στη λειτουργία ασφαλείας"
}, },
"groups": { "groups": {
"running": "Εν ενεργεία", "running": "Εν ενεργεία",
@@ -1323,11 +1330,6 @@
"updated": "Πρόσφατα ενημερωμένα", "updated": "Πρόσφατα ενημερωμένα",
"category": "Κατηγορία" "category": "Κατηγορία"
}, },
"columns": {
"title": "Στήλες",
"auto": "Αυτόματο",
"count": "{{count}} στήλες"
},
"table": { "table": {
"name": "Όνομα", "name": "Όνομα",
"description": "Περιγραφή", "description": "Περιγραφή",
@@ -1652,7 +1654,12 @@
"gridView": "Πλέγμα", "gridView": "Πλέγμα",
"listView": "Λίστα", "listView": "Λίστα",
"compactRows": "Συμπαγείς γραμμές", "compactRows": "Συμπαγείς γραμμές",
"comfortableRows": "Άνετες γραμμές" "comfortableRows": "Άνετες γραμμές",
"columns": {
"title": "Στήλες",
"auto": "Αυτόματο",
"count": "Πλάτος {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Αποθηκεύτηκε. Πληκτρολογήστε για να το αντικαταστήσετε." "secretSaved": "Αποθηκεύτηκε. Πληκτρολογήστε για να το αντικαταστήσετε."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} está listo para instalar", "ready": "Termix {{version}} está listo para instalar",
"readyHint": "Se instalará la próxima vez que reinicies Termix." "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": { "companion": {
"close": "Cerrar panel", "close": "Cerrar panel",
"moveSide": "Mover panel", "moveSide": "Mover panel",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Restablecer el valor predeterminado para todos", "resetEveryone": "Restablecer el valor predeterminado para todos",
"update": "Actualizar {{name}} a {{version}}", "update": "Actualizar {{name}} a {{version}}",
"disable": "Desactivar {{name}}", "disable": "Desactivar {{name}}",
"enable": "Activar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Buscar diseños y temas", "install": "Buscar diseños y temas",
"installTitle": "Diseños y temas", "installTitle": "Diseños y temas",
"installButton": "Instalar", "installButton": "Instalar",
@@ -1326,7 +1332,8 @@
"disabled": "Desactivado", "disabled": "Desactivado",
"failed": "Detenido inesperadamente", "failed": "Detenido inesperadamente",
"blocked": "Esperando a otro complemento", "blocked": "Esperando a otro complemento",
"server": "Se ejecuta en tu servidor" "server": "Se ejecuta en tu servidor",
"safeMode": "Desactivado en modo seguro"
}, },
"groups": { "groups": {
"running": "En ejecución", "running": "En ejecución",
@@ -1340,11 +1347,6 @@
"updated": "Actualizados recientemente", "updated": "Actualizados recientemente",
"category": "Categoría" "category": "Categoría"
}, },
"columns": {
"title": "Columnas",
"auto": "Automático",
"count": "{{count}} de ancho"
},
"table": { "table": {
"name": "Nombre", "name": "Nombre",
"description": "Descripción", "description": "Descripción",
@@ -1694,7 +1696,12 @@
"gridView": "Cuadrícula", "gridView": "Cuadrícula",
"listView": "Lista", "listView": "Lista",
"compactRows": "Filas compactas", "compactRows": "Filas compactas",
"comfortableRows": "Filas cómodas" "comfortableRows": "Filas cómodas",
"columns": {
"title": "Columnas",
"auto": "Automático",
"count": "{{count}} de ancho"
}
}, },
"fields": { "fields": {
"secretSaved": "Guardado. Escribe para reemplazarlo." "secretSaved": "Guardado. Escribe para reemplazarlo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} on paigaldamiseks valmis", "ready": "Termix {{version}} on paigaldamiseks valmis",
"readyHint": "See paigaldatakse järgmisel Termixi taaskäivitamisel." "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": { "companion": {
"close": "Sulge paneel", "close": "Sulge paneel",
"moveSide": "Liiguta paneeli", "moveSide": "Liiguta paneeli",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Taasta vaikimisi seadistus kõigile", "resetEveryone": "Taasta vaikimisi seadistus kõigile",
"update": "Uuenda {{name}} versioonile {{version}}", "update": "Uuenda {{name}} versioonile {{version}}",
"disable": "Lülita {{name}} välja", "disable": "Lülita {{name}} välja",
"enable": "Lülita {{name}} sisse",
"startAgain": "Käivita {{name}} uuesti",
"install": "Otsi paigutusi ja teemasid", "install": "Otsi paigutusi ja teemasid",
"installTitle": "Paigutused ja teemad", "installTitle": "Paigutused ja teemad",
"installButton": "Paigalda", "installButton": "Paigalda",
@@ -1309,7 +1315,8 @@
"disabled": "Keelatud", "disabled": "Keelatud",
"failed": "Peatati ootamatult", "failed": "Peatati ootamatult",
"blocked": "Ootab teist pistikprogrammi", "blocked": "Ootab teist pistikprogrammi",
"server": "Töötab sinu serveris" "server": "Töötab sinu serveris",
"safeMode": "Turvarežiimis välja lülitatud"
}, },
"groups": { "groups": {
"running": "Käimas", "running": "Käimas",
@@ -1323,11 +1330,6 @@
"updated": "Hiljuti uuendatud", "updated": "Hiljuti uuendatud",
"category": "Kategooria" "category": "Kategooria"
}, },
"columns": {
"title": "Veerud",
"auto": "Automaatne",
"count": "{{count}} lai"
},
"table": { "table": {
"name": "Nimi", "name": "Nimi",
"description": "Kirjeldus", "description": "Kirjeldus",
@@ -1652,7 +1654,12 @@
"gridView": "Ruudustik", "gridView": "Ruudustik",
"listView": "Loend", "listView": "Loend",
"compactRows": "Kompaktsead read", "compactRows": "Kompaktsead read",
"comfortableRows": "Mugavad read" "comfortableRows": "Mugavad read",
"columns": {
"title": "Veerud",
"auto": "Automaatne",
"count": "{{count}} veeru laiune"
}
}, },
"fields": { "fields": {
"secretSaved": "Salvestatud. Tüüpi, et asendada." "secretSaved": "Salvestatud. Tüüpi, et asendada."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} on valmis asennettavaksi", "ready": "Termix {{version}} on valmis asennettavaksi",
"readyHint": "Se asennetaan seuraavan kerran, kun Termix käynnistetään uudelleen." "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": { "companion": {
"close": "Sulje paneeli", "close": "Sulje paneeli",
"moveSide": "Siirrä paneeli", "moveSide": "Siirrä paneeli",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Palauta oletus kaikille", "resetEveryone": "Palauta oletus kaikille",
"update": "Päivitä {{name}} versioon {{version}}", "update": "Päivitä {{name}} versioon {{version}}",
"disable": "Sammuta {{name}}", "disable": "Sammuta {{name}}",
"enable": "Ota {{name}} käyttöön",
"startAgain": "Käynnistä {{name}} uudelleen",
"install": "Etsi asetteluja ja teemoja", "install": "Etsi asetteluja ja teemoja",
"installTitle": "Asettelut ja teemat", "installTitle": "Asettelut ja teemat",
"installButton": "Asenna", "installButton": "Asenna",
@@ -1309,7 +1315,8 @@
"disabled": "Poistettu käytöstä", "disabled": "Poistettu käytöstä",
"failed": "Pysähtyi odottamatta", "failed": "Pysähtyi odottamatta",
"blocked": "Odottaa toista lisäosaa", "blocked": "Odottaa toista lisäosaa",
"server": "Käynnistyy palvelimellasi" "server": "Käynnistyy palvelimellasi",
"safeMode": "Pois käytöstä turvatoiminnossa"
}, },
"groups": { "groups": {
"running": "Käynnissä", "running": "Käynnissä",
@@ -1323,11 +1330,6 @@
"updated": "Viimeksi päivitetty", "updated": "Viimeksi päivitetty",
"category": "Kategoria" "category": "Kategoria"
}, },
"columns": {
"title": "Sarakkeet",
"auto": "Automaattinen",
"count": "{{count}} saraketta leveä"
},
"table": { "table": {
"name": "Nimi", "name": "Nimi",
"description": "Kuvaus", "description": "Kuvaus",
@@ -1652,7 +1654,12 @@
"gridView": "Ruudukko", "gridView": "Ruudukko",
"listView": "Lista", "listView": "Lista",
"compactRows": "Tiiviit rivit", "compactRows": "Tiiviit rivit",
"comfortableRows": "Mukavat rivit" "comfortableRows": "Mukavat rivit",
"columns": {
"title": "Sarakkeet",
"auto": "Automaattinen",
"count": "{{count}} saraketta leveä"
}
}, },
"fields": { "fields": {
"secretSaved": "Tallennettu. Kirjoita korvataksesi sen." "secretSaved": "Tallennettu. Kirjoita korvataksesi sen."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} est prêt à être installé", "ready": "Termix {{version}} est prêt à être installé",
"readyHint": "Il sera installé au prochain redémarrage de Termix." "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": { "companion": {
"close": "Fermer le panneau", "close": "Fermer le panneau",
"moveSide": "Déplacer le panneau", "moveSide": "Déplacer le panneau",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Réinitialiser la valeur par défaut pour tout le monde", "resetEveryone": "Réinitialiser la valeur par défaut pour tout le monde",
"update": "Mettre à jour {{name}} vers {{version}}", "update": "Mettre à jour {{name}} vers {{version}}",
"disable": "Désactiver {{name}}", "disable": "Désactiver {{name}}",
"enable": "Activer {{name}}",
"startAgain": "Relancer {{name}}",
"install": "Trouver des dispositions et des thèmes", "install": "Trouver des dispositions et des thèmes",
"installTitle": "Dispositions et thèmes", "installTitle": "Dispositions et thèmes",
"installButton": "Installer", "installButton": "Installer",
@@ -1326,7 +1332,8 @@
"disabled": "Désactivé", "disabled": "Désactivé",
"failed": "Arrêt inattendu", "failed": "Arrêt inattendu",
"blocked": "En attente d'un autre plugin", "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": { "groups": {
"running": "En cours d'exécution", "running": "En cours d'exécution",
@@ -1340,11 +1347,6 @@
"updated": "Récemment mis à jour", "updated": "Récemment mis à jour",
"category": "Catégorie" "category": "Catégorie"
}, },
"columns": {
"title": "Colonnes",
"auto": "Auto",
"count": "{{count}} de large"
},
"table": { "table": {
"name": "Nom", "name": "Nom",
"description": "Description", "description": "Description",
@@ -1694,7 +1696,12 @@
"gridView": "Grille", "gridView": "Grille",
"listView": "Liste", "listView": "Liste",
"compactRows": "Lignes compactes", "compactRows": "Lignes compactes",
"comfortableRows": "Lignes confortables" "comfortableRows": "Lignes confortables",
"columns": {
"title": "Colonnes",
"auto": "Auto",
"count": "{{count}} de large"
}
}, },
"fields": { "fields": {
"secretSaved": "Enregistré. Tapez pour le remplacer." "secretSaved": "Enregistré. Tapez pour le remplacer."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} מוכן להתקנה", "ready": "Termix {{version}} מוכן להתקנה",
"readyHint": "הוא מותקן בפעם הבאה ש-Termix מופעל מחדש." "readyHint": "הוא מותקן בפעם הבאה ש-Termix מופעל מחדש."
}, },
"safeMode": {
"title": "מצב בטוח",
"description": "רק תוספי פריסה ועיצוב פועלים. הפעל מחדש את השרת ללא המשתנה TERMIX_SAFE_MODE כדי להפעיל מחדש את שאר התוספים."
},
"companion": { "companion": {
"close": "סגור לוח", "close": "סגור לוח",
"moveSide": "הזז לוח", "moveSide": "הזז לוח",
@@ -1115,6 +1119,8 @@
"resetEveryone": "אפס את ברירת המחדל לכולם", "resetEveryone": "אפס את ברירת המחדל לכולם",
"update": "עדכן את {{name}} ל-{{version}}", "update": "עדכן את {{name}} ל-{{version}}",
"disable": "כבה את {{name}}", "disable": "כבה את {{name}}",
"enable": "הפעל את {{name}}",
"startAgain": "הפעל מחדש את {{name}}",
"install": "חפש פריסות וערכות נושא", "install": "חפש פריסות וערכות נושא",
"installTitle": "פריסות וערכות נושא", "installTitle": "פריסות וערכות נושא",
"installButton": "התקן", "installButton": "התקן",
@@ -1326,7 +1332,8 @@
"disabled": "מושבת", "disabled": "מושבת",
"failed": "הופסק ללא ציפייה", "failed": "הופסק ללא ציפייה",
"blocked": "ממתין לתוסף אחר", "blocked": "ממתין לתוסף אחר",
"server": "רץ על השרת שלך" "server": "רץ על השרת שלך",
"safeMode": "כבוי במצב בטוח"
}, },
"groups": { "groups": {
"running": "פעילים", "running": "פעילים",
@@ -1340,11 +1347,6 @@
"updated": "עודכן לאחרונה", "updated": "עודכן לאחרונה",
"category": "קטגוריה" "category": "קטגוריה"
}, },
"columns": {
"title": "עמודות",
"auto": "אוטומטי",
"count": "רוחב של {{count}}"
},
"table": { "table": {
"name": "שם", "name": "שם",
"description": "תיאור", "description": "תיאור",
@@ -1694,7 +1696,12 @@
"gridView": "רשת", "gridView": "רשת",
"listView": "רשימה", "listView": "רשימה",
"compactRows": "שורות צפופות", "compactRows": "שורות צפופות",
"comfortableRows": "שורות נוחות" "comfortableRows": "שורות נוחות",
"columns": {
"title": "עמודות",
"auto": "אוטומטי",
"count": "רוחב של {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "נשמר. הקלד כדי להחליף אותו." "secretSaved": "נשמר. הקלד כדי להחליף אותו."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} इंस्टॉल करने के लिए तैयार है", "ready": "Termix {{version}} इंस्टॉल करने के लिए तैयार है",
"readyHint": "यह अगली बार जब Termix पुनरारंभ होगा तब इंस्टॉल होगा।" "readyHint": "यह अगली बार जब Termix पुनरारंभ होगा तब इंस्टॉल होगा।"
}, },
"safeMode": {
"title": "सुरक्षित मोड",
"description": "केवल लेआउट और थीम प्लगइन चल रहे हैं। बाकी सब कुछ वापस चालू करने के लिए TERMIX_SAFE_MODE के बिना सर्वर को पुनः आरंभ करें।"
},
"companion": { "companion": {
"close": "पैनल बंद करें", "close": "पैनल बंद करें",
"moveSide": "पैनल स्थानांतरित करें", "moveSide": "पैनल स्थानांतरित करें",
@@ -1098,6 +1102,8 @@
"resetEveryone": "सभी के लिए डिफ़ॉल्ट रीसेट करें", "resetEveryone": "सभी के लिए डिफ़ॉल्ट रीसेट करें",
"update": "{{name}} को {{version}} पर अपडेट करें", "update": "{{name}} को {{version}} पर अपडेट करें",
"disable": "{{name}} बंद करें", "disable": "{{name}} बंद करें",
"enable": "{{name}} चालू करें",
"startAgain": "{{name}} को फिर से शुरू करें",
"install": "लेआउट और थीम खोजें", "install": "लेआउट और थीम खोजें",
"installTitle": "लेआउट और थीम", "installTitle": "लेआउट और थीम",
"installButton": "इंस्टॉल करें", "installButton": "इंस्टॉल करें",
@@ -1309,7 +1315,8 @@
"disabled": "अक्षम", "disabled": "अक्षम",
"failed": "अप्रत्याशित रूप से रुका", "failed": "अप्रत्याशित रूप से रुका",
"blocked": "दूसरे प्लगइन का इंतज़ार कर रहा है", "blocked": "दूसरे प्लगइन का इंतज़ार कर रहा है",
"server": "आपके सर्वर पर चलता है" "server": "आपके सर्वर पर चलता है",
"safeMode": "सुरक्षित मोड में बंद"
}, },
"groups": { "groups": {
"running": "चल रहे", "running": "चल रहे",
@@ -1323,11 +1330,6 @@
"updated": "हाल ही में अपडेट किया गया", "updated": "हाल ही में अपडेट किया गया",
"category": "श्रेणी" "category": "श्रेणी"
}, },
"columns": {
"title": "कॉलम",
"auto": "स्वचालित",
"count": "{{count}} चौड़ा"
},
"table": { "table": {
"name": "नाम", "name": "नाम",
"description": "विवरण", "description": "विवरण",
@@ -1652,7 +1654,12 @@
"gridView": "ग्रिड", "gridView": "ग्रिड",
"listView": "सूची", "listView": "सूची",
"compactRows": "संक्षिप्त पंक्तियाँ", "compactRows": "संक्षिप्त पंक्तियाँ",
"comfortableRows": "आरामदायक पंक्तियाँ" "comfortableRows": "आरामदायक पंक्तियाँ",
"columns": {
"title": "कॉलम",
"auto": "स्वचालित",
"count": "{{count}} चौड़ा"
}
}, },
"fields": { "fields": {
"secretSaved": "सहेजा गया। इसे बदलने के लिए टाइप करें।" "secretSaved": "सहेजा गया। इसे बदलने के लिए टाइप करें।"
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} je spreman za instalaciju", "ready": "Termix {{version}} je spreman za instalaciju",
"readyHint": "Instalirat će se sljedeći put kada se Termix ponovno pokrene." "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": { "companion": {
"close": "Zatvori panel", "close": "Zatvori panel",
"moveSide": "Pomakni panel", "moveSide": "Pomakni panel",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Vrati zadano za sve", "resetEveryone": "Vrati zadano za sve",
"update": "Ažuriraj {{name}} na {{version}}", "update": "Ažuriraj {{name}} na {{version}}",
"disable": "Isključi {{name}}", "disable": "Isključi {{name}}",
"enable": "Uključi {{name}}",
"startAgain": "Ponovno pokreni {{name}}",
"install": "Pronađi rasporede i teme", "install": "Pronađi rasporede i teme",
"installTitle": "Rasporedi i teme", "installTitle": "Rasporedi i teme",
"installButton": "Instaliraj", "installButton": "Instaliraj",
@@ -1326,7 +1332,8 @@
"disabled": "Onemogućeno", "disabled": "Onemogućeno",
"failed": "Neočekivano zaustavljeno", "failed": "Neočekivano zaustavljeno",
"blocked": "Čeka drugi dodatak", "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": { "groups": {
"running": "Pokrenuto", "running": "Pokrenuto",
@@ -1340,11 +1347,6 @@
"updated": "Nedavno ažurirano", "updated": "Nedavno ažurirano",
"category": "Kategorija" "category": "Kategorija"
}, },
"columns": {
"title": "Stupci",
"auto": "Automatski",
"count": "{{count}} široko"
},
"table": { "table": {
"name": "Naziv", "name": "Naziv",
"description": "Opis", "description": "Opis",
@@ -1694,7 +1696,12 @@
"gridView": "Mreža", "gridView": "Mreža",
"listView": "Popis", "listView": "Popis",
"compactRows": "Kompaktni redovi", "compactRows": "Kompaktni redovi",
"comfortableRows": "Prostrani redovi" "comfortableRows": "Prostrani redovi",
"columns": {
"title": "Stupci",
"auto": "Automatski",
"count": "Širina: {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Spremljeno. Upišite za zamjenu." "secretSaved": "Spremljeno. Upišite za zamjenu."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "A Termix {{version}} verziója készen áll a telepítésre", "ready": "A Termix {{version}} verziója készen áll a telepítésre",
"readyHint": "A következő újraindításkor települ." "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": { "companion": {
"close": "Panel bezárása", "close": "Panel bezárása",
"moveSide": "Panel áthelyezése", "moveSide": "Panel áthelyezése",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Az alapértelmezett visszaállítása mindenkinek", "resetEveryone": "Az alapértelmezett visszaállítása mindenkinek",
"update": "{{name}} frissítése a {{version}} verzióra", "update": "{{name}} frissítése a {{version}} verzióra",
"disable": "{{name}} kikapcsolása", "disable": "{{name}} kikapcsolása",
"enable": "{{name}} bekapcsolása",
"startAgain": "{{name}} újraindítása",
"install": "Elrendezések és témák keresése", "install": "Elrendezések és témák keresése",
"installTitle": "Elrendezések és témák", "installTitle": "Elrendezések és témák",
"installButton": "Telepítés", "installButton": "Telepítés",
@@ -1309,7 +1315,8 @@
"disabled": "Letiltva", "disabled": "Letiltva",
"failed": "Váratlanul leállt", "failed": "Váratlanul leállt",
"blocked": "Várakozás egy másik bővítményre", "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": { "groups": {
"running": "Fut", "running": "Fut",
@@ -1323,11 +1330,6 @@
"updated": "Legutóbb frissítve", "updated": "Legutóbb frissítve",
"category": "Kategória" "category": "Kategória"
}, },
"columns": {
"title": "Oszlopok",
"auto": "Automatikus",
"count": "{{count}} oszlop széles"
},
"table": { "table": {
"name": "Név", "name": "Név",
"description": "Leírás", "description": "Leírás",
@@ -1652,7 +1654,12 @@
"gridView": "Rács", "gridView": "Rács",
"listView": "Lista", "listView": "Lista",
"compactRows": "Kompakt sorok", "compactRows": "Kompakt sorok",
"comfortableRows": "Kényelmes sorok" "comfortableRows": "Kényelmes sorok",
"columns": {
"title": "Oszlopok",
"auto": "Automatikus",
"count": "{{count}} oszlop széles"
}
}, },
"fields": { "fields": {
"secretSaved": "Mentve. Írjon be új értéket a helyettesítéshez." "secretSaved": "Mentve. Írjon be új értéket a helyettesítéshez."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} siap untuk diinstal", "ready": "Termix {{version}} siap untuk diinstal",
"readyHint": "Ini akan diinstal saat Termix dimulai ulang berikutnya." "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": { "companion": {
"close": "Tutup panel", "close": "Tutup panel",
"moveSide": "Pindahkan panel", "moveSide": "Pindahkan panel",
@@ -1081,6 +1085,8 @@
"resetEveryone": "Atur ulang default untuk semua orang", "resetEveryone": "Atur ulang default untuk semua orang",
"update": "Perbarui {{name}} ke {{version}}", "update": "Perbarui {{name}} ke {{version}}",
"disable": "Matikan {{name}}", "disable": "Matikan {{name}}",
"enable": "Aktifkan {{name}}",
"startAgain": "Mulai ulang {{name}}",
"install": "Cari tata letak dan tema", "install": "Cari tata letak dan tema",
"installTitle": "Tata letak dan tema", "installTitle": "Tata letak dan tema",
"installButton": "Instal", "installButton": "Instal",
@@ -1292,7 +1298,8 @@
"disabled": "Dinonaktifkan", "disabled": "Dinonaktifkan",
"failed": "Berhenti secara tak terduga", "failed": "Berhenti secara tak terduga",
"blocked": "Menunggu plugin lain", "blocked": "Menunggu plugin lain",
"server": "Berjalan di server Anda" "server": "Berjalan di server Anda",
"safeMode": "Nonaktif di mode aman"
}, },
"groups": { "groups": {
"running": "Berjalan", "running": "Berjalan",
@@ -1306,11 +1313,6 @@
"updated": "Baru diperbarui", "updated": "Baru diperbarui",
"category": "Kategori" "category": "Kategori"
}, },
"columns": {
"title": "Kolom",
"auto": "Otomatis",
"count": "Lebar {{count}}"
},
"table": { "table": {
"name": "Nama", "name": "Nama",
"description": "Deskripsi", "description": "Deskripsi",
@@ -1610,7 +1612,12 @@
"gridView": "Grid", "gridView": "Grid",
"listView": "Daftar", "listView": "Daftar",
"compactRows": "Baris ringkas", "compactRows": "Baris ringkas",
"comfortableRows": "Baris nyaman" "comfortableRows": "Baris nyaman",
"columns": {
"title": "Kolom",
"auto": "Otomatis",
"count": "Lebar {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Tersimpan. Ketik untuk menggantinya." "secretSaved": "Tersimpan. Ketik untuk menggantinya."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} è pronto per l'installazione", "ready": "Termix {{version}} è pronto per l'installazione",
"readyHint": "Verrà installato al prossimo riavvio di Termix." "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": { "companion": {
"close": "Chiudi pannello", "close": "Chiudi pannello",
"moveSide": "Sposta pannello", "moveSide": "Sposta pannello",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Reimposta il predefinito per tutti", "resetEveryone": "Reimposta il predefinito per tutti",
"update": "Aggiorna {{name}} a {{version}}", "update": "Aggiorna {{name}} a {{version}}",
"disable": "Disattiva {{name}}", "disable": "Disattiva {{name}}",
"enable": "Attiva {{name}}",
"startAgain": "Riavvia {{name}}",
"install": "Trova layout e temi", "install": "Trova layout e temi",
"installTitle": "Layout e temi", "installTitle": "Layout e temi",
"installButton": "Installa", "installButton": "Installa",
@@ -1326,7 +1332,8 @@
"disabled": "Disabilitato", "disabled": "Disabilitato",
"failed": "Arrestato inaspettatamente", "failed": "Arrestato inaspettatamente",
"blocked": "In attesa di un altro plugin", "blocked": "In attesa di un altro plugin",
"server": "Eseguito sul tuo server" "server": "Eseguito sul tuo server",
"safeMode": "Disattivato in modalità sicura"
}, },
"groups": { "groups": {
"running": "In esecuzione", "running": "In esecuzione",
@@ -1340,11 +1347,6 @@
"updated": "Aggiornati di recente", "updated": "Aggiornati di recente",
"category": "Categoria" "category": "Categoria"
}, },
"columns": {
"title": "Colonne",
"auto": "Automatico",
"count": "Larghezza {{count}}"
},
"table": { "table": {
"name": "Nome", "name": "Nome",
"description": "Descrizione", "description": "Descrizione",
@@ -1694,7 +1696,12 @@
"gridView": "Griglia", "gridView": "Griglia",
"listView": "Elenco", "listView": "Elenco",
"compactRows": "Righe compatte", "compactRows": "Righe compatte",
"comfortableRows": "Righe spaziose" "comfortableRows": "Righe spaziose",
"columns": {
"title": "Colonne",
"auto": "Automatico",
"count": "{{count}} colonne"
}
}, },
"fields": { "fields": {
"secretSaved": "Salvato. Digita per sostituirlo." "secretSaved": "Salvato. Digita per sostituirlo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} のインストール準備ができました", "ready": "Termix {{version}} のインストール準備ができました",
"readyHint": "Termix を再起動したときにインストールされます。" "readyHint": "Termix を再起動したときにインストールされます。"
}, },
"safeMode": {
"title": "セーフモード",
"description": "レイアウトとテーマのプラグインのみが実行されています。TERMIX_SAFE_MODE を設定せずにサーバーを再起動すると、他のプラグインが再度有効になります。"
},
"companion": { "companion": {
"close": "パネルを閉じる", "close": "パネルを閉じる",
"moveSide": "パネルを移動", "moveSide": "パネルを移動",
@@ -1081,6 +1085,8 @@
"resetEveryone": "すべてのユーザーのデフォルトをリセット", "resetEveryone": "すべてのユーザーのデフォルトをリセット",
"update": "{{name}} を {{version}} に更新", "update": "{{name}} を {{version}} に更新",
"disable": "{{name}} を無効にする", "disable": "{{name}} を無効にする",
"enable": "{{name}} を有効にする",
"startAgain": "{{name}} を再起動する",
"install": "レイアウトとテーマを探す", "install": "レイアウトとテーマを探す",
"installTitle": "レイアウトとテーマ", "installTitle": "レイアウトとテーマ",
"installButton": "インストール", "installButton": "インストール",
@@ -1292,7 +1298,8 @@
"disabled": "無効", "disabled": "無効",
"failed": "予期せず停止", "failed": "予期せず停止",
"blocked": "他のプラグインを待機中", "blocked": "他のプラグインを待機中",
"server": "サーバー上で実行" "server": "サーバー上で実行",
"safeMode": "セーフモードで無効"
}, },
"groups": { "groups": {
"running": "実行中", "running": "実行中",
@@ -1306,11 +1313,6 @@
"updated": "最近更新", "updated": "最近更新",
"category": "カテゴリ" "category": "カテゴリ"
}, },
"columns": {
"title": "列",
"auto": "自動",
"count": "{{count}} 列"
},
"table": { "table": {
"name": "名前", "name": "名前",
"description": "説明", "description": "説明",
@@ -1610,7 +1612,12 @@
"gridView": "グリッド", "gridView": "グリッド",
"listView": "リスト", "listView": "リスト",
"compactRows": "コンパクトな行", "compactRows": "コンパクトな行",
"comfortableRows": "ゆったりした行" "comfortableRows": "ゆったりした行",
"columns": {
"title": "カラム",
"auto": "自動",
"count": "{{count}} カラム"
}
}, },
"fields": { "fields": {
"secretSaved": "保存しました。入力して置き換えてください。" "secretSaved": "保存しました。入力して置き換えてください。"
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} 설치 준비 완료", "ready": "Termix {{version}} 설치 준비 완료",
"readyHint": "Termix가 다음에 재시작될 때 설치됩니다." "readyHint": "Termix가 다음에 재시작될 때 설치됩니다."
}, },
"safeMode": {
"title": "안전 모드",
"description": "레이아웃 및 테마 플러그인만 실행 중입니다. TERMIX_SAFE_MODE 없이 서버를 다시 시작하여 나머지 기능을 다시 활성화하세요."
},
"companion": { "companion": {
"close": "패널 닫기", "close": "패널 닫기",
"moveSide": "패널 이동", "moveSide": "패널 이동",
@@ -1081,6 +1085,8 @@
"resetEveryone": "모두의 기본값 초기화", "resetEveryone": "모두의 기본값 초기화",
"update": "{{name}}을(를) {{version}}(으)로 업데이트", "update": "{{name}}을(를) {{version}}(으)로 업데이트",
"disable": "{{name}} 끄기", "disable": "{{name}} 끄기",
"enable": "{{name}} 켜기",
"startAgain": "{{name}} 다시 시작",
"install": "레이아웃 및 테마 찾기", "install": "레이아웃 및 테마 찾기",
"installTitle": "레이아웃 및 테마", "installTitle": "레이아웃 및 테마",
"installButton": "설치", "installButton": "설치",
@@ -1292,7 +1298,8 @@
"disabled": "비활성화됨", "disabled": "비활성화됨",
"failed": "예기치 않게 중지됨", "failed": "예기치 않게 중지됨",
"blocked": "다른 플러그인 대기 중", "blocked": "다른 플러그인 대기 중",
"server": "서버에서 실행 중" "server": "서버에서 실행 중",
"safeMode": "안전 모드에서 꺼짐"
}, },
"groups": { "groups": {
"running": "실행 중", "running": "실행 중",
@@ -1306,11 +1313,6 @@
"updated": "최근 업데이트순", "updated": "최근 업데이트순",
"category": "카테고리순" "category": "카테고리순"
}, },
"columns": {
"title": "열",
"auto": "자동",
"count": "{{count}}열 너비"
},
"table": { "table": {
"name": "이름", "name": "이름",
"description": "설명", "description": "설명",
@@ -1610,7 +1612,12 @@
"gridView": "그리드", "gridView": "그리드",
"listView": "목록", "listView": "목록",
"compactRows": "간결한 행", "compactRows": "간결한 행",
"comfortableRows": "넓은 행" "comfortableRows": "넓은 행",
"columns": {
"title": "열",
"auto": "자동",
"count": "{{count}}열 너비"
}
}, },
"fields": { "fields": {
"secretSaved": "저장되었습니다. 입력하여 교체하세요." "secretSaved": "저장되었습니다. 입력하여 교체하세요."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} paruošta įdiegimui", "ready": "Termix {{version}} paruošta įdiegimui",
"readyHint": "Ji bus įdiegta kitą kartą paleidus Termix iš naujo." "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": { "companion": {
"close": "Uždaryti skydelį", "close": "Uždaryti skydelį",
"moveSide": "Perkelti skydelį", "moveSide": "Perkelti skydelį",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Atstatyti numatytąjį visiems", "resetEveryone": "Atstatyti numatytąjį visiems",
"update": "Atnaujinti {{name}} į {{version}}", "update": "Atnaujinti {{name}} į {{version}}",
"disable": "Išjungti {{name}}", "disable": "Išjungti {{name}}",
"enable": "Įjungti „{{name}}“",
"startAgain": "Paleisti „{{name}}“ iš naujo",
"install": "Ieškoti išdėstymų ir temų", "install": "Ieškoti išdėstymų ir temų",
"installTitle": "Išdėstymai ir temos", "installTitle": "Išdėstymai ir temos",
"installButton": "Įdiegti", "installButton": "Įdiegti",
@@ -1343,7 +1349,8 @@
"disabled": "Išjungta", "disabled": "Išjungta",
"failed": "Nenumatomai sustabdyta", "failed": "Nenumatomai sustabdyta",
"blocked": "Laukiama kito priedo", "blocked": "Laukiama kito priedo",
"server": "Veikia jūsų serveryje" "server": "Veikia jūsų serveryje",
"safeMode": "Išjungta saugiu režimu"
}, },
"groups": { "groups": {
"running": "Veikia", "running": "Veikia",
@@ -1357,11 +1364,6 @@
"updated": "Neseniai atnaujinti", "updated": "Neseniai atnaujinti",
"category": "Kategorija" "category": "Kategorija"
}, },
"columns": {
"title": "Stulpeliai",
"auto": "Automatiškai",
"count": "{{count}} pločio"
},
"table": { "table": {
"name": "Pavadinimas", "name": "Pavadinimas",
"description": "Aprašymas", "description": "Aprašymas",
@@ -1736,7 +1738,12 @@
"gridView": "Tinklelis", "gridView": "Tinklelis",
"listView": "Sąrašas", "listView": "Sąrašas",
"compactRows": "Kompaktiškos eilutės", "compactRows": "Kompaktiškos eilutės",
"comfortableRows": "Patogios eilutės" "comfortableRows": "Patogios eilutės",
"columns": {
"title": "Stulpeliai",
"auto": "Automatiškai",
"count": "{{count}} pločio"
}
}, },
"fields": { "fields": {
"secretSaved": "Išsaugota. Įveskite, kad pakeistumėte." "secretSaved": "Išsaugota. Įveskite, kad pakeistumėte."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} ir gatavs instalēšanai", "ready": "Termix {{version}} ir gatavs instalēšanai",
"readyHint": "Tas tiks instalēts nākamajā reizi, kad Termix tiks restartēts." "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": { "companion": {
"close": "Aizvērt paneli", "close": "Aizvērt paneli",
"moveSide": "Pārvietot paneli", "moveSide": "Pārvietot paneli",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Atiestatīt noklusējumu visiem", "resetEveryone": "Atiestatīt noklusējumu visiem",
"update": "Atjaunināt {{name}} uz {{version}}", "update": "Atjaunināt {{name}} uz {{version}}",
"disable": "Izslēgt {{name}}", "disable": "Izslēgt {{name}}",
"enable": "Ieslēgt {{name}}",
"startAgain": "Atkārtoti palaist {{name}}",
"install": "Atrast izkārtojumus un tēmas", "install": "Atrast izkārtojumus un tēmas",
"installTitle": "Izkārtojumi un tēmas", "installTitle": "Izkārtojumi un tēmas",
"installButton": "Instalēt", "installButton": "Instalēt",
@@ -1326,7 +1332,8 @@
"disabled": "Atspējots", "disabled": "Atspējots",
"failed": "Gaidāms apstājās", "failed": "Gaidāms apstājās",
"blocked": "Gaida citu spraudni", "blocked": "Gaida citu spraudni",
"server": "Darbojas jūsu serverī" "server": "Darbojas jūsu serverī",
"safeMode": "Izslēgts drošajā režīmā"
}, },
"groups": { "groups": {
"running": "Darbojas", "running": "Darbojas",
@@ -1340,11 +1347,6 @@
"updated": "Nesen atjaunināti", "updated": "Nesen atjaunināti",
"category": "Kategorija" "category": "Kategorija"
}, },
"columns": {
"title": "Kolonnas",
"auto": "Automātiski",
"count": "{{count}} kolonnu platums"
},
"table": { "table": {
"name": "Nosaukums", "name": "Nosaukums",
"description": "Apraksts", "description": "Apraksts",
@@ -1694,7 +1696,12 @@
"gridView": "Režģis", "gridView": "Režģis",
"listView": "Saraksts", "listView": "Saraksts",
"compactRows": "Kompaktas rindas", "compactRows": "Kompaktas rindas",
"comfortableRows": "Plašas rindas" "comfortableRows": "Plašas rindas",
"columns": {
"title": "Kolonnas",
"auto": "Automātiski",
"count": "{{count}} kolonnu platums"
}
}, },
"fields": { "fields": {
"secretSaved": "Saglabāts. Ierakstiet, lai to aizvietotu." "secretSaved": "Saglabāts. Ierakstiet, lai to aizvietotu."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} sedia untuk dipasang", "ready": "Termix {{version}} sedia untuk dipasang",
"readyHint": "Ia akan dipasang pada kali seterusnya Termix dimulakan semula." "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": { "companion": {
"close": "Tutup panel", "close": "Tutup panel",
"moveSide": "Pindahkan panel", "moveSide": "Pindahkan panel",
@@ -1081,6 +1085,8 @@
"resetEveryone": "Set semula lalai untuk semua orang", "resetEveryone": "Set semula lalai untuk semua orang",
"update": "Kemas kini {{name}} ke {{version}}", "update": "Kemas kini {{name}} ke {{version}}",
"disable": "Matikan {{name}}", "disable": "Matikan {{name}}",
"enable": "Hidupkan {{name}}",
"startAgain": "Mulakan {{name}} semula",
"install": "Cari susun atur dan tema", "install": "Cari susun atur dan tema",
"installTitle": "Susun atur dan tema", "installTitle": "Susun atur dan tema",
"installButton": "Pasang", "installButton": "Pasang",
@@ -1292,7 +1298,8 @@
"disabled": "Dilumpuhkan", "disabled": "Dilumpuhkan",
"failed": "Berhenti secara tidak dijangka", "failed": "Berhenti secara tidak dijangka",
"blocked": "Menunggu plugin lain", "blocked": "Menunggu plugin lain",
"server": "Berjalan pada pelayan anda" "server": "Berjalan pada pelayan anda",
"safeMode": "Mati dalam modus selamat"
}, },
"groups": { "groups": {
"running": "Berjalan", "running": "Berjalan",
@@ -1306,11 +1313,6 @@
"updated": "Dikemas kini baru-baru ini", "updated": "Dikemas kini baru-baru ini",
"category": "Kategori" "category": "Kategori"
}, },
"columns": {
"title": "Lajur",
"auto": "Auto",
"count": "Lebar {{count}}"
},
"table": { "table": {
"name": "Nama", "name": "Nama",
"description": "Keterangan", "description": "Keterangan",
@@ -1610,7 +1612,12 @@
"gridView": "Grid", "gridView": "Grid",
"listView": "Senarai", "listView": "Senarai",
"compactRows": "Baris padat", "compactRows": "Baris padat",
"comfortableRows": "Baris selesa" "comfortableRows": "Baris selesa",
"columns": {
"title": "Lajur",
"auto": "Auto",
"count": "Lebar {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Disimpan. Taip untuk menggantikannya." "secretSaved": "Disimpan. Taip untuk menggantikannya."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} is klaar om te installeren", "ready": "Termix {{version}} is klaar om te installeren",
"readyHint": "Het wordt geïnstalleerd de volgende keer dat Termix herstart." "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": { "companion": {
"close": "Sluit paneel", "close": "Sluit paneel",
"moveSide": "Verplaats paneel", "moveSide": "Verplaats paneel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Standaard voor iedereen terugzetten", "resetEveryone": "Standaard voor iedereen terugzetten",
"update": "Update {{name}} naar {{version}}", "update": "Update {{name}} naar {{version}}",
"disable": "Zet {{name}} uit", "disable": "Zet {{name}} uit",
"enable": "{{name}} inschakelen",
"startAgain": "{{name}} opnieuw starten",
"install": "Lay-outs en thema's zoeken", "install": "Lay-outs en thema's zoeken",
"installTitle": "Lay-outs en thema's", "installTitle": "Lay-outs en thema's",
"installButton": "Installeren", "installButton": "Installeren",
@@ -1309,7 +1315,8 @@
"disabled": "Uitgeschakeld", "disabled": "Uitgeschakeld",
"failed": "Onverwacht gestopt", "failed": "Onverwacht gestopt",
"blocked": "Wachten op een andere plugin", "blocked": "Wachten op een andere plugin",
"server": "Wordt uitgevoerd op je server" "server": "Wordt uitgevoerd op je server",
"safeMode": "Uitgeschakeld in veilige modus"
}, },
"groups": { "groups": {
"running": "Actief", "running": "Actief",
@@ -1323,11 +1330,6 @@
"updated": "Recent bijgewerkt", "updated": "Recent bijgewerkt",
"category": "Categorie" "category": "Categorie"
}, },
"columns": {
"title": "Kolommen",
"auto": "Automatisch",
"count": "{{count}} kolommen breed"
},
"table": { "table": {
"name": "Naam", "name": "Naam",
"description": "Beschrijving", "description": "Beschrijving",
@@ -1652,7 +1654,12 @@
"gridView": "Raster", "gridView": "Raster",
"listView": "Lijst", "listView": "Lijst",
"compactRows": "Compacte rijen", "compactRows": "Compacte rijen",
"comfortableRows": "Ruime rijen" "comfortableRows": "Ruime rijen",
"columns": {
"title": "Kolommen",
"auto": "Automatisch",
"count": "{{count}} kolommen breed"
}
}, },
"fields": { "fields": {
"secretSaved": "Opgeslagen. Typ om het te vervangen." "secretSaved": "Opgeslagen. Typ om het te vervangen."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} er klar til installasjon", "ready": "Termix {{version}} er klar til installasjon",
"readyHint": "Den installeres neste gang Termix starter på nytt." "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": { "companion": {
"close": "Lukk panel", "close": "Lukk panel",
"moveSide": "Flytt panel", "moveSide": "Flytt panel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Tilbakestill standard for alle", "resetEveryone": "Tilbakestill standard for alle",
"update": "Oppdater {{name}} til {{version}}", "update": "Oppdater {{name}} til {{version}}",
"disable": "Slå av {{name}}", "disable": "Slå av {{name}}",
"enable": "Slå på {{name}}",
"startAgain": "Start {{name}} på nytt",
"install": "Finn oppsett og temaer", "install": "Finn oppsett og temaer",
"installTitle": "Oppsett og temaer", "installTitle": "Oppsett og temaer",
"installButton": "Installer", "installButton": "Installer",
@@ -1309,7 +1315,8 @@
"disabled": "Deaktivert", "disabled": "Deaktivert",
"failed": "Stoppet uventet", "failed": "Stoppet uventet",
"blocked": "Venter på et annet plugin", "blocked": "Venter på et annet plugin",
"server": "Kjører på serveren din" "server": "Kjører på serveren din",
"safeMode": "Av i sikker modus"
}, },
"groups": { "groups": {
"running": "Kjører", "running": "Kjører",
@@ -1323,11 +1330,6 @@
"updated": "Nylig oppdatert", "updated": "Nylig oppdatert",
"category": "Kategori" "category": "Kategori"
}, },
"columns": {
"title": "Kolonner",
"auto": "Auto",
"count": "{{count}} bred"
},
"table": { "table": {
"name": "Navn", "name": "Navn",
"description": "Beskrivelse", "description": "Beskrivelse",
@@ -1652,7 +1654,12 @@
"gridView": "Rutenett", "gridView": "Rutenett",
"listView": "Liste", "listView": "Liste",
"compactRows": "Kompakte rader", "compactRows": "Kompakte rader",
"comfortableRows": "Komfortable rader" "comfortableRows": "Komfortable rader",
"columns": {
"title": "Kolonner",
"auto": "Auto",
"count": "{{count}} bred"
}
}, },
"fields": { "fields": {
"secretSaved": "Lagret. Skriv for å erstatte den." "secretSaved": "Lagret. Skriv for å erstatte den."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} jest gotowy do instalacji", "ready": "Termix {{version}} jest gotowy do instalacji",
"readyHint": "Zostanie zainstalowany przy następnym ponownym uruchomieniu Termix." "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": { "companion": {
"close": "Zamknij panel", "close": "Zamknij panel",
"moveSide": "Przenieś panel", "moveSide": "Przenieś panel",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Przywróć domyślne ustawienia dla wszystkich", "resetEveryone": "Przywróć domyślne ustawienia dla wszystkich",
"update": "Aktualizuj {{name}} do wersji {{version}}", "update": "Aktualizuj {{name}} do wersji {{version}}",
"disable": "Wyłącz {{name}}", "disable": "Wyłącz {{name}}",
"enable": "Włącz {{name}}",
"startAgain": "Uruchom ponownie {{name}}",
"install": "Szukaj układów i motywów", "install": "Szukaj układów i motywów",
"installTitle": "Układy i motywy", "installTitle": "Układy i motywy",
"installButton": "Zainstaluj", "installButton": "Zainstaluj",
@@ -1343,7 +1349,8 @@
"disabled": "Wyłączone", "disabled": "Wyłączone",
"failed": "Zatrzymane nieoczekiwanie", "failed": "Zatrzymane nieoczekiwanie",
"blocked": "Oczekiwanie na inną wtyczkę", "blocked": "Oczekiwanie na inną wtyczkę",
"server": "Uruchamiane na Twoim serwerze" "server": "Uruchamiane na Twoim serwerze",
"safeMode": "Wyłączone w trybie awaryjnym"
}, },
"groups": { "groups": {
"running": "Uruchomione", "running": "Uruchomione",
@@ -1357,11 +1364,6 @@
"updated": "Ostatnio zaktualizowane", "updated": "Ostatnio zaktualizowane",
"category": "Kategoria" "category": "Kategoria"
}, },
"columns": {
"title": "Kolumny",
"auto": "Automatycznie",
"count": "{{count}} kolumn"
},
"table": { "table": {
"name": "Nazwa", "name": "Nazwa",
"description": "Opis", "description": "Opis",
@@ -1736,7 +1738,12 @@
"gridView": "Siatka", "gridView": "Siatka",
"listView": "Lista", "listView": "Lista",
"compactRows": "Zwięzłe wiersze", "compactRows": "Zwięzłe wiersze",
"comfortableRows": "Przestrzenne wiersze" "comfortableRows": "Przestrzenne wiersze",
"columns": {
"title": "Kolumny",
"auto": "Automatycznie",
"count": "Szerokość: {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Zapisano. Wpisz nową wartość, aby ją zmienić." "secretSaved": "Zapisano. Wpisz nową wartość, aby ją zmienić."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} está pronto para instalação", "ready": "Termix {{version}} está pronto para instalação",
"readyHint": "Será instalado na próxima vez que o Termix reiniciar." "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": { "companion": {
"close": "Fechar painel", "close": "Fechar painel",
"moveSide": "Mover painel", "moveSide": "Mover painel",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Restaurar o padrão para todos", "resetEveryone": "Restaurar o padrão para todos",
"update": "Atualizar {{name}} para {{version}}", "update": "Atualizar {{name}} para {{version}}",
"disable": "Desativar {{name}}", "disable": "Desativar {{name}}",
"enable": "Ativar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Encontrar layouts e temas", "install": "Encontrar layouts e temas",
"installTitle": "Layouts e temas", "installTitle": "Layouts e temas",
"installButton": "Instalar", "installButton": "Instalar",
@@ -1326,7 +1332,8 @@
"disabled": "Desativado", "disabled": "Desativado",
"failed": "Parado inesperadamente", "failed": "Parado inesperadamente",
"blocked": "Aguardando outro plugin", "blocked": "Aguardando outro plugin",
"server": "Executa no seu servidor" "server": "Executa no seu servidor",
"safeMode": "Desativado no modo de segurança"
}, },
"groups": { "groups": {
"running": "Em execução", "running": "Em execução",
@@ -1340,11 +1347,6 @@
"updated": "Atualizados recentemente", "updated": "Atualizados recentemente",
"category": "Categoria" "category": "Categoria"
}, },
"columns": {
"title": "Colunas",
"auto": "Automático",
"count": "{{count}} de largura"
},
"table": { "table": {
"name": "Nome", "name": "Nome",
"description": "Descrição", "description": "Descrição",
@@ -1694,7 +1696,12 @@
"gridView": "Grade", "gridView": "Grade",
"listView": "Lista", "listView": "Lista",
"compactRows": "Linhas compactas", "compactRows": "Linhas compactas",
"comfortableRows": "Linhas confortáveis" "comfortableRows": "Linhas confortáveis",
"columns": {
"title": "Colunas",
"auto": "Automático",
"count": "{{count}} colunas"
}
}, },
"fields": { "fields": {
"secretSaved": "Salvo. Digite para substituí-lo." "secretSaved": "Salvo. Digite para substituí-lo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "O Termix {{version}} está pronto para instalar", "ready": "O Termix {{version}} está pronto para instalar",
"readyHint": "Será instalado na próxima reinicialização do Termix." "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": { "companion": {
"close": "Fechar painel", "close": "Fechar painel",
"moveSide": "Mover painel", "moveSide": "Mover painel",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Repor a predefinição para todos", "resetEveryone": "Repor a predefinição para todos",
"update": "Atualizar {{name}} para {{version}}", "update": "Atualizar {{name}} para {{version}}",
"disable": "Desativar {{name}}", "disable": "Desativar {{name}}",
"enable": "Ativar {{name}}",
"startAgain": "Reiniciar {{name}}",
"install": "Procurar layouts e temas", "install": "Procurar layouts e temas",
"installTitle": "Layouts e temas", "installTitle": "Layouts e temas",
"installButton": "Instalar", "installButton": "Instalar",
@@ -1326,7 +1332,8 @@
"disabled": "Desativado", "disabled": "Desativado",
"failed": "Parado inesperadamente", "failed": "Parado inesperadamente",
"blocked": "À espera de outro plugin", "blocked": "À espera de outro plugin",
"server": "Executa no seu servidor" "server": "Executa no seu servidor",
"safeMode": "Desativado no modo seguro"
}, },
"groups": { "groups": {
"running": "Em execução", "running": "Em execução",
@@ -1340,11 +1347,6 @@
"updated": "Atualizados recentemente", "updated": "Atualizados recentemente",
"category": "Categoria" "category": "Categoria"
}, },
"columns": {
"title": "Colunas",
"auto": "Automático",
"count": "{{count}} de largura"
},
"table": { "table": {
"name": "Nome", "name": "Nome",
"description": "Descrição", "description": "Descrição",
@@ -1694,7 +1696,12 @@
"gridView": "Grelha", "gridView": "Grelha",
"listView": "Lista", "listView": "Lista",
"compactRows": "Linhas compactas", "compactRows": "Linhas compactas",
"comfortableRows": "Linhas confortáveis" "comfortableRows": "Linhas confortáveis",
"columns": {
"title": "Colunas",
"auto": "Automático",
"count": "{{count}} de largura"
}
}, },
"fields": { "fields": {
"secretSaved": "Guardado. Escreva para substituir." "secretSaved": "Guardado. Escreva para substituir."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} este gata de instalare", "ready": "Termix {{version}} este gata de instalare",
"readyHint": "Se instalează data viitoare când Termix se repornește." "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": { "companion": {
"close": "Închide panoul", "close": "Închide panoul",
"moveSide": "Mută panoul", "moveSide": "Mută panoul",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Resetează implicitul pentru toți", "resetEveryone": "Resetează implicitul pentru toți",
"update": "Actualizează {{name}} la {{version}}", "update": "Actualizează {{name}} la {{version}}",
"disable": "Dezactivează {{name}}", "disable": "Dezactivează {{name}}",
"enable": "Activează {{name}}",
"startAgain": "Pornește din nou {{name}}",
"install": "Găsește aspecte și teme", "install": "Găsește aspecte și teme",
"installTitle": "Aspecte și teme", "installTitle": "Aspecte și teme",
"installButton": "Instalează", "installButton": "Instalează",
@@ -1326,7 +1332,8 @@
"disabled": "Dezactivat", "disabled": "Dezactivat",
"failed": "Oprit neașteptat", "failed": "Oprit neașteptat",
"blocked": "Așteaptă un alt plugin", "blocked": "Așteaptă un alt plugin",
"server": "Rulează pe serverul tău" "server": "Rulează pe serverul tău",
"safeMode": "Oprit în modul sigur"
}, },
"groups": { "groups": {
"running": "În execuție", "running": "În execuție",
@@ -1340,11 +1347,6 @@
"updated": "Actualizate recent", "updated": "Actualizate recent",
"category": "Categorie" "category": "Categorie"
}, },
"columns": {
"title": "Coloane",
"auto": "Automat",
"count": "{{count}} coloane"
},
"table": { "table": {
"name": "Nume", "name": "Nume",
"description": "Descriere", "description": "Descriere",
@@ -1694,7 +1696,12 @@
"gridView": "Grilă", "gridView": "Grilă",
"listView": "Listă", "listView": "Listă",
"compactRows": "Rânduri compacte", "compactRows": "Rânduri compacte",
"comfortableRows": "Rânduri confortabile" "comfortableRows": "Rânduri confortabile",
"columns": {
"title": "Coloane",
"auto": "Automat",
"count": "{{count}} coloane"
}
}, },
"fields": { "fields": {
"secretSaved": "Salvat. Tastează pentru a-l înlocui." "secretSaved": "Salvat. Tastează pentru a-l înlocui."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} готова к установке", "ready": "Termix {{version}} готова к установке",
"readyHint": "Она будет установлена при следующем перезапуске Termix." "readyHint": "Она будет установлена при следующем перезапуске Termix."
}, },
"safeMode": {
"title": "Безопасный режим",
"description": "Работают только плагины макета и темы. Перезапустите сервер без флага TERMIX_SAFE_MODE, чтобы включить остальные плагины."
},
"companion": { "companion": {
"close": "Закрыть панель", "close": "Закрыть панель",
"moveSide": "Переместить панель", "moveSide": "Переместить панель",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Сбросить настройки по умолчанию для всех", "resetEveryone": "Сбросить настройки по умолчанию для всех",
"update": "Обновить {{name}} до версии {{version}}", "update": "Обновить {{name}} до версии {{version}}",
"disable": "Отключить {{name}}", "disable": "Отключить {{name}}",
"enable": "Включить {{name}}",
"startAgain": "Запустить {{name}} заново",
"install": "Найти макеты и темы", "install": "Найти макеты и темы",
"installTitle": "Макеты и темы", "installTitle": "Макеты и темы",
"installButton": "Установить", "installButton": "Установить",
@@ -1343,7 +1349,8 @@
"disabled": "Отключен", "disabled": "Отключен",
"failed": "Неожиданно остановлен", "failed": "Неожиданно остановлен",
"blocked": "Ожидание другого плагина", "blocked": "Ожидание другого плагина",
"server": "Работает на вашем сервере" "server": "Работает на вашем сервере",
"safeMode": "Отключено в безопасном режиме"
}, },
"groups": { "groups": {
"running": "Запущенные", "running": "Запущенные",
@@ -1357,11 +1364,6 @@
"updated": "Недавно обновленные", "updated": "Недавно обновленные",
"category": "Категория" "category": "Категория"
}, },
"columns": {
"title": "Столбцы",
"auto": "Авто",
"count": "{{count}} столбцов"
},
"table": { "table": {
"name": "Имя", "name": "Имя",
"description": "Описание", "description": "Описание",
@@ -1736,7 +1738,12 @@
"gridView": "Сетка", "gridView": "Сетка",
"listView": "Список", "listView": "Список",
"compactRows": "Компактные строки", "compactRows": "Компактные строки",
"comfortableRows": "Удобные строки" "comfortableRows": "Удобные строки",
"columns": {
"title": "Колонки",
"auto": "Авто",
"count": "{{count}} колонок"
}
}, },
"fields": { "fields": {
"secretSaved": "Сохранено. Введите новый текст, чтобы заменить." "secretSaved": "Сохранено. Введите новый текст, чтобы заменить."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} je pripravený na inštaláciu", "ready": "Termix {{version}} je pripravený na inštaláciu",
"readyHint": "Nainštaluje sa pri ďalšom reštarte Termixu." "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": { "companion": {
"close": "Zatvoriť panel", "close": "Zatvoriť panel",
"moveSide": "Presunúť panel", "moveSide": "Presunúť panel",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Obnoviť predvolené nastavenie pre všetkých", "resetEveryone": "Obnoviť predvolené nastavenie pre všetkých",
"update": "Aktualizovať {{name}} na verziu {{version}}", "update": "Aktualizovať {{name}} na verziu {{version}}",
"disable": "Vypnúť {{name}}", "disable": "Vypnúť {{name}}",
"enable": "Povoliť {{name}}",
"startAgain": "Spustiť {{name}} znova",
"install": "Nájsť rozloženia a témy", "install": "Nájsť rozloženia a témy",
"installTitle": "Rozloženia a témy", "installTitle": "Rozloženia a témy",
"installButton": "Nainštalovať", "installButton": "Nainštalovať",
@@ -1343,7 +1349,8 @@
"disabled": "Zakázané", "disabled": "Zakázané",
"failed": "Neočakávane zastavené", "failed": "Neočakávane zastavené",
"blocked": "Čaká na iný plugin", "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": { "groups": {
"running": "Bežiaci", "running": "Bežiaci",
@@ -1357,11 +1364,6 @@
"updated": "Nedávno aktualizované", "updated": "Nedávno aktualizované",
"category": "Kategória" "category": "Kategória"
}, },
"columns": {
"title": "Stĺpce",
"auto": "Automaticky",
"count": "{{count}} stĺpcov široké"
},
"table": { "table": {
"name": "Názov", "name": "Názov",
"description": "Popis", "description": "Popis",
@@ -1736,7 +1738,12 @@
"gridView": "Mriežka", "gridView": "Mriežka",
"listView": "Zoznam", "listView": "Zoznam",
"compactRows": "Kompaktné riadky", "compactRows": "Kompaktné riadky",
"comfortableRows": "Pohodlné riadky" "comfortableRows": "Pohodlné riadky",
"columns": {
"title": "Stĺpce",
"auto": "Automaticky",
"count": "Šírka {{count}}"
}
}, },
"fields": { "fields": {
"secretSaved": "Uložené. Pre nahradenie zadajte novú hodnotu." "secretSaved": "Uložené. Pre nahradenie zadajte novú hodnotu."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} je pripravljen za namestitev", "ready": "Termix {{version}} je pripravljen za namestitev",
"readyHint": "Namesti se naslednjič, ko se Termix ponovno zažene." "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": { "companion": {
"close": "Zapri ploščo", "close": "Zapri ploščo",
"moveSide": "Premakni ploščo", "moveSide": "Premakni ploščo",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Ponastavi privzeto za vse", "resetEveryone": "Ponastavi privzeto za vse",
"update": "Posodobi {{name}} na {{version}}", "update": "Posodobi {{name}} na {{version}}",
"disable": "Izklopi {{name}}", "disable": "Izklopi {{name}}",
"enable": "Vklopi {{name}}",
"startAgain": "Znova zaženi {{name}}",
"install": "Poišči razporede in teme", "install": "Poišči razporede in teme",
"installTitle": "Razporedi in teme", "installTitle": "Razporedi in teme",
"installButton": "Namesti", "installButton": "Namesti",
@@ -1343,7 +1349,8 @@
"disabled": "Onemogočen", "disabled": "Onemogočen",
"failed": "Nepričakovano ustavljen", "failed": "Nepričakovano ustavljen",
"blocked": "Čaka na drug vtičnik", "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": { "groups": {
"running": "Delujoči", "running": "Delujoči",
@@ -1357,11 +1364,6 @@
"updated": "Nedavno posodobljeni", "updated": "Nedavno posodobljeni",
"category": "Kategorija" "category": "Kategorija"
}, },
"columns": {
"title": "Stolpci",
"auto": "Samodejno",
"count": "{{count}} širok"
},
"table": { "table": {
"name": "Ime", "name": "Ime",
"description": "Opis", "description": "Opis",
@@ -1736,7 +1738,12 @@
"gridView": "Mreža", "gridView": "Mreža",
"listView": "Seznam", "listView": "Seznam",
"compactRows": "Kompaktni vrstice", "compactRows": "Kompaktni vrstice",
"comfortableRows": "Udobne vrstice" "comfortableRows": "Udobne vrstice",
"columns": {
"title": "Stolpci",
"auto": "Samodejno",
"count": "{{count}} širok"
}
}, },
"fields": { "fields": {
"secretSaved": "Shranjeno. Vnesite za zamenjavo." "secretSaved": "Shranjeno. Vnesite za zamenjavo."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} је спреман за инсталацију", "ready": "Termix {{version}} је спреман за инсталацију",
"readyHint": "Инсталира се следећи пут када се Termix поново покрене." "readyHint": "Инсталира се следећи пут када се Termix поново покрене."
}, },
"safeMode": {
"title": "Безбедан режим",
"description": "Раде само додаци за распоред и тему. Поново покрените сервер без TERMIX_SAFE_MODE да бисте укључили остале."
},
"companion": { "companion": {
"close": "Затвори панел", "close": "Затвори панел",
"moveSide": "Помери панел", "moveSide": "Помери панел",
@@ -1115,6 +1119,8 @@
"resetEveryone": "Ресетуј подразумевано за све", "resetEveryone": "Ресетуј подразумевано за све",
"update": "Ажурирај {{name}} на {{version}}", "update": "Ажурирај {{name}} на {{version}}",
"disable": "Искључи {{name}}", "disable": "Искључи {{name}}",
"enable": "Укључи {{name}}",
"startAgain": "Поново покрените {{name}}",
"install": "Пронађи распоред и теме", "install": "Пронађи распоред и теме",
"installTitle": "Распореди и теме", "installTitle": "Распореди и теме",
"installButton": "Инсталирај", "installButton": "Инсталирај",
@@ -1326,7 +1332,8 @@
"disabled": "Онемогућено", "disabled": "Онемогућено",
"failed": "Неочекивано заустављено", "failed": "Неочекивано заустављено",
"blocked": "Чека на други додатак", "blocked": "Чека на други додатак",
"server": "Ради на вашем серверу" "server": "Ради на вашем серверу",
"safeMode": "Искључено у безбедном режиму"
}, },
"groups": { "groups": {
"running": "Покренуто", "running": "Покренуто",
@@ -1340,11 +1347,6 @@
"updated": "Недавно ажурирано", "updated": "Недавно ажурирано",
"category": "Категорија" "category": "Категорија"
}, },
"columns": {
"title": "Колоне",
"auto": "Аутоматски",
"count": "{{count}} широка"
},
"table": { "table": {
"name": "Име", "name": "Име",
"description": "Опис", "description": "Опис",
@@ -1694,7 +1696,12 @@
"gridView": "Мрежа", "gridView": "Мрежа",
"listView": "Листа", "listView": "Листа",
"compactRows": "Компактни редови", "compactRows": "Компактни редови",
"comfortableRows": "Удобни редови" "comfortableRows": "Удобни редови",
"columns": {
"title": "Колоне",
"auto": "Аутоматски",
"count": "{{count}} широка"
}
}, },
"fields": { "fields": {
"secretSaved": "Сачувано. Унесите да бисте га заменили." "secretSaved": "Сачувано. Унесите да бисте га заменили."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} är redo att installeras", "ready": "Termix {{version}} är redo att installeras",
"readyHint": "Den installeras nästa gång Termix startas om." "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": { "companion": {
"close": "Stäng panel", "close": "Stäng panel",
"moveSide": "Flytta panel", "moveSide": "Flytta panel",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Återställ standarden för alla", "resetEveryone": "Återställ standarden för alla",
"update": "Uppdatera {{name}} till {{version}}", "update": "Uppdatera {{name}} till {{version}}",
"disable": "Stäng av {{name}}", "disable": "Stäng av {{name}}",
"enable": "Aktivera {{name}}",
"startAgain": "Starta {{name}} igen",
"install": "Hitta layouter och teman", "install": "Hitta layouter och teman",
"installTitle": "Layouter och teman", "installTitle": "Layouter och teman",
"installButton": "Installera", "installButton": "Installera",
@@ -1309,7 +1315,8 @@
"disabled": "Inaktiverad", "disabled": "Inaktiverad",
"failed": "Stoppades oväntat", "failed": "Stoppades oväntat",
"blocked": "Väntar på ett annat plugin", "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": { "groups": {
"running": "Körs", "running": "Körs",
@@ -1323,11 +1330,6 @@
"updated": "Nyligen uppdaterade", "updated": "Nyligen uppdaterade",
"category": "Kategori" "category": "Kategori"
}, },
"columns": {
"title": "Kolumner",
"auto": "Auto",
"count": "{{count}} breda"
},
"table": { "table": {
"name": "Namn", "name": "Namn",
"description": "Beskrivning", "description": "Beskrivning",
@@ -1652,7 +1654,12 @@
"gridView": "Rutnät", "gridView": "Rutnät",
"listView": "Lista", "listView": "Lista",
"compactRows": "Kompakta rader", "compactRows": "Kompakta rader",
"comfortableRows": "Bekväma rader" "comfortableRows": "Bekväma rader",
"columns": {
"title": "Kolumner",
"auto": "Automatiskt",
"count": "{{count}} bred"
}
}, },
"fields": { "fields": {
"secretSaved": "Sparat. Skriv för att ersätta det." "secretSaved": "Sparat. Skriv för att ersätta det."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix เวอร์ชัน {{version}} พร้อมติดตั้ง", "ready": "Termix เวอร์ชัน {{version}} พร้อมติดตั้ง",
"readyHint": "จะติดตั้งเมื่อ Termix รีสตาร์ทครั้งถัดไป" "readyHint": "จะติดตั้งเมื่อ Termix รีสตาร์ทครั้งถัดไป"
}, },
"safeMode": {
"title": "โหมดปลอดภัย",
"description": "กำลังทำงานเฉพาะปลั๊กอินเลย์เอาต์และธีมเท่านั้น โปรดรีสตาร์ทเซิร์ฟเวอร์โดยยกเลิกการตั้งค่า TERMIX_SAFE_MODE เพื่อเปิดใช้งานส่วนที่เหลือ"
},
"companion": { "companion": {
"close": "ปิดแผง", "close": "ปิดแผง",
"moveSide": "ย้ายแผง", "moveSide": "ย้ายแผง",
@@ -1081,6 +1085,8 @@
"resetEveryone": "รีเซ็ตค่าเริ่มต้นสำหรับทุกคน", "resetEveryone": "รีเซ็ตค่าเริ่มต้นสำหรับทุกคน",
"update": "อัปเดต {{name}} เป็น {{version}}", "update": "อัปเดต {{name}} เป็น {{version}}",
"disable": "ปิดใช้งาน {{name}}", "disable": "ปิดใช้งาน {{name}}",
"enable": "เปิดใช้งาน {{name}}",
"startAgain": "เริ่ม {{name}} อีกครั้ง",
"install": "ค้นหาการจัดวางและธีม", "install": "ค้นหาการจัดวางและธีม",
"installTitle": "การจัดวางและธีม", "installTitle": "การจัดวางและธีม",
"installButton": "ติดตั้ง", "installButton": "ติดตั้ง",
@@ -1292,7 +1298,8 @@
"disabled": "ปิดใช้งาน", "disabled": "ปิดใช้งาน",
"failed": "หยุดทำงานโดยไม่คาดคิด", "failed": "หยุดทำงานโดยไม่คาดคิด",
"blocked": "กำลังรอปลั๊กอินอื่น", "blocked": "กำลังรอปลั๊กอินอื่น",
"server": "ทำงานบนเซิร์ฟเวอร์ของคุณ" "server": "ทำงานบนเซิร์ฟเวอร์ของคุณ",
"safeMode": "ปิดใช้งานในโหมดปลอดภัย"
}, },
"groups": { "groups": {
"running": "กำลังทำงาน", "running": "กำลังทำงาน",
@@ -1306,11 +1313,6 @@
"updated": "อัปเดตล่าสุด", "updated": "อัปเดตล่าสุด",
"category": "หมวดหมู่" "category": "หมวดหมู่"
}, },
"columns": {
"title": "คอลัมน์",
"auto": "อัตโนมัติ",
"count": "กว้าง {{count}} คอลัมน์"
},
"table": { "table": {
"name": "ชื่อ", "name": "ชื่อ",
"description": "คำอธิบาย", "description": "คำอธิบาย",
@@ -1610,7 +1612,12 @@
"gridView": "ตาราง", "gridView": "ตาราง",
"listView": "รายการ", "listView": "รายการ",
"compactRows": "แถวแบบกะทัดรัด", "compactRows": "แถวแบบกะทัดรัด",
"comfortableRows": "แถวแบบสบายตา" "comfortableRows": "แถวแบบสบายตา",
"columns": {
"title": "คอลัมน์",
"auto": "อัตโนมัติ",
"count": "กว้าง {{count}} คอลัมน์"
}
}, },
"fields": { "fields": {
"secretSaved": "บันทึกแล้ว พิมพ์เพื่อแทนที่" "secretSaved": "บันทึกแล้ว พิมพ์เพื่อแทนที่"
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} yüklenmeye hazır", "ready": "Termix {{version}} yüklenmeye hazır",
"readyHint": "Termix yeniden başladığında yüklenir." "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": { "companion": {
"close": "Paneli kapat", "close": "Paneli kapat",
"moveSide": "Paneli taşı", "moveSide": "Paneli taşı",
@@ -1098,6 +1102,8 @@
"resetEveryone": "Herkes için varsayılanı sıfırla", "resetEveryone": "Herkes için varsayılanı sıfırla",
"update": "{{name}}'i {{version}} sürümüne güncelle", "update": "{{name}}'i {{version}} sürümüne güncelle",
"disable": "{{name}}'i kapat", "disable": "{{name}}'i kapat",
"enable": "{{name}}'i aç",
"startAgain": "{{name}}'i yeniden başlat",
"install": "Düzen ve temaları bul", "install": "Düzen ve temaları bul",
"installTitle": "Düzenler ve temalar", "installTitle": "Düzenler ve temalar",
"installButton": "Yükle", "installButton": "Yükle",
@@ -1309,7 +1315,8 @@
"disabled": "Devre dışı", "disabled": "Devre dışı",
"failed": "Beklenmedik şekilde durdu", "failed": "Beklenmedik şekilde durdu",
"blocked": "Başka bir eklentiyi bekliyor", "blocked": "Başka bir eklentiyi bekliyor",
"server": "Sunucunuzda çalışır" "server": "Sunucunuzda çalışır",
"safeMode": "Güvenli modda kapalı"
}, },
"groups": { "groups": {
"running": "Çalışıyor", "running": "Çalışıyor",
@@ -1323,11 +1330,6 @@
"updated": "Son güncellenen", "updated": "Son güncellenen",
"category": "Kategori" "category": "Kategori"
}, },
"columns": {
"title": "Sütunlar",
"auto": "Otomatik",
"count": "{{count}} geniş"
},
"table": { "table": {
"name": "Ad", "name": "Ad",
"description": "Açıklama", "description": "Açıklama",
@@ -1652,7 +1654,12 @@
"gridView": "Izgara", "gridView": "Izgara",
"listView": "Liste", "listView": "Liste",
"compactRows": "Sıkı satırlar", "compactRows": "Sıkı satırlar",
"comfortableRows": "Rahat satırlar" "comfortableRows": "Rahat satırlar",
"columns": {
"title": "Sütunlar",
"auto": "Otomatik",
"count": "{{count}} geniş"
}
}, },
"fields": { "fields": {
"secretSaved": "Kaydedildi. Değiştirmek için yazın." "secretSaved": "Kaydedildi. Değiştirmek için yazın."
+14 -7
View File
@@ -235,6 +235,10 @@
"ready": "Termix {{version}} готова до встановлення", "ready": "Termix {{version}} готова до встановлення",
"readyHint": "Вона встановиться під час наступного перезапуску Termix." "readyHint": "Вона встановиться під час наступного перезапуску Termix."
}, },
"safeMode": {
"title": "Безпечний режим",
"description": "Працюють лише плагіни макету та теми. Перезапустіть сервер без змінної TERMIX_SAFE_MODE, щоб увімкнути решту плагінів."
},
"companion": { "companion": {
"close": "Закрити панель", "close": "Закрити панель",
"moveSide": "Перемістити панель", "moveSide": "Перемістити панель",
@@ -1132,6 +1136,8 @@
"resetEveryone": "Скинути значення за замовчуванням для всіх", "resetEveryone": "Скинути значення за замовчуванням для всіх",
"update": "Оновити {{name}} до {{version}}", "update": "Оновити {{name}} до {{version}}",
"disable": "Вимкнути {{name}}", "disable": "Вимкнути {{name}}",
"enable": "Увімкнути {{name}}",
"startAgain": "Запустити {{name}} знову",
"install": "Знайти макети та теми", "install": "Знайти макети та теми",
"installTitle": "Макети та теми", "installTitle": "Макети та теми",
"installButton": "Встановити", "installButton": "Встановити",
@@ -1343,7 +1349,8 @@
"disabled": "Вимкнено", "disabled": "Вимкнено",
"failed": "Зупинено несподівано", "failed": "Зупинено несподівано",
"blocked": "Очікує на інший плагін", "blocked": "Очікує на інший плагін",
"server": "Працює на вашому сервері" "server": "Працює на вашому сервері",
"safeMode": "Вимкнено в безпечному режимі"
}, },
"groups": { "groups": {
"running": "Запущено", "running": "Запущено",
@@ -1357,11 +1364,6 @@
"updated": "Нещодавно оновлено", "updated": "Нещодавно оновлено",
"category": "Категорія" "category": "Категорія"
}, },
"columns": {
"title": "Стовпці",
"auto": "Авто",
"count": "{{count}} стовпців"
},
"table": { "table": {
"name": "Назва", "name": "Назва",
"description": "Опис", "description": "Опис",
@@ -1736,7 +1738,12 @@
"gridView": "Сітка", "gridView": "Сітка",
"listView": "Список", "listView": "Список",
"compactRows": "Компактні рядки", "compactRows": "Компактні рядки",
"comfortableRows": "Зручні рядки" "comfortableRows": "Зручні рядки",
"columns": {
"title": "Стовпці",
"auto": "Авто",
"count": "{{count}} стовпців"
}
}, },
"fields": { "fields": {
"secretSaved": "Збережено. Введіть текст, щоб замінити його." "secretSaved": "Збережено. Введіть текст, щоб замінити його."
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more